diff --git a/TEMPLATES.md b/TEMPLATES.md index 105cc29..f294b1a 100644 --- a/TEMPLATES.md +++ b/TEMPLATES.md @@ -1,6 +1,6 @@ # Templates -Catalog of 50 templates from [templates.json](templates.json). +Catalog of 51 templates from [templates.json](templates.json). | Template | Thumbnail | Live demo | Tech stack | | --- | --- | --- | --- | @@ -54,3 +54,4 @@ Catalog of 50 templates from [templates.json](templates.json). | [On Sale](templates/on-sale/) | On Sale thumbnail · [Full preview](https://github.com/hostinger/hostinger-templates/blob/main/templates/on-sale/preview/on-sale-homepage.png?raw=true) | [Open demo](https://on-sale.hostingertemplates.com) | SvelteKit with adapter-static, Vite | | [The Rooms](templates/the-rooms/) | The Rooms thumbnail · [Full preview](https://github.com/hostinger/hostinger-templates/blob/main/templates/the-rooms/preview/the-rooms-homepage.png?raw=true) | [Open demo](https://the-rooms.hostingertemplates.com) | Nuxt 3 (Vue 3), Nitro server rendering | | [Model](templates/model/) | Model thumbnail · [Full preview](https://github.com/hostinger/hostinger-templates/blob/main/templates/model/preview/model-homepage.png?raw=true) | [Open demo](https://model.hostingertemplates.com) | Next.js 16 App Router, React | +| [Route Library Halloween](templates/route-library-halloween/) | Route Library Halloween thumbnail · [Full preview](https://github.com/hostinger/hostinger-templates/blob/main/templates/route-library-halloween/preview/route-library-halloween-homepage.png?raw=true) | [Open demo](https://route-library-halloween.hostingertemplates.com) | Express 5 with server-rendered EJS views | diff --git a/templates.json b/templates.json index 0bbd2ac..9308657 100644 --- a/templates.json +++ b/templates.json @@ -5877,6 +5877,129 @@ "outputDirectory": "out", "entryFile": "" } + }, + { + "id": "route-library-halloween", + "name": "Route Library Halloween", + "summary": "A night running club's route book in a dark Halloween palette, with distance and climb filters, SVG elevation profiles, and a GPX download for every route.", + "description": "Route Library Halloween is the autumn-night edition of Route Library, dressed for the season in near-black with deep orange and purple accents, a harvest-moon masthead and spooky but practical copy. It gives a club that runs by headtorch a public, filterable route book where every route has a measured distance, an honest climb figure, a surface and grade, a named start point, a written description and a downloadable GPX file, all presented under the club's own name and contact details.", + "demoUrl": "https://route-library-halloween.hostingertemplates.com", + "bucket": "Non-technical", + "categories": [ + "Fitness", + "Event" + ], + "whoItsFor": "A running club with a Halloween or night-running theme", + "classification": { + "categories": [ + "clubs-and-community", + "sports-and-recreation", + "content-directory", + "seasonal-events" + ], + "useCases": [ + "routes", + "gpx", + "filtering", + "halloween" + ], + "frameworks": [ + "express" + ] + }, + "features": [ + { + "id": "distance-climb-filters", + "name": "Distance and climb filters", + "description": "Distance and climb filter chips backed by shareable query-parameter URLs (/?distance=short), with progressive client-side instant filtering and history.pushState URL sync." + }, + { + "id": "route-detail-pages", + "name": "Server-rendered route pages", + "description": "Five seeded night routes, each with a server-rendered detail page at /routes/ that loads directly." + }, + { + "id": "elevation-profile-charts", + "name": "SVG elevation profiles", + "description": "Inline SVG elevation profile on every route page, built from the committed data points: area wash, gridlines, distance/elevation axes, a labelled high-point marker and a hover crosshair with tooltip." + }, + { + "id": "halloween-theme", + "name": "Halloween night theme", + "description": "Near-black palette with deep orange and purple accents, a pure-CSS harvest moon in the masthead and a night-tinted club photo, all driven by tokens in one stylesheet." + } + ], + "technology": { + "languages": [ + "TypeScript" + ], + "frameworks": [ + "Express" + ], + "styling": [ + "Plain hand-written CSS with centralized design tokens" + ], + "data": [ + "Shareable URL and query-parameter state", + "Committed JSON data and configuration", + "Typed TypeScript content and configuration" + ], + "deployment": [ + "Node.js server application built from source" + ] + }, + "compatibility": { + "runtimes": [ + { + "name": "Node.js", + "versions": [ + "22+" + ] + } + ] + }, + "media": { + "thumbnail": { + "path": "templates/route-library-halloween/preview/route-library-halloween-thumbnail.png", + "url": "https://github.com/hostinger/hostinger-templates/blob/main/templates/route-library-halloween/preview/route-library-halloween-thumbnail.png?raw=true", + "source": "viewport", + "width": 1440, + "height": 900, + "aspectRatio": "16:10", + "variants": [ + { + "width": 480, + "path": "templates/route-library-halloween/preview/route-library-halloween-thumbnail-480.webp" + }, + { + "width": 960, + "path": "templates/route-library-halloween/preview/route-library-halloween-thumbnail-960.webp" + } + ] + }, + "preview": { + "path": "templates/route-library-halloween/preview/route-library-halloween-homepage.png", + "url": "https://github.com/hostinger/hostinger-templates/blob/main/templates/route-library-halloween/preview/route-library-halloween-homepage.png?raw=true", + "source": "fullPage", + "width": 1440, + "height": 3198 + } + }, + "searchTerms": [ + "halloween website template", + "night running club", + "running club website", + "route book", + "gpx download", + "elevation profile chart", + "dark orange purple theme", + "express ejs server template" + ], + "deploy": { + "buildCommand": "build", + "outputDirectory": "", + "entryFile": "dist/server.js" + } } ] } diff --git a/templates/route-library-halloween/.env.example b/templates/route-library-halloween/.env.example new file mode 100644 index 0000000..b63dd89 --- /dev/null +++ b/templates/route-library-halloween/.env.example @@ -0,0 +1 @@ +PORT=4361 diff --git a/templates/route-library-halloween/.gitignore b/templates/route-library-halloween/.gitignore new file mode 100644 index 0000000..7cf31a7 --- /dev/null +++ b/templates/route-library-halloween/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env* +!.env.example +*.tsbuildinfo +.DS_Store diff --git a/templates/route-library-halloween/LICENSE b/templates/route-library-halloween/LICENSE new file mode 100644 index 0000000..6791161 --- /dev/null +++ b/templates/route-library-halloween/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Template Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/templates/route-library-halloween/README.md b/templates/route-library-halloween/README.md new file mode 100644 index 0000000..f7aa530 --- /dev/null +++ b/templates/route-library-halloween/README.md @@ -0,0 +1,119 @@ +# Route Library Halloween + +A Halloween-themed night running club route book: filter routes by distance and climb, read an elevation profile for every route, and download the GPX. + +## Overview + +Route Library Halloween is a dark, autumn-night edition of the Route Library template: a small server-rendered web app for a running club that heads out by headtorch once the clocks go back. The home page opens under a harvest moon straight into the night route book — a filter bar for distance (under 10 km, 10–25 km, over 25 km) and climb (under 300 m, 300–700 m, over 700 m) above a grid of route cards, each carrying a mini elevation sparkline, distance, climb, surface, grade, start point and a GPX download. + +Filtering happens on the server through query parameters (`/?distance=short&climb=steep`), so every filtered view is a shareable URL that works without JavaScript; a small enhancement script makes the same filters instant in the browser and keeps the URL in sync. Every route also has its own server-rendered page at `/routes/` with a full inline SVG elevation profile — hover for exact figures, or open the accessible data table — plus a night-running safety note and a GPX download served with the correct content type. + +The demo content is a fictional Lake District night running club, Hollowfell Night Harriers, with five internally consistent routes such as the Will-o'-the-Wisp Loop and the Headless Horseshoe, club nights that build up to an All Hallows' Run on 31 October, and a FAQ about headtorches and fancy dress. The palette is near-black with deep orange and purple accents; the copy is spooky but stays practical. + +## Features + +- Distance and climb filter chips backed by shareable query-parameter URLs (`/?distance=short`), with progressive client-side instant filtering and `history.pushState` URL sync +- Five seeded night routes, each with a server-rendered detail page at `/routes/` that loads directly +- Inline SVG elevation profile on every route page, built from the committed data points: area wash, gridlines, distance/elevation axes, a labelled high-point marker and a hover crosshair with tooltip +- Mini profile sparkline on every route card, drawn from the same data +- GPX download per route, served with `Content-Type: application/gpx+xml` and a `Content-Disposition: attachment` header +- Profile data table behind a disclosure on each route page, so every chart figure is reachable without hover or JavaScript +- Halloween masthead with a pure-CSS harvest moon and purple/orange glows, plus a night-tinted club photo — no extra image assets +- Empty state with a mailto fallback when no routes match the chosen filters +- Unknown `/routes/` URLs get a "vanished into the mist" 404 page listing the real routes +- Five-question FAQ rendered from JSON with `FAQPage` JSON-LD in the layout head +- Club section with meet point, club-night times and a mailto contact built from the same displayed data +- Semantic HTML, keyboard-reachable controls, visible focus states and layouts verified down to 320px + +## Tech stack + +- **Language:** TypeScript +- **Framework:** Express 5 with server-rendered EJS views +- **Build tool:** tsc (TypeScript compiler); tsx for the dev server +- **Styling:** Plain hand-written CSS with centralized design tokens +- **Linting:** ESLint (flat config with typescript-eslint) + +## Getting started + +### Prerequisites + +- Node.js 22 or newer +- npm + +### Installation + +```bash +npm install +``` + +### Development + +```bash +npm run dev +``` + +Open the local URL printed by the server (defaults to `http://localhost:4361`). + +## Available scripts + +- `npm run dev` — start the local development server with tsx watch +- `npm run build` — type-check and compile the server to `dist/` +- `npm run lint` — run ESLint +- `npm run preview` — run the compiled production server from `dist/` + +The server reads the `PORT` environment variable and defaults to `4361`, so `PORT=4361 npm run preview` and `npm run preview` are equivalent. + +## Project structure + +```text +src/ +├── data/ # site.json and routes.json — all editable content +├── types/ # shared content types +├── utils/ # filtering, formatting, SVG chart geometry, JSON-LD +├── views/ # EJS templates and partials +└── server.ts # Express app: routes, static serving, 404, errors +public/ +├── gpx/ # one downloadable GPX file per route +├── images/ # local photos +├── js/ # progressive-enhancement scripts +├── styles/ # main.css with all design tokens +└── favicon.svg +``` + +This is a Node server app, not a static export. The compiled server in `dist/` resolves views from `src/views/`, content from `src/data/` and static assets from `public/` relative to the project root, so the build needs no copy step — deploy the whole project folder and run `node dist/server.js`. + +## Personalizing + +### Content and business data + +Edit `src/data/site.json` for the club name, area, tagline, heading, intro, about text, meet point, club-night times (including the All Hallows' Run), route librarian contact (the mailto links are built from `librarian.email`), the night-running disclaimer, navigation and the FAQ list. Edit `src/data/routes.json` for the routes — each entry holds `name`, `slug`, `distanceKm`, `elevationGainM`, `surface`, `difficulty` (`Easy`, `Moderate` or `Hard`), `startPoint`, `description` and a `profile` array of `{ "km", "m" }` points. Keep the first profile point at km 0, the last at `distanceKm`, and the summed uphill differences equal to `elevationGainM`; add a matching GPX file at `public/gpx/.gpx`. Filter thresholds and labels live in `src/utils/filters.ts`. Page titles and the 404 wording are set in `src/server.ts` and `src/views/`. + +### Branding and styles + +Edit `public/styles/main.css`. All design tokens sit in the `:root` block at the top: the night palette (`--paper`, `--night-deep`, `--ink`), the deep orange accent (`--accent`, `--accent-strong`), the purple accents (`--purple`, `--purple-soft`), the Google Fonts `@import` (IM Fell English for display headings, Karla for body text, Spline Sans Mono for figures) and the purple topographic background pattern. The harvest moon is the `.masthead::before` rule, the club-photo night tint is `.club-photo img` plus `.club-photo::after`, and the display-heading font assignments are grouped under "Display headings"; responsive rules follow at the end of the same file. To drop the Halloween theme, swap those tokens and remove the moon and tint rules. Chart geometry (size, padding, tick steps, the minimum elevation span that stops flat routes looking mountainous) lives in `src/utils/elevation.ts`. + +### Images + +Local images live in `public/images/`. The club-section photo is referenced in `src/views/index.ejs`; replace `public/images/fell-runners.jpg` with a photo around 1600 px wide (portrait 3:4 shows uncropped on desktop) and update the `alt`, `width` and `height` attributes. The night-time tint is applied in CSS, so any daylight photo will pick up the theme. The favicon (crescent moon over a hill) is `public/favicon.svg`, and the header brand mark is inline SVG in `src/views/partials/top.ejs`. The current photo was sourced from Pexels contributor Alin Serban. + +### Routes and features + +HTTP routes live in `src/server.ts`: the filterable index (`/`), the route pages (`/routes/:slug`, validated against the route book with a 404 fallback) and the GPX headers set on `express.static`. Page markup lives in `src/views/` (`index.ejs`, `route.ejs`, `not-found.ejs` and `partials/`). The client-side filter and chart-hover enhancements are `public/js/filters.js` and `public/js/profile-hover.js`; both are optional layers over fully working server-rendered pages. + +### Environment variables + +`PORT` — the port the Express server listens on, defaulting to `4361`. See `.env.example`. The server reads it straight from the environment (`PORT=8080 npm run preview`); no dotenv loader is involved. + +## Production build + +```bash +npm run build +npm run preview +``` + +Production files are written to `dist/`. `npm run preview` runs the compiled Node server (`node dist/server.js`) on port 4361 unless `PORT` is set — this is a server app and must stay running behind your host's Node process manager. + +## Screenshots + +- Thumbnail: `preview/route-library-halloween-thumbnail.png` +- Full page: `preview/route-library-halloween-homepage.png` diff --git a/templates/route-library-halloween/eslint.config.js b/templates/route-library-halloween/eslint.config.js new file mode 100644 index 0000000..3969b6b --- /dev/null +++ b/templates/route-library-halloween/eslint.config.js @@ -0,0 +1,24 @@ +import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +export default defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + { + ignores: ['dist/'], + }, + { + files: ['public/js/**/*.js'], + languageOptions: { + globals: { + document: 'readonly', + window: 'readonly', + history: 'readonly', + location: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + }, + }, + }, +); diff --git a/templates/route-library-halloween/package-lock.json b/templates/route-library-halloween/package-lock.json new file mode 100644 index 0000000..d65bffc --- /dev/null +++ b/templates/route-library-halloween/package-lock.json @@ -0,0 +1,2742 @@ +{ + "name": "route-library-halloween", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "route-library-halloween", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "ejs": "^6.0.1", + "express": "^5.2.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/express": "^5.0.6", + "@types/node": "^26.4.0", + "eslint": "^10.9.1", + "tsx": "^4.23.12", + "typescript": "^6.0.3", + "typescript-eslint": "^8.68.0" + }, + "engines": { + "node": ">=22" + } + }, + "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/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.68.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", + "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/templates/route-library-halloween/package.json b/templates/route-library-halloween/package.json new file mode 100644 index 0000000..c0a3041 --- /dev/null +++ b/templates/route-library-halloween/package.json @@ -0,0 +1,41 @@ +{ + "name": "route-library-halloween", + "version": "0.0.0", + "private": true, + "description": "A Halloween-themed night running club route book: filter routes by distance and climb, read elevation profiles, download GPX.", + "type": "module", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "lint": "eslint .", + "preview": "node dist/server.js" + }, + "engines": { + "node": ">=22" + }, + "keywords": [ + "express", + "ejs", + "typescript", + "running-club", + "gpx", + "route-library", + "halloween", + "night-running" + ], + "license": "MIT", + "dependencies": { + "ejs": "^6.0.1", + "express": "^5.2.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/express": "^5.0.6", + "@types/node": "^26.4.0", + "eslint": "^10.9.1", + "tsx": "^4.23.12", + "typescript": "^6.0.3", + "typescript-eslint": "^8.68.0" + } +} diff --git a/templates/route-library-halloween/preview/route-library-halloween-homepage.png b/templates/route-library-halloween/preview/route-library-halloween-homepage.png new file mode 100644 index 0000000..a85d4c5 Binary files /dev/null and b/templates/route-library-halloween/preview/route-library-halloween-homepage.png differ diff --git a/templates/route-library-halloween/preview/route-library-halloween-thumbnail-480.webp b/templates/route-library-halloween/preview/route-library-halloween-thumbnail-480.webp new file mode 100644 index 0000000..cac0ecc Binary files /dev/null and b/templates/route-library-halloween/preview/route-library-halloween-thumbnail-480.webp differ diff --git a/templates/route-library-halloween/preview/route-library-halloween-thumbnail-960.webp b/templates/route-library-halloween/preview/route-library-halloween-thumbnail-960.webp new file mode 100644 index 0000000..e85c61e Binary files /dev/null and b/templates/route-library-halloween/preview/route-library-halloween-thumbnail-960.webp differ diff --git a/templates/route-library-halloween/preview/route-library-halloween-thumbnail.png b/templates/route-library-halloween/preview/route-library-halloween-thumbnail.png new file mode 100644 index 0000000..b39a731 Binary files /dev/null and b/templates/route-library-halloween/preview/route-library-halloween-thumbnail.png differ diff --git a/templates/route-library-halloween/public/favicon.svg b/templates/route-library-halloween/public/favicon.svg new file mode 100644 index 0000000..841673e --- /dev/null +++ b/templates/route-library-halloween/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/route-library-halloween/public/gpx/all-hallows-long-round.gpx b/templates/route-library-halloween/public/gpx/all-hallows-long-round.gpx new file mode 100644 index 0000000..0e53360 --- /dev/null +++ b/templates/route-library-halloween/public/gpx/all-hallows-long-round.gpx @@ -0,0 +1,49 @@ + + + + All Hallows' Long Round + 28.7 km with 232 m of climb. From the Hollowfell Night Harriers night route book. + + + All Hallows' Long Round + Our long steady one: valley tracks and bridleways linking Elterwater, Great Langdale, Little Langdale and Skelwith. Nothing steep and nowhere technical — just honest distance, with the Langdale Pikes looming black against the stars the whole way round. Two sensible exit points if the night stops being fun. + + 70.0 + 81.5 + 93.0 + 104.0 + 114.6 + 122.3 + 108.4 + 94.4 + 100.6 + 116.2 + 125.9 + 117.4 + 108.9 + 105.3 + 122.4 + 139.4 + 152.1 + 139.0 + 125.8 + 114.8 + 122.6 + 130.5 + 137.3 + 122.9 + 108.4 + 94.0 + 106.5 + 118.9 + 131.4 + 121.4 + 110.2 + 99.1 + 110.8 + 122.1 + 96.0 + 70.0 + + + diff --git a/templates/route-library-halloween/public/gpx/headless-horseshoe.gpx b/templates/route-library-halloween/public/gpx/headless-horseshoe.gpx new file mode 100644 index 0000000..110d330 --- /dev/null +++ b/templates/route-library-halloween/public/gpx/headless-horseshoe.gpx @@ -0,0 +1,48 @@ + + + + Headless Horseshoe + 17.5 km with 959 m of climb. From the Hollowfell Night Harriers night route book. + + + Headless Horseshoe + The classic horseshoe over eight named tops: up by Nab Scar and Heron Pike, along the ridge to Fairfield's broad summit plateau, then back over Hart Crag, Dove Crag and the Pikes. After dark it is easy to lose your head for navigation — the plateau is featureless in mist. Full fell kit, a spare torch and a running partner, no exceptions. + + 64.0 + 108.6 + 153.2 + 201.9 + 252.6 + 303.8 + 358.7 + 413.6 + 469.9 + 526.6 + 571.4 + 603.4 + 603.2 + 652.4 + 748.4 + 748.0 + 776.3 + 864.4 + 808.5 + 755.3 + 803.0 + 794.6 + 765.6 + 789.2 + 723.6 + 646.9 + 577.6 + 508.4 + 430.8 + 353.1 + 281.7 + 211.4 + 154.3 + 109.1 + 64.0 + + + diff --git a/templates/route-library-halloween/public/gpx/lantern-terrace-circuit.gpx b/templates/route-library-halloween/public/gpx/lantern-terrace-circuit.gpx new file mode 100644 index 0000000..43d5de5 --- /dev/null +++ b/templates/route-library-halloween/public/gpx/lantern-terrace-circuit.gpx @@ -0,0 +1,37 @@ + + + + Lantern Terrace Circuit + 9.4 km with 310 m of climb. From the Hollowfell Night Harriers night route book. + + + Lantern Terrace Circuit + A proper little circuit with one honest pull up to Loughrigg's summit cairn, then the terrace path with the lights of Grasmere laid out below. The drop past Rydal Cave is steep and loose, and the cave has a habit of echoing back footsteps that are not yours. Trail shoes and a strong headtorch after rain. + + 62.0 + 77.4 + 92.9 + 114.8 + 138.5 + 166.9 + 198.5 + 256.6 + 329.6 + 292.9 + 247.5 + 216.1 + 185.2 + 183.5 + 190.6 + 167.2 + 136.6 + 137.7 + 144.3 + 122.6 + 97.6 + 102.4 + 92.0 + 62.0 + + + diff --git a/templates/route-library-halloween/public/gpx/will-o-the-wisp-loop.gpx b/templates/route-library-halloween/public/gpx/will-o-the-wisp-loop.gpx new file mode 100644 index 0000000..820e301 --- /dev/null +++ b/templates/route-library-halloween/public/gpx/will-o-the-wisp-loop.gpx @@ -0,0 +1,37 @@ + + + + Will-o'-the-Wisp Loop + 6.8 km with 95 m of climb. From the Hollowfell Night Harriers night route book. + + + Will-o'-the-Wisp Loop + The club's gentle night out: along the River Rothay, around the Under Loughrigg lane and back over the stone footbridge, with lights flickering on the water the whole way. Barely a climb worth naming, though the two short rises past Fox How will still make you jump. Runnable end to end in road shoes, and the route we use for the All Hallows' Run. + + 62.0 + 68.8 + 75.5 + 75.9 + 72.5 + 72.2 + 80.3 + 88.4 + 90.3 + 87.4 + 84.4 + 91.1 + 99.5 + 96.5 + 91.0 + 91.5 + 99.1 + 105.0 + 93.6 + 82.2 + 86.8 + 97.7 + 85.1 + 62.0 + + + diff --git a/templates/route-library-halloween/public/gpx/witching-hour-wansfell.gpx b/templates/route-library-halloween/public/gpx/witching-hour-wansfell.gpx new file mode 100644 index 0000000..684c617 --- /dev/null +++ b/templates/route-library-halloween/public/gpx/witching-hour-wansfell.gpx @@ -0,0 +1,37 @@ + + + + Witching Hour Wansfell + 11.6 km with 480 m of climb. From the Hollowfell Night Harriers night route book. + + + Witching Hour Wansfell + Up the stone steps out of town and onto Wansfell Pike the direct way — half an hour of proper climbing in the dark — then over the top and down through Troutbeck's lanes and Skelghyll Woods, where the owls are louder than the runners. On a clear night the moon lays a silver road down the length of Windermere. + + 64.0 + 108.4 + 152.9 + 206.1 + 259.3 + 328.0 + 398.8 + 453.0 + 460.9 + 415.5 + 370.9 + 326.6 + 279.8 + 234.1 + 199.4 + 183.0 + 204.3 + 193.9 + 156.9 + 158.2 + 171.7 + 133.9 + 97.1 + 64.0 + + + diff --git a/templates/route-library-halloween/public/images/fell-runners.jpg b/templates/route-library-halloween/public/images/fell-runners.jpg new file mode 100644 index 0000000..7325227 Binary files /dev/null and b/templates/route-library-halloween/public/images/fell-runners.jpg differ diff --git a/templates/route-library-halloween/public/js/filters.js b/templates/route-library-halloween/public/js/filters.js new file mode 100644 index 0000000..ac4ca37 --- /dev/null +++ b/templates/route-library-halloween/public/js/filters.js @@ -0,0 +1,95 @@ +// Progressive enhancement for the route filters. +// Without JavaScript the chips are ordinary links and the server filters +// via ?distance= and ?climb=. With JavaScript, filtering is instant and +// the URL is kept in sync so every view stays shareable. + +const filterBar = document.querySelector('[data-filter-bar]'); +const grid = document.querySelector('[data-route-grid]'); +const countLine = document.querySelector('[data-result-count]'); +const emptyState = document.querySelector('[data-empty-state]'); + +if (filterBar && grid && countLine && emptyState) { + const chips = Array.from(filterBar.querySelectorAll('a[data-filter-id]')); + const cards = Array.from(grid.querySelectorAll('[data-distance]')); + const resetLink = document.querySelector('[data-filter-reset]'); + + const knownIds = (group) => + chips + .filter((chip) => chip.dataset.filterGroup === group) + .map((chip) => chip.dataset.filterId); + + const readStateFromUrl = () => { + const params = new URLSearchParams(location.search); + const pick = (group) => { + const value = params.get(group); + return value && knownIds(group).includes(value) ? value : 'all'; + }; + return { distance: pick('distance'), climb: pick('climb') }; + }; + + let state = readStateFromUrl(); + + const hrefFor = (distance, climb) => { + const params = new URLSearchParams(); + if (distance !== 'all') { + params.set('distance', distance); + } + if (climb !== 'all') { + params.set('climb', climb); + } + const query = params.toString(); + return query ? `/?${query}` : '/'; + }; + + const apply = () => { + let shown = 0; + for (const card of cards) { + const matches = + (state.distance === 'all' || card.dataset.distance === state.distance) && + (state.climb === 'all' || card.dataset.climb === state.climb); + card.hidden = !matches; + if (matches) { + shown += 1; + } + } + + countLine.textContent = `Showing ${shown} of ${cards.length} routes`; + emptyState.hidden = shown > 0; + + for (const chip of chips) { + const group = chip.dataset.filterGroup; + const id = chip.dataset.filterId; + const nextDistance = group === 'distance' ? id : state.distance; + const nextClimb = group === 'climb' ? id : state.climb; + chip.setAttribute('href', hrefFor(nextDistance, nextClimb)); + if (state[group] === id) { + chip.setAttribute('aria-current', 'true'); + } else { + chip.removeAttribute('aria-current'); + } + } + }; + + for (const chip of chips) { + chip.addEventListener('click', (event) => { + event.preventDefault(); + state = { ...state, [chip.dataset.filterGroup]: chip.dataset.filterId }; + apply(); + history.pushState({}, '', hrefFor(state.distance, state.climb)); + }); + } + + if (resetLink) { + resetLink.addEventListener('click', (event) => { + event.preventDefault(); + state = { distance: 'all', climb: 'all' }; + apply(); + history.pushState({}, '', '/'); + }); + } + + window.addEventListener('popstate', () => { + state = readStateFromUrl(); + apply(); + }); +} diff --git a/templates/route-library-halloween/public/js/profile-hover.js b/templates/route-library-halloween/public/js/profile-hover.js new file mode 100644 index 0000000..93e4021 --- /dev/null +++ b/templates/route-library-halloween/public/js/profile-hover.js @@ -0,0 +1,89 @@ +// Hover crosshair for the elevation profile chart. +// The chart is fully readable without this script: the SVG carries a text +// description and the profile data table lists every figure. + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +const initProfileHover = (container) => { + const svg = container.querySelector('svg'); + let payload; + try { + payload = JSON.parse(container.dataset.points || 'null'); + } catch { + payload = null; + } + if (!svg || !payload || !Array.isArray(payload.points) || payload.points.length === 0) { + return; + } + + const points = payload.points; + const plot = payload.plot; + const viewWidth = svg.viewBox.baseVal.width; + const viewHeight = svg.viewBox.baseVal.height; + + const cursor = document.createElementNS(SVG_NS, 'g'); + cursor.setAttribute('class', 'chart-cursor'); + cursor.style.display = 'none'; + + const cursorLine = document.createElementNS(SVG_NS, 'line'); + cursorLine.setAttribute('y1', String(plot.top)); + cursorLine.setAttribute('y2', String(plot.bottom)); + + const cursorDot = document.createElementNS(SVG_NS, 'circle'); + cursorDot.setAttribute('r', '4.5'); + + cursor.append(cursorLine, cursorDot); + svg.append(cursor); + + const tooltip = document.createElement('div'); + tooltip.className = 'chart-tooltip'; + tooltip.hidden = true; + const tooltipValue = document.createElement('span'); + tooltipValue.className = 'chart-tooltip-value'; + const tooltipLabel = document.createElement('span'); + tooltipLabel.className = 'chart-tooltip-label'; + tooltip.append(tooltipValue, tooltipLabel); + container.append(tooltip); + + const showAt = (clientX) => { + const rect = svg.getBoundingClientRect(); + const viewX = ((clientX - rect.left) / rect.width) * viewWidth; + + let nearest = points[0]; + for (const point of points) { + if (Math.abs(point.x - viewX) < Math.abs(nearest.x - viewX)) { + nearest = point; + } + } + + cursorLine.setAttribute('x1', String(nearest.x)); + cursorLine.setAttribute('x2', String(nearest.x)); + cursorDot.setAttribute('cx', String(nearest.x)); + cursorDot.setAttribute('cy', String(nearest.y)); + cursor.style.display = ''; + + tooltipValue.textContent = `${nearest.m.toLocaleString('en-GB')} m`; + tooltipLabel.textContent = `at ${nearest.km} km`; + tooltip.hidden = false; + + const cssX = (nearest.x / viewWidth) * rect.width; + const cssY = (nearest.y / viewHeight) * rect.height; + const half = tooltip.offsetWidth / 2; + const clampedX = Math.min(Math.max(cssX, half + 4), rect.width - half - 4); + tooltip.style.left = `${clampedX}px`; + tooltip.style.top = `${cssY}px`; + }; + + svg.addEventListener('pointermove', (event) => { + showAt(event.clientX); + }); + svg.addEventListener('pointerleave', () => { + cursor.style.display = 'none'; + tooltip.hidden = true; + }); +}; + +const chartContainer = document.querySelector('[data-profile-chart]'); +if (chartContainer) { + initProfileHover(chartContainer); +} diff --git a/templates/route-library-halloween/public/styles/main.css b/templates/route-library-halloween/public/styles/main.css new file mode 100644 index 0000000..523ea49 --- /dev/null +++ b/templates/route-library-halloween/public/styles/main.css @@ -0,0 +1,1297 @@ +@import url('https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&family=Karla:wght@400;500;600;700;800&family=Spline+Sans+Mono:wght@400;500;600;700&display=swap'); + +/* ------------------------------------------------------------------ */ +/* Design tokens — edit colors, fonts and spacing here */ +/* ------------------------------------------------------------------ */ + +:root { + --paper: #0b0a10; + --paper-raised: #15121d; + --paper-tint: #1e1929; + --ink: #f3ece0; + --ink-soft: #c9bfd6; + --ink-faint: #9a8fae; + --contour: #5b4580; + --contour-strong: #4a3a66; + --hairline: #2a2338; + --accent: #ff7a1a; + --accent-deep: #ffa25c; + --accent-strong: #ff9440; + --accent-wash: rgba(255, 122, 26, 0.13); + --on-accent: #160b02; + --purple: #9d4edd; + --purple-soft: #c9a2ff; + --purple-wash: rgba(157, 78, 221, 0.16); + --night-deep: #050408; + --font-display: 'IM Fell English', Georgia, 'Times New Roman', serif; + --font-sans: 'Karla', 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'Spline Sans Mono', 'Courier New', monospace; + --content-width: 1120px; + --narrow-width: 880px; + --radius-card: 12px; + --radius-small: 6px; + --topo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='280' height='280' viewBox='0 0 280 280'%3E%3Cg fill='none' stroke='%239d4edd' stroke-opacity='0.22' stroke-width='1'%3E%3Cpath d='M16 92C16 52 46 20 92 22 138 24 160 56 156 96 152 136 118 164 76 158 38 153 16 128 16 92Z'/%3E%3Cpath d='M34 92C34 62 56 38 92 40 126 42 142 66 138 96 134 126 110 146 78 141 50 137 34 118 34 92Z'/%3E%3Cpath d='M52 92C52 72 66 56 92 58 114 60 124 76 120 96 116 116 100 128 80 124 62 120 52 108 52 92Z'/%3E%3Cpath d='M68 92C68 82 76 74 90 75 102 76 108 84 105 95 102 106 92 112 82 109 72 106 68 100 68 92Z'/%3E%3Cpath d='M166 208C166 182 184 162 212 164 238 166 254 186 250 212 246 236 224 252 198 246 176 241 166 228 166 208Z'/%3E%3Cpath d='M182 208C182 192 194 180 212 182 228 184 238 196 234 212 230 226 216 234 202 230 188 226 182 220 182 208Z'/%3E%3Cpath d='M197 208C197 201 203 195 212 196 220 197 224 203 222 210 220 217 213 221 206 218 199 215 197 213 197 208Z'/%3E%3Cpath d='M234 60C234 50 242 42 252 44 262 46 267 54 264 62 261 70 252 75 244 71 236 67 234 66 234 60Z'/%3E%3Cpath d='M242 60C242 55 246 52 252 53 257 54 259 58 257 62 255 66 250 68 246 65 242 62 242 62 242 60Z'/%3E%3C/g%3E%3C/svg%3E"); +} + +/* ------------------------------------------------------------------ */ +/* Base */ +/* ------------------------------------------------------------------ */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--font-sans); + font-size: 1rem; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3 { + margin: 0; + line-height: 1.15; + font-weight: 700; + letter-spacing: -0.01em; +} + + +p { + margin: 0; +} + +a { + color: var(--accent-deep); + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +a:hover { + color: var(--accent); +} + +img { + max-width: 100%; + display: block; +} + +[hidden] { + display: none !important; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; +} + +.mono { + font-family: var(--font-mono); + font-weight: 600; +} + +.eyebrow { + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--purple-soft); + margin-bottom: 10px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + left: 16px; + top: -48px; + z-index: 10; + background: var(--accent); + color: var(--on-accent); + padding: 8px 14px; + border-radius: 0 0 var(--radius-small) var(--radius-small); + transition: top 0.15s ease; +} + +.skip-link:focus-visible { + top: 0; +} + +/* ------------------------------------------------------------------ */ +/* Header */ +/* ------------------------------------------------------------------ */ + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; + max-width: var(--content-width); + margin: 0 auto; + padding: 18px 24px; + border-bottom: 1px solid var(--hairline); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: var(--ink); +} + +.brand-mark { + width: 40px; + height: 40px; + fill: none; + stroke: var(--purple-soft); + stroke-width: 2; + stroke-linecap: round; +} + +.brand-mark-dot { + fill: var(--accent); + stroke: none; +} + +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.brand-name { + font-weight: 800; + font-size: 1.06rem; + letter-spacing: -0.01em; +} + +.brand-area { + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-faint); +} + +.site-nav { + display: flex; + gap: 22px; +} + +.site-nav a { + text-decoration: none; + color: var(--ink-soft); + font-weight: 600; + font-size: 0.95rem; + padding: 4px 2px; + border-bottom: 2px solid transparent; +} + +.site-nav a:hover { + color: var(--accent-deep); + border-bottom-color: var(--purple); +} + +/* ------------------------------------------------------------------ */ +/* Masthead */ +/* ------------------------------------------------------------------ */ + +.masthead { + position: relative; + overflow: hidden; + background-color: var(--night-deep); + background-image: + radial-gradient(ellipse 70% 90% at 82% 0%, rgba(157, 78, 221, 0.32), transparent 65%), + radial-gradient(ellipse 60% 70% at 10% 110%, rgba(255, 122, 26, 0.16), transparent 60%), + var(--topo); + border-bottom: 1px solid var(--contour-strong); +} + +/* Harvest moon: a pure-CSS glow so there is no image to swap out. */ +.masthead::before { + content: ''; + position: absolute; + top: 38px; + right: max(6vw, calc((100vw - var(--content-width)) / 2 + 24px)); + width: 150px; + height: 150px; + border-radius: 50%; + background: radial-gradient(circle at 38% 36%, #ffe2b8 0%, #ffb15c 38%, #ff7a1a 72%, #b9470a 100%); + box-shadow: + 0 0 60px 12px rgba(255, 122, 26, 0.35), + 0 0 160px 40px rgba(157, 78, 221, 0.25); + opacity: 0.92; + pointer-events: none; +} + +.masthead-inner { + position: relative; + max-width: var(--content-width); + margin: 0 auto; + padding: 64px 24px 56px; +} + +.masthead h1 { + font-size: clamp(2rem, 4.4vw, 3.05rem); + font-weight: 800; + max-width: 21ch; +} + +.masthead-intro { + margin-top: 16px; + max-width: 58ch; + font-size: 1.06rem; + color: var(--ink-soft); +} + +.masthead-stats { + margin-top: 22px; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: baseline; + font-size: 0.95rem; + color: var(--ink-soft); +} + +.masthead-stats .mono { + color: var(--ink); + font-size: 1.02rem; +} + +.stat-dot { + color: var(--purple); +} + +/* ------------------------------------------------------------------ */ +/* Filter bar */ +/* ------------------------------------------------------------------ */ + +.library { + max-width: var(--content-width); + margin: 0 auto; + padding: 34px 24px 66px; +} + +.filter-bar { + background: var(--paper-raised); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + padding: 18px 22px; + display: grid; + gap: 14px; +} + +.filter-group { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; +} + +.filter-label { + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--ink-faint); + min-width: 72px; +} + +.chip-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + display: inline-block; + padding: 6px 14px; + border: 1px solid var(--contour-strong); + border-radius: 999px; + text-decoration: none; + color: var(--ink-soft); + font-size: 0.9rem; + font-weight: 600; + background: transparent; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.chip:hover { + background: var(--purple-wash); + border-color: var(--purple); + color: var(--ink); +} + +.chip[aria-current='true'] { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); +} + +.result-count { + margin: 18px 2px 14px; + font-family: var(--font-mono); + font-size: 0.82rem; + color: var(--ink-faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* ------------------------------------------------------------------ */ +/* Route cards */ +/* ------------------------------------------------------------------ */ + +.route-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); + gap: 22px; +} + +.route-card-inner { + background: var(--paper-raised); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + overflow: hidden; + height: 100%; + display: flex; + flex-direction: column; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.route-card-inner:hover { + border-color: var(--contour-strong); + box-shadow: 0 0 0 1px var(--purple-wash), 0 10px 30px rgba(157, 78, 221, 0.18); +} + +.card-spark-wrap { + display: block; + height: 92px; + background: var(--paper-tint); + border-bottom: 1px solid var(--hairline); +} + +.card-spark { + display: block; + width: 100%; + height: 100%; +} + +.spark-area { + fill: var(--accent-wash); +} + +.spark-line { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.card-body { + padding: 18px 20px 20px; + display: flex; + flex-direction: column; + gap: 14px; + flex: 1; +} + +.card-name { + font-size: 1.28rem; + font-weight: 700; +} + +.card-name a { + color: var(--ink); + text-decoration: none; +} + +.card-name a:hover { + color: var(--accent-deep); + text-decoration: underline; + text-decoration-color: var(--accent); +} + +.stat-row { + margin: 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px 16px; +} + +.stat dt { + font-family: var(--font-mono); + font-size: 0.66rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.13em; + color: var(--ink-faint); +} + +.stat dd { + margin: 2px 0 0; +} + +.stat-value { + font-family: var(--font-mono); + font-weight: 600; + font-size: 1.02rem; + font-variant-numeric: tabular-nums; +} + +.stat-arrow { + color: var(--accent); +} + +.stat-text { + font-size: 0.95rem; + font-weight: 500; +} + +.difficulty { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; +} + +.difficulty-easy { + background: rgba(201, 162, 255, 0.12); + color: var(--purple-soft); +} + +.difficulty-moderate { + background: rgba(255, 196, 92, 0.12); + color: #ffd27a; +} + +.difficulty-hard { + background: var(--accent-wash); + color: var(--accent-deep); + border: 1px solid rgba(255, 122, 26, 0.45); +} + +.card-start { + font-size: 0.86rem; + color: var(--ink-faint); + border-top: 1px solid var(--hairline); + padding-top: 12px; +} + +.card-description { + font-size: 0.95rem; + color: var(--ink-soft); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-actions { + margin-top: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.card-link { + font-weight: 700; + font-size: 0.95rem; + text-decoration: none; +} + +.card-link:hover { + text-decoration: underline; +} + +.card-gpx { + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 600; + text-decoration: none; + border: 1px solid var(--contour-strong); + border-radius: var(--radius-small); + padding: 4px 10px; + color: var(--ink-soft); +} + +.card-gpx:hover { + border-color: var(--accent); + color: var(--accent-deep); + background: var(--accent-wash); +} + +.empty-state { + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + background-color: var(--paper-raised); + background-image: var(--topo); + padding: 44px 28px; + text-align: center; + display: grid; + gap: 12px; + justify-items: center; +} + +.empty-title { + font-weight: 700; + font-size: 1.15rem; +} + +.empty-state p { + max-width: 52ch; + color: var(--ink-soft); +} + +/* ------------------------------------------------------------------ */ +/* Club section */ +/* ------------------------------------------------------------------ */ + +.club { + border-top: 1px solid var(--contour-strong); + border-bottom: 1px solid var(--contour-strong); + background-color: var(--night-deep); + background-image: + radial-gradient(ellipse 60% 80% at 0% 50%, rgba(157, 78, 221, 0.2), transparent 60%), + var(--topo); +} + +.club-inner { + max-width: var(--content-width); + margin: 0 auto; + padding: 60px 24px; + display: grid; + grid-template-columns: 5fr 6fr; + gap: 48px; + align-items: center; +} + +.club-photo { + position: relative; + margin: 0; + border-radius: var(--radius-card); + overflow: hidden; + border: 1px solid var(--contour); + box-shadow: 0 0 40px rgba(157, 78, 221, 0.22); +} + +.club-photo img { + width: 100%; + height: auto; + aspect-ratio: 3 / 4; + object-fit: cover; + filter: grayscale(1) contrast(1.15) brightness(0.62); +} + +/* Night tint over the photo: purple sky fading to an ember-orange ground. */ +.club-photo::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(76, 29, 149, 0.7) 0%, rgba(40, 14, 70, 0.35) 55%, rgba(255, 122, 26, 0.38) 100%); + mix-blend-mode: color; + pointer-events: none; +} + +.club-text h2 { + font-size: clamp(1.6rem, 2.6vw, 2.1rem); + font-weight: 800; +} + +.club-about { + margin-top: 14px; + color: var(--ink-soft); + max-width: 56ch; +} + +.club-meet { + margin-top: 24px; +} + +.club-meet h3 { + font-family: var(--font-mono); + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--ink-faint); +} + +.meet-point { + margin-top: 8px; + color: var(--ink-soft); +} + +.club-nights { + list-style: none; + margin: 14px 0 0; + padding: 0; + display: grid; + gap: 0; +} + +.club-nights li { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + align-items: baseline; + padding: 10px 0; + border-top: 1px solid var(--hairline); +} + +.night-day { + font-size: 0.9rem; + min-width: 150px; +} + +.night-detail { + color: var(--ink-soft); + font-size: 0.95rem; +} + +.club-contact { + margin-top: 26px; +} + +/* ------------------------------------------------------------------ */ +/* Buttons */ +/* ------------------------------------------------------------------ */ + +.button { + display: inline-block; + background: var(--accent); + color: var(--on-accent); + font-weight: 700; + font-size: 0.98rem; + text-decoration: none; + padding: 11px 22px; + border-radius: var(--radius-small); + transition: background 0.15s ease; +} + +.button:hover { + background: var(--accent-strong); + color: var(--on-accent); + box-shadow: 0 0 18px rgba(255, 122, 26, 0.35); +} + +.button-secondary { + background: transparent; + color: var(--accent-deep); + border: 1.5px solid var(--accent); +} + +.button-secondary:hover { + background: var(--accent-wash); + color: var(--accent-deep); +} + +/* ------------------------------------------------------------------ */ +/* FAQ */ +/* ------------------------------------------------------------------ */ + +.faq { + max-width: var(--content-width); + margin: 0 auto; + padding: 60px 24px 72px; + display: grid; + grid-template-columns: 1fr 2fr; + gap: 40px; +} + +.faq-head h2 { + font-size: clamp(1.5rem, 2.4vw, 1.9rem); + font-weight: 800; + max-width: 14ch; +} + +.faq-list details { + border-bottom: 1px solid var(--hairline); +} + +.faq-list details:first-of-type { + border-top: 1px solid var(--hairline); +} + +.faq-list summary { + list-style: none; + cursor: pointer; + padding: 16px 34px 16px 2px; + font-weight: 700; + font-size: 1.02rem; + position: relative; +} + +.faq-list summary::-webkit-details-marker { + display: none; +} + +.faq-list summary::after { + content: '+'; + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + font-size: 1.1rem; + color: var(--purple-soft); +} + +.faq-list details[open] summary::after { + content: '−'; +} + +.faq-list details p { + padding: 0 2px 18px; + color: var(--ink-soft); + max-width: 62ch; +} + +/* ------------------------------------------------------------------ */ +/* Footer */ +/* ------------------------------------------------------------------ */ + +.site-footer { + background: var(--night-deep); + border-top: 1px solid var(--contour-strong); + color: var(--ink-soft); +} + +.footer-inner { + max-width: var(--content-width); + margin: 0 auto; + padding: 40px 24px 48px; + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 32px; +} + +.footer-name { + font-weight: 700; + color: var(--ink); +} + +.footer-tagline { + margin-top: 6px; + font-size: 0.92rem; + color: var(--ink-faint); + max-width: 40ch; +} + +.footer-note { + font-size: 0.88rem; + color: var(--ink-faint); + max-width: 62ch; +} + +.footer-contact { + margin-top: 12px; + font-size: 0.92rem; +} + +.footer-contact a { + color: var(--accent-deep); +} + +.footer-contact a:hover { + color: var(--ink); +} + +/* ------------------------------------------------------------------ */ +/* Route detail page */ +/* ------------------------------------------------------------------ */ + +.route-page { + max-width: var(--narrow-width); + margin: 0 auto; + padding: 26px 24px 70px; +} + +.breadcrumb { + margin-bottom: 26px; +} + +.breadcrumb a { + text-decoration: none; + font-weight: 600; + font-size: 0.92rem; + color: var(--ink-soft); +} + +.breadcrumb a:hover { + color: var(--accent-deep); +} + +.route-head h1 { + font-size: clamp(1.9rem, 4vw, 2.6rem); + font-weight: 800; +} + +.route-start { + margin: 10px 0 22px; + color: var(--ink-soft); +} + +.route-head .stat-row { + grid-template-columns: repeat(4, auto); + justify-content: start; + gap: 12px 44px; + border-top: 1px solid var(--hairline); + border-bottom: 1px solid var(--hairline); + padding: 16px 2px; +} + +.route-actions { + margin-top: 22px; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.gpx-note { + font-size: 0.78rem; + font-weight: 500; + color: var(--ink-faint); +} + +/* Elevation profile chart */ + +.profile-section { + margin-top: 46px; +} + +.profile-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.profile-head h2 { + font-size: 1.4rem; + font-weight: 800; +} + +.profile-peak { + font-size: 0.9rem; + color: var(--ink-soft); +} + +.profile-peak .mono { + color: var(--ink); +} + +.profile-figure { + margin: 16px 0 0; +} + +.profile-chart { + position: relative; + background: var(--paper-raised); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + padding: 10px 6px 4px; + overflow-x: auto; +} + +.profile-svg { + display: block; + width: 100%; + min-width: 560px; + height: auto; +} + +.chart-grid line { + stroke: var(--hairline); + stroke-width: 1; +} + +.chart-area { + fill: var(--accent-wash); +} + +.chart-line { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.axis-line, +.axis-tick { + stroke: var(--contour-strong); + stroke-width: 1; +} + +.axis-text { + fill: var(--ink-soft); + font-family: var(--font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.chart-peak circle { + fill: var(--accent); + stroke: var(--paper-raised); + stroke-width: 2; +} + +.chart-peak text { + fill: var(--ink); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; +} + +.chart-cursor line { + stroke: var(--ink-faint); + stroke-width: 1; +} + +.chart-cursor circle { + fill: var(--accent); + stroke: var(--paper-raised); + stroke-width: 2; +} + +.chart-tooltip { + position: absolute; + transform: translate(-50%, calc(-100% - 14px)); + background: var(--paper-tint); + color: var(--ink); + border: 1px solid var(--contour); + border-radius: var(--radius-small); + padding: 7px 11px; + pointer-events: none; + white-space: nowrap; + display: grid; + gap: 1px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55); +} + +.chart-tooltip-value { + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.95rem; +} + +.chart-tooltip-label { + font-size: 0.75rem; + color: var(--ink-faint); +} + +.profile-caption { + margin-top: 10px; + font-size: 0.85rem; + color: var(--ink-faint); +} + +.profile-data { + margin-top: 18px; + border: 1px solid var(--hairline); + border-radius: var(--radius-small); + background: var(--paper-raised); +} + +.profile-data summary { + list-style: none; + cursor: pointer; + padding: 12px 16px; + font-weight: 600; + font-size: 0.92rem; + color: var(--ink-soft); +} + +.profile-data summary::-webkit-details-marker { + display: none; +} + +.profile-data summary::before { + content: '+ '; + font-family: var(--font-mono); + color: var(--accent); +} + +.profile-data[open] summary::before { + content: '− '; +} + +.profile-data table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 0.86rem; + font-variant-numeric: tabular-nums; +} + +.profile-data th, +.profile-data td { + text-align: left; + padding: 7px 16px; + border-top: 1px solid var(--hairline); +} + +.profile-data th { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-faint); +} + +/* Route notes */ + +.route-notes { + margin-top: 46px; +} + +.route-notes h2 { + font-size: 1.4rem; + font-weight: 800; +} + +.route-description { + margin-top: 12px; + font-size: 1.04rem; + color: var(--ink-soft); + max-width: 68ch; +} + +.judgment-note { + margin-top: 22px; + border-left: 3px solid var(--accent); + background: var(--accent-wash); + border-radius: 0 var(--radius-small) var(--radius-small) 0; + padding: 14px 18px; +} + +.judgment-note p { + font-size: 0.92rem; + color: var(--ink-soft); + max-width: 68ch; +} + +/* More routes */ + +.route-more { + margin-top: 46px; +} + +.route-more h2 { + font-size: 1.4rem; + font-weight: 800; +} + +.more-list { + list-style: none; + margin: 14px 0 0; + padding: 0; +} + +.more-list li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px 18px; + flex-wrap: wrap; + padding: 12px 2px; + border-bottom: 1px solid var(--hairline); +} + +.more-list li:first-child { + border-top: 1px solid var(--hairline); +} + +.more-list a { + font-weight: 700; + text-decoration: none; + color: var(--ink); +} + +.more-list a:hover { + color: var(--accent-deep); + text-decoration: underline; + text-decoration-color: var(--accent); +} + +.more-meta { + font-size: 0.8rem; + font-weight: 500; + color: var(--ink-faint); +} + +/* ------------------------------------------------------------------ */ +/* Not found */ +/* ------------------------------------------------------------------ */ + +.not-found { + max-width: 640px; + margin: 0 auto; + padding: 70px 24px 90px; +} + +.not-found h1 { + font-size: clamp(1.8rem, 4vw, 2.4rem); + font-weight: 800; +} + +.not-found > p { + margin-top: 14px; + color: var(--ink-soft); +} + +.not-found .more-list { + margin-top: 22px; +} + +.not-found .button { + margin-top: 26px; +} + +/* ------------------------------------------------------------------ */ +/* Display headings */ +/* ------------------------------------------------------------------ */ +.masthead h1, +.club-text h2, +.faq-head h2, +.route-head h1, +.profile-head h2, +.route-notes h2, +.route-more h2, +.not-found h1 { + font-family: var(--font-display); + font-weight: 400; + letter-spacing: 0; + line-height: 1.08; +} + +/* ------------------------------------------------------------------ */ +/* Responsive */ +/* ------------------------------------------------------------------ */ + +@media (max-width: 900px) { + .club-inner { + grid-template-columns: 1fr; + gap: 30px; + padding-block: 44px; + } + + .club-photo img { + aspect-ratio: 3 / 2; + } + + .faq { + grid-template-columns: 1fr; + gap: 22px; + padding-block: 48px 56px; + } + + .footer-inner { + grid-template-columns: 1fr; + gap: 22px; + } +} + +@media (max-width: 720px) { + .masthead-inner { + padding: 38px 16px 34px; + } + + .masthead::before { + top: 18px; + right: 16px; + width: 76px; + height: 76px; + opacity: 0.55; + } + + .site-header, + .library, + .club-inner, + .faq, + .footer-inner, + .route-page, + .not-found { + padding-left: 16px; + padding-right: 16px; + } + + .filter-group { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .route-head .stat-row { + grid-template-columns: repeat(2, 1fr); + gap: 12px 18px; + } + + .site-nav { + gap: 16px; + } + + .axis-text { + font-size: 15px; + } + + .chart-peak text { + font-size: 16px; + } +} + +@media (max-width: 360px) { + .brand-name { + font-size: 0.98rem; + } + + .chip { + padding: 6px 11px; + font-size: 0.86rem; + } + + .stat-row { + gap: 10px 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} diff --git a/templates/route-library-halloween/src/data/routes.json b/templates/route-library-halloween/src/data/routes.json new file mode 100644 index 0000000..8110458 --- /dev/null +++ b/templates/route-library-halloween/src/data/routes.json @@ -0,0 +1,133 @@ +[ + { + "name": "Will-o'-the-Wisp Loop", + "slug": "will-o-the-wisp-loop", + "distanceKm": 6.8, + "elevationGainM": 95, + "surface": "Riverside path", + "difficulty": "Easy", + "startPoint": "Rothay Park gates, Ambleside", + "description": "The club's gentle night out: along the River Rothay, around the Under Loughrigg lane and back over the stone footbridge, with lights flickering on the water the whole way. Barely a climb worth naming, though the two short rises past Fox How will still make you jump. Runnable end to end in road shoes, and the route we use for the All Hallows' Run.", + "profile": [ + { "km": 0, "m": 62 }, + { "km": 0.7, "m": 78 }, + { "km": 1.4, "m": 70 }, + { "km": 2.2, "m": 92 }, + { "km": 3, "m": 84 }, + { "km": 3.6, "m": 101 }, + { "km": 4.3, "m": 88 }, + { "km": 5, "m": 106 }, + { "km": 5.7, "m": 79 }, + { "km": 6.3, "m": 101 }, + { "km": 6.8, "m": 62 } + ] + }, + { + "name": "Lantern Terrace Circuit", + "slug": "lantern-terrace-circuit", + "distanceKm": 9.4, + "elevationGainM": 310, + "surface": "Mixed trail", + "difficulty": "Moderate", + "startPoint": "White Moss car park, between Rydal and Grasmere", + "description": "A proper little circuit with one honest pull up to Loughrigg's summit cairn, then the terrace path with the lights of Grasmere laid out below. The drop past Rydal Cave is steep and loose, and the cave has a habit of echoing back footsteps that are not yours. Trail shoes and a strong headtorch after rain.", + "profile": [ + { "km": 0, "m": 62 }, + { "km": 0.9, "m": 96 }, + { "km": 1.8, "m": 148 }, + { "km": 2.6, "m": 210 }, + { "km": 3.3, "m": 335 }, + { "km": 4.1, "m": 246 }, + { "km": 5, "m": 178 }, + { "km": 5.8, "m": 192 }, + { "km": 6.6, "m": 132 }, + { "km": 7.4, "m": 145 }, + { "km": 8.2, "m": 96 }, + { "km": 8.8, "m": 106 }, + { "km": 9.4, "m": 62 } + ] + }, + { + "name": "Witching Hour Wansfell", + "slug": "witching-hour-wansfell", + "distanceKm": 11.6, + "elevationGainM": 480, + "surface": "Fell paths", + "difficulty": "Moderate", + "startPoint": "Market Cross, Ambleside", + "description": "Up the stone steps out of town and onto Wansfell Pike the direct way — half an hour of proper climbing in the dark — then over the top and down through Troutbeck's lanes and Skelghyll Woods, where the owls are louder than the runners. On a clear night the moon lays a silver road down the length of Windermere.", + "profile": [ + { "km": 0, "m": 64 }, + { "km": 1, "m": 152 }, + { "km": 2.1, "m": 268 }, + { "km": 3, "m": 396 }, + { "km": 3.8, "m": 482 }, + { "km": 4.7, "m": 401 }, + { "km": 5.6, "m": 322 }, + { "km": 6.5, "m": 238 }, + { "km": 7.4, "m": 176 }, + { "km": 8.3, "m": 214 }, + { "km": 9.2, "m": 148 }, + { "km": 10.1, "m": 172 }, + { "km": 10.9, "m": 110 }, + { "km": 11.6, "m": 64 } + ] + }, + { + "name": "Headless Horseshoe", + "slug": "headless-horseshoe", + "distanceKm": 17.5, + "elevationGainM": 959, + "surface": "Fell and ridge", + "difficulty": "Hard", + "startPoint": "Rydal Road bridge, Ambleside", + "description": "The classic horseshoe over eight named tops: up by Nab Scar and Heron Pike, along the ridge to Fairfield's broad summit plateau, then back over Hart Crag, Dove Crag and the Pikes. After dark it is easy to lose your head for navigation — the plateau is featureless in mist. Full fell kit, a spare torch and a running partner, no exceptions.", + "profile": [ + { "km": 0, "m": 64 }, + { "km": 1.2, "m": 168 }, + { "km": 2.5, "m": 296 }, + { "km": 3.7, "m": 424 }, + { "km": 4.9, "m": 556 }, + { "km": 5.8, "m": 612 }, + { "km": 6.4, "m": 598 }, + { "km": 7.3, "m": 766 }, + { "km": 8, "m": 736 }, + { "km": 8.8, "m": 873 }, + { "km": 9.7, "m": 748 }, + { "km": 10.5, "m": 822 }, + { "km": 11.2, "m": 760 }, + { "km": 11.9, "m": 792 }, + { "km": 12.8, "m": 656 }, + { "km": 13.9, "m": 508 }, + { "km": 15, "m": 342 }, + { "km": 16.2, "m": 178 }, + { "km": 17.5, "m": 64 } + ] + }, + { + "name": "All Hallows' Long Round", + "slug": "all-hallows-long-round", + "distanceKm": 28.7, + "elevationGainM": 232, + "surface": "Gravel and bridleway", + "difficulty": "Moderate", + "startPoint": "Elterwater village green", + "description": "Our long steady one: valley tracks and bridleways linking Elterwater, Great Langdale, Little Langdale and Skelwith. Nothing steep and nowhere technical — just honest distance, with the Langdale Pikes looming black against the stars the whole way round. Two sensible exit points if the night stops being fun.", + "profile": [ + { "km": 0, "m": 70 }, + { "km": 2, "m": 98 }, + { "km": 4, "m": 124 }, + { "km": 6, "m": 90 }, + { "km": 8, "m": 128 }, + { "km": 10.5, "m": 102 }, + { "km": 13, "m": 154 }, + { "km": 15.5, "m": 114 }, + { "km": 18, "m": 138 }, + { "km": 20.5, "m": 94 }, + { "km": 23, "m": 132 }, + { "km": 25.5, "m": 98 }, + { "km": 27, "m": 124 }, + { "km": 28.7, "m": 70 } + ] + } +] diff --git a/templates/route-library-halloween/src/data/site.json b/templates/route-library-halloween/src/data/site.json new file mode 100644 index 0000000..0706404 --- /dev/null +++ b/templates/route-library-halloween/src/data/site.json @@ -0,0 +1,72 @@ +{ + "clubName": "Hollowfell Night Harriers", + "area": "Ambleside, Lake District", + "tagline": "A night running club's route book — lantern-tested, argued over by candlelight.", + "heading": "Routes for the long, dark nights", + "intro": "From the night the clocks go back until the light evenings of spring, we run the fells by headtorch. Every route in this book has been run after dark on club nights. Filter by how far and how much climb you dare, study the profile before the shadows do, and take the GPX with you.", + "about": "We are forty-odd runners who think the fells are at their best by moonlight. The club has kept a route book since 1978, written up by lantern after every All Hallows' Run; this is the same book, typed up, measured properly and shared. Visitors are welcome to borrow a route or join a night run — bring a headtorch, and a spare.", + "founded": "1978", + "meetPoint": { + "name": "Market Cross, Ambleside", + "detail": "We gather ten minutes before each run for a headtorch check. No light, no run." + }, + "clubNights": [ + { + "day": "Tuesday", + "time": "18:30", + "detail": "Lantern hill reps — short, steep and lit by forty headtorches." + }, + { + "day": "Friday", + "time": "19:00", + "detail": "The moonlit long run — nobody gets left in the dark." + }, + { + "day": "31 October", + "time": "19:00", + "detail": "The All Hallows' Run — fancy dress optional, headtorch mandatory." + } + ], + "librarian": { + "name": "Morwenna Ashdown", + "role": "Keeper of the route book", + "email": "routes@hollowfellnight.example" + }, + "disclaimer": "Routes are documented by club members from their own night runs. Paths that are obvious by day vanish after dark, becks flood and fell weather turns by the hour — check conditions before you set off, carry a headtorch with a spare battery, map and compass, and run at your own judgment.", + "navigation": [ + { + "label": "Routes", + "href": "/" + }, + { + "label": "Night runs", + "href": "/#club" + }, + { + "label": "FAQ", + "href": "/#faq" + } + ], + "faqs": [ + { + "question": "Do I need to be a member to run these routes?", + "answer": "No. The whole book is public and every route follows public rights of way or open access land. If you enjoy them, come along to a club night — your first three runs with us are free, and we will lend you a headtorch for the first one." + }, + { + "question": "How bright does my headtorch need to be?", + "answer": "For the riverside and valley routes, 200 lumens is plenty. Anything graded Moderate or Hard wants 400 lumens or more and a spare battery or second torch — the fells are far darker than town, and mist swallows light." + }, + { + "question": "What should I carry on the fell routes at night?", + "answer": "Waterproof top and bottoms, hat, gloves, map, compass, whistle, food and a fully charged headtorch plus a spare. On the Headless Horseshoe we also ask you to run in pairs — nobody crosses Fairfield's plateau alone after dark." + }, + { + "question": "Is the All Hallows' Run in fancy dress?", + "answer": "Optional, but encouraged. Capes and masks are out on safety grounds, so expect glow-in-the-dark skeleton tops and pumpkin hats instead. We run the gentle Will-o'-the-Wisp Loop, so nobody needs to be quick — just home before the witching hour." + }, + { + "question": "When and where does the club meet?", + "answer": "Tuesdays at 18:30 for lantern hill reps and Fridays at 19:00 for the moonlit long run, both from the Market Cross in Ambleside. No need to book — just turn up ten minutes early with your torch charged." + } + ] +} diff --git a/templates/route-library-halloween/src/server.ts b/templates/route-library-halloween/src/server.ts new file mode 100644 index 0000000..f05ebe8 --- /dev/null +++ b/templates/route-library-halloween/src/server.ts @@ -0,0 +1,187 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import express from 'express'; +import type { NextFunction, Request, Response } from 'express'; +import type { RouteRecord } from './types/content.js'; +import { loadRoutes, loadSiteContent } from './utils/content.js'; +import { buildProfileChart, buildSparkline } from './utils/elevation.js'; +import { + CLIMB_FILTERS, + DISTANCE_FILTERS, + buildFilterHref, + climbBucket, + distanceBucket, + filterRoutes, + parseClimbFilter, + parseDistanceFilter, + type ClimbFilterId, + type DistanceFilterId, +} from './utils/filters.js'; +import { formatKm, formatMetres } from './utils/format.js'; +import { buildFaqJsonLd } from './utils/schema.js'; + +/** + * The compiled server runs from `dist/`, the dev server from `src/`. + * Both sit one level below the project root, so views, data and static + * assets are resolved from there and the build needs no copy step. + */ +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const site = loadSiteContent(rootDir); +const routes = loadRoutes(rootDir); +const routeBySlug = new Map(routes.map((route) => [route.slug, route])); + +const app = express(); +app.disable('x-powered-by'); +app.set('view engine', 'ejs'); +app.set('views', path.join(rootDir, 'src', 'views')); + +// Shared locals available to every view. +app.locals.site = site; +app.locals.faqJsonLd = buildFaqJsonLd(site.faqs); + +app.use( + express.static(path.join(rootDir, 'public'), { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.gpx')) { + res.setHeader('Content-Type', 'application/gpx+xml'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${path.basename(filePath)}"`, + ); + } + }, + }), +); + +function toRouteCard(route: RouteRecord) { + return { + name: route.name, + slug: route.slug, + surface: route.surface, + difficulty: route.difficulty, + startPoint: route.startPoint, + description: route.description, + distanceLabel: formatKm(route.distanceKm), + climbLabel: formatMetres(route.elevationGainM), + href: `/routes/${route.slug}`, + gpxHref: `/gpx/${route.slug}.gpx`, + distanceBucket: distanceBucket(route), + climbBucket: climbBucket(route), + sparkline: buildSparkline(route.profile), + }; +} + +function toFilterChips( + options: ReadonlyArray<{ id: Id; label: string }>, + group: 'distance' | 'climb', + distance: DistanceFilterId, + climb: ClimbFilterId, +) { + return options.map((option) => ({ + id: option.id, + label: option.label, + active: group === 'distance' ? option.id === distance : option.id === climb, + href: + group === 'distance' + ? buildFilterHref(option.id as DistanceFilterId, climb) + : buildFilterHref(distance, option.id as ClimbFilterId), + })); +} + +app.get('/', (req, res) => { + const distance = parseDistanceFilter(req.query.distance); + const climb = parseClimbFilter(req.query.climb); + const matchedSlugs = new Set( + filterRoutes(routes, distance, climb).map((route) => route.slug), + ); + + const totalKm = routes.reduce((sum, route) => sum + route.distanceKm, 0); + const totalClimb = routes.reduce( + (sum, route) => sum + route.elevationGainM, + 0, + ); + + res.render('index', { + pageTitle: `${site.clubName} — Night Route Book`, + metaDescription: site.intro, + pageScripts: ['/js/filters.js'], + distanceChips: toFilterChips(DISTANCE_FILTERS, 'distance', distance, climb), + climbChips: toFilterChips(CLIMB_FILTERS, 'climb', distance, climb), + cards: routes.map((route) => ({ + ...toRouteCard(route), + matched: matchedSlugs.has(route.slug), + })), + matchedCount: matchedSlugs.size, + totals: { + count: routes.length, + distanceLabel: formatKm(totalKm), + climbLabel: formatMetres(totalClimb), + }, + }); +}); + +app.get('/routes/:slug', (req, res) => { + const route = routeBySlug.get(req.params.slug); + if (!route) { + renderNotFound(res); + return; + } + + res.render('route', { + pageTitle: `${route.name} — ${site.clubName}`, + metaDescription: route.description, + pageScripts: ['/js/profile-hover.js'], + route, + chart: buildProfileChart(route.profile), + distanceLabel: formatKm(route.distanceKm), + climbLabel: formatMetres(route.elevationGainM), + gpxHref: `/gpx/${route.slug}.gpx`, + gpxFileName: `${route.slug}.gpx`, + otherRoutes: routes + .filter((other) => other.slug !== route.slug) + .map((other) => ({ + name: other.name, + href: `/routes/${other.slug}`, + distanceLabel: formatKm(other.distanceKm), + climbLabel: formatMetres(other.elevationGainM), + difficulty: other.difficulty, + })), + }); +}); + +function renderNotFound(res: Response): void { + res.status(404).render('not-found', { + pageTitle: `Lost in the mist — ${site.clubName}`, + metaDescription: `That page is not in the ${site.clubName} night route book.`, + pageScripts: [], + allRoutes: routes.map((route) => ({ + name: route.name, + href: `/routes/${route.slug}`, + distanceLabel: formatKm(route.distanceKm), + })), + }); +} + +app.use((req, res) => { + renderNotFound(res); +}); + +app.use((err: Error, _req: Request, res: Response, next: NextFunction) => { + if (res.headersSent) { + next(err); + return; + } + console.error(err); + res + .status(500) + .type('text/plain') + .send('Something went wrong on our side. Please try again.'); +}); + +const parsedPort = Number.parseInt(process.env.PORT ?? '', 10); +const port = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 4361; + +app.listen(port, () => { + console.log(`${site.clubName} night route book running at http://localhost:${port}`); +}); diff --git a/templates/route-library-halloween/src/types/content.ts b/templates/route-library-halloween/src/types/content.ts new file mode 100644 index 0000000..e33c312 --- /dev/null +++ b/templates/route-library-halloween/src/types/content.ts @@ -0,0 +1,71 @@ +/** Shapes for the committed JSON content in `src/data/`. */ + +export interface SiteNavLink { + label: string; + href: string; +} + +export interface SiteFaq { + question: string; + answer: string; +} + +export interface ClubNight { + day: string; + time: string; + detail: string; +} + +export interface MeetPoint { + name: string; + detail: string; +} + +export interface RouteLibrarian { + name: string; + role: string; + email: string; +} + +export interface SiteContent { + clubName: string; + area: string; + tagline: string; + heading: string; + intro: string; + about: string; + founded: string; + meetPoint: MeetPoint; + clubNights: ClubNight[]; + librarian: RouteLibrarian; + disclaimer: string; + navigation: SiteNavLink[]; + faqs: SiteFaq[]; +} + +/** One sampled point of an elevation profile. */ +export interface ProfilePoint { + /** Distance from the start, in kilometres. */ + km: number; + /** Elevation above sea level, in metres. */ + m: number; +} + +export type RouteDifficulty = 'Easy' | 'Moderate' | 'Hard'; + +export interface RouteRecord { + name: string; + slug: string; + distanceKm: number; + elevationGainM: number; + surface: string; + difficulty: RouteDifficulty; + startPoint: string; + description: string; + /** + * Elevation profile from start to finish. The first point sits at km 0, + * the last point at `distanceKm`, and the summed uphill differences + * should match `elevationGainM`. + */ + profile: ProfilePoint[]; +} diff --git a/templates/route-library-halloween/src/utils/content.ts b/templates/route-library-halloween/src/utils/content.ts new file mode 100644 index 0000000..de22e44 --- /dev/null +++ b/templates/route-library-halloween/src/utils/content.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { RouteRecord, SiteContent } from '../types/content.js'; + +function readJson(filePath: string): T { + return JSON.parse(readFileSync(filePath, 'utf8')) as T; +} + +export function loadSiteContent(rootDir: string): SiteContent { + return readJson(path.join(rootDir, 'src', 'data', 'site.json')); +} + +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Loads the route book and fails fast with a clear message when an edit to + * `src/data/routes.json` breaks an assumption the app relies on. + */ +export function loadRoutes(rootDir: string): RouteRecord[] { + const routes = readJson( + path.join(rootDir, 'src', 'data', 'routes.json'), + ); + const seenSlugs = new Set(); + + for (const route of routes) { + if (!SLUG_PATTERN.test(route.slug)) { + throw new Error( + `Route "${route.name}" has an invalid slug "${route.slug}". Use lowercase letters, numbers and hyphens.`, + ); + } + if (seenSlugs.has(route.slug)) { + throw new Error(`Duplicate route slug "${route.slug}" in routes.json.`); + } + seenSlugs.add(route.slug); + + if (route.profile.length < 2) { + throw new Error( + `Route "${route.name}" needs at least two elevation profile points.`, + ); + } + } + + return routes; +} diff --git a/templates/route-library-halloween/src/utils/elevation.ts b/templates/route-library-halloween/src/utils/elevation.ts new file mode 100644 index 0000000..37d1c02 --- /dev/null +++ b/templates/route-library-halloween/src/utils/elevation.ts @@ -0,0 +1,208 @@ +import type { ProfilePoint } from '../types/content.js'; +import { formatKm, formatMetres } from './format.js'; + +export interface AxisTick { + pos: number; + label: string; +} + +export interface ChartPoint { + x: number; + y: number; + km: number; + m: number; +} + +export interface ProfileChart { + width: number; + height: number; + plot: { left: number; right: number; top: number; bottom: number }; + areaPath: string; + linePath: string; + xTicks: AxisTick[]; + yTicks: AxisTick[]; + peak: ChartPoint; + peakLabel: string; + peakLabelX: number; + points: ChartPoint[]; + ariaLabel: string; +} + +export interface SparklineChart { + width: number; + height: number; + areaPath: string; + linePath: string; +} + +const CHART_WIDTH = 760; +const CHART_HEIGHT = 300; +const CHART_PADDING = { left: 56, right: 22, top: 34, bottom: 40 }; + +const SPARK_WIDTH = 320; +const SPARK_HEIGHT = 64; +const SPARK_PADDING = 3; + +/** + * Minimum elevation span (m) a chart is drawn against, so a nearly flat + * route is not stretched into looking mountainous. + */ +const MIN_ELEVATION_SPAN = 160; + +const X_TICK_STEPS = [1, 2, 5, 10]; +const Y_TICK_STEPS = [25, 50, 100, 200, 250, 500]; +const MAX_X_TICKS = 8; +const MAX_Y_TICKS = 5; + +const round1 = (value: number): number => Math.round(value * 10) / 10; + +function pickStep(range: number, steps: number[], maxTicks: number): number { + for (const step of steps) { + if (range / step <= maxTicks) { + return step; + } + } + return steps[steps.length - 1] ?? 1; +} + +interface ElevationDomain { + low: number; + high: number; + step: number; +} + +function elevationDomain(profile: ProfilePoint[]): ElevationDomain { + const elevations = profile.map((point) => point.m); + const min = Math.min(...elevations); + const max = Math.max(...elevations); + const span = Math.max(max - min, MIN_ELEVATION_SPAN); + const step = pickStep(span, Y_TICK_STEPS, MAX_Y_TICKS); + + const paddedLow = Math.max(0, min - (span - (max - min)) / 2); + const low = Math.floor(paddedLow / step) * step; + const high = Math.ceil(Math.max(paddedLow + span, max) / step) * step; + return { low, high, step }; +} + +function buildPaths(points: ChartPoint[], baselineY: number): { + linePath: string; + areaPath: string; +} { + const segments = points.map( + (point, index) => `${index === 0 ? 'M' : 'L'}${point.x} ${point.y}`, + ); + const linePath = segments.join(' '); + const first = points[0]; + const last = points[points.length - 1]; + if (!first || !last) { + return { linePath: '', areaPath: '' }; + } + const areaPath = `${linePath} L${last.x} ${baselineY} L${first.x} ${baselineY} Z`; + return { linePath, areaPath }; +} + +/** Builds the geometry for the full elevation profile chart on route pages. */ +export function buildProfileChart(profile: ProfilePoint[]): ProfileChart { + if (profile.length < 2) { + throw new Error('An elevation profile needs at least two points.'); + } + + const plot = { + left: CHART_PADDING.left, + right: CHART_WIDTH - CHART_PADDING.right, + top: CHART_PADDING.top, + bottom: CHART_HEIGHT - CHART_PADDING.bottom, + }; + const plotWidth = plot.right - plot.left; + const plotHeight = plot.bottom - plot.top; + + const lastPoint = profile[profile.length - 1]; + const maxKm = lastPoint ? lastPoint.km : 0; + const { low, high, step } = elevationDomain(profile); + + const toX = (km: number): number => round1(plot.left + (km / maxKm) * plotWidth); + const toY = (m: number): number => + round1(plot.bottom - ((m - low) / (high - low)) * plotHeight); + + const points: ChartPoint[] = profile.map((point) => ({ + x: toX(point.km), + y: toY(point.m), + km: point.km, + m: point.m, + })); + + const { linePath, areaPath } = buildPaths(points, plot.bottom); + + const xStep = pickStep(maxKm, X_TICK_STEPS, MAX_X_TICKS); + const xTicks: AxisTick[] = []; + for (let km = 0; km <= maxKm; km += xStep) { + const isLast = km + xStep > maxKm; + xTicks.push({ pos: toX(km), label: isLast ? `${km} km` : `${km}` }); + } + + const yTicks: AxisTick[] = []; + for (let m = low; m <= high; m += step) { + yTicks.push({ pos: toY(m), label: m === low ? `${m} m` : `${m}` }); + } + + let peak = points[0] as ChartPoint; + for (const point of points) { + if (point.m > peak.m) { + peak = point; + } + } + const peakLabelX = Math.min(Math.max(peak.x, plot.left + 34), plot.right - 34); + + const minElevation = Math.min(...profile.map((point) => point.m)); + const maxElevation = Math.max(...profile.map((point) => point.m)); + const ariaLabel = + `Elevation profile over ${formatKm(maxKm)}: ` + + `from ${formatMetres(minElevation)} to ${formatMetres(maxElevation)} above sea level. ` + + 'Full figures in the profile data table below.'; + + return { + width: CHART_WIDTH, + height: CHART_HEIGHT, + plot, + areaPath, + linePath, + xTicks, + yTicks, + peak, + peakLabel: formatMetres(peak.m), + peakLabelX: round1(peakLabelX), + points, + ariaLabel, + }; +} + +/** Builds the small decorative profile drawn on every route card. */ +export function buildSparkline(profile: ProfilePoint[]): SparklineChart { + if (profile.length < 2) { + throw new Error('An elevation profile needs at least two points.'); + } + + const lastPoint = profile[profile.length - 1]; + const maxKm = lastPoint ? lastPoint.km : 0; + const { low, high } = elevationDomain(profile); + + const plotWidth = SPARK_WIDTH - SPARK_PADDING * 2; + const plotHeight = SPARK_HEIGHT - SPARK_PADDING * 2; + const baselineY = SPARK_HEIGHT - SPARK_PADDING; + + const points: ChartPoint[] = profile.map((point) => ({ + x: round1(SPARK_PADDING + (point.km / maxKm) * plotWidth), + y: round1(baselineY - ((point.m - low) / (high - low)) * plotHeight), + km: point.km, + m: point.m, + })); + + const { linePath, areaPath } = buildPaths(points, baselineY); + + return { + width: SPARK_WIDTH, + height: SPARK_HEIGHT, + areaPath, + linePath, + }; +} diff --git a/templates/route-library-halloween/src/utils/filters.ts b/templates/route-library-halloween/src/utils/filters.ts new file mode 100644 index 0000000..9d3439e --- /dev/null +++ b/templates/route-library-halloween/src/utils/filters.ts @@ -0,0 +1,110 @@ +import type { RouteRecord } from '../types/content.js'; + +export type DistanceFilterId = 'all' | 'short' | 'medium' | 'long'; +export type ClimbFilterId = 'all' | 'gentle' | 'rolling' | 'steep'; + +export interface FilterOption { + id: Id; + label: string; + matches: (route: RouteRecord) => boolean; +} + +/** Distance buckets shown in the filter bar. Tweak thresholds here. */ +export const DISTANCE_FILTERS: ReadonlyArray> = [ + { id: 'all', label: 'All distances', matches: () => true }, + { id: 'short', label: 'Under 10 km', matches: (route) => route.distanceKm < 10 }, + { + id: 'medium', + label: '10–25 km', + matches: (route) => route.distanceKm >= 10 && route.distanceKm <= 25, + }, + { id: 'long', label: 'Over 25 km', matches: (route) => route.distanceKm > 25 }, +]; + +/** Climb buckets shown in the filter bar. Tweak thresholds here. */ +export const CLIMB_FILTERS: ReadonlyArray> = [ + { id: 'all', label: 'Any climb', matches: () => true }, + { + id: 'gentle', + label: 'Under 300 m', + matches: (route) => route.elevationGainM < 300, + }, + { + id: 'rolling', + label: '300–700 m', + matches: (route) => route.elevationGainM >= 300 && route.elevationGainM <= 700, + }, + { + id: 'steep', + label: 'Over 700 m', + matches: (route) => route.elevationGainM > 700, + }, +]; + +function parseFilter( + options: ReadonlyArray>, + value: unknown, + fallback: Id, +): Id { + if (typeof value !== 'string') { + return fallback; + } + const match = options.find((option) => option.id === value); + return match ? match.id : fallback; +} + +/** Maps a `?distance=` query value to a known filter id, defaulting to `all`. */ +export function parseDistanceFilter(value: unknown): DistanceFilterId { + return parseFilter(DISTANCE_FILTERS, value, 'all'); +} + +/** Maps a `?climb=` query value to a known filter id, defaulting to `all`. */ +export function parseClimbFilter(value: unknown): ClimbFilterId { + return parseFilter(CLIMB_FILTERS, value, 'all'); +} + +export function filterRoutes( + routes: RouteRecord[], + distance: DistanceFilterId, + climb: ClimbFilterId, +): RouteRecord[] { + const distanceOption = DISTANCE_FILTERS.find((option) => option.id === distance); + const climbOption = CLIMB_FILTERS.find((option) => option.id === climb); + return routes.filter( + (route) => + (distanceOption?.matches(route) ?? true) && + (climbOption?.matches(route) ?? true), + ); +} + +/** Builds the shareable URL for a filter combination, omitting defaults. */ +export function buildFilterHref( + distance: DistanceFilterId, + climb: ClimbFilterId, +): string { + const params = new URLSearchParams(); + if (distance !== 'all') { + params.set('distance', distance); + } + if (climb !== 'all') { + params.set('climb', climb); + } + const query = params.toString(); + return query ? `/?${query}` : '/'; +} + +/** The single non-`all` distance bucket a route belongs to. */ +export function distanceBucket(route: RouteRecord): DistanceFilterId { + const match = DISTANCE_FILTERS.find( + (option) => option.id !== 'all' && option.matches(route), + ); + return match ? match.id : 'all'; +} + +/** The single non-`all` climb bucket a route belongs to. */ +export function climbBucket(route: RouteRecord): ClimbFilterId { + const match = CLIMB_FILTERS.find( + (option) => option.id !== 'all' && option.matches(route), + ); + return match ? match.id : 'all'; +} diff --git a/templates/route-library-halloween/src/utils/format.ts b/templates/route-library-halloween/src/utils/format.ts new file mode 100644 index 0000000..3a07a4a --- /dev/null +++ b/templates/route-library-halloween/src/utils/format.ts @@ -0,0 +1,16 @@ +const KM_FORMAT = new Intl.NumberFormat('en-GB', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, +}); + +const METRES_FORMAT = new Intl.NumberFormat('en-GB'); + +/** `6.8` → `"6.8 km"` */ +export function formatKm(km: number): string { + return `${KM_FORMAT.format(km)} km`; +} + +/** `959` → `"959 m"`, `1250` → `"1,250 m"` */ +export function formatMetres(metres: number): string { + return `${METRES_FORMAT.format(metres)} m`; +} diff --git a/templates/route-library-halloween/src/utils/schema.ts b/templates/route-library-halloween/src/utils/schema.ts new file mode 100644 index 0000000..e1126b2 --- /dev/null +++ b/templates/route-library-halloween/src/utils/schema.ts @@ -0,0 +1,21 @@ +import type { SiteFaq } from '../types/content.js'; + +/** + * Serializes the FAQ copy from `src/data/site.json` as FAQPage JSON-LD, + * escaped so the string is safe to inline inside a ` + <% }) %> + + diff --git a/templates/route-library-halloween/src/views/partials/stat-row.ejs b/templates/route-library-halloween/src/views/partials/stat-row.ejs new file mode 100644 index 0000000..1c31e7c --- /dev/null +++ b/templates/route-library-halloween/src/views/partials/stat-row.ejs @@ -0,0 +1,18 @@ +
+
+
Distance
+
<%= distanceLabel %>
+
+
+
Climb
+
<%= climbLabel %>
+
+
+
Surface
+
<%= surface %>
+
+
+
Grade
+
<%= difficulty %>
+
+
diff --git a/templates/route-library-halloween/src/views/partials/top.ejs b/templates/route-library-halloween/src/views/partials/top.ejs new file mode 100644 index 0000000..aed50cf --- /dev/null +++ b/templates/route-library-halloween/src/views/partials/top.ejs @@ -0,0 +1,33 @@ + + + + + + <%= pageTitle %> + + + + + + + + + +
diff --git a/templates/route-library-halloween/src/views/route.ejs b/templates/route-library-halloween/src/views/route.ejs new file mode 100644 index 0000000..4b6a304 --- /dev/null +++ b/templates/route-library-halloween/src/views/route.ejs @@ -0,0 +1,111 @@ +<%- include('partials/top') %> + +
+ + +
+

From the night route book

+

<%= route.name %>

+

Starts at <%= route.startPoint %>

+ <%- include('partials/stat-row', { + distanceLabel: distanceLabel, + climbLabel: climbLabel, + surface: route.surface, + difficulty: route.difficulty, + }) %> +
+ Download GPX + <%= gpxFileName %> +
+
+ +
+
+

Elevation profile

+

High point <%= chart.peakLabel %>

+
+
+
+ + + <% chart.yTicks.forEach((tick) => { %> + + <% }) %> + + + + + + <% chart.xTicks.forEach((tick) => { %> + + <%= tick.label %> + <% }) %> + <% chart.yTicks.forEach((tick) => { %> + <%= tick.label %> + <% }) %> + + + + <%= chart.peakLabel %> + + +
+
+ Distance in kilometres across, elevation in metres up. Hover the chart, or open the data table, for exact figures. +
+
+
+ Profile data table + + + + + + + + + + <% route.profile.forEach((point) => { %> + + + + + <% }) %> + +
Elevation by distance for <%= route.name %>
DistanceElevation
<%= point.km %> km<%= point.m %> m
+
+
+ +
+

What lies ahead

+

<%= route.description %>

+ +
+ +
+

More from the night route book

+
    + <% otherRoutes.forEach((other) => { %> +
  • + <%= other.name %> + <%= other.distanceLabel %> · <%= other.climbLabel %> ↑ · <%= other.difficulty %> +
  • + <% }) %> +
+
+
+ +<%- include('partials/bottom') %> diff --git a/templates/route-library-halloween/tsconfig.json b/templates/route-library-halloween/tsconfig.json new file mode 100644 index 0000000..97dd55a --- /dev/null +++ b/templates/route-library-halloween/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}