Compare commits
171 Commits
c99442b54b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
467f9a4e71
|
|||
|
8bd794bccb
|
|||
|
f52d6b4d4b
|
|||
|
9b5cfe5e49
|
|||
|
9fe9d95e36
|
|||
|
cd7912fa8f
|
|||
|
fb54f6907f
|
|||
|
94c8212078
|
|||
|
ac76bfba34
|
|||
|
0f6c50f854
|
|||
|
8a67f5fba8
|
|||
|
b49a299371
|
|||
|
f1c0304b14
|
|||
|
164fdb2916
|
|||
|
ae4adc4023
|
|||
|
17ccfa1b41
|
|||
|
dfc3142eeb
|
|||
|
fdea8416a0
|
|||
|
399d57217a
|
|||
|
0e70e30738
|
|||
|
1918d240db
|
|||
|
316a340494
|
|||
|
59b4630746
|
|||
|
8459327717
|
|||
|
a4c2efe4f3
|
|||
|
cb16b25444
|
|||
|
583d1b724c
|
|||
|
3fc539e6fb
|
|||
|
c2862f4c21
|
|||
|
38c3df8187
|
|||
|
530308033b
|
|||
|
c155fc33b4
|
|||
|
a2869c1d87
|
|||
|
a8902dcf11
|
|||
|
c9b2773de4
|
|||
|
909b02049d
|
|||
|
d4a8288ecf
|
|||
|
4114b0109f
|
|||
|
169f8798f3
|
|||
|
8f843833e0
|
|||
|
35872d731a
|
|||
|
2347a02fcb
|
|||
|
5540d37c72
|
|||
|
6483c55fce
|
|||
|
603240bf93
|
|||
|
53695b8244
|
|||
|
48d971c216
|
|||
|
bb1d494c48
|
|||
|
896e42f5d9
|
|||
|
7bede8cd64
|
|||
|
3b524e9c70
|
|||
|
59f40b9f05
|
|||
|
7b7fbed472
|
|||
|
e3ccd96c7b
|
|||
|
a1aa722512
|
|||
|
706dedbdc5
|
|||
|
2a8721fde0
|
|||
|
3331536ddd
|
|||
|
d957c746d5
|
|||
|
cfdd58fb18
|
|||
|
2c3886296c
|
|||
|
c082da700d
|
|||
|
fe08e06a02
|
|||
|
fd2d8a58d9
|
|||
|
f3d16d5187
|
|||
|
928774084f
|
|||
|
8c09b0b2f4
|
|||
|
5ac56db46c
|
|||
|
5fd8027d3e
|
|||
|
e87b8bd864
|
|||
|
eeed31aaf4
|
|||
|
e59e9679da
|
|||
|
685f4cc892
|
|||
|
60e651de72
|
|||
|
98417046bc
|
|||
|
244050fa75
|
|||
|
0814803fc7
|
|||
|
eb2ffac536
|
|||
|
9a97e41c28
|
|||
|
109ac8e13a
|
|||
|
6275b526d8
|
|||
|
6456804fc3
|
|||
|
585c03a11e
|
|||
|
0372c50084
|
|||
|
065c435d8b
|
|||
|
1bceabe967
|
|||
|
86c72c2dc3
|
|||
|
4623d7a1f7
|
|||
|
d59cc0a732
|
|||
|
ecbd24d7a4
|
|||
|
7e33ea833e
|
|||
|
b10634f831
|
|||
|
e85a2508e8
|
|||
|
096d6e2868
|
|||
|
68b078c146
|
|||
|
2af845bfc6
|
|||
|
6875e8762e
|
|||
|
4ed0251bb4
|
|||
|
6871e703e8
|
|||
|
f02a11afd2
|
|||
|
eb9d7a17b3
|
|||
|
ccca1a7959
|
|||
|
2e8685d02b
|
|||
|
bcdb9a9c4b
|
|||
|
dbce9629a5
|
|||
|
79f4dbb101
|
|||
|
71f7322624
|
|||
|
bd9e9b397f
|
|||
|
ea1a85e935
|
|||
|
d540b82e85
|
|||
|
d7f96f35c2
|
|||
|
3dcb5c7f2b
|
|||
|
28b96a8dc0
|
|||
|
3347619816
|
|||
|
ac05367ee4
|
|||
|
609405da81
|
|||
|
c521a9ec68
|
|||
|
936c59debc
|
|||
|
d8abcbf74b
|
|||
|
4ad218cc39
|
|||
|
3cd2a678a6
|
|||
|
e5d218820b
|
|||
|
70506e169a
|
|||
|
538b70d139
|
|||
|
58247dab89
|
|||
|
f7ae3f20af
|
|||
|
8aeba13c6c
|
|||
|
71196c8b4b
|
|||
|
ce42d70741
|
|||
|
e7293ac496
|
|||
|
86ff4c5953
|
|||
|
504a6f410f
|
|||
|
c73363e93d
|
|||
|
43ea2cca22
|
|||
|
0ab98690eb
|
|||
|
a8b0d3c722
|
|||
|
b8e5155e2d
|
|||
|
8c75a2ddda
|
|||
|
c01dff197f
|
|||
|
38330d7020
|
|||
|
03875f2be6
|
|||
|
ff6a7ce01a
|
|||
|
87bf5d100e
|
|||
|
076c6efb38
|
|||
|
4112e38306
|
|||
|
0da3b130e4
|
|||
|
bb0895c9b5
|
|||
|
c912afd46a
|
|||
|
800a544190
|
|||
|
dfeeeb5fdf
|
|||
|
eb3604f9ea
|
|||
|
3b4318206d
|
|||
|
cf3fe84d95
|
|||
|
abb59f46a6
|
|||
|
ebc59cbf6b
|
|||
|
934d0d981b
|
|||
|
5638913b1d
|
|||
|
9a15779a44
|
|||
|
f807a43d58
|
|||
|
8611275bca
|
|||
|
91e1efda6f
|
|||
|
6d3165f405
|
|||
|
e9ebe492fb
|
|||
|
36058d1b94
|
|||
|
0a188ad4ab
|
|||
|
def176db4d
|
|||
|
ae8c699640
|
|||
|
dc1c9b32e9
|
|||
|
f0ad5b67a5
|
|||
|
a056618696
|
|||
|
cf5ac96fc3
|
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Pre-commit: normalise hike track altitudes.
|
||||
#
|
||||
# Any added/modified src/content/hikes/<slug>/track.gpx is run through
|
||||
# scripts/fix-altitudes.ts (swisstopo swissALTI3D heights at each exact point)
|
||||
# and re-staged, so committed tracks always carry corrected elevation instead of
|
||||
# raw phone-GPS noise. Commits that don't touch a track.gpx are a fast no-op.
|
||||
#
|
||||
# Network failures degrade gracefully: fix-altitudes keeps a point's original
|
||||
# elevation when it can't resolve it, exits 0, and the commit proceeds.
|
||||
#
|
||||
# Caveat: a touched track.gpx is re-staged in full, so partial (`git add -p`)
|
||||
# staging of a track.gpx won't survive. These files are generated, so that's fine.
|
||||
set -euo pipefail
|
||||
|
||||
# Staged Added/Copied/Modified track.gpx paths, NUL-delimited so non-ASCII slug
|
||||
# dirs (e.g. "…pfäffikersee") come through as raw bytes, unquoted.
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
case "$f" in
|
||||
src/content/hikes/*/track.gpx) files+=("$f") ;;
|
||||
esac
|
||||
done < <(git diff --cached --name-only -z --diff-filter=ACM -- src/content/hikes)
|
||||
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Map each path to its <slug> (the directory under src/content/hikes/).
|
||||
slugs=()
|
||||
for f in "${files[@]}"; do
|
||||
s=${f#src/content/hikes/}
|
||||
slugs+=("${s%/track.gpx}")
|
||||
done
|
||||
|
||||
echo "[pre-commit] fix-altitudes: ${slugs[*]}"
|
||||
pnpm exec vite-node scripts/fix-altitudes.ts "${slugs[@]}"
|
||||
|
||||
# Re-stage so the corrected elevations are what actually gets committed.
|
||||
git add -- "${files[@]}"
|
||||
@@ -7,11 +7,43 @@ node_modules
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
.env_*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
# USDA bulk data downloads (regenerated by scripts/import-usda-nutrition.ts)
|
||||
data/usda/
|
||||
# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env)
|
||||
static/shopping/supercard.svg
|
||||
static/shopping/cumulus.svg
|
||||
# Hikes build outputs (regenerated by scripts/build-hikes.ts at prebuild)
|
||||
static/hikes/
|
||||
hikes-assets/
|
||||
src/lib/data/hikes.generated.ts
|
||||
# Tile-proxy build artefacts + secrets (the source tree itself is tracked).
|
||||
# The binary is dropped next to Cargo.toml by `make build`; its name happens
|
||||
# to collide with the directory it lives in, so the path is fully qualified
|
||||
# here to avoid the nested-gitignore quirk that previously hid the source.
|
||||
/tile-proxy/tile-proxy
|
||||
/tile-proxy/target/
|
||||
/tile-proxy/.env
|
||||
# Private image build outputs (regenerated by scripts/build-private-images.ts).
|
||||
# Sources are private + large, so they're ignored too — only the README is kept.
|
||||
private-assets/
|
||||
src/lib/data/privateImages.generated.ts
|
||||
src/lib/assets/private-images/*
|
||||
!src/lib/assets/private-images/README.md
|
||||
# Build-script disk caches (Swisstopo identify, BRouter responses, ...)
|
||||
scripts/.cache/
|
||||
# Loose working-tree scratch files (notes, photos, prototypes) that aren't
|
||||
# part of the committed source.
|
||||
/HIKES_PLAN.md
|
||||
/additional_apologetics.md
|
||||
/header_jellyfin.html
|
||||
/person-hiking.svg
|
||||
/PXL_*.jpg
|
||||
/PXL_*.MP.jpg
|
||||
src-tauri/icons/_safezone_template_*.png
|
||||
src-tauri/target/
|
||||
src-tauri/*.keystore
|
||||
# Android: ignore build output and caches, track source files
|
||||
@@ -19,3 +51,9 @@ src-tauri/gen/android/.gradle/
|
||||
src-tauri/gen/android/app/build/
|
||||
src-tauri/gen/android/buildSrc/.gradle/
|
||||
src-tauri/gen/android/buildSrc/build/
|
||||
|
||||
# Hike content: track the writing (index.svx), route (track.gpx) and icons,
|
||||
# but not the source photos (huge; re-encoded into static assets at build time).
|
||||
src/content/hikes/*/images/
|
||||
src/content/hikes/*/private/
|
||||
src/content/hikes/*/cover.*
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
# Repository Instructions
|
||||
## Commits
|
||||
|
||||
- **Never** append `Co-Authored-By: Claude ...` (or any similar AI-attribution trailer) to commit messages. Do not add it even if a default template or prior convention suggests it.
|
||||
- Do not include "Generated with Claude Code" footers or similar watermarks in commit messages, PR bodies, or any files in this repo.
|
||||
|
||||
### Versioning
|
||||
|
||||
When committing, bump version numbers as appropriate using semver:
|
||||
|
||||
- **patch** (x.y.Z): bug fixes, minor styling tweaks, small corrections
|
||||
- **minor** (x.Y.0): new features, significant UI changes, new pages/routes
|
||||
- **major** (X.0.0): breaking changes, major redesigns, data model changes
|
||||
|
||||
Version files to update:
|
||||
- `package.json` — site version (bump on every commit)
|
||||
- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes.
|
||||
|
||||
## Available MCP Tools:
|
||||
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
@@ -30,9 +48,9 @@ Svelte 5 removed event modifiers like `on:click|preventDefault`. Use inline hand
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
|
||||
## Theming Rules
|
||||
# Theming Rules
|
||||
|
||||
### Semantic CSS Variables (ALWAYS use these, NEVER hardcode Nord values for themed properties)
|
||||
## Semantic CSS Variables (ALWAYS use these, NEVER hardcode Nord values for themed properties)
|
||||
|
||||
| Purpose | Variable | Light resolves to | Dark resolves to |
|
||||
|---|---|---|---|
|
||||
@@ -46,22 +64,22 @@ After completing the code, ask the user if they want a playground link. Only cal
|
||||
| Tertiary text (descriptions) | `--color-text-tertiary` | nord2 | nord5 |
|
||||
| Borders | `--color-border` | nord4 | nord2/3 |
|
||||
|
||||
### What NOT to do
|
||||
## What NOT to do
|
||||
- **NEVER** use `var(--nord0)` through `var(--nord6)` for backgrounds, text, or borders — these don't adapt to theme
|
||||
- **NEVER** write `@media (prefers-color-scheme: dark)` or `:global(:root[data-theme="dark"])` override blocks — semantic variables handle both themes automatically
|
||||
- **NEVER** use `var(--font-default-dark)` or `var(--accent-dark)` — these are legacy
|
||||
|
||||
### Primary interactive elements
|
||||
## Primary interactive elements
|
||||
- Background: `var(--color-primary)` (nord10 light / nord8 dark)
|
||||
- Hover: `var(--color-primary-hover)`
|
||||
- Active: `var(--color-primary-active)`
|
||||
- Text on primary bg: `var(--color-text-on-primary)`
|
||||
|
||||
### Accent colors (OK to use directly, they work in both themes)
|
||||
## Accent colors (OK to use directly, they work in both themes)
|
||||
- `var(--blue)`, `var(--red)`, `var(--green)`, `var(--orange)` — named accent colors
|
||||
- `var(--nord10)`, `var(--nord11)`, `var(--nord12)`, `var(--nord14)` — OK for hover states of accent-colored buttons only
|
||||
|
||||
### Chart.js theme reactivity
|
||||
## Chart.js theme reactivity
|
||||
Charts don't use CSS variables. Use the `isDark()` pattern from `FitnessChart.svelte`:
|
||||
```js
|
||||
function isDark() {
|
||||
@@ -74,58 +92,46 @@ const textColor = isDark() ? '#D8DEE9' : '#2E3440';
|
||||
```
|
||||
Re-create the chart on theme change via `MutationObserver` on `data-theme` + `matchMedia` listener.
|
||||
|
||||
### Form inputs
|
||||
## Form inputs
|
||||
- Background: `var(--color-bg-tertiary)`
|
||||
- Border: `var(--color-border)`
|
||||
- Text: `var(--color-text-primary)`
|
||||
- Label: `var(--color-text-secondary)`
|
||||
|
||||
### Toggle component
|
||||
## Toggle component
|
||||
Use `Toggle.svelte` (iOS-style) instead of raw `<input type="checkbox">` for user-facing boolean switches.
|
||||
|
||||
## Site-Wide Design Language
|
||||
|
||||
### Layout & Spacing
|
||||
## Layout & Spacing
|
||||
- Max content width: `1000px`–`1200px` with `margin-inline: auto`
|
||||
- Card/grid gaps: `2rem` desktop, `1rem` tablet, `0.5rem` mobile
|
||||
- Breakpoints: `410px` (small mobile), `560px` (tablet), `900px` (rosary), `1024px` (desktop)
|
||||
|
||||
### Border Radius Tokens
|
||||
## Border Radius Tokens
|
||||
- `--radius-pill: 1000px` — nav bar, pill buttons
|
||||
- `--radius-card: 20px` — major cards (recipe cards)
|
||||
- `--radius-lg: 0.75rem` — medium rounded elements
|
||||
- `--radius-md: 0.5rem` — standard rounding
|
||||
- `--radius-sm: 0.3rem` — small elements
|
||||
|
||||
### Shadow Tokens
|
||||
## Shadow Tokens
|
||||
- `--shadow-sm` / `--shadow-md` / `--shadow-lg` / `--shadow-hover` — use these, don't hardcode
|
||||
- Shadows are spread-based (`0 0 Xem Yem`) not offset-based
|
||||
|
||||
### Hover & Interaction Patterns
|
||||
## Hover & Interaction Patterns
|
||||
- Cards/links: `scale: 1.02` + shadow elevation on hover
|
||||
- Tags/pills: `scale: 1.05` with `--transition-fast` (100ms)
|
||||
- Standard transitions: `--transition-normal` (200ms)
|
||||
- Nav bar: glassmorphism (`backdrop-filter: blur(16px)`, semi-transparent bg)
|
||||
|
||||
### Typography
|
||||
## Typography
|
||||
- Font stack: Helvetica, Arial, "Noto Sans", sans-serif
|
||||
- Size tokens: `--text-sm` through `--text-3xl`
|
||||
- Headings in grids: `1.5rem` desktop → `1.2rem` tablet → `0.95rem` mobile
|
||||
|
||||
### Surfaces & Cards
|
||||
## Surfaces & Cards
|
||||
- Use `--color-surface` / `--color-surface-hover` for card backgrounds
|
||||
- Use `--color-bg-elevated` for hover/active states
|
||||
- Recipe cards: 300px wide, `--radius-card` corners
|
||||
- Global utility classes: `.g-icon-badge` (circular), `.g-pill` (pill-shaped)
|
||||
|
||||
## Versioning
|
||||
|
||||
When committing, bump version numbers as appropriate using semver:
|
||||
|
||||
- **patch** (x.y.Z): bug fixes, minor styling tweaks, small corrections
|
||||
- **minor** (x.Y.0): new features, significant UI changes, new pages/routes
|
||||
- **major** (X.0.0): breaking changes, major redesigns, data model changes
|
||||
|
||||
Version files to update:
|
||||
- `package.json` — site version (bump on every commit)
|
||||
- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes.
|
||||
|
||||
@@ -173,7 +173,6 @@ Generated: 2025-11-18
|
||||
- `EditButton.svelte` - Edit button (floating)
|
||||
- `FavoriteButton.svelte` - Toggle favorite
|
||||
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
|
||||
- `CardAdd.svelte` - Add recipe card placeholder
|
||||
- `FormSection.svelte` - Styled form section wrapper
|
||||
- `Header.svelte` - Page header
|
||||
- `UserHeader.svelte` - User-specific header
|
||||
@@ -190,7 +189,6 @@ Generated: 2025-11-18
|
||||
|
||||
#### Recipe-Specific Components
|
||||
- `Recipes.svelte` - Recipe list display
|
||||
- `RecipeEditor.svelte` - Recipe editing form
|
||||
- `RecipeNote.svelte` - Recipe notes display
|
||||
- `EditRecipe.svelte` - Edit recipe modal
|
||||
- `EditRecipeNote.svelte` - Edit recipe notes
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# TODO
|
||||
|
||||
## Perf (audit 2026-04-23)
|
||||
|
||||
Order = impact. Font items + app.html preload intentionally skipped.
|
||||
|
||||
- [x] 1. Lucide subpath imports — convert `from '@lucide/svelte'` barrel imports to `@lucide/svelte/icons/<kebab-name>` so Vite tree-shakes per-icon (current 748 KB shared chunk)
|
||||
- [x] 2. Chart.js dynamic import in `FitnessChart.svelte` (drop 244 KB from non-stats fitness routes)
|
||||
- [x] 3. Recipe API endpoints — drop `JSON.parse(JSON.stringify(...))` double-serialize (9 endpoints). Client-side shuffle / cache headers deferred (would require rethinking hero preload + hydration)
|
||||
- [x] 4. Favorites page — drop unnecessary `all_brief` fetch (verified Search uses `favoritesOnly` so `allRecipes` was redundant)
|
||||
- [x] 5. Replace redundant `locals.auth()` with `locals.session` across all routes (68 files, 107 sites — loaders, actions, API endpoints)
|
||||
- [x] 6. Stream fitness stats loader — muscleHeatmap, nutritionStats, periods, sharedPeriods now stream via `{#await}`. `stats` still awaited (too many chart $deriveds depend on it)
|
||||
- [x] 7. Muscle-heatmap endpoint — add projection + O(1) bucket math. Overview already had a projection; set-subfield narrowing was attempted but reverted (returned malformed sets). Timeseries cap not feasible: totals are lifetime-scoped.
|
||||
- [x] 8. Calendar payload trim — `yearDays` narrowed to `{iso, color}` (needle lookup only), new pre-filtered `feastDots` array carries feast-specific metadata. Also fixed a stray double `locals.session ?? (locals.session ?? …)` in both calendar page loaders.
|
||||
- [x] 9. History sessions endpoint — projection narrowed to exactly what SessionCard reads (drops notes, templates, mode, endTime, session-level gpsPreview); added `.lean()`.
|
||||
- [x] 10. `Cache-Control` headers: 8 h public on the shuffled recipe list endpoints (`all_brief`, `category/[c]`, `tag/[t]`, `icon/[i]`, `in_season/[m]`) — rand_array is seeded per UTC day, safe to share. 1 h public on distinct-value lists (`category`, `tag`, `icon`). 5 min public on recipe detail. `private 1h` on fitness `/exercises/filters`. Calendar page skipped (session serialised into layout HTML).
|
||||
- [x] 11. Search — debounce was already 100 ms. Instead of a server-side `_searchKey` (would duplicate text over the wire), memoise per-recipe normalized string in a `WeakMap` on the client — built lazily, reused across every subsequent keystroke.
|
||||
|
||||
## Features
|
||||
[x] on /fitness/measure, fill "Past measurements" in SSR only for the last 10 measurements. anything further should be fetched client side on mount to decreae initial page load time. use a "show more" button and paginate measurments.
|
||||
[x] on /fitness/measure (resp. their associated logging API routes), consolidate measurements by day. If we want to log another measurement, overwriting an old one, show a warning to indicate this. disparate measurements (e.g., weight and bodyfat) should not show this warning but simply be merged into one log entry for that day.
|
||||
[x] on /fitness/measure in the past measurments tab, show more than "Body measurements only" if we don't have Bodyweight logged. we can be a bit more elaborate in our syntax here tbh.
|
||||
[x] add a button on /fitness/measure/body-parts for each measurement directly below to say "Same value", instead of having to hit +, then - to lock in same number
|
||||
[x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph)
|
||||
[x] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking?
|
||||
[x] on /fitness/stats/histoy/<part> for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?)
|
||||
[x] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
|
||||
[x] swap heart emoji on recipe favorites to lucide icon
|
||||
[x] coop and migros cards on shopping list for scanning
|
||||
[x] login icon from lucide in header
|
||||
[ ] Investigate self-hosting BRouter
|
||||
[ ] Use the same color swisstopo map both for light and dark mode (currentyl only light mode)
|
||||
[ ] pre-compute required map tiles for all tiles on the route (and adjacent enough to be visibile by default on sane screen sizes) and create a fetch instruction for the server. (separate step: create a swiss-topo caching service which smoothly interpolates with non-switzerland service tiles for spots outside of switzerland)
|
||||
[ ] expand compatibility outside of switzerland with non-swiss topo map
|
||||
[ ] align design better with swizterland mobility
|
||||
[ ] allow for difficulty cardio, difficulty technique and T1-T6 labelling
|
||||
[ ] allow for Switzerland Mobility like hike icons (with alpine blue white blue, red white red, and yellow hiking shields as a fallback alternative)
|
||||
[ ] Add smoothing distance for elevation calculations on GPS-tracled workouts (3 meters? more?)
|
||||
|
||||
## Refactor Recipe Search Component
|
||||
|
||||
Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte` component for the visual input part. This will:
|
||||
@@ -10,3 +47,45 @@ Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte`
|
||||
Files involved:
|
||||
- `src/lib/components/Search.svelte` - refactor to use SearchInput
|
||||
- `src/lib/components/SearchInput.svelte` - the reusable input component
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1. $app/stores → $app/state (biggest, most mechanical)
|
||||
Old: import { page } from '$app/stores' + $page.url.pathname
|
||||
New: import { page } from '$app/state' + page.url.pathname (no $, it's a rune now).
|
||||
Runes-based, smaller bundle (no store wrapper), cleaner SSR. Codebase has dozens of $app/stores imports — same kind
|
||||
of codemod-able migration as hrefs. Available since 2.12. $app/stores is deprecated.
|
||||
|
||||
2. Convert legacy stores to .svelte.ts rune state
|
||||
Files like $lib/stores/recipeTranslation.ts, $lib/stores/language.ts use writable(). Modern pattern: .svelte.ts files
|
||||
with $state() + exported getters/setters. Better TS inference, no $ prefix, no auto-subscription gotchas.
|
||||
|
||||
3. Remote functions for new API code ($app/server, since 2.27)
|
||||
Replaces hand-rolled +server.ts + client fetch with type-safe server functions called like normal funcs. Major
|
||||
refactor for existing /api/** (lots of files), so probably only adopt for new endpoints — not worth churning the
|
||||
existing ~80 API routes.
|
||||
|
||||
4. prerender = true audit
|
||||
Static-ish pages (faith catechesis, latin prayers, apologetics arguments) are great candidates. Skip-SSR for static
|
||||
content = faster cold loads + cheaper hosting. Currently nothing's prerendered — quick win where applicable.
|
||||
|
||||
5. @sveltejs/enhanced-img
|
||||
Transparent image optimization (responsive srcset, AVIF/WebP, blur placeholders) at build time. Recipe hero images
|
||||
and saint-day cards would benefit visibly. Drop-in via <enhanced:img src="...">.
|
||||
|
||||
6. {@attach} over use: (Svelte 5 attachments)
|
||||
Newer API for DOM-lifecycle hooks. Supports spread + library composition use: can't. Low urgency; only matters when
|
||||
writing new lifecycle code.
|
||||
|
||||
7. Shallow routing for modals/galleries
|
||||
pushState + <a> flow lets modals participate in history without full navigation. Useful if you ever add a
|
||||
recipe-image lightbox or apologetics-arg overlay. Net-new feature, not a migration.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.43.1",
|
||||
"version": "1.96.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare": "git config core.hooksPath .githooks || true",
|
||||
"dev": "vite dev",
|
||||
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts",
|
||||
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts && pnpm exec vite-node scripts/build-private-images.ts",
|
||||
"build": "vite build",
|
||||
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts && UV_THREADPOOL_SIZE=12 pnpm exec vite-node scripts/precompress.ts",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -22,12 +24,15 @@
|
||||
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"deploy:dry": "bash scripts/deploy.sh --dry-run",
|
||||
"photos:push": "bash scripts/hike-photos.sh push",
|
||||
"photos:pull": "bash scripts/hike-photos.sh pull",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/kit": "^2.56.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
@@ -37,7 +42,9 @@
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"bwip-js": "^4.10.1",
|
||||
"jsdom": "^27.2.0",
|
||||
"mdsvex": "^0.12.7",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tslib": "^2.8.1",
|
||||
@@ -57,6 +64,7 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"exifr": "^7.1.3",
|
||||
"file-type": "^19.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"mongoose": "^9.4.1",
|
||||
|
||||
@@ -38,6 +38,9 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
exifr:
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
file-type:
|
||||
specifier: ^19.0.0
|
||||
version: 19.6.0
|
||||
@@ -66,6 +69,9 @@ importers:
|
||||
'@sveltejs/adapter-auto':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))
|
||||
'@sveltejs/enhanced-img':
|
||||
specifier: ^0.10.4
|
||||
version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.56.1
|
||||
version: 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
@@ -93,9 +99,15 @@ importers:
|
||||
'@vitest/ui':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(vitest@4.1.2)
|
||||
bwip-js:
|
||||
specifier: ^4.10.1
|
||||
version: 4.10.1
|
||||
jsdom:
|
||||
specifier: ^27.2.0
|
||||
version: 27.2.0
|
||||
mdsvex:
|
||||
specifier: ^0.12.7
|
||||
version: 0.12.7(svelte@5.55.1)
|
||||
svelte:
|
||||
specifier: ^5.55.1
|
||||
version: 5.55.1
|
||||
@@ -916,6 +928,13 @@ packages:
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.4.0
|
||||
|
||||
'@sveltejs/enhanced-img@0.10.4':
|
||||
resolution: {integrity: sha512-Am5nmAKUo7Nboqq7Dhtfn7dcXA087d7gIz6Vecn1opB41aJ680+0q9U9KvEcMgduOyeiwckTIOQOx4Mmq9GcvA==}
|
||||
peerDependencies:
|
||||
'@sveltejs/vite-plugin-svelte': ^6.0.0 || ^7.0.0
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.3.0 || >=7.0.0
|
||||
|
||||
'@sveltejs/kit@2.56.1':
|
||||
resolution: {integrity: sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==}
|
||||
engines: {node: '>=18.13'}
|
||||
@@ -1076,6 +1095,9 @@ packages:
|
||||
'@types/leaflet@1.9.21':
|
||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/node-cron@3.0.11':
|
||||
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||
|
||||
@@ -1088,6 +1110,9 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
'@types/webidl-conversions@7.0.0':
|
||||
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
|
||||
|
||||
@@ -1194,6 +1219,10 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
bwip-js@4.10.1:
|
||||
resolution: {integrity: sha512-I/cEPiXsu7dRCp78PpVY4gdIXmbH752n8dMC+DStM77XPkrzeathdYrjnZ/i/vZPIxXTUWc+JxgJ/MvbodqPLA==}
|
||||
hasBin: true
|
||||
|
||||
cac@7.0.0:
|
||||
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -1334,6 +1363,9 @@ packages:
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
exifr@7.1.3:
|
||||
resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1426,6 +1458,10 @@ packages:
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
imagetools-core@9.1.0:
|
||||
resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
indent-string@4.0.0:
|
||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1578,6 +1614,11 @@ packages:
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
mdsvex@0.12.7:
|
||||
resolution: {integrity: sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw==}
|
||||
peerDependencies:
|
||||
svelte: ^3.56.0 || ^4.0.0 || ^5.0.0-next.120
|
||||
|
||||
memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
@@ -1731,6 +1772,13 @@ packages:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
||||
prism-svelte@0.4.7:
|
||||
resolution: {integrity: sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==}
|
||||
|
||||
prismjs@1.30.0:
|
||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1880,6 +1928,11 @@ packages:
|
||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
svelte-parse-markup@0.1.5:
|
||||
resolution: {integrity: sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==}
|
||||
peerDependencies:
|
||||
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.1
|
||||
|
||||
svelte@5.55.1:
|
||||
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1961,6 +2014,25 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
unist-util-is@4.1.0:
|
||||
resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==}
|
||||
|
||||
unist-util-stringify-position@2.0.3:
|
||||
resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==}
|
||||
|
||||
unist-util-visit-parents@3.1.1:
|
||||
resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==}
|
||||
|
||||
unist-util-visit@2.0.3:
|
||||
resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==}
|
||||
|
||||
vfile-message@2.0.4:
|
||||
resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==}
|
||||
|
||||
vite-imagetools@9.0.3:
|
||||
resolution: {integrity: sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
vite-node@6.0.0:
|
||||
resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2695,6 +2767,19 @@ snapshots:
|
||||
'@sveltejs/kit': 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
rollup: 4.60.1
|
||||
|
||||
'@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
magic-string: 0.30.21
|
||||
sharp: 0.34.5
|
||||
svelte: 5.55.1
|
||||
svelte-parse-markup: 0.1.5(svelte@5.55.1)
|
||||
vite: 8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)
|
||||
vite-imagetools: 9.0.3(rollup@4.60.1)
|
||||
zimmerframe: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
'@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
@@ -2840,6 +2925,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
'@types/node-cron@3.0.11': {}
|
||||
|
||||
'@types/node@22.18.0':
|
||||
@@ -2850,6 +2939,8 @@ snapshots:
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/webidl-conversions@7.0.0': {}
|
||||
|
||||
'@types/whatwg-url@13.0.0':
|
||||
@@ -2952,6 +3043,8 @@ snapshots:
|
||||
buffer-from@1.1.2:
|
||||
optional: true
|
||||
|
||||
bwip-js@4.10.1: {}
|
||||
|
||||
cac@7.0.0: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
@@ -3087,6 +3180,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
exifr@7.1.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
@@ -3179,6 +3274,8 @@ snapshots:
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
imagetools-core@9.1.0: {}
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
ip@2.0.1:
|
||||
@@ -3312,6 +3409,16 @@ snapshots:
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
mdsvex@0.12.7(svelte@5.55.1):
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 2.0.11
|
||||
prism-svelte: 0.4.7
|
||||
prismjs: 1.30.0
|
||||
svelte: 5.55.1
|
||||
unist-util-visit: 2.0.3
|
||||
vfile-message: 2.0.4
|
||||
|
||||
memory-pager@1.5.0: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
@@ -3433,6 +3540,10 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
prism-svelte@0.4.7: {}
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
@@ -3661,6 +3772,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-parse-markup@0.1.5(svelte@5.55.1):
|
||||
dependencies:
|
||||
svelte: 5.55.1
|
||||
|
||||
svelte@5.55.1:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -3743,6 +3858,36 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
unist-util-is@4.1.0: {}
|
||||
|
||||
unist-util-stringify-position@2.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
unist-util-visit-parents@3.1.1:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-is: 4.1.0
|
||||
|
||||
unist-util-visit@2.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-is: 4.1.0
|
||||
unist-util-visit-parents: 3.1.1
|
||||
|
||||
vfile-message@2.0.4:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-stringify-position: 2.0.3
|
||||
|
||||
vite-imagetools@9.0.3(rollup@4.60.1):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||
imagetools-core: 9.1.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
vite-node@6.0.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
cac: 7.0.0
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Postbuild: turn each prerendered /errors/<status> route into a self-contained
|
||||
* HTML file at build/client/errors/<status>.html for nginx error_page use.
|
||||
*
|
||||
* - Inlines every <link rel="stylesheet"> by replacing it with <style>.
|
||||
* - Strips <script type="module"> and <link rel="modulepreload"> (csr=false,
|
||||
* so JS is dead weight and a missing-asset risk if upstream is dead).
|
||||
* - Leaves font/image URLs alone — nginx serves them from the same root.
|
||||
* - Emits matching .gz + .br for nginx gzip_static / brotli_static.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/build-error-page.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve, join, posix } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzipSync, brotliCompressSync, constants as zlib } from 'node:zlib';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const PRERENDER_DIR = join(ROOT, 'build/prerendered/errors');
|
||||
const CLIENT = join(ROOT, 'build/client');
|
||||
const OUT_DIR = join(CLIENT, 'errors');
|
||||
|
||||
// Error pages may be served from arbitrary domains via nginx's default_server
|
||||
// catchall. Rewrite the home-link to an absolute canonical URL so clicking
|
||||
// the logo always lands on the real site.
|
||||
const CANONICAL_HOME = 'https://bocken.org/';
|
||||
|
||||
// Marker for idempotent script injection (so re-runs don't stack copies).
|
||||
const LANG_SCRIPT_MARKER = 'data-error-toggles';
|
||||
// Wires up language + theme toggles without Svelte hydration. Runs early
|
||||
// so <html data-lang="…"> is set before paint (avoids flash of both langs).
|
||||
// The icon inside the theme button is Svelte-reactive and stays at the
|
||||
// SSR-rendered shape; the actual theme cycle + persistence still works.
|
||||
const LANG_SCRIPT = `
|
||||
<script ${LANG_SCRIPT_MARKER}>
|
||||
(function(){try{
|
||||
var html=document.documentElement;
|
||||
var pref=localStorage.getItem('preferredLanguage');
|
||||
var lang=(pref==='en'||pref==='de')?pref:'de';
|
||||
html.setAttribute('data-lang',lang);
|
||||
var wire=function(){
|
||||
var langBtn=document.getElementById('lang-toggle');
|
||||
if(langBtn){
|
||||
var refresh=function(){
|
||||
var cur=html.getAttribute('data-lang')||'de';
|
||||
var next=cur==='de'?'en':'de';
|
||||
langBtn.textContent=next.toUpperCase();
|
||||
langBtn.setAttribute('aria-label',next==='en'?'Switch to English':'Auf Deutsch wechseln');
|
||||
};
|
||||
refresh();
|
||||
langBtn.addEventListener('click',function(){
|
||||
var cur=html.getAttribute('data-lang')||'de';
|
||||
var next=cur==='de'?'en':'de';
|
||||
html.setAttribute('data-lang',next);
|
||||
try{localStorage.setItem('preferredLanguage',next);}catch(_){}
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
var themeBtn=document.querySelector('button[aria-label^="Toggle theme"]');
|
||||
if(themeBtn){
|
||||
var CYCLE=['system','light','dark'];
|
||||
var getTheme=function(){
|
||||
var s=localStorage.getItem('theme');
|
||||
return (s==='light'||s==='dark')?s:'system';
|
||||
};
|
||||
var applyTheme=function(t){
|
||||
if(t==='system'){delete html.dataset.theme;try{localStorage.removeItem('theme');}catch(_){}}
|
||||
else{html.dataset.theme=t;try{localStorage.setItem('theme',t);}catch(_){}}
|
||||
themeBtn.setAttribute('aria-label','Toggle theme ('+t+')');
|
||||
themeBtn.setAttribute('title','Theme: '+t);
|
||||
};
|
||||
themeBtn.addEventListener('click',function(){
|
||||
var cur=getTheme();
|
||||
var next=CYCLE[(CYCLE.indexOf(cur)+1)%CYCLE.length];
|
||||
applyTheme(next);
|
||||
});
|
||||
}
|
||||
};
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wire);
|
||||
else wire();
|
||||
}catch(_){}})();
|
||||
</script>`;
|
||||
|
||||
if (!existsSync(PRERENDER_DIR)) {
|
||||
console.error(`[error-page] missing prerender dir: ${PRERENDER_DIR}`);
|
||||
console.error('[error-page] is /errors/[status=httpStatus]/+page.ts setting `prerender = true` with `entries()`?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// Recursively collect every prerendered html under build/prerendered/errors,
|
||||
// so we pick up nested language variants (errors/en/<status>.html).
|
||||
function walk(dir: string, prefix = ''): { rel: string; abs: string }[] {
|
||||
const out: { rel: string; abs: string }[] = [];
|
||||
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
||||
const abs = join(dir, ent.name);
|
||||
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
|
||||
if (ent.isDirectory()) out.push(...walk(abs, rel));
|
||||
else if (ent.isFile() && ent.name.endsWith('.html')) out.push({ rel, abs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = walk(PRERENDER_DIR);
|
||||
if (sources.length === 0) {
|
||||
console.error(`[error-page] no .html files under ${PRERENDER_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve a possibly-relative href (../foo, ./foo, /foo) against the page's
|
||||
// path (e.g. /errors/503.html) into a path inside CLIENT.
|
||||
function resolveAsset(href: string, pagePath: string): string {
|
||||
const abs = posix.resolve(posix.dirname(pagePath), href); // e.g. /_app/immutable/assets/x.css
|
||||
return join(CLIENT, abs.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
function inline(html: string, pagePath: string): string {
|
||||
// Inline <link rel="stylesheet"> regardless of attribute order.
|
||||
html = html.replace(/<link\b[^>]*>/g, (tag) => {
|
||||
if (!/\brel=["']stylesheet["']/.test(tag)) return tag;
|
||||
const m = tag.match(/\bhref=["']([^"']+)["']/);
|
||||
if (!m) return tag;
|
||||
const cssPath = resolveAsset(m[1], pagePath);
|
||||
if (!existsSync(cssPath)) {
|
||||
console.warn(`[error-page] stylesheet not found, leaving link tag: ${m[1]}`);
|
||||
return tag;
|
||||
}
|
||||
return `<style>${readFileSync(cssPath, 'utf8')}</style>`;
|
||||
});
|
||||
// Drop module preloads and module scripts — nothing should hydrate.
|
||||
html = html.replace(/<link[^>]*\brel=["']modulepreload["'][^>]*>\s*/g, '');
|
||||
html = html.replace(/<script[^>]*\btype=["']module["'][^>]*>[\s\S]*?<\/script>\s*/g, '');
|
||||
|
||||
// Point the brand/home link at the canonical site (the page may be served
|
||||
// from any domain when used as nginx's default_server fallback).
|
||||
html = html.replace(/<a\b[^>]*\bclass="[^"]*\bhome-link\b[^"]*"[^>]*>/g, (tag) =>
|
||||
tag.replace(/\bhref="[^"]*"/, `href="${CANONICAL_HOME}"`)
|
||||
);
|
||||
|
||||
// Inject the language-toggle bootstrap script just before </head> so
|
||||
// <html data-lang="…"> is set before the body paints (avoids flash of
|
||||
// both languages). Idempotent — if the marker is already present, skip.
|
||||
if (!html.includes(LANG_SCRIPT_MARKER)) {
|
||||
html = html.replace('</head>', `${LANG_SCRIPT}</head>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
for (const { rel, abs } of sources) {
|
||||
const dst = join(OUT_DIR, rel);
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
const html = inline(readFileSync(abs, 'utf8'), `/errors/${rel}`);
|
||||
const buf = Buffer.from(html, 'utf8');
|
||||
writeFileSync(dst, buf);
|
||||
writeFileSync(`${dst}.gz`, gzipSync(buf, { level: 9 }));
|
||||
writeFileSync(`${dst}.br`, brotliCompressSync(buf, {
|
||||
params: { [zlib.BROTLI_PARAM_QUALITY]: 11 }
|
||||
}));
|
||||
console.log(`[error-page] wrote errors/${rel} (${(buf.length / 1024).toFixed(1)} kB) + .gz + .br`);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Build script for private (auth-gated) images rendered via `<Image private>`.
|
||||
*
|
||||
* Public images use @sveltejs/enhanced-img, which emits PUBLIC hashed assets
|
||||
* into the client bundle — fine for anything anyone may see. Private images
|
||||
* must not be publicly reachable, so they can't go through enhanced-img. This
|
||||
* script mirrors the hikes private pipeline instead:
|
||||
*
|
||||
* 1. Scan `src/lib/assets/private-images/` (recursively) for raster sources.
|
||||
* 2. Encode each into AVIF + WebP at multiple widths with sharp, named by
|
||||
* content hash, into `private-assets/` — a tree OUTSIDE the client bundle
|
||||
* and outside `/static`, so SvelteKit/Vite never serve it directly.
|
||||
* 3. Emit `src/lib/data/privateImages.generated.ts`: a manifest mapping each
|
||||
* source path to its responsive variant, with URLs under `/private-images/`
|
||||
* (the auth-gated endpoint at src/routes/private-images/[...file]/+server.ts).
|
||||
*
|
||||
* Deploy rsyncs `private-assets/` to the server, where nginx serves it only via
|
||||
* an `internal` location (`/protected-images/`) reachable through X-Accel-Redirect
|
||||
* from the endpoint — never publicly. In dev the endpoint streams from disk.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import sharp from 'sharp';
|
||||
import type { PrivateImageVariant } from '../src/types/images.js';
|
||||
|
||||
const ROOT = path.resolve(process.cwd());
|
||||
const SRC_DIR = path.join(ROOT, 'src', 'lib', 'assets', 'private-images');
|
||||
// Encoded output. Sibling of `hikes-assets/` and, like it, gitignored + rsynced
|
||||
// to the server by scripts/deploy.sh (never bundled, never under /static).
|
||||
const OUT_DIR = path.join(ROOT, 'private-assets');
|
||||
const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'privateImages.generated.ts');
|
||||
|
||||
// Same responsive ladder + qualities as the hikes encoder, for consistency.
|
||||
const IMAGE_WIDTHS = [480, 960, 1600] as const;
|
||||
const AVIF_QUALITY = 55;
|
||||
const WEBP_QUALITY = 82;
|
||||
const RASTER_RE = /\.(jpe?g|png|webp|avif|tiff?|gif|heic|heif)$/i;
|
||||
// Sharp releases the JS thread while libvips runs, so a small pool ~linearly
|
||||
// speeds up encoding. Cap at 4 to avoid thrashing smaller boxes.
|
||||
const CONCURRENCY = Math.max(2, Math.min(os.cpus().length, 4));
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
worker: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= items.length) return;
|
||||
results[i] = await worker(items[i], i);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let out: string[] = [];
|
||||
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out = out.concat(await walk(full));
|
||||
else if (RASTER_RE.test(e.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encode(
|
||||
srcPath: string
|
||||
): Promise<{ key: string; variant: PrivateImageVariant; outNames: string[] }> {
|
||||
const buffer = await fs.readFile(srcPath);
|
||||
// Content hash names the output files: an existing file is byte-identical, so
|
||||
// re-encodes are skipped and stale ones get swept. The source basename is
|
||||
// dropped so original filenames don't leak into the (guessable) URLs.
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 8);
|
||||
|
||||
const meta = await sharp(buffer).metadata();
|
||||
const intrinsicW = meta.width ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
|
||||
const intrinsicH = meta.height ?? 0;
|
||||
|
||||
let widths = IMAGE_WIDTHS.filter((w) => w <= intrinsicW);
|
||||
if (widths.length === 0) widths = [intrinsicW];
|
||||
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
type Job = { w: number; fmt: 'avif' | 'webp'; file: string; quality: number };
|
||||
const jobs: Job[] = [];
|
||||
const avif: string[] = [];
|
||||
const webp: string[] = [];
|
||||
const outNames: string[] = [];
|
||||
let largestWebp = '';
|
||||
|
||||
for (const w of widths) {
|
||||
const avifName = `${hash}.${w}.avif`;
|
||||
const webpName = `${hash}.${w}.webp`;
|
||||
jobs.push({ w, fmt: 'avif', file: path.join(OUT_DIR, avifName), quality: AVIF_QUALITY });
|
||||
jobs.push({ w, fmt: 'webp', file: path.join(OUT_DIR, webpName), quality: WEBP_QUALITY });
|
||||
avif.push(`/private-images/${avifName} ${w}w`);
|
||||
webp.push(`/private-images/${webpName} ${w}w`);
|
||||
largestWebp = `/private-images/${webpName}`;
|
||||
outNames.push(avifName, webpName);
|
||||
}
|
||||
|
||||
const presence = await Promise.all(jobs.map((j) => pathExists(j.file)));
|
||||
const pending = jobs.filter((_, i) => !presence[i]);
|
||||
await Promise.all(
|
||||
pending.map(async (j) => {
|
||||
const pipeline = sharp(buffer).rotate().resize({ width: j.w, withoutEnlargement: true });
|
||||
if (j.fmt === 'avif') await pipeline.avif({ quality: j.quality }).toFile(j.file);
|
||||
else await pipeline.webp({ quality: j.quality }).toFile(j.file);
|
||||
})
|
||||
);
|
||||
|
||||
const largestW = widths[widths.length - 1];
|
||||
const scale = largestW / intrinsicW;
|
||||
const height = Math.round((intrinsicH || largestW) * scale);
|
||||
// Manifest key: source path relative to SRC_DIR, forward-slashed, so a caller
|
||||
// writes <Image src="blog/cover.jpg" private />.
|
||||
const key = path.relative(SRC_DIR, srcPath).split(path.sep).join('/');
|
||||
|
||||
return {
|
||||
key,
|
||||
variant: {
|
||||
src: largestWebp,
|
||||
srcsetAvif: avif.join(', '),
|
||||
srcsetWebp: webp.join(', '),
|
||||
width: largestW,
|
||||
height
|
||||
},
|
||||
outNames
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = await walk(SRC_DIR);
|
||||
if (files.length > 0) {
|
||||
console.log(`[build-private-images] encoding ${files.length} image(s) (concurrency=${CONCURRENCY})…`);
|
||||
}
|
||||
|
||||
const results = await runWithConcurrency(files, CONCURRENCY, (f) => encode(f));
|
||||
|
||||
const manifest: Record<string, PrivateImageVariant> = {};
|
||||
const keep = new Set<string>();
|
||||
for (const r of results) {
|
||||
manifest[r.key] = r.variant;
|
||||
for (const n of r.outNames) keep.add(n);
|
||||
}
|
||||
|
||||
// Sweep encodes from prior builds whose source was removed or changed.
|
||||
if (await pathExists(OUT_DIR)) {
|
||||
const existing = await fs.readdir(OUT_DIR);
|
||||
const orphans = existing.filter((f) => !keep.has(f));
|
||||
if (orphans.length > 0) {
|
||||
await Promise.all(orphans.map((f) => fs.unlink(path.join(OUT_DIR, f)).catch(() => {})));
|
||||
console.log(`[build-private-images] removed ${orphans.length} orphaned file(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true });
|
||||
const banner =
|
||||
'// AUTO-GENERATED by scripts/build-private-images.ts — do not edit by hand.\n' +
|
||||
"import type { PrivateImageVariant } from '$types/images';\n\n";
|
||||
const body = `export const PRIVATE_IMAGES: Record<string, PrivateImageVariant> = ${JSON.stringify(
|
||||
manifest,
|
||||
null,
|
||||
2
|
||||
)};\n`;
|
||||
await fs.writeFile(MANIFEST_OUT, banner + body);
|
||||
|
||||
console.log(
|
||||
`[build-private-images] wrote ${Object.keys(manifest).length} entry(ies) to ${path.relative(ROOT, MANIFEST_OUT)}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[build-private-images] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Migrate `$app/stores` (deprecated) to `$app/state` (rune-based).
|
||||
*
|
||||
* For each .svelte file:
|
||||
* - Rewrite `from '$app/stores'` → `from '$app/state'`
|
||||
* - For each named import, drop the `$` prefix from auto-subscriptions:
|
||||
* `$page.url.pathname` → `page.url.pathname`
|
||||
* `$navigating` → `navigating`
|
||||
* `$updated` → `updated`
|
||||
* Aliased imports (`page as appPage`) are tracked, so `$appPage` becomes `appPage`.
|
||||
*
|
||||
* Skips:
|
||||
* - Non-.svelte files (server-only code uses getRequestEvent instead).
|
||||
* - Files importing other things from $app/stores that don't have a state equivalent
|
||||
* (none observed in this repo).
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/codemod-app-stores-to-state.ts [--dry]
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
|
||||
const SRC = 'src';
|
||||
const DRY = process.argv.includes('--dry');
|
||||
|
||||
const STORES_IMPORT_RE =
|
||||
/import\s*\{([^}]+)\}\s*from\s*['"]\$app\/stores['"]\s*;?/;
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const s = statSync(p);
|
||||
if (s.isDirectory()) walk(p, out);
|
||||
else if (extname(p) === '.svelte') out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseImports(inner: string): Array<{ orig: string; local: string }> {
|
||||
return inner
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((spec) => {
|
||||
const m = spec.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
|
||||
if (!m) return null;
|
||||
return { orig: m[1], local: m[2] ?? m[1] };
|
||||
})
|
||||
.filter((x): x is { orig: string; local: string } => x !== null);
|
||||
}
|
||||
|
||||
function rewriteFile(src: string): { code: string; changed: boolean } {
|
||||
const m = STORES_IMPORT_RE.exec(src);
|
||||
if (!m) return { code: src, changed: false };
|
||||
|
||||
const imports = parseImports(m[1]);
|
||||
if (imports.length === 0) return { code: src, changed: false };
|
||||
|
||||
// Replace the import path; preserve the same import shape.
|
||||
let out = src.replace(STORES_IMPORT_RE, (full) =>
|
||||
full.replace(/['"]\$app\/stores['"]/, "'$app/state'")
|
||||
);
|
||||
|
||||
// Drop `$` prefix from each local name where it appears as a store
|
||||
// auto-subscription (i.e. $name followed by a non-word boundary).
|
||||
for (const { local } of imports) {
|
||||
const re = new RegExp(`\\$${local}\\b`, 'g');
|
||||
out = out.replace(re, local);
|
||||
}
|
||||
|
||||
return { code: out, changed: out !== src };
|
||||
}
|
||||
|
||||
const files = walk(SRC);
|
||||
let changed = 0;
|
||||
for (const f of files) {
|
||||
const orig = readFileSync(f, 'utf8');
|
||||
const { code, changed: didChange } = rewriteFile(orig);
|
||||
if (!didChange) continue;
|
||||
if (!DRY) writeFileSync(f, code);
|
||||
changed++;
|
||||
console.log(` ${f}`);
|
||||
}
|
||||
console.log(`\n${DRY ? '[dry] ' : ''}${changed} files migrated`);
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Bucket 2 codemod: replace template-literal hrefs that start with `/` and
|
||||
* contain `{expr}` interpolations with `resolve(routeId, { ... })`.
|
||||
*
|
||||
* Skips:
|
||||
* - tags: <link>, <image> (svg), <use>, <textPath>
|
||||
* - hrefs not starting with `/`
|
||||
* - hrefs containing `?` or `#` (query/fragment) — handle manually
|
||||
* - mixed segments like `view-{id}`
|
||||
* - paths matching 0 or >1 routes
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/codemod-href-resolve-bucket2.ts [--dry] [--verbose]
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
|
||||
const SRC = 'src';
|
||||
const ROUTES = 'src/routes';
|
||||
const DRY = process.argv.includes('--dry');
|
||||
|
||||
const SKIP_TAGS = new Set(['link', 'image', 'use', 'textpath']);
|
||||
|
||||
// --- Route tree ---------------------------------------------------------
|
||||
|
||||
type Dir = { name: string; subdirs: Dir[] };
|
||||
|
||||
function loadTree(dir: string, name = ''): Dir {
|
||||
const subdirs: Dir[] = [];
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!e.isDirectory()) continue;
|
||||
if (e.name === 'api' || e.name.startsWith('.')) continue;
|
||||
subdirs.push(loadTree(join(dir, e.name), e.name));
|
||||
}
|
||||
return { name, subdirs };
|
||||
}
|
||||
|
||||
const ROUTE_TREE = loadTree(ROUTES);
|
||||
|
||||
// --- Path parsing -------------------------------------------------------
|
||||
|
||||
type HrefSeg = { kind: 'literal'; text: string } | { kind: 'param'; expr: string };
|
||||
|
||||
function hasUnbracedChar(path: string, chars: string): boolean {
|
||||
let depth = 0;
|
||||
for (const c of path) {
|
||||
if (c === '{') depth++;
|
||||
else if (c === '}') depth--;
|
||||
else if (depth === 0 && chars.includes(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parsePath(path: string): HrefSeg[] | null {
|
||||
if (!path.startsWith('/')) return null;
|
||||
if (hasUnbracedChar(path, '?#')) return null;
|
||||
if (path.includes('//')) return null;
|
||||
// Split on `/`, but only outside of {...}
|
||||
const parts: string[] = [];
|
||||
let buf = '';
|
||||
let depth = 0;
|
||||
for (const c of path.slice(1)) {
|
||||
if (c === '{') { depth++; buf += c; }
|
||||
else if (c === '}') { depth--; buf += c; }
|
||||
else if (c === '/' && depth === 0) { parts.push(buf); buf = ''; }
|
||||
else buf += c;
|
||||
}
|
||||
parts.push(buf);
|
||||
if (parts.length === 1 && parts[0] === '') return [];
|
||||
const segs: HrefSeg[] = [];
|
||||
for (const p of parts) {
|
||||
if (p === '') return null;
|
||||
const m = p.match(/^\{([^}]+)\}$/);
|
||||
if (m) {
|
||||
segs.push({ kind: 'param', expr: m[1] });
|
||||
} else if (!p.includes('{') && !p.includes('}')) {
|
||||
segs.push({ kind: 'literal', text: p });
|
||||
} else {
|
||||
return null; // mixed segment
|
||||
}
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
function paramInfo(
|
||||
name: string
|
||||
): { paramName: string; isRest: boolean } | null {
|
||||
let body = name;
|
||||
if (body.startsWith('[[') && body.endsWith(']]')) {
|
||||
body = body.slice(2, -2);
|
||||
} else if (body.startsWith('[') && body.endsWith(']')) {
|
||||
body = body.slice(1, -1);
|
||||
} else return null;
|
||||
const isRest = body.startsWith('...');
|
||||
if (isRest) body = body.slice(3);
|
||||
const eq = body.indexOf('=');
|
||||
const paramName = eq >= 0 ? body.slice(0, eq) : body;
|
||||
return { paramName, isRest };
|
||||
}
|
||||
|
||||
// --- Tree matching ------------------------------------------------------
|
||||
|
||||
type Match = { routeId: string; params: Array<[string, string]> };
|
||||
|
||||
function matchTree(
|
||||
dir: Dir,
|
||||
segs: HrefSeg[],
|
||||
routePath: string[],
|
||||
params: Array<[string, string]>
|
||||
): Match[] {
|
||||
if (segs.length === 0) {
|
||||
const id = routePath.length === 0 ? '/' : '/' + routePath.join('/');
|
||||
return [{ routeId: id, params }];
|
||||
}
|
||||
const [seg, ...rest] = segs;
|
||||
const out: Match[] = [];
|
||||
for (const sub of dir.subdirs) {
|
||||
// Route groups are transparent — they don't consume a URL segment
|
||||
// but DO appear in the route ID.
|
||||
if (sub.name.startsWith('(') && sub.name.endsWith(')')) {
|
||||
out.push(...matchTree(sub, segs, [...routePath, sub.name], params));
|
||||
continue;
|
||||
}
|
||||
if (seg.kind === 'literal') {
|
||||
if (sub.name === seg.text) {
|
||||
out.push(
|
||||
...matchTree(sub, rest, [...routePath, sub.name], params)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const info = paramInfo(sub.name);
|
||||
if (info && !info.isRest) {
|
||||
out.push(
|
||||
...matchTree(sub, rest, [...routePath, sub.name], [
|
||||
...params,
|
||||
[info.paramName, seg.expr]
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Output formatting --------------------------------------------------
|
||||
|
||||
function isIdentifier(s: string): boolean {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
|
||||
}
|
||||
|
||||
function formatParams(params: Array<[string, string]>): string {
|
||||
if (params.length === 0) return '';
|
||||
const items = params.map(([name, expr]) => {
|
||||
const trimmed = expr.trim();
|
||||
if (isIdentifier(trimmed) && trimmed === name) return name;
|
||||
return `${name}: ${trimmed}`;
|
||||
});
|
||||
return `, { ${items.join(', ')} }`;
|
||||
}
|
||||
|
||||
// --- Rewrite ------------------------------------------------------------
|
||||
|
||||
const HREF_RE =
|
||||
/(<([A-Za-z][\w.-]*)\b[^>]*?\s)href="(\/[^"]*\{[^"]*\}[^"]*)"/gs;
|
||||
|
||||
type Skip = { path: string; reason: string };
|
||||
|
||||
function rewriteHrefs(src: string): {
|
||||
code: string;
|
||||
changed: number;
|
||||
skipped: Skip[];
|
||||
} {
|
||||
let changed = 0;
|
||||
const skipped: Skip[] = [];
|
||||
const code = src.replace(HREF_RE, (full, prefix, tag, path) => {
|
||||
if (SKIP_TAGS.has(tag.toLowerCase())) return full;
|
||||
const segs = parsePath(path);
|
||||
if (!segs) {
|
||||
skipped.push({ path, reason: 'unparsable (mixed/query/fragment)' });
|
||||
return full;
|
||||
}
|
||||
const matches = matchTree(ROUTE_TREE, segs, [], []);
|
||||
if (matches.length === 0) {
|
||||
skipped.push({ path, reason: 'no route match' });
|
||||
return full;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
skipped.push({
|
||||
path,
|
||||
reason: `${matches.length} ambiguous matches: ${matches.map((m) => m.routeId).join(' | ')}`
|
||||
});
|
||||
return full;
|
||||
}
|
||||
const { routeId, params } = matches[0];
|
||||
changed++;
|
||||
return `${prefix}href={resolve('${routeId}'${formatParams(params)})}`;
|
||||
});
|
||||
return { code, changed, skipped };
|
||||
}
|
||||
|
||||
// --- Import injection ---------------------------------------------------
|
||||
|
||||
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/;
|
||||
const PATHS_IMPORT_RE =
|
||||
/import\s*\{([^}]*)\}\s*from\s*['"]\$app\/paths['"]\s*;?/;
|
||||
|
||||
function ensureResolveImport(src: string): string {
|
||||
const m = SCRIPT_RE.exec(src);
|
||||
if (!m) {
|
||||
return `<script lang="ts">\n\timport { resolve } from '$app/paths';\n</script>\n\n${src}`;
|
||||
}
|
||||
const [scriptFull, attrs, body] = m;
|
||||
const pm = PATHS_IMPORT_RE.exec(body);
|
||||
if (pm) {
|
||||
const inner = pm[1];
|
||||
if (/\bresolve\b/.test(inner)) return src;
|
||||
const merged = inner.trim().replace(/,?\s*$/, '') + ', resolve';
|
||||
const newImport = `import { ${merged} } from '$app/paths';`;
|
||||
const newBody = body.replace(PATHS_IMPORT_RE, newImport);
|
||||
return src.replace(scriptFull, `<script${attrs}>${newBody}</script>`);
|
||||
}
|
||||
const im = body.match(/^([ \t]*)import\b/m);
|
||||
const indent = im ? im[1] : '\t';
|
||||
const opening = `<script${attrs}>`;
|
||||
return src.replace(
|
||||
scriptFull,
|
||||
`${opening}\n${indent}import { resolve } from '$app/paths';${body}</script>`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Driver -------------------------------------------------------------
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const s = statSync(p);
|
||||
if (s.isDirectory()) walk(p, out);
|
||||
else if (extname(p) === '.svelte') out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = walk(SRC);
|
||||
let totalFiles = 0;
|
||||
let totalReplacements = 0;
|
||||
const allSkipped: Array<{ file: string } & Skip> = [];
|
||||
|
||||
for (const f of files) {
|
||||
const orig = readFileSync(f, 'utf8');
|
||||
const { code, changed, skipped } = rewriteHrefs(orig);
|
||||
for (const s of skipped) allSkipped.push({ file: f, ...s });
|
||||
if (changed === 0) continue;
|
||||
const final = ensureResolveImport(code);
|
||||
if (!DRY) writeFileSync(f, final);
|
||||
totalFiles++;
|
||||
totalReplacements += changed;
|
||||
console.log(`${changed.toString().padStart(3)} ${f}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${DRY ? '[dry] ' : ''}${totalReplacements} replacements across ${totalFiles} files`
|
||||
);
|
||||
if (allSkipped.length > 0) {
|
||||
console.log(`\n--- ${allSkipped.length} skipped hrefs ---`);
|
||||
for (const s of allSkipped) {
|
||||
console.log(` ${s.file}\n ${s.path} [${s.reason}]`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Bucket 1 codemod: replace literal href="/path" with href={resolve('/path')}
|
||||
* in .svelte files, and inject `import { resolve } from '$app/paths'`.
|
||||
*
|
||||
* Skips:
|
||||
* - non-anchor tags: <link>, <image> (svg), <use>
|
||||
* - external/protocol URLs: http(s)://, //host, mailto:, tel:
|
||||
* - fragments (#...) and empty values
|
||||
* - existing dynamic hrefs ({...})
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/codemod-href-resolve.ts [--dry]
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
|
||||
const ROOT = 'src';
|
||||
const DRY = process.argv.includes('--dry');
|
||||
|
||||
const SKIP_TAGS = new Set(['link', 'image', 'use']);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const s = statSync(p);
|
||||
if (s.isDirectory()) walk(p, out);
|
||||
else if (extname(p) === '.svelte') out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match: opening of element, then its attributes, then href="/...".
|
||||
* Group 1 = full prefix incl. tag-name, Group 2 = tag name, Group 3 = path.
|
||||
*/
|
||||
// Excludes `{` and `}` so Svelte template interpolations inside the
|
||||
// attribute value (e.g. href="/{lang}/foo") are NOT treated as literals.
|
||||
const HREF_RE =
|
||||
/(<([A-Za-z][\w.-]*)\b[^>]*?\s)href="(\/[^"{}]*)"/gs;
|
||||
|
||||
function rewriteHrefs(src: string): { code: string; changed: number } {
|
||||
let changed = 0;
|
||||
const code = src.replace(HREF_RE, (full, prefix, tag, path) => {
|
||||
if (SKIP_TAGS.has(tag.toLowerCase())) return full;
|
||||
// Skip protocol-relative just in case
|
||||
if (path.startsWith('//')) return full;
|
||||
changed++;
|
||||
return `${prefix}href={resolve('${path}')}`;
|
||||
});
|
||||
return { code, changed };
|
||||
}
|
||||
|
||||
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/;
|
||||
const PATHS_IMPORT_RE =
|
||||
/import\s*\{([^}]*)\}\s*from\s*['"]\$app\/paths['"]\s*;?/;
|
||||
|
||||
function ensureResolveImport(src: string): string {
|
||||
const scriptMatch = SCRIPT_RE.exec(src);
|
||||
if (!scriptMatch) {
|
||||
// No script tag — prepend a TS one.
|
||||
return `<script lang="ts">\n\timport { resolve } from '$app/paths';\n</script>\n\n${src}`;
|
||||
}
|
||||
const [scriptFull, attrs, body] = scriptMatch;
|
||||
const pathsMatch = PATHS_IMPORT_RE.exec(body);
|
||||
if (pathsMatch) {
|
||||
const inner = pathsMatch[1];
|
||||
if (/\bresolve\b/.test(inner)) return src; // already imported
|
||||
const merged = inner.trim().replace(/,?\s*$/, '') + ', resolve';
|
||||
const newImport = `import { ${merged} } from '$app/paths';`;
|
||||
const newBody = body.replace(PATHS_IMPORT_RE, newImport);
|
||||
return src.replace(scriptFull, `<script${attrs}>${newBody}</script>`);
|
||||
}
|
||||
// Inject new import line. Detect indent from first import line if present.
|
||||
const importMatch = body.match(/^([ \t]*)import\b/m);
|
||||
const indent = importMatch ? importMatch[1] : '\t';
|
||||
// Insert right after the opening script tag's newline.
|
||||
const opening = `<script${attrs}>`;
|
||||
const insertion = `\n${indent}import { resolve } from '$app/paths';`;
|
||||
const newScript = opening + insertion + body + '</script>';
|
||||
return src.replace(scriptFull, newScript);
|
||||
}
|
||||
|
||||
function processFile(path: string): { changed: number } {
|
||||
const orig = readFileSync(path, 'utf8');
|
||||
const { code: rewritten, changed } = rewriteHrefs(orig);
|
||||
if (changed === 0) return { changed: 0 };
|
||||
const final = ensureResolveImport(rewritten);
|
||||
if (!DRY) writeFileSync(path, final);
|
||||
return { changed };
|
||||
}
|
||||
|
||||
const files = walk(ROOT);
|
||||
let totalFiles = 0;
|
||||
let totalReplacements = 0;
|
||||
for (const f of files) {
|
||||
const { changed } = processFile(f);
|
||||
if (changed > 0) {
|
||||
totalFiles++;
|
||||
totalReplacements += changed;
|
||||
console.log(`${changed.toString().padStart(3)} ${f}`);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`\n${DRY ? '[dry] ' : ''}${totalReplacements} replacements across ${totalFiles} files`
|
||||
);
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Migrate i18n call sites from t('key', lang) to t.key (or t[expr] for
|
||||
* dynamic keys), where t = m[lang] derived once per file. Generic version
|
||||
* — pass the i18n module path and the directories to scan.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec vite-node scripts/codemod-i18n-t-to-m.ts \
|
||||
* --module=$lib/js/cospendI18n \
|
||||
* --root=src/routes/'[cospendRoot=cospendRoot]' \
|
||||
* --root=src/lib/components/cospend \
|
||||
* [--dry]
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const DRY = args.includes('--dry');
|
||||
const modArg = args.find((a) => a.startsWith('--module='));
|
||||
if (!modArg) {
|
||||
console.error('missing --module=<path>');
|
||||
process.exit(1);
|
||||
}
|
||||
const modulePath = modArg.slice('--module='.length);
|
||||
const roots = args
|
||||
.filter((a) => a.startsWith('--root='))
|
||||
.map((a) => a.slice('--root='.length));
|
||||
if (roots.length === 0) {
|
||||
console.error('missing --root=<dir> (at least one)');
|
||||
process.exit(1);
|
||||
}
|
||||
const fnFlag = args.find((a) => a.startsWith('--fn='));
|
||||
const FN = fnFlag ? fnFlag.slice('--fn='.length) : 't';
|
||||
const mFlag = args.find((a) => a.startsWith('--m='));
|
||||
const M_NAME = mFlag ? mFlag.slice('--m='.length) : 'm';
|
||||
|
||||
// Match imports from any path ending in the module basename — call sites
|
||||
// reach calendarI18n via wildly different relative-path depths, so we
|
||||
// don't pin the full path.
|
||||
function esc(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
const IMPORT_RE = new RegExp(
|
||||
`import\\s*\\{([^}]+)\\}\\s*from\\s*(['"])([^'"]*${esc(modulePath)})\\2\\s*;?`
|
||||
);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const s = statSync(p);
|
||||
if (s.isDirectory()) walk(p, out);
|
||||
else if (extname(p) === '.svelte' || extname(p) === '.ts') out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function migrate(src: string): { code: string; changed: boolean } {
|
||||
const m0 = IMPORT_RE.exec(src);
|
||||
if (!m0) return { code: src, changed: false };
|
||||
|
||||
const items = m0[1].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (!items.includes(FN)) return { code: src, changed: false };
|
||||
|
||||
const matchedPath = m0[3];
|
||||
|
||||
// 1. Rewrite import: drop FN, ensure M_NAME present. Preserve original path.
|
||||
const fnIdx = items.indexOf(FN);
|
||||
items.splice(fnIdx, 1);
|
||||
if (!items.includes(M_NAME)) items.push(M_NAME);
|
||||
let out = src.replace(IMPORT_RE, `import { ${items.join(', ')} } from '${matchedPath}';`);
|
||||
|
||||
// 2. Insert `const FN = $derived(M_NAME[lang]);` at the right spot.
|
||||
const insertion = `const ${FN} = $derived(${M_NAME}[lang]);`;
|
||||
let inserted = false;
|
||||
|
||||
const langDerivedRe =
|
||||
/^([ \t]*)(const\s+lang\s*=\s*\$derived\((?:[^()]|\([^()]*\))+\)\s*;?)([ \t]*\n)/m;
|
||||
if (langDerivedRe.test(out)) {
|
||||
out = out.replace(langDerivedRe, (_, indent, decl, nl) => {
|
||||
inserted = true;
|
||||
return `${indent}${decl}${nl}${indent}${insertion}${nl}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
const propsRe =
|
||||
/^([ \t]*)(let\s*\{[\s\S]*?\}\s*=\s*\$props(?:<[\s\S]*?>)?\(\)\s*;?)([ \t]*\n)/m;
|
||||
out = out.replace(propsRe, (full, indent, decl, nl) => {
|
||||
if (!/\blang\b/.test(decl)) return full;
|
||||
inserted = true;
|
||||
return `${indent}${decl}${nl}${indent}${insertion}${nl}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
console.warn(` WARN: could not auto-insert \`${insertion}\` — manual fix needed`);
|
||||
}
|
||||
|
||||
// Build dynamic regex for FN(...) — escape `1962`-style suffixes.
|
||||
const fnEsc = FN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// 3. FN('static_key', lang) → FN.static_key (snake_case OR camelCase identifier)
|
||||
out = out.replace(
|
||||
new RegExp(`\\b${fnEsc}\\(\\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]\\s*,\\s*lang\\s*\\)`, 'g'),
|
||||
`${FN}.$1`
|
||||
);
|
||||
// 4. FN(<expr>, lang) → FN[<expr>]
|
||||
out = out.replace(
|
||||
new RegExp(`\\b${fnEsc}\\(((?:[^()]|\\([^()]*\\))+?)\\s*,\\s*lang\\s*\\)`, 'g'),
|
||||
(_match, expr) => `${FN}[${expr.trim()}]`
|
||||
);
|
||||
|
||||
return { code: out, changed: out !== src };
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const root of roots) {
|
||||
for (const f of walk(root)) {
|
||||
const orig = readFileSync(f, 'utf8');
|
||||
const { code, changed } = migrate(orig);
|
||||
if (!changed) continue;
|
||||
if (!DRY) writeFileSync(f, code);
|
||||
total++;
|
||||
console.log(` ${f}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${DRY ? '[dry] ' : ''}${total} files migrated`);
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build locally and rsync artifacts to the production server.
|
||||
# Avoids running pnpm / npm / any git-hosted prepare step on the server.
|
||||
#
|
||||
# Assumes:
|
||||
# - Local machine matches the server's arch + libc (linux-x64-glibc).
|
||||
# - Local Node major version matches the server's.
|
||||
# - Root SSH to $REMOTE works (key-based).
|
||||
#
|
||||
# Usage: scripts/deploy.sh [--dry-run]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${REMOTE:-root@bocken.org}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
|
||||
REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
|
||||
SERVICE="${SERVICE:-homepage.service}"
|
||||
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
|
||||
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
|
||||
# Hike images live outside the Node app: nginx serves /hikes/<slug>/images/
|
||||
# directly from disk and gates /hikes/<slug>/private/ through Node via
|
||||
# X-Accel-Redirect. The build pipeline writes them to ./hikes-assets/ and we
|
||||
# rsync that tree to the path nginx serves from.
|
||||
HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}"
|
||||
HIKES_ASSETS_OWNER="${HIKES_ASSETS_OWNER:-http:http}"
|
||||
# Private (auth-gated) images for <Image private>. Built into ./private-assets/
|
||||
# and served by nginx ONLY via an `internal` location reached through the
|
||||
# endpoint's X-Accel-Redirect — add this once to the server's nginx config:
|
||||
# location /protected-images/ { internal; alias /var/www/static/private-images/; }
|
||||
PRIVATE_ASSETS_DIR="${PRIVATE_ASSETS_DIR:-/var/www/static/private-images}"
|
||||
PRIVATE_ASSETS_OWNER="${PRIVATE_ASSETS_OWNER:-http:http}"
|
||||
|
||||
DRY=""
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
DRY="--dry-run"
|
||||
echo ":: DRY RUN — no files will be transferred"
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo ":: Sanity-checking local/remote toolchain parity"
|
||||
local_node=$(node --version)
|
||||
remote_node=$(ssh "$REMOTE" 'node --version')
|
||||
if [[ "${local_node%%.*}" != "${remote_node%%.*}" ]]; then
|
||||
echo "!! Node major mismatch: local $local_node vs remote $remote_node"
|
||||
echo " Native modules (sharp, onnxruntime, bson) may break. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
echo " node $local_node (match)"
|
||||
|
||||
echo ":: Installing deps (frozen lockfile)"
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Build against production env, NOT the dev .env. SvelteKit's
|
||||
# `$env/static/private` (IMAGE_DIR, DB creds, …) is inlined at BUILD time, so a
|
||||
# build that picks up the dev .env ships dev values to prod — e.g. the relative
|
||||
# IMAGE_DIR="./imgs/" that resolves under the service's dist/ cwd instead of the
|
||||
# real served image dir. We export .env_prod into the environment; real env vars
|
||||
# take precedence over .env files in Vite/SvelteKit's env loading, so this wins
|
||||
# for the whole `pnpm build` lifecycle (prebuild vite-node scripts + build).
|
||||
PROD_ENV="${PROD_ENV:-.env_prod}"
|
||||
if [[ ! -f "$PROD_ENV" ]]; then
|
||||
echo "!! $PROD_ENV not found in $(pwd) — refusing to build with the dev .env."
|
||||
echo " Create $PROD_ENV with production values (IMAGE_DIR must be an"
|
||||
echo " ABSOLUTE path to the served recipe-image dir, DB creds, etc.)."
|
||||
exit 1
|
||||
fi
|
||||
echo ":: Building (env from $PROD_ENV)"
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$PROD_ENV"
|
||||
set +a
|
||||
pnpm build
|
||||
|
||||
if [[ ! -d build ]]; then
|
||||
echo "!! build/ not produced — aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The server's systemd unit runs from $REMOTE_DIR/dist, so map build → dist.
|
||||
echo ":: Syncing build/ → $REMOTE:$REMOTE_DIR/dist/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
build/ "$REMOTE:$REMOTE_DIR/dist/"
|
||||
|
||||
echo ":: Syncing node_modules/ → $REMOTE:$REMOTE_DIR/node_modules/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
node_modules/ "$REMOTE:$REMOTE_DIR/node_modules/"
|
||||
|
||||
echo ":: Syncing static/ → $REMOTE:$REMOTE_DIR/static/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
static/ "$REMOTE:$REMOTE_DIR/static/"
|
||||
|
||||
echo ":: Syncing package.json + pnpm-lock.yaml"
|
||||
rsync -az $DRY \
|
||||
package.json pnpm-lock.yaml "$REMOTE:$REMOTE_DIR/"
|
||||
|
||||
if [[ ! -d build/client/errors ]]; then
|
||||
echo "!! build/client/errors not produced — postbuild error-page step did not run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ":: Syncing error pages → $REMOTE:$ERROR_PAGES_DIR/"
|
||||
ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/"
|
||||
|
||||
if [[ -d hikes-assets ]]; then
|
||||
echo ":: Syncing hikes-assets/ → $REMOTE:$HIKES_ASSETS_DIR/"
|
||||
ssh "$REMOTE" "mkdir -p $HIKES_ASSETS_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
hikes-assets/ "$REMOTE:$HIKES_ASSETS_DIR/"
|
||||
else
|
||||
echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync"
|
||||
fi
|
||||
|
||||
if [[ -d private-assets ]]; then
|
||||
echo ":: Syncing private-assets/ → $REMOTE:$PRIVATE_ASSETS_DIR/"
|
||||
ssh "$REMOTE" "mkdir -p $PRIVATE_ASSETS_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
private-assets/ "$REMOTE:$PRIVATE_ASSETS_DIR/"
|
||||
else
|
||||
echo ":: No private-assets/ dir — skipping auth-gated image sync"
|
||||
fi
|
||||
|
||||
if [[ -n "$DRY" ]]; then
|
||||
echo ":: Dry run complete — no service restart"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ":: Fixing ownership on server"
|
||||
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR && if [[ -d $HIKES_ASSETS_DIR ]]; then chown -R $HIKES_ASSETS_OWNER $HIKES_ASSETS_DIR; fi && if [[ -d $PRIVATE_ASSETS_DIR ]]; then chown -R $PRIVATE_ASSETS_OWNER $PRIVATE_ASSETS_DIR; fi"
|
||||
|
||||
echo ":: Restarting $SERVICE"
|
||||
ssh "$REMOTE" "systemctl restart $SERVICE"
|
||||
|
||||
echo ":: Verifying service is active"
|
||||
sleep 2
|
||||
if ssh "$REMOTE" "systemctl is-active --quiet $SERVICE"; then
|
||||
echo " $SERVICE is running"
|
||||
else
|
||||
echo "!! $SERVICE failed to start — check logs:"
|
||||
ssh "$REMOTE" "journalctl -u $SERVICE -n 30 --no-pager"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ":: Deploy complete"
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* One-shot fetch of the 26 Swiss cantonal coats of arms (Wappen) from
|
||||
* Wikimedia Commons into `static/cantons/<iso-code>.svg`. Files are
|
||||
* public-domain Swiss official insignia (PD-CH-coat-of-arms); we keep
|
||||
* the source filename in a header comment for traceability.
|
||||
*
|
||||
* Re-run with `pnpm exec vite-node scripts/download-cantons.ts` to refresh
|
||||
* any missing files. Existing files are left alone — the cantonal arms
|
||||
* don't change.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
type CantonEntry = {
|
||||
code: string; // ISO 3166-2:CH (lowercase for filename)
|
||||
commonsFile: string; // Commons filename WITHOUT the `File:` prefix
|
||||
};
|
||||
|
||||
// Names follow the "Wappen <German-name> matt.svg" convention used across
|
||||
// almost all cantons on Commons. The handful of exceptions (Basel-Stadt,
|
||||
// Basel-Landschaft, the two Appenzells) are spelt out explicitly. If a
|
||||
// fetch returns 404 the script logs the failure and continues so the
|
||||
// remaining cantons still land.
|
||||
const CANTONS: CantonEntry[] = [
|
||||
{ code: 'ag', commonsFile: 'Wappen Aargau matt.svg' },
|
||||
{ code: 'ai', commonsFile: 'Wappen Appenzell Innerrhoden matt.svg' },
|
||||
{ code: 'ar', commonsFile: 'Wappen Appenzell Ausserrhoden matt.svg' },
|
||||
{ code: 'be', commonsFile: 'Wappen Bern matt.svg' },
|
||||
{ code: 'bl', commonsFile: 'Wappen Basel-Landschaft matt.svg' },
|
||||
{ code: 'bs', commonsFile: 'Wappen Basel-Stadt matt.svg' },
|
||||
{ code: 'fr', commonsFile: 'Wappen Freiburg matt.svg' },
|
||||
{ code: 'ge', commonsFile: 'Wappen Genf matt.svg' },
|
||||
{ code: 'gl', commonsFile: 'Wappen Glarus matt.svg' },
|
||||
{ code: 'gr', commonsFile: 'Wappen Graubünden matt.svg' },
|
||||
{ code: 'ju', commonsFile: 'Wappen Jura matt.svg' },
|
||||
{ code: 'lu', commonsFile: 'Wappen Luzern matt.svg' },
|
||||
{ code: 'ne', commonsFile: 'Wappen Neuenburg matt.svg' },
|
||||
{ code: 'nw', commonsFile: 'Wappen Nidwalden matt.svg' },
|
||||
{ code: 'ow', commonsFile: 'Wappen Obwalden matt.svg' },
|
||||
{ code: 'sg', commonsFile: 'Wappen St. Gallen matt.svg' },
|
||||
{ code: 'sh', commonsFile: 'Wappen Schaffhausen matt.svg' },
|
||||
{ code: 'so', commonsFile: 'Wappen Solothurn matt.svg' },
|
||||
{ code: 'sz', commonsFile: 'Wappen Schwyz matt.svg' },
|
||||
{ code: 'tg', commonsFile: 'Wappen Thurgau matt.svg' },
|
||||
{ code: 'ti', commonsFile: 'Wappen Tessin matt.svg' },
|
||||
{ code: 'ur', commonsFile: 'Wappen Uri matt.svg' },
|
||||
{ code: 'vd', commonsFile: 'Wappen Waadt matt.svg' },
|
||||
{ code: 'vs', commonsFile: 'Wappen Wallis matt.svg' },
|
||||
{ code: 'zg', commonsFile: 'Wappen Zug matt.svg' },
|
||||
{ code: 'zh', commonsFile: 'Wappen Zürich matt.svg' }
|
||||
];
|
||||
|
||||
const OUT_DIR = path.resolve(process.cwd(), 'static', 'cantons');
|
||||
const UA = 'bocken-homepage cantons-downloader (https://bocken.org)';
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/** Resolve a Commons `File:Foo.svg` to its actual upload.wikimedia.org URL
|
||||
* via the public API. Returns null on failure (typo in filename, etc.). */
|
||||
async function resolveCommonsUrl(file: string): Promise<string | null> {
|
||||
const url =
|
||||
'https://commons.wikimedia.org/w/api.php' +
|
||||
'?action=query&format=json&prop=imageinfo&iiprop=url' +
|
||||
'&titles=' + encodeURIComponent('File:' + file);
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as {
|
||||
query?: { pages?: Record<string, { imageinfo?: Array<{ url?: string }> }> };
|
||||
};
|
||||
const pages = json.query?.pages;
|
||||
if (!pages) return null;
|
||||
for (const page of Object.values(pages)) {
|
||||
const u = page.imageinfo?.[0]?.url;
|
||||
if (u) return u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadCanton(c: CantonEntry): Promise<'ok' | 'cached' | 'failed'> {
|
||||
const outPath = path.join(OUT_DIR, `${c.code}.svg`);
|
||||
if (await exists(outPath)) return 'cached';
|
||||
|
||||
const url = await resolveCommonsUrl(c.commonsFile);
|
||||
if (!url) {
|
||||
console.warn(`[cantons] ${c.code}: could not resolve Commons file "${c.commonsFile}"`);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) {
|
||||
console.warn(`[cantons] ${c.code}: HTTP ${res.status} fetching ${url}`);
|
||||
return 'failed';
|
||||
}
|
||||
const body = await res.text();
|
||||
// Don't prepend anything: most of these files start with an `<?xml … ?>`
|
||||
// declaration, and that MUST be the very first thing in the file or
|
||||
// strict XML parsers (including browsers loading via `<img>`) reject
|
||||
// the document. Provenance is tracked in the CANTONS table above
|
||||
// instead — keep it out of the file bytes.
|
||||
await fs.writeFile(outPath, body);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
let ok = 0, cached = 0, failed = 0;
|
||||
for (const c of CANTONS) {
|
||||
const r = await downloadCanton(c);
|
||||
if (r === 'ok') ok++;
|
||||
else if (r === 'cached') cached++;
|
||||
else failed++;
|
||||
if (r === 'ok') console.log(`[cantons] ${c.code}: downloaded`);
|
||||
else if (r === 'cached') console.log(`[cantons] ${c.code}: cached`);
|
||||
}
|
||||
console.log(`[cantons] done — ${ok} downloaded, ${cached} cached, ${failed} failed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[cantons] fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Re-derive track-point altitudes from a real terrain model.
|
||||
*
|
||||
* Phone GPS altitude is noisy (often ±10-20 m), which throws off the elevation
|
||||
* profile and the ascend/descend stats. This script keeps every point's exact
|
||||
* lat/lon and only rewrites its `<ele>`, sourcing the height from swisstopo's
|
||||
* swissALTI3D / DHM25 combined model (~0.5-2 m vertical accuracy) at that exact
|
||||
* coordinate.
|
||||
*
|
||||
* 1. Collect every `<wpt>` and `<trkpt>` in each `track.gpx`.
|
||||
* 2. Convert WGS84 → LV95 (swisstopo approximate formula, ~1 m horizontal —
|
||||
* negligible for an elevation lookup).
|
||||
* 3. Ask swisstopo for the height of each distinct point (one batched
|
||||
* `profile.json` POST per ~1000 points; per-point `height` as a fallback),
|
||||
* cached on disk so re-runs and shared points are free.
|
||||
* 4. Surgically replace each point's `<ele>` value, leaving coordinates,
|
||||
* timestamps, `<bocken:image>` extensions and all formatting untouched.
|
||||
*
|
||||
* swisstopo only covers Switzerland: points outside CH keep their original
|
||||
* elevation and are reported as skipped.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec vite-node scripts/fix-altitudes.ts [slug...] [--dry-run]
|
||||
* (no slug → every hike under src/content/hikes/)
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(process.cwd());
|
||||
const CONTENT_DIR = path.join(ROOT, 'src', 'content', 'hikes');
|
||||
const CACHE_DIR = path.join(ROOT, 'scripts', '.cache');
|
||||
const CACHE_FILE = path.join(CACHE_DIR, 'swisstopo-elevation.json');
|
||||
|
||||
const PROFILE_URL = 'https://api3.geo.admin.ch/rest/services/profile.json';
|
||||
const HEIGHT_URL = 'https://api3.geo.admin.ch/rest/services/height';
|
||||
// swisstopo's profile service handles a few thousand vertices per call; keep
|
||||
// chunks well under that so the POST body and response stay modest.
|
||||
const PROFILE_CHUNK = 1000;
|
||||
|
||||
// Matches a <wpt>/<trkpt> opening tag and its immediate <ele> child. The route
|
||||
// builder always writes `<ele>` as the first child (verified across every
|
||||
// track.gpx), so a single capture group around the value is enough to rewrite.
|
||||
const POINT_ELE_RE =
|
||||
/(<(?:wpt|trkpt)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>\s*<ele>)([^<]*)(<\/ele>)/g;
|
||||
|
||||
type Cache = Record<string, number>;
|
||||
|
||||
/** WGS84 (lat/lon, degrees) → CH1903+/LV95 (E, N), swisstopo approx formula. */
|
||||
function wgs84ToLV95(lat: number, lon: number): [number, number] {
|
||||
const phi = (lat * 3600 - 169028.66) / 10000;
|
||||
const lam = (lon * 3600 - 26782.5) / 10000;
|
||||
const E =
|
||||
2600072.37 +
|
||||
211455.93 * lam -
|
||||
10938.51 * lam * phi -
|
||||
0.36 * lam * phi * phi -
|
||||
44.54 * lam ** 3;
|
||||
const N =
|
||||
1200147.07 +
|
||||
308807.95 * phi +
|
||||
3745.25 * lam * lam +
|
||||
76.63 * phi * phi -
|
||||
194.56 * lam * lam * phi +
|
||||
119.79 * phi ** 3;
|
||||
return [Math.round(E * 100) / 100, Math.round(N * 100) / 100];
|
||||
}
|
||||
|
||||
const enKey = (E: number, N: number): string => `${E.toFixed(2)},${N.toFixed(2)}`;
|
||||
|
||||
async function loadCache(): Promise<Cache> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(CACHE_FILE, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCache(cache: Cache): Promise<void> {
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(CACHE_FILE, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
/** Batched height lookup. Returns a map of `enKey` → height for resolved points. */
|
||||
async function fetchProfile(coords: [number, number][]): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
if (coords.length < 2) return out;
|
||||
const body = new URLSearchParams({
|
||||
geom: JSON.stringify({ type: 'LineString', coordinates: coords }),
|
||||
sr: '2056',
|
||||
distinct_points: 'true',
|
||||
nb_points: String(coords.length),
|
||||
offset: '0'
|
||||
});
|
||||
const res = await fetch(PROFILE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
});
|
||||
if (!res.ok) throw new Error(`profile.json HTTP ${res.status}`);
|
||||
const rows = (await res.json()) as Array<{
|
||||
alts?: Record<string, number | null>;
|
||||
easting: number;
|
||||
northing: number;
|
||||
}>;
|
||||
for (const r of rows) {
|
||||
const h = r.alts?.COMB ?? r.alts?.DTM2 ?? r.alts?.DTM25;
|
||||
if (typeof h === 'number') out.set(enKey(r.easting, r.northing), h);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Single-point fallback (also the only option for a 1-point chunk). */
|
||||
async function fetchHeight(E: number, N: number): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(`${HEIGHT_URL}?easting=${E}&northing=${N}&sr=2056`);
|
||||
if (!res.ok) return null;
|
||||
const j = (await res.json()) as { height?: string | number; success?: boolean };
|
||||
if (j.success === false) return null;
|
||||
const h = typeof j.height === 'string' ? parseFloat(j.height) : j.height;
|
||||
return typeof h === 'number' && Number.isFinite(h) ? h : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PointKey = string; // `${latStr},${lonStr}` exactly as written in the file
|
||||
|
||||
async function fixTrack(slug: string, cache: Cache, dryRun: boolean): Promise<void> {
|
||||
const file = path.join(CONTENT_DIR, slug, 'track.gpx');
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(file, 'utf-8');
|
||||
} catch {
|
||||
console.warn(`[fix-altitudes] ${slug}: no track.gpx, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Distinct points, keyed by the exact lat/lon strings in the file so the
|
||||
// rewrite can match without any float round-tripping.
|
||||
const points = new Map<PointKey, { lat: number; lon: number; E: number; N: number }>();
|
||||
for (const m of text.matchAll(POINT_ELE_RE)) {
|
||||
const key = `${m[2]},${m[3]}`;
|
||||
if (!points.has(key)) {
|
||||
const lat = parseFloat(m[2]);
|
||||
const lon = parseFloat(m[3]);
|
||||
const [E, N] = wgs84ToLV95(lat, lon);
|
||||
points.set(key, { lat, lon, E, N });
|
||||
}
|
||||
}
|
||||
if (points.size === 0) {
|
||||
console.warn(`[fix-altitudes] ${slug}: no points found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve heights for any points not already cached.
|
||||
const uncached = [...points.values()].filter((p) => cache[enKey(p.E, p.N)] === undefined);
|
||||
if (uncached.length > 0) {
|
||||
for (let i = 0; i < uncached.length; i += PROFILE_CHUNK) {
|
||||
const chunk = uncached.slice(i, i + PROFILE_CHUNK);
|
||||
let resolved = new Map<string, number>();
|
||||
try {
|
||||
resolved = await fetchProfile(chunk.map((p) => [p.E, p.N] as [number, number]));
|
||||
} catch (err) {
|
||||
console.warn(`[fix-altitudes] ${slug}: profile batch failed (${String(err)}), falling back per-point`);
|
||||
}
|
||||
for (const p of chunk) {
|
||||
const k = enKey(p.E, p.N);
|
||||
let h = resolved.get(k);
|
||||
if (h === undefined) h = (await fetchHeight(p.E, p.N)) ?? undefined;
|
||||
if (h !== undefined) cache[k] = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite each <ele> in place; tally changes and out-of-CH skips.
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let maxDelta = 0;
|
||||
const fixed = text.replace(POINT_ELE_RE, (full, open, latStr, lonStr, oldEle, close) => {
|
||||
const p = points.get(`${latStr},${lonStr}`)!;
|
||||
const h = cache[enKey(p.E, p.N)];
|
||||
if (h === undefined) {
|
||||
skipped++;
|
||||
return full; // outside CH coverage — keep original elevation
|
||||
}
|
||||
const newEle = h.toFixed(1);
|
||||
const old = parseFloat(oldEle);
|
||||
if (Number.isFinite(old)) maxDelta = Math.max(maxDelta, Math.abs(h - old));
|
||||
if (newEle !== oldEle.trim()) updated++;
|
||||
return `${open}${newEle}${close}`;
|
||||
});
|
||||
|
||||
const summary =
|
||||
`${points.size} distinct pts · ${updated} ele rewritten · ` +
|
||||
`max Δ ${maxDelta.toFixed(1)} m` +
|
||||
(skipped > 0 ? ` · ${skipped} kept (outside CH)` : '');
|
||||
if (dryRun) {
|
||||
console.log(`[fix-altitudes] ${slug}: ${summary} (dry-run, not written)`);
|
||||
return;
|
||||
}
|
||||
if (fixed !== text) {
|
||||
await fs.writeFile(file, fixed);
|
||||
console.log(`[fix-altitudes] ${slug}: ${summary}`);
|
||||
} else {
|
||||
console.log(`[fix-altitudes] ${slug}: already up to date (${summary})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const slugArgs = args.filter((a) => !a.startsWith('--'));
|
||||
|
||||
let slugs = slugArgs;
|
||||
if (slugs.length === 0) {
|
||||
const entries = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
|
||||
slugs = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('TODO-'))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const cache = await loadCache();
|
||||
for (const slug of slugs) {
|
||||
await fixTrack(slug, cache, dryRun);
|
||||
}
|
||||
await saveCache(cache);
|
||||
console.log(`[fix-altitudes] done (${slugs.length} track(s), cache: ${Object.keys(cache).length} pts)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[fix-altitudes] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Build-time generation of bilingual Bible quotes per HTTP error status.
|
||||
*
|
||||
* Looks up curated references in static/allioli.tsv (DE) + static/drb.tsv (EN)
|
||||
* via the existing bible reference parser, then writes the resolved verses to
|
||||
* src/lib/data/errorQuotes.json for the prerendered /errors/[status] pages.
|
||||
*
|
||||
* - Add or change a status by editing REFS below.
|
||||
* - Refs use the abbreviations defined in the TSVs (e.g. Mt 7,7 / Mt 7:7).
|
||||
* - Fails the build if any reference cannot be resolved.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/generate-error-quotes.ts
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { lookupReference } from '../src/lib/server/bible';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const ALLIOLI = join(ROOT, 'static/allioli.tsv');
|
||||
const DRB = join(ROOT, 'static/drb.tsv');
|
||||
const OUT = join(ROOT, 'src/lib/data/errorQuotes.json');
|
||||
|
||||
// Curated refs. Abbreviations must match the TSV's `abbreviation` column.
|
||||
const REFS: Record<number, { de: string; en: string }> = {
|
||||
401: { de: 'Mt 7,7', en: 'Mt 7:7' },
|
||||
403: { de: 'Mt 7,14', en: 'Mt 7:14' },
|
||||
404: { de: 'Mt 7,8', en: 'Mt 7:8' },
|
||||
500: { de: '2Kor 4,7', en: '2Cor 4:7' },
|
||||
502: { de: '1Mo 11,9', en: 'Gn 11:9' },
|
||||
503: { de: 'Ps 37,7', en: 'Ps 37:7' },
|
||||
504: { de: 'Jes 40,31', en: 'Is 40:31' }
|
||||
};
|
||||
|
||||
type ResolvedQuote = { text: string; reference: string };
|
||||
|
||||
function resolveOne(ref: string, tsv: string): ResolvedQuote {
|
||||
const result = lookupReference(ref, tsv);
|
||||
if (!result || result.verses.length === 0) {
|
||||
throw new Error(`could not resolve reference "${ref}" in ${tsv}`);
|
||||
}
|
||||
// Range refs join verses with a space. Display reference reuses the
|
||||
// original input so the UI keeps the canonical "Mt 7,7" / "Mt 7:7" form.
|
||||
const text = result.verses.map((v) => v.text).join(' ');
|
||||
return { text, reference: ref };
|
||||
}
|
||||
|
||||
const out: Record<string, { de: ResolvedQuote; en: ResolvedQuote }> = {};
|
||||
for (const [status, refs] of Object.entries(REFS)) {
|
||||
out[status] = {
|
||||
de: resolveOne(refs.de, ALLIOLI),
|
||||
en: resolveOne(refs.en, DRB)
|
||||
};
|
||||
console.log(`[error-quotes] ${status}: ${refs.de} / ${refs.en}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
||||
console.log(`[error-quotes] wrote ${OUT.replace(ROOT + '/', '')} (${Object.keys(out).length} statuses)`);
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Build-time generation of loyalty-card barcode SVGs.
|
||||
*
|
||||
* Reads card numbers from env vars and writes static/shopping/supercard.svg
|
||||
* + static/shopping/cumulus.svg. Fails the build if any required env is
|
||||
* unset so deploys can't silently ship a broken UI.
|
||||
*
|
||||
* SHOPPING_COOP_SUPERCARD_NUMBER → Data Matrix (Coop Supercard)
|
||||
* SHOPPING_MIGROS_CUMULUS_NUMBER → Code 128 (Migros Cumulus)
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/generate-loyalty-cards.ts
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { toSVG } from 'bwip-js/node';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(HERE, '..', 'static', 'shopping');
|
||||
|
||||
type CardSpec = {
|
||||
envVar: string;
|
||||
filename: string;
|
||||
bcid: 'datamatrix' | 'code128';
|
||||
scale: number;
|
||||
parsefnc?: boolean;
|
||||
};
|
||||
|
||||
const cards: CardSpec[] = [
|
||||
// Coop Supercard uses GS1 Data Matrix with FNC1 separators between fields.
|
||||
// Put ^FNC1 in the env value wherever the real symbol has a separator
|
||||
// (dmtxread -G prints them as 0x1D); parsefnc: true turns each ^FNC1 into
|
||||
// a genuine FNC1 codeword so the regenerated code matches the card.
|
||||
{ envVar: 'SHOPPING_COOP_SUPERCARD_NUMBER', filename: 'supercard.svg', bcid: 'datamatrix', scale: 6, parsefnc: true },
|
||||
{ envVar: 'SHOPPING_MIGROS_CUMULUS_NUMBER', filename: 'cumulus.svg', bcid: 'code128', scale: 3 }
|
||||
];
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const missing = cards.filter((c) => !process.env[c.envVar]?.trim()).map((c) => c.envVar);
|
||||
if (missing.length) {
|
||||
console.error(`[loyalty-cards] missing required env: ${missing.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const card of cards) {
|
||||
const value = process.env[card.envVar]!.trim();
|
||||
const outPath = resolve(OUT_DIR, card.filename);
|
||||
|
||||
const svg = toSVG({
|
||||
bcid: card.bcid,
|
||||
text: value,
|
||||
scale: card.scale,
|
||||
includetext: false,
|
||||
paddingwidth: 8,
|
||||
paddingheight: 8,
|
||||
...(card.parsefnc ? { parsefnc: true } : {})
|
||||
});
|
||||
|
||||
writeFileSync(outPath, svg, 'utf8');
|
||||
console.log(`[loyalty-cards] wrote ${card.filename} (${card.bcid})`);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync hike *source* photos to/from a backup server, keeping them out of git.
|
||||
#
|
||||
# The repo tracks each hike's index.svx + track.gpx — the manifest of which
|
||||
# images a hike uses (by content hash) and where they sit on the route. The
|
||||
# original JPEGs are large and live here instead of in git. `push` backs the
|
||||
# local photos up; `pull` restores them so any machine can run build-hikes and
|
||||
# reproduce the encoded static assets.
|
||||
#
|
||||
# Only photo files are transferred — images/, private/ and root cover.* —
|
||||
# mirroring the .gitignore rules; the text files stay in git and are skipped.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/hike-photos.sh push [--dry-run] [--delete]
|
||||
# scripts/hike-photos.sh pull [--dry-run] [--delete]
|
||||
#
|
||||
# --dry-run show what would transfer, change nothing
|
||||
# --delete mirror exactly (remove extra files on the destination) — careful
|
||||
#
|
||||
# Config (env vars, with defaults):
|
||||
# REMOTE SSH host (default root@bocken.org)
|
||||
# HIKE_PHOTOS_DIR remote dir for originals (default /var/backups/hike-photos)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${REMOTE:-root@bocken.org}"
|
||||
HIKE_PHOTOS_DIR="${HIKE_PHOTOS_DIR:-/var/backups/hike-photos}"
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
LOCAL="src/content/hikes/"
|
||||
REMOTE_PATH="$REMOTE:$HIKE_PHOTOS_DIR/"
|
||||
|
||||
cmd="${1:-}"
|
||||
shift || true
|
||||
|
||||
EXTRA=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) EXTRA+=(--dry-run); echo ":: DRY RUN — nothing will be transferred" ;;
|
||||
--delete) EXTRA+=(--delete) ;;
|
||||
*) echo "!! Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Transfer only the photo files: descend into each hike dir, take images/,
|
||||
# private/ and a root cover.*, drop everything else (index.svx, track.gpx,
|
||||
# icon.svg — those are versioned in git). Empty dirs (e.g. text-only TODO
|
||||
# drafts) are pruned so the backup stays clean.
|
||||
FILTERS=(
|
||||
--prune-empty-dirs
|
||||
--include='/*/'
|
||||
--include='/*/images/'
|
||||
--include='/*/images/**'
|
||||
--include='/*/private/'
|
||||
--include='/*/private/**'
|
||||
--include='/*/cover.*'
|
||||
--exclude='*'
|
||||
)
|
||||
|
||||
case "$cmd" in
|
||||
push)
|
||||
echo ":: Pushing hike photos → $REMOTE_PATH"
|
||||
ssh "$REMOTE" "mkdir -p '$HIKE_PHOTOS_DIR'"
|
||||
rsync -avh --info=progress2 "${EXTRA[@]}" "${FILTERS[@]}" "$LOCAL" "$REMOTE_PATH"
|
||||
;;
|
||||
pull)
|
||||
echo ":: Pulling hike photos ← $REMOTE_PATH"
|
||||
mkdir -p "$LOCAL"
|
||||
rsync -avh --info=progress2 "${EXTRA[@]}" "${FILTERS[@]}" "$REMOTE_PATH" "$LOCAL"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {push|pull} [--dry-run] [--delete]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ":: done"
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* One-time migration: convert legacy `season: number[]` (months 1–12) on every
|
||||
* Recipe document to the new `seasonRanges: SeasonRange[]` shape.
|
||||
*
|
||||
* Contiguous months are coalesced into a single range. A wrap across the year
|
||||
* boundary (e.g. months [11, 12, 1, 2]) merges into one wrapping range
|
||||
* Nov 1 → Feb 28; non-contiguous months stay as separate ranges.
|
||||
*
|
||||
* The legacy `season` field is then $unset.
|
||||
*
|
||||
* Run before deploying the new code path:
|
||||
* pnpm exec vite-node scripts/migrate-season-to-ranges.ts
|
||||
*
|
||||
* Idempotent: a recipe with no `season` field is left untouched.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const envPath = resolve(import.meta.dirname ?? '.', '..', '.env');
|
||||
const envText = readFileSync(envPath, 'utf-8');
|
||||
const mongoMatch = envText.match(/^MONGO_URL="?([^"\n]+)"?/m);
|
||||
if (!mongoMatch) { console.error('MONGO_URL not found in .env'); process.exit(1); }
|
||||
const MONGO_URL = mongoMatch[1];
|
||||
|
||||
const LAST_DAY = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
type FixedRange = { startM: number; endM: number };
|
||||
|
||||
/**
|
||||
* Coalesce a set of months (1–12) into contiguous ranges, merging the
|
||||
* year-boundary wrap if both Jan and Dec runs are present.
|
||||
*/
|
||||
function coalesceMonths(months: number[]): FixedRange[] {
|
||||
const sorted = [...new Set(months.filter(m => Number.isInteger(m) && m >= 1 && m <= 12))].sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const runs: FixedRange[] = [];
|
||||
let runStart = sorted[0];
|
||||
let runEnd = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i] === runEnd + 1) {
|
||||
runEnd = sorted[i];
|
||||
} else {
|
||||
runs.push({ startM: runStart, endM: runEnd });
|
||||
runStart = sorted[i];
|
||||
runEnd = sorted[i];
|
||||
}
|
||||
}
|
||||
runs.push({ startM: runStart, endM: runEnd });
|
||||
|
||||
// Merge the trailing-Dec run into the leading-Jan run so a winter span
|
||||
// like [11,12,1,2] becomes one wrapping Nov→Feb range instead of two.
|
||||
if (runs.length >= 2 && runs[0].startM === 1 && runs[runs.length - 1].endM === 12) {
|
||||
const wrapped = { startM: runs[runs.length - 1].startM, endM: runs[0].endM };
|
||||
return [wrapped, ...runs.slice(1, -1)];
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function rangeFromRun(run: FixedRange) {
|
||||
return {
|
||||
start: { kind: 'fixed', m: run.startM, d: 1 },
|
||||
end: { kind: 'fixed', m: run.endM, d: LAST_DAY[run.endM - 1] }
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mongoose.connect(MONGO_URL);
|
||||
const Recipe = mongoose.connection.collection('recipes');
|
||||
|
||||
const cursor = Recipe.find({ season: { $exists: true } });
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
while (await cursor.hasNext()) {
|
||||
const doc = await cursor.next() as any;
|
||||
if (!doc) break;
|
||||
|
||||
const months: number[] = Array.isArray(doc.season) ? doc.season : [];
|
||||
const runs = coalesceMonths(months);
|
||||
|
||||
if (runs.length === 0) {
|
||||
await Recipe.updateOne({ _id: doc._id }, { $unset: { season: '' } });
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonRanges = runs.map(rangeFromRun);
|
||||
|
||||
await Recipe.updateOne(
|
||||
{ _id: doc._id },
|
||||
{ $set: { seasonRanges }, $unset: { season: '' } }
|
||||
);
|
||||
migrated++;
|
||||
if (migrated % 25 === 0) console.log(` migrated ${migrated}…`);
|
||||
}
|
||||
|
||||
console.log(`\nDone. Migrated: ${migrated}. Skipped (empty season): ${skipped}.`);
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Postbuild: precompress static build output for nginx `gzip_static` /
|
||||
* `brotli_static`.
|
||||
*
|
||||
* Replaces adapter-node's `precompress: true`, which brotli-q11 + gzips EVERY
|
||||
* file in build/client single-threaded — including ~90 MB of already-compressed
|
||||
* jpg/mp4/png/webp/woff2 (zero gain) and 20 MB+ text blobs at q11 (~30 s each).
|
||||
*
|
||||
* This version instead:
|
||||
* - only touches compressible text types (skips binaries entirely),
|
||||
* - tunes brotli quality down for large files (q11 is wildly slow past a few MB
|
||||
* for marginal ratio gains over q10/q9),
|
||||
* - runs gzip + brotli concurrently across the libuv threadpool,
|
||||
* - skips files that already have a .br/.gz sibling (e.g. the error pages the
|
||||
* build-error-page step emits), so it's idempotent.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/precompress.ts
|
||||
*/
|
||||
|
||||
// The async gzip/brotli calls run on libuv's threadpool. Its size must be set
|
||||
// before the pool is first used — by the time this module runs under vite-node
|
||||
// the pool is already up, so postbuild sets UV_THREADPOOL_SIZE on the command
|
||||
// line (the authoritative knob). This line is just a fallback default for
|
||||
// direct `vite-node scripts/precompress.ts` runs and won't override an
|
||||
// already-set value.
|
||||
import os from 'node:os';
|
||||
const CORES = Math.max(1, os.cpus().length);
|
||||
process.env.UV_THREADPOOL_SIZE ||= String(Math.min(CORES, 12));
|
||||
|
||||
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
|
||||
import { join, resolve, dirname, extname, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzip, brotliCompress, constants as zlib } from 'node:zlib';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
const brotliAsync = promisify(brotliCompress);
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TARGET_DIRS = ['build/client', 'build/prerendered'];
|
||||
|
||||
// Only these extensions are worth compressing; everything else (images, video,
|
||||
// fonts, archives) is already compressed and skipped.
|
||||
const COMPRESSIBLE = new Set([
|
||||
'.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.json', '.map',
|
||||
'.svg', '.xml', '.txt', '.tsv', '.csv', '.wasm', '.webmanifest', '.ico'
|
||||
]);
|
||||
|
||||
// Server-side-only data that nonetheless lands in build/client and is read back
|
||||
// from disk server-side (never delivered to a browser). A .br/.gz sibling for
|
||||
// these is dead weight nginx never serves — and they're the largest, slowest
|
||||
// files in the tree, so skipping them is where almost all the time goes. They
|
||||
// must still exist UNCOMPRESSED for the server reads, so we skip rather than
|
||||
// remove them. Two kinds:
|
||||
// - bible TSVs: read via src/lib/server/staticAsset.ts → resolveStaticAsset
|
||||
// - ML embedding JSONs: `?url`-imported by $lib/server/{nutritionMatcher,
|
||||
// shoppingCategorizer}.ts and read via SvelteKit's read(); emitted into
|
||||
// _app/immutable/assets/ with a content hash (…Embeddings.<hash>.json).
|
||||
const SERVER_ONLY_NAMES = new Set(['allioli.tsv', 'drb.tsv']);
|
||||
const SERVER_ONLY_RE = /embeddings\.[^/]*\.json$/i;
|
||||
function isServerOnly(file: string): boolean {
|
||||
const base = basename(file);
|
||||
return SERVER_ONLY_NAMES.has(base) || SERVER_ONLY_RE.test(base);
|
||||
}
|
||||
|
||||
// Don't bother compressing tiny files — overhead/headers outweigh the savings.
|
||||
const MIN_BYTES = 1024;
|
||||
|
||||
/** Pick a brotli quality that balances ratio against time for large files. */
|
||||
function brotliQuality(size: number): number {
|
||||
if (size > 4 * 1024 * 1024) return 9; // >4 MB: q9 (q11 would take 30 s+)
|
||||
if (size > 1024 * 1024) return 10; // 1–4 MB
|
||||
return 11; // small files: max ratio, still fast
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // dir doesn't exist (e.g. no prerendered output) — skip
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else if (entry.isFile()) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
for (const rel of TARGET_DIRS) {
|
||||
for await (const f of walk(join(ROOT, rel))) {
|
||||
const ext = extname(f).toLowerCase();
|
||||
if (!COMPRESSIBLE.has(ext)) continue;
|
||||
if (f.endsWith('.gz') || f.endsWith('.br')) continue;
|
||||
if (isServerOnly(f)) continue;
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let saved = 0;
|
||||
let written = 0;
|
||||
|
||||
async function compressOne(file: string): Promise<void> {
|
||||
const buf = await readFile(file);
|
||||
if (buf.length < MIN_BYTES) return;
|
||||
|
||||
const jobs: Promise<void>[] = [];
|
||||
|
||||
if (!(await exists(file + '.gz'))) {
|
||||
jobs.push(
|
||||
gzipAsync(buf, { level: zlib.Z_BEST_COMPRESSION }).then(async (out) => {
|
||||
if (out.length < buf.length) {
|
||||
await writeFile(file + '.gz', out);
|
||||
written++;
|
||||
saved += buf.length - out.length;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await exists(file + '.br'))) {
|
||||
jobs.push(
|
||||
brotliAsync(buf, {
|
||||
params: {
|
||||
[zlib.BROTLI_PARAM_QUALITY]: brotliQuality(buf.length),
|
||||
[zlib.BROTLI_PARAM_SIZE_HINT]: buf.length
|
||||
}
|
||||
}).then(async (out) => {
|
||||
if (out.length < buf.length) {
|
||||
await writeFile(file + '.br', out);
|
||||
written++;
|
||||
saved += buf.length - out.length;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
/** Run `tasks` with at most `limit` in flight at once. */
|
||||
async function pool<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let i = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
await fn(items[idx]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const files = await collect();
|
||||
console.log(`[precompress] ${files.length} compressible files, ${CORES} cores`);
|
||||
await pool(files, CORES, compressOne);
|
||||
console.log(
|
||||
`[precompress] wrote ${written} files, saved ${(saved / 1048576).toFixed(1)} MB in ${(
|
||||
(Date.now() - t0) / 1000
|
||||
).toFixed(1)}s`
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Split a single-file i18n module (with an object literal whose values are
|
||||
* `Record<locale, string>`) into per-locale files under
|
||||
* src/lib/i18n/<namespace>/<locale>.ts.
|
||||
*
|
||||
* The first locale is the source of truth; others use `as const satisfies
|
||||
* Record<keyof typeof <first>, string>` so missing translations fail
|
||||
* type-checking.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/split-i18n.ts <source> <namespace> <locales,csv> [--marker=<marker>] [--basename=<name>]
|
||||
* e.g. ... cospendI18n.ts cospend de,en
|
||||
* ... calendarI18n.ts calendar de,en,la --marker='export const ui = {' --basename=de
|
||||
*
|
||||
* Defaults: marker = `const translations: Translations = {`, basename = first locale.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
|
||||
const [, , srcPath, namespace, localesCsv, ...flags] = process.argv;
|
||||
if (!srcPath || !namespace || !localesCsv) {
|
||||
console.error(
|
||||
'usage: split-i18n.ts <source> <namespace> <locales,csv> [--marker=...] [--basename=...]'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const locales = localesCsv.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const markerFlag = flags.find((f) => f.startsWith('--marker='));
|
||||
const startMarker = markerFlag
|
||||
? markerFlag.slice('--marker='.length)
|
||||
: 'const translations: Translations = {';
|
||||
const basenameFlag = flags.find((f) => f.startsWith('--basename='));
|
||||
const fileBase = basenameFlag ? basenameFlag.slice('--basename='.length) : '';
|
||||
|
||||
const src = readFileSync(srcPath, 'utf8');
|
||||
|
||||
// Slice the translations object body
|
||||
const startIdx = src.indexOf(startMarker);
|
||||
if (startIdx === -1) throw new Error(`marker not found in ${srcPath}: ${startMarker}`);
|
||||
// Object literal can close with `};` or `} as const;` — pick the earliest match.
|
||||
const candA = src.indexOf('\n};', startIdx);
|
||||
const candB = src.indexOf('\n} as const', startIdx);
|
||||
const endIdx =
|
||||
candA < 0 ? candB : candB < 0 ? candA : Math.min(candA, candB);
|
||||
if (endIdx === -1) throw new Error('translations object end not found');
|
||||
const body = src.slice(startIdx + startMarker.length, endIdx);
|
||||
|
||||
// Match each translation entry boundary: `key: { ...inner... },`. Each
|
||||
// entry's body is then parsed independently for `loc: 'value'` pairs, so
|
||||
// locale order in the source file doesn't matter.
|
||||
const entryRe = /^\s*(\w+)\s*:\s*\{([\s\S]*?)\}\s*,?\s*$/gm;
|
||||
// Match `loc: '...'` OR `loc: "..."` (double quotes are used when the string
|
||||
// contains a literal apostrophe).
|
||||
const localeRe = /(\w+)\s*:\s*(?:'([^']*)'|"((?:\\.|[^"\\])*)")/g;
|
||||
|
||||
function decodeJsString(raw: string, doubleQuoted: boolean): string {
|
||||
if (doubleQuoted) {
|
||||
// Already valid JSON (escapes preserved). Parse directly.
|
||||
return JSON.parse('"' + raw + '"');
|
||||
}
|
||||
// Single-quoted: convert any \' → ' and escape literal " for JSON.
|
||||
const jsonReady = '"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"';
|
||||
return JSON.parse(jsonReady);
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
const entries: Entry[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = entryRe.exec(body)) !== null) {
|
||||
const inner = m[2];
|
||||
const values: Record<string, string> = {};
|
||||
let lm: RegExpExecArray | null;
|
||||
while ((lm = localeRe.exec(inner)) !== null) {
|
||||
const single = lm[2];
|
||||
const double = lm[3];
|
||||
values[lm[1]] = single !== undefined
|
||||
? decodeJsString(single, false)
|
||||
: decodeJsString(double, true);
|
||||
}
|
||||
for (const loc of locales) {
|
||||
if (!(loc in values)) {
|
||||
throw new Error(`entry "${m[1]}" is missing locale "${loc}"`);
|
||||
}
|
||||
}
|
||||
entries.push({ key: m[1], values });
|
||||
}
|
||||
console.log(`extracted ${entries.length} entries`);
|
||||
|
||||
const outDir = `src/lib/i18n/${namespace}`;
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const sourceLocale = locales[0];
|
||||
|
||||
// Optional file prefix lets us split multiple tables into the same dir
|
||||
// (e.g. calendar `ui` → de.ts, calendar `ui1962` → de_1962.ts).
|
||||
const path = (loc: string) => `${outDir}/${fileBase ? `${loc}_${fileBase}` : loc}.ts`;
|
||||
|
||||
// Write the source-of-truth locale (no satisfies clause).
|
||||
{
|
||||
const lines = [
|
||||
'/** Generated by scripts/split-i18n.ts. */',
|
||||
`/** ${sourceLocale.toUpperCase()} ${namespace}${fileBase ? ` (${fileBase})` : ''} UI strings — source of truth for the key set. */`,
|
||||
'',
|
||||
`export const ${sourceLocale} = {`
|
||||
];
|
||||
for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[sourceLocale])},`);
|
||||
lines.push('} as const;', '');
|
||||
writeFileSync(path(sourceLocale), lines.join('\n'));
|
||||
}
|
||||
|
||||
// Write the other locales with `satisfies` constraint.
|
||||
const sourceFile = fileBase ? `${sourceLocale}_${fileBase}` : sourceLocale;
|
||||
for (let i = 1; i < locales.length; i++) {
|
||||
const loc = locales[i];
|
||||
const lines = [
|
||||
'/** Generated by scripts/split-i18n.ts. */',
|
||||
`import type { ${sourceLocale} } from './${sourceFile}';`,
|
||||
'',
|
||||
`export const ${loc} = {`
|
||||
];
|
||||
for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[loc])},`);
|
||||
lines.push(
|
||||
`} as const satisfies Record<keyof typeof ${sourceLocale}, string>;`,
|
||||
''
|
||||
);
|
||||
writeFileSync(path(loc), lines.join('\n'));
|
||||
}
|
||||
|
||||
console.log(`wrote ${locales.map(path).join(', ')}`);
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Build-time static hero-map renderer for individual hikes.
|
||||
*
|
||||
* Fetches the Swisstopo raster tiles covering each hike's bbox, composites
|
||||
* them into one PNG via sharp, draws the trail polyline + start/end markers
|
||||
* on top, and emits a single WebP. The result is served as `<img>` in the
|
||||
* detail page's hero so the user sees an exact replica of the live map
|
||||
* during the few hundred milliseconds it takes Leaflet to dynamic-import,
|
||||
* fetch tiles, and render — eliminating the perceived load delay.
|
||||
*
|
||||
* Tiles are content-cached on disk; rendered heroes are name-cached by
|
||||
* content hash so a re-build with unchanged GPX is a no-op.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const TILE_SIZE = 256;
|
||||
const TILE_CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'swisstopo-tiles');
|
||||
// Swisstopo serves the WMTS tiles from wmts10–wmts100. Spread across a
|
||||
// couple of sub-domains so we don't hammer a single origin during initial
|
||||
// build (browsers see different hosts; the disk cache makes follow-up
|
||||
// builds a non-event regardless).
|
||||
const SUBDOMAINS = ['wmts10', 'wmts20'] as const;
|
||||
const USER_AGENT = 'bocken-homepage build-hikes';
|
||||
|
||||
function tileUrl(sub: string, layer: string, z: number, x: number, y: number): string {
|
||||
return `https://${sub}.geo.admin.ch/1.0.0/${layer}/default/current/3857/${z}/${x}/${y}.jpeg`;
|
||||
}
|
||||
|
||||
/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */
|
||||
export function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } {
|
||||
const n = 2 ** zoom;
|
||||
const x = ((lng + 180) / 360) * n * TILE_SIZE;
|
||||
const latRad = (lat * Math.PI) / 180;
|
||||
const y =
|
||||
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n * TILE_SIZE;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` = network failure (we'll count it against the abort threshold).
|
||||
* `'blank'` = HTTP 4xx, i.e. the tile is intentionally not served — for
|
||||
* the Swisstopo Pixelkarte that means we're outside Switzerland's bbox.
|
||||
* The overview hero canvas extends into DE/IT/FR, so we treat blanks as
|
||||
* "OK, just nothing there" rather than failures. */
|
||||
type TileResult = Buffer | 'blank' | null;
|
||||
|
||||
async function fetchTile(
|
||||
layer: string,
|
||||
z: number,
|
||||
x: number,
|
||||
y: number
|
||||
): Promise<TileResult> {
|
||||
const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`;
|
||||
const cachePath = path.join(TILE_CACHE_DIR, key);
|
||||
try {
|
||||
return await fs.readFile(cachePath);
|
||||
} catch { /* miss */ }
|
||||
|
||||
const sub = SUBDOMAINS[(x + y) % SUBDOMAINS.length];
|
||||
try {
|
||||
const res = await fetch(tileUrl(sub, layer, z, x, y), {
|
||||
headers: { 'User-Agent': USER_AGENT }
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 4xx means "we don't serve this tile" (out-of-bounds for the
|
||||
// Swiss data set). Anything else (5xx) is a real failure.
|
||||
if (res.status >= 400 && res.status < 500) return 'blank';
|
||||
if (process.env.STATIC_MAP_DEBUG) {
|
||||
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} HTTP ${res.status}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await fs.mkdir(TILE_CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(cachePath, buf);
|
||||
return buf;
|
||||
} catch (err) {
|
||||
if (process.env.STATIC_MAP_DEBUG) {
|
||||
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} error:`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeSvgNumber(n: number): string {
|
||||
// Keep SVG path compact but precise enough for 1600 px rendering.
|
||||
return n.toFixed(1);
|
||||
}
|
||||
|
||||
export interface RenderStaticMapPhotoMarker {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface StaticMapPose {
|
||||
zoom: number;
|
||||
centerLat: number;
|
||||
centerLng: number;
|
||||
/** Origin in zoom-pixel space — top-left of the output canvas. The
|
||||
* renderer needs it; the caller doesn't, but exposing it keeps the
|
||||
* `computePose` ↔ `renderStaticMap` interface stateless. */
|
||||
originX: number;
|
||||
originY: number;
|
||||
}
|
||||
|
||||
export interface ComputeStaticMapPoseOpts {
|
||||
bbox: [number, number, number, number];
|
||||
/** Canvas dimensions for centering / tile fetching. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
paddingPx?: number;
|
||||
/** Reference dimensions used purely for zoom selection. Defaults to
|
||||
* `width × height` — but pass the expected *display* size (not the
|
||||
* rendered canvas size) when you want zoom to match Leaflet's
|
||||
* `fitBounds` at the user's viewport. The renderer still draws the
|
||||
* full `width × height` canvas around the chosen zoom, so wider
|
||||
* viewports get more context without the bbox being cropped on
|
||||
* smaller ones. */
|
||||
fitWidth?: number;
|
||||
fitHeight?: number;
|
||||
/** Upper bound on the zoom search — mirrors Leaflet's `fitBounds({ maxZoom })`.
|
||||
* Use this when the live map clamps its zoom so the static hero doesn't
|
||||
* land at a more detailed level than Leaflet will ever show. */
|
||||
maxZoom?: number;
|
||||
}
|
||||
|
||||
/** Pure-math pass: pick the zoom + centre + canvas origin that the static
|
||||
* renderer would use for these inputs. Identical for light- and dark-
|
||||
* themed renders, so callers can compute it once and re-use. */
|
||||
export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapPose | null {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const paddingPx = opts.paddingPx ?? 24;
|
||||
const fitWidth = opts.fitWidth ?? width;
|
||||
const fitHeight = opts.fitHeight ?? height;
|
||||
const maxZoom = opts.maxZoom ?? 18;
|
||||
|
||||
const [minLat, minLng, maxLat, maxLng] = opts.bbox;
|
||||
if (
|
||||
!Number.isFinite(minLat) || !Number.isFinite(minLng) ||
|
||||
!Number.isFinite(maxLat) || !Number.isFinite(maxLng)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const innerW = Math.max(1, fitWidth - 2 * paddingPx);
|
||||
const innerH = Math.max(1, fitHeight - 2 * paddingPx);
|
||||
|
||||
// Pick the highest integer zoom where the bbox fits inside the
|
||||
// reference inner rectangle. This mirrors Leaflet's `fitBounds`
|
||||
// integer-zoom search, so a viewport matching `fitWidth × fitHeight`
|
||||
// will choose the same zoom Leaflet does for the same bbox.
|
||||
let zoom = 7;
|
||||
for (let z = maxZoom; z >= 7; z--) {
|
||||
const tl = lngLatToPx(minLng, maxLat, z);
|
||||
const br = lngLatToPx(maxLng, minLat, z);
|
||||
if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) {
|
||||
zoom = z;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const centerLat = (minLat + maxLat) / 2;
|
||||
const centerLng = (minLng + maxLng) / 2;
|
||||
const c = lngLatToPx(centerLng, centerLat, zoom);
|
||||
const originX = Math.round(c.x - width / 2);
|
||||
const originY = Math.round(c.y - height / 2);
|
||||
|
||||
return { zoom, centerLat, centerLng, originX, originY };
|
||||
}
|
||||
|
||||
export interface RenderStaticMapOpts {
|
||||
/** Pre-computed pose (zoom + centre + origin). Get this via
|
||||
* `computeStaticMapPose(...)`. Shared by light- and dark-themed
|
||||
* renders so both variants align perfectly. */
|
||||
pose: StaticMapPose;
|
||||
/** Track polyline as `[lat, lng]` tuples (any length). */
|
||||
polyline: Array<[number, number]>;
|
||||
color: string;
|
||||
outputPath: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Swisstopo WMTS layer ID. Defaults to the schematic Pixelkarte (the
|
||||
* same base layer Leaflet starts with on the detail page). */
|
||||
layer?: string;
|
||||
/** Optional image-point markers to burn into the SVG overlay alongside
|
||||
* the start/end dots. Pass only the points safe to render in a public-
|
||||
* facing image — private photos should be filtered out by the caller. */
|
||||
photoMarkers?: RenderStaticMapPhotoMarker[];
|
||||
/** Fill colour for the photo marker dots. Should match the live
|
||||
* HikePhoto marker styling (`--color-primary`). */
|
||||
photoMarkerColor?: string;
|
||||
/** Border colour for the photo marker dots — matches the live
|
||||
* `.hike-photo-marker .badge` `border-color: var(--color-surface)` so
|
||||
* the static blends in with the active theme's surface colour. */
|
||||
photoMarkerBorderColor?: string;
|
||||
/** Stroke colour of the Lucide `camera` icon inside the badge. Matches
|
||||
* the live badge's `color: var(--color-text-on-primary)` — white on
|
||||
* the light theme's mid-blue primary, dark on the dark theme's light-
|
||||
* blue primary. */
|
||||
photoMarkerIconColor?: string;
|
||||
}
|
||||
|
||||
/** Fetch every Swisstopo tile covering the canvas at the given pose, then
|
||||
* composite them into a single PNG buffer. Returns `null` when fewer than
|
||||
* half the tiles arrive (a patchy hero is worse than no hero). Shared by
|
||||
* `renderStaticMap` (per-hike hero) and `renderOverviewMap` (the /hikes
|
||||
* landing-page hero) so both pull the same tile cache and use the same
|
||||
* fallback colour. */
|
||||
async function composeBaseMap(
|
||||
pose: StaticMapPose,
|
||||
width: number,
|
||||
height: number,
|
||||
layer: string
|
||||
): Promise<Buffer | null> {
|
||||
const { zoom, originX, originY } = pose;
|
||||
|
||||
const minTileX = Math.floor(originX / TILE_SIZE);
|
||||
const maxTileX = Math.floor((originX + width - 1) / TILE_SIZE);
|
||||
const minTileY = Math.floor(originY / TILE_SIZE);
|
||||
const maxTileY = Math.floor((originY + height - 1) / TILE_SIZE);
|
||||
|
||||
// Parallel tile fetches — disk cache makes follow-up builds essentially
|
||||
// free, but the first build pulls ~6–20 tiles per per-hike hero and
|
||||
// considerably more for the overview hero.
|
||||
const tileJobs: Array<{ tx: number; ty: number; left: number; top: number }> = [];
|
||||
for (let ty = minTileY; ty <= maxTileY; ty++) {
|
||||
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
||||
tileJobs.push({
|
||||
tx,
|
||||
ty,
|
||||
left: tx * TILE_SIZE - originX,
|
||||
top: ty * TILE_SIZE - originY
|
||||
});
|
||||
}
|
||||
}
|
||||
const tileBufs = await Promise.all(
|
||||
tileJobs.map(async (job) => ({
|
||||
job,
|
||||
buf: await fetchTile(layer, zoom, job.tx, job.ty)
|
||||
}))
|
||||
);
|
||||
|
||||
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
|
||||
let networkFailures = 0;
|
||||
for (const { job, buf } of tileBufs) {
|
||||
if (buf === null) {
|
||||
networkFailures++;
|
||||
continue;
|
||||
}
|
||||
if (buf === 'blank') continue; // out-of-bounds, draw the fallback grey
|
||||
composites.push({ input: buf, left: job.left, top: job.top });
|
||||
}
|
||||
// Network-failure threshold (not "fewer than half present"): blank
|
||||
// out-of-bounds tiles are an expected outcome for the overview hero
|
||||
// that extends past Switzerland's edges, so they don't count against
|
||||
// the abort threshold.
|
||||
if (networkFailures > tileJobs.length / 2) return null;
|
||||
|
||||
// Tile composite is identical regardless of UI theme — we deliberately
|
||||
// don't invert the Pixelkarte for dark mode (its colour palette doesn't
|
||||
// survive a naive invert). Only the SVG overlay above changes per theme.
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } }
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** Render and write a single static hero map at the given pose. Returns
|
||||
* `false` on failure (zero tiles fetched, degenerate inputs). */
|
||||
export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolean> {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
|
||||
const { zoom, originX, originY } = opts.pose;
|
||||
|
||||
if (opts.polyline.length < 2) return false;
|
||||
|
||||
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
|
||||
if (!mapBuf) return false;
|
||||
|
||||
// SVG overlay — polyline + photo markers + start/end dots.
|
||||
const pathParts: string[] = [];
|
||||
for (let i = 0; i < opts.polyline.length; i++) {
|
||||
const [lat, lng] = opts.polyline[i];
|
||||
const p = lngLatToPx(lng, lat, zoom);
|
||||
const px = p.x - originX;
|
||||
const py = p.y - originY;
|
||||
pathParts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
|
||||
}
|
||||
const start = opts.polyline[0];
|
||||
const end = opts.polyline[opts.polyline.length - 1];
|
||||
const startP = lngLatToPx(start[1], start[0], zoom);
|
||||
const endP = lngLatToPx(end[1], end[0], zoom);
|
||||
const sx = escapeSvgNumber(startP.x - originX);
|
||||
const sy = escapeSvgNumber(startP.y - originY);
|
||||
const ex = escapeSvgNumber(endP.x - originX);
|
||||
const ey = escapeSvgNumber(endP.y - originY);
|
||||
|
||||
const photoMarkerColor = opts.photoMarkerColor ?? '#5e81ac';
|
||||
const photoMarkerBorderColor = opts.photoMarkerBorderColor ?? '#eceff4';
|
||||
const photoMarkerIconColor = opts.photoMarkerIconColor ?? '#fff';
|
||||
// Match HikeMap's `.hike-photo-marker .badge` — 28 px Nord-blue circle
|
||||
// with a 2 px theme-surface border, holding a 14 px theme-on-primary
|
||||
// Lucide `camera` icon. The camera icon paths are the literal Lucide
|
||||
// source (lucide-camera).
|
||||
const photoMarkers = (opts.photoMarkers ?? [])
|
||||
.map((m) => {
|
||||
const p = lngLatToPx(m.lng, m.lat, zoom);
|
||||
const cx = escapeSvgNumber(p.x - originX);
|
||||
const cy = escapeSvgNumber(p.y - originY);
|
||||
return (
|
||||
`<g transform="translate(${cx} ${cy})">` +
|
||||
`<circle r="14" fill="${photoMarkerColor}" stroke="${photoMarkerBorderColor}" stroke-width="2"/>` +
|
||||
`<g transform="translate(-7 -7) scale(0.5833)" stroke="${photoMarkerIconColor}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
|
||||
`<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>` +
|
||||
`<circle cx="12" cy="13" r="3"/>` +
|
||||
`</g>` +
|
||||
`</g>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
const overlay = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||
`<path d="${pathParts.join(' ')}" fill="none" stroke="${opts.color}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>` +
|
||||
photoMarkers +
|
||||
`<circle cx="${sx}" cy="${sy}" r="9" fill="#a3be8c" stroke="#fff" stroke-width="3"/>` +
|
||||
`<circle cx="${ex}" cy="${ey}" r="9" fill="#bf616a" stroke="#fff" stroke-width="3"/>` +
|
||||
`</svg>`
|
||||
);
|
||||
|
||||
await sharp(mapBuf)
|
||||
.composite([{ input: overlay, left: 0, top: 0 }])
|
||||
.webp({ quality: 78 })
|
||||
.toFile(opts.outputPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overview hero (one image for the whole /hikes index page).
|
||||
// Same tile composite as `renderStaticMap`, but the overlay draws many
|
||||
// polylines (one per hike, coloured by SAC tier) and no per-route start /
|
||||
// end / photo markers — the map is a finder, not a detail view.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RenderOverviewPolyline {
|
||||
points: Array<[number, number]>;
|
||||
color: string;
|
||||
/** Indices where a new disconnected sub-path begins (multi-day stage gaps
|
||||
* >1 km), so the line isn't drawn across an overnight transfer. */
|
||||
breaks?: number[];
|
||||
}
|
||||
|
||||
export interface RenderOverviewMapOpts {
|
||||
pose: StaticMapPose;
|
||||
polylines: RenderOverviewPolyline[];
|
||||
outputPath: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
layer?: string;
|
||||
}
|
||||
|
||||
export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise<boolean> {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
|
||||
const { zoom, originX, originY } = opts.pose;
|
||||
|
||||
const drawable = opts.polylines.filter((p) => p.points.length >= 2);
|
||||
if (drawable.length === 0) return false;
|
||||
|
||||
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
|
||||
if (!mapBuf) return false;
|
||||
|
||||
// One <path> per hike polyline. The overview map is rendered fairly
|
||||
// zoomed-out, so even ≤150-point preview polylines stay compact.
|
||||
const paths = drawable
|
||||
.map((line) => {
|
||||
const breakSet = new Set(line.breaks ?? []);
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < line.points.length; i++) {
|
||||
const [lat, lng] = line.points[i];
|
||||
const p = lngLatToPx(lng, lat, zoom);
|
||||
const px = p.x - originX;
|
||||
const py = p.y - originY;
|
||||
// Start a fresh sub-path at index 0 and at every stage break.
|
||||
const cmd = i === 0 || breakSet.has(i) ? 'M' : 'L';
|
||||
parts.push(cmd + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
|
||||
}
|
||||
return (
|
||||
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
|
||||
`stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.9"/>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
const overlay = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||
paths +
|
||||
`</svg>`
|
||||
);
|
||||
|
||||
await sharp(mapBuf)
|
||||
.composite([{ input: overlay, left: 0, top: 0 }])
|
||||
.webp({ quality: 78 })
|
||||
.toFile(opts.outputPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Translates apologetik English data → target language via DeepL.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec vite-node scripts/translate-apologetik.ts # default DE
|
||||
* pnpm exec vite-node scripts/translate-apologetik.ts -- --lang=DE
|
||||
*
|
||||
* Reads: src/lib/data/apologetik.ts (English source of truth)
|
||||
* Writes: src/lib/data/apologetik.<lang>.ts
|
||||
*
|
||||
* Note: DeepL does not support Latin. For LA, translate manually or wire a
|
||||
* different provider.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Minimal .env loader — avoid extra deps.
|
||||
function loadEnv() {
|
||||
try {
|
||||
const raw = readFileSync(resolve(process.cwd(), '.env'), 'utf8');
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
} catch {
|
||||
// no .env — fine, rely on process env
|
||||
}
|
||||
}
|
||||
loadEnv();
|
||||
|
||||
import {
|
||||
ARCHETYPES,
|
||||
ARGUMENTS,
|
||||
POS_VOICES,
|
||||
POS_LAYERS,
|
||||
POS_ARGUMENTS,
|
||||
type Archetype,
|
||||
type Argument,
|
||||
type Counter,
|
||||
type PosVoice,
|
||||
type PosLayer,
|
||||
type PosArgument,
|
||||
type PosCounter
|
||||
} from '../src/lib/data/apologetik';
|
||||
|
||||
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
|
||||
const DEEPL_API_URL = process.env.DEEPL_API_URL || 'https://api-free.deepl.com/v2/translate';
|
||||
|
||||
if (!DEEPL_API_KEY) {
|
||||
console.error('DEEPL_API_KEY missing from .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const argLang = process.argv.find((a) => a.startsWith('--lang='))?.split('=')[1];
|
||||
const TARGET_LANG = (argLang ?? 'DE').toUpperCase();
|
||||
const FILE_LANG = TARGET_LANG.toLowerCase();
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
// Manual overrides applied after DeepL translation, keyed by English source.
|
||||
// Use for cases where DeepL produces a wrong / inconsistent German rendering
|
||||
// that should survive regeneration.
|
||||
const OVERRIDES: Record<string, Record<string, string>> = {
|
||||
DE: {
|
||||
// generic-masculine for archetype role names
|
||||
'The Scientist': 'Der Wissenschaftler'
|
||||
}
|
||||
};
|
||||
|
||||
async function translateBatch(texts: string[]): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const toFetch: { idx: number; text: string }[] = [];
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const cached = cache.get(texts[i]);
|
||||
if (cached !== undefined) out[i] = cached;
|
||||
else toFetch.push({ idx: i, text: texts[i] });
|
||||
}
|
||||
for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
|
||||
const chunk = toFetch.slice(i, i + BATCH_SIZE);
|
||||
const body = {
|
||||
text: chunk.map((c) => c.text),
|
||||
source_lang: 'EN',
|
||||
target_lang: TARGET_LANG,
|
||||
preserve_formatting: true,
|
||||
formality: 'prefer_more'
|
||||
};
|
||||
const resp = await fetch(DEEPL_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const t = await resp.text();
|
||||
throw new Error(`DeepL ${resp.status}: ${t}`);
|
||||
}
|
||||
const data = (await resp.json()) as { translations: { text: string }[] };
|
||||
data.translations.forEach((tr, j) => {
|
||||
const slot = chunk[j];
|
||||
out[slot.idx] = tr.text;
|
||||
cache.set(slot.text, tr.text);
|
||||
});
|
||||
process.stdout.write(` · translated ${Math.min(i + BATCH_SIZE, toFetch.length)}/${toFetch.length}\n`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Helper: collect translatable strings from an object's selected fields,
|
||||
// queue them, and return a setter that applies the translations back.
|
||||
type Job = {
|
||||
get: () => string;
|
||||
set: (v: string) => void;
|
||||
};
|
||||
|
||||
const jobs: Job[] = [];
|
||||
|
||||
function field<T extends object, K extends keyof T>(obj: T, key: K) {
|
||||
if (typeof obj[key] !== 'string') return;
|
||||
jobs.push({
|
||||
get: () => obj[key] as unknown as string,
|
||||
set: (v) => {
|
||||
(obj as any)[key] = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function arrayField<T>(arr: T[], key: keyof T) {
|
||||
for (const item of arr) field(item as any, key as any);
|
||||
}
|
||||
|
||||
function stringArray(arr: string[]) {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const idx = i;
|
||||
jobs.push({
|
||||
get: () => arr[idx],
|
||||
set: (v) => {
|
||||
arr[idx] = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- clone source data ----------
|
||||
function cloneArchetype(a: Archetype): Archetype {
|
||||
return { ...a };
|
||||
}
|
||||
function cloneCounter(c: Counter): Counter {
|
||||
return { ...c, body: [...c.body], cites: [...c.cites] };
|
||||
}
|
||||
function cloneArgument(a: Argument): Argument {
|
||||
const counters: Record<string, Counter> = {};
|
||||
for (const [k, v] of Object.entries(a.counters)) counters[k] = cloneCounter(v);
|
||||
return { ...a, related: [...a.related], counters };
|
||||
}
|
||||
function clonePosVoice(v: PosVoice): PosVoice {
|
||||
return { ...v };
|
||||
}
|
||||
function clonePosLayer(l: PosLayer): PosLayer {
|
||||
return { ...l };
|
||||
}
|
||||
function clonePosCounter(c: PosCounter): PosCounter {
|
||||
return { ...c, body: [...c.body], cites: [...c.cites] };
|
||||
}
|
||||
function clonePosArgument(a: PosArgument): PosArgument {
|
||||
const voices: Record<string, PosCounter> = {};
|
||||
for (const [k, v] of Object.entries(a.voices)) voices[k] = clonePosCounter(v);
|
||||
return {
|
||||
...a,
|
||||
related: [...a.related],
|
||||
voices,
|
||||
scripture: { ...a.scripture }
|
||||
};
|
||||
}
|
||||
|
||||
const archetypesOut: Record<string, Archetype> = {};
|
||||
for (const [k, v] of Object.entries(ARCHETYPES)) archetypesOut[k] = cloneArchetype(v);
|
||||
const argumentsOut: Argument[] = ARGUMENTS.map(cloneArgument);
|
||||
const posVoicesOut: Record<string, PosVoice> = {};
|
||||
for (const [k, v] of Object.entries(POS_VOICES)) posVoicesOut[k] = clonePosVoice(v);
|
||||
const posLayersOut: PosLayer[] = POS_LAYERS.map(clonePosLayer);
|
||||
const posArgsOut: PosArgument[] = POS_ARGUMENTS.map(clonePosArgument);
|
||||
|
||||
// ---------- queue translation jobs ----------
|
||||
//
|
||||
// What we DON'T translate:
|
||||
// - id, n, related (cross-link keys)
|
||||
// - color, colorSoft, colorHex, glyph, font (visual)
|
||||
// - era (numeric / dates)
|
||||
// - cites (bibliographic — keep canonical English)
|
||||
// - scripture.ref (book chapter:verse)
|
||||
// - layer (enum key)
|
||||
// - strength (number)
|
||||
|
||||
// archetypes — translate name + sub. DeepL leaves canonical proper nouns alone
|
||||
// (e.g. "Pascal") and localizes ones with established forms ("Thomas von Aquin",
|
||||
// "Franz von Assisi", "Augustinus"). Role names ("The Logician") get translated
|
||||
// idiomatically.
|
||||
for (const a of Object.values(archetypesOut)) {
|
||||
field(a, 'name');
|
||||
field(a, 'sub');
|
||||
}
|
||||
|
||||
// arguments
|
||||
for (const a of argumentsOut) {
|
||||
field(a, 'title');
|
||||
field(a, 'short');
|
||||
field(a, 'steel');
|
||||
field(a, 'quote');
|
||||
field(a, 'quoteBy');
|
||||
field(a, 'pub');
|
||||
for (const c of Object.values(a.counters)) {
|
||||
field(c, 'lede');
|
||||
stringArray(c.body);
|
||||
}
|
||||
}
|
||||
|
||||
// pos voices — translate name + sub (same rationale as archetypes).
|
||||
for (const v of Object.values(posVoicesOut)) {
|
||||
field(v, 'name');
|
||||
field(v, 'sub');
|
||||
}
|
||||
|
||||
// pos layers
|
||||
for (const l of posLayersOut) {
|
||||
field(l, 'title');
|
||||
field(l, 'sub');
|
||||
}
|
||||
|
||||
// pos arguments
|
||||
for (const a of posArgsOut) {
|
||||
field(a, 'title');
|
||||
field(a, 'claim');
|
||||
field(a, 'thesis');
|
||||
if (a.note) field(a, 'note');
|
||||
field(a.scripture, 'text');
|
||||
for (const c of Object.values(a.voices)) {
|
||||
field(c, 'lede');
|
||||
stringArray(c.body);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Queued ${jobs.length} translation jobs · target ${TARGET_LANG}`);
|
||||
|
||||
// Site is Swiss High German — no ß. Bible quotes are sourced from Allioli at
|
||||
// runtime and untouched by this pass, so this only affects translated prose.
|
||||
function postProcess(s: string): string {
|
||||
if (TARGET_LANG === 'DE') return s.replace(/ß/g, 'ss');
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- run translations ----------
|
||||
const inputs = jobs.map((j) => j.get());
|
||||
const outputs = await translateBatch(inputs);
|
||||
const overrides = OVERRIDES[TARGET_LANG] ?? {};
|
||||
let overrideHits = 0;
|
||||
jobs.forEach((j, i) => {
|
||||
const en = inputs[i];
|
||||
if (overrides[en] !== undefined) {
|
||||
j.set(postProcess(overrides[en]));
|
||||
overrideHits++;
|
||||
} else {
|
||||
j.set(postProcess(outputs[i]));
|
||||
}
|
||||
});
|
||||
if (overrideHits) console.log(`Applied ${overrideHits} manual override(s)`);
|
||||
|
||||
console.log(`Done · cache hits saved ${jobs.length - cache.size} duplicate calls`);
|
||||
|
||||
// ---------- emit file ----------
|
||||
function ts(value: unknown, indent = 0): string {
|
||||
const pad = '\t'.repeat(indent);
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'string') return JSON.stringify(value);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '[]';
|
||||
const inner = value.map((v) => `${pad}\t${ts(v, indent + 1)}`).join(',\n');
|
||||
return `[\n${inner}\n${pad}]`;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as object);
|
||||
if (entries.length === 0) return '{}';
|
||||
const inner = entries
|
||||
.map(([k, v]) => `${pad}\t${JSON.stringify(k)}: ${ts(v, indent + 1)}`)
|
||||
.join(',\n');
|
||||
return `{\n${inner}\n${pad}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
const header = `// AUTO-GENERATED by scripts/translate-apologetik.ts — DO NOT EDIT BY HAND.
|
||||
// Source: src/lib/data/apologetik.ts (EN) · Target: ${TARGET_LANG} · Generated ${new Date().toISOString()}
|
||||
//
|
||||
// To regenerate: pnpm exec vite-node scripts/translate-apologetik.ts -- --lang=${TARGET_LANG}
|
||||
|
||||
import type {
|
||||
\tArchetype,
|
||||
\tArgument,
|
||||
\tPosArgument,
|
||||
\tPosLayer,
|
||||
\tPosVoice
|
||||
} from './apologetik';
|
||||
|
||||
`;
|
||||
|
||||
const content = [
|
||||
header,
|
||||
`export const ARCHETYPES_${TARGET_LANG}: Record<string, Archetype> = ${ts(archetypesOut)};`,
|
||||
'',
|
||||
`export const ARGUMENTS_${TARGET_LANG}: Argument[] = ${ts(argumentsOut)};`,
|
||||
'',
|
||||
`export const POS_VOICES_${TARGET_LANG}: Record<string, PosVoice> = ${ts(posVoicesOut)};`,
|
||||
'',
|
||||
`export const POS_LAYERS_${TARGET_LANG}: PosLayer[] = ${ts(posLayersOut)};`,
|
||||
'',
|
||||
`export const POS_ARGUMENTS_${TARGET_LANG}: PosArgument[] = ${ts(posArgsOut)};`,
|
||||
''
|
||||
].join('\n');
|
||||
|
||||
const outPath = resolve(process.cwd(), `src/lib/data/apologetik.${FILE_LANG}.ts`);
|
||||
writeFileSync(outPath, content, 'utf8');
|
||||
console.log(`✓ Wrote ${outPath}`);
|
||||
@@ -144,7 +144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bocken"
|
||||
version = "0.4.0"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bocken"
|
||||
version = "0.5.0"
|
||||
version = "0.5.3"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- Step detector sensor (cadence during GPS workouts); runtime-requested on API 29+ -->
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
@@ -20,6 +20,12 @@ import java.util.Locale
|
||||
|
||||
class AndroidBridge(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val REQ_BACKGROUND_LOCATION = 1002
|
||||
const val REQ_NOTIFICATIONS = 1003
|
||||
const val REQ_ACTIVITY_RECOGNITION = 1004
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun startLocationService(ttsConfigJson: String, startPaused: Boolean) {
|
||||
if (context is Activity) {
|
||||
@@ -31,7 +37,7 @@ class AndroidBridge(private val context: Context) {
|
||||
ActivityCompat.requestPermissions(
|
||||
context,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
1003
|
||||
REQ_NOTIFICATIONS
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +50,20 @@ class AndroidBridge(private val context: Context) {
|
||||
ActivityCompat.requestPermissions(
|
||||
context,
|
||||
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
|
||||
1002
|
||||
REQ_BACKGROUND_LOCATION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Request activity recognition on Android 10+ (required for step detector / cadence)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
context,
|
||||
arrayOf(Manifest.permission.ACTIVITY_RECOGNITION),
|
||||
REQ_ACTIVITY_RECOGNITION
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -104,6 +123,15 @@ class AndroidBridge(private val context: Context) {
|
||||
return LocationForegroundService.getIntervalState()
|
||||
}
|
||||
|
||||
/** True if cadence (step detector) is usable — permission granted or not required (pre-Q). */
|
||||
@JavascriptInterface
|
||||
fun hasActivityRecognitionPermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.ACTIVITY_RECOGNITION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-vibrate bypassing silent/DND by using USAGE_ACCESSIBILITY attributes.
|
||||
* Why: default web Vibration API uses USAGE_TOUCH which Android silences.
|
||||
|
||||
@@ -4,9 +4,11 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.Manifest
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
@@ -24,6 +26,7 @@ import android.os.Looper
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Collections
|
||||
@@ -696,8 +699,22 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener, Sensor
|
||||
notificationManager?.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun hasActivityRecognitionPermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
this, Manifest.permission.ACTIVITY_RECOGNITION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun startStepDetector() {
|
||||
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
if (!hasActivityRecognitionPermission()) {
|
||||
Log.d(TAG, "Step detector skipped — ACTIVITY_RECOGNITION not granted")
|
||||
return
|
||||
}
|
||||
if (stepDetector != null) return // already registered
|
||||
if (sensorManager == null) {
|
||||
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
}
|
||||
stepDetector = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)
|
||||
if (stepDetector != null) {
|
||||
sensorManager?.registerListener(this, stepDetector, SensorManager.SENSOR_DELAY_FASTEST)
|
||||
@@ -707,6 +724,12 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener, Sensor
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from MainActivity when ACTIVITY_RECOGNITION is granted mid-session. */
|
||||
fun onActivityRecognitionGranted() {
|
||||
Log.d(TAG, "ACTIVITY_RECOGNITION granted — retrying step detector registration")
|
||||
startStepDetector()
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun startLocationUpdates() {
|
||||
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.bocken.app
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -13,4 +14,17 @@ class MainActivity : TauriActivity() {
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
webView.addJavascriptInterface(AndroidBridge(this), "AndroidBridge")
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == AndroidBridge.REQ_ACTIVITY_RECOGNITION &&
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
LocationForegroundService.instance?.onActivityRecognitionGranted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 385 B |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 380 B |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 395 B |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 844 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 769 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 952 B After Width: | Height: | Size: 473 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 719 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="g1">
|
||||
<rect
|
||||
style="fill:#2e3440;stroke-width:15;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect1"
|
||||
width="1024"
|
||||
height="1024"
|
||||
x="0"
|
||||
y="0" />
|
||||
<g
|
||||
id="g2"
|
||||
transform="matrix(6.5209236,0,0,6.5209236,362.8589,246.15055)">
|
||||
<g
|
||||
class="stroke"
|
||||
id="branches"
|
||||
transform="translate(-42.033271,-37.145192)"
|
||||
style="stroke:#d8dee9;stroke-opacity:1">
|
||||
<path
|
||||
d="m 65.113709,84.638921 c -0.346049,-9.794303 8.85917,-32.693347 8.85917,-32.693347"
|
||||
id="path1"
|
||||
style="fill:none;stroke:#d8dee9;stroke-width:3;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
d="m 65.108044,84.684262 c 0.346049,-9.794303 -8.85917,-32.693347 -8.85917,-32.693347"
|
||||
id="path2"
|
||||
style="fill:none;stroke:#d8dee9;stroke-width:3;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
class="leaf"
|
||||
id="g1-2"
|
||||
style="fill:#d8dee9;fill-opacity:1">
|
||||
<path
|
||||
d="M 0,0 C 6.633,-3.91 14.348,-4.302 20.992,-1.732 20.009,5.333 15.93,11.893 9.31,15.795 2.69,19.697 -5.025,20.088 -11.669,17.519 -10.7,10.462 -6.62,3.901 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,4.116564,13.543871)"
|
||||
id="path3"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c -6.62,3.901 -14.335,4.293 -20.979,1.724 0.97,-7.058 5.049,-13.618 11.669,-17.519 6.633,-3.91 14.348,-4.301 20.992,-1.732 C 10.699,-10.462 6.62,-3.902 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,10.339434,19.278333)"
|
||||
id="path4"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.633,-3.909 14.348,-4.301 20.992,-1.731 20.009,5.333 15.93,11.894 9.31,15.795 2.69,19.697 -5.026,20.088 -11.669,17.52 -10.7,10.461 -6.62,3.902 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,10.903454,36.572256)"
|
||||
id="path5"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.644,-2.57 14.358,-2.178 20.992,1.732 27.612,5.633 31.691,12.194 32.661,19.25 26.017,21.82 18.302,21.429 11.682,17.527 5.062,13.625 0.982,7.065 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,32.871328,24.119748)"
|
||||
id="path6"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.62,3.901 10.699,10.461 11.669,17.519 5.025,20.088 -2.689,19.696 -9.31,15.795 -15.93,11.893 -20.009,5.333 -20.992,-1.732 -14.348,-4.301 -6.633,-3.91 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,35.741597,35.870171)"
|
||||
id="path7"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m -27.40181,13.441787 c 6.644,-2.57 14.359,-2.178 20.9920004,1.731 6.62000002,3.902 10.699,10.461 11.669,17.519 -6.644,2.569 -14.359,2.178 -20.9790004,-1.724 -6.62,-3.901 -10.7,-10.462 -11.682,-17.526"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,43.12113,17.474745)"
|
||||
id="path8"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c 1.271,7.579 -1.125,14.922 -5.904,20.205 -6.242,-3.433 -10.906,-9.591 -12.178,-17.169 -1.275,-7.594 1.123,-14.937 5.902,-20.22 C -5.936,-13.736 -1.273,-7.578 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,20.082753,7.127875)"
|
||||
id="path9"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c 1.271,7.579 -1.125,14.922 -5.904,20.206 -6.242,-3.434 -10.906,-9.592 -12.178,-17.17 -1.275,-7.593 1.123,-14.937 5.902,-20.22 C -5.937,-13.736 -1.273,-7.578 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,26.963346,20.756878)"
|
||||
id="path10"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 4.779,5.283 7.176,12.627 5.901,20.22 4.629,27.798 -0.035,33.956 -6.277,37.39 -11.055,32.106 -13.453,24.763 -12.18,17.184 -10.908,9.606 -6.244,3.448 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,29.06985,14.051408)"
|
||||
id="path11"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
</g>
|
||||
<path
|
||||
class="fill"
|
||||
d="m 23.308833,63.179301 -3.288947,-3.831872 2.16535,-2.433461 1.123597,-1.262592 1.119364,1.257653 2.169936,2.4384 z M 37.853155,39.714993 c -0.02117,0.08396 -0.9652,3.0988 -3.220508,5.991225 -1.128536,1.453444 -2.574573,2.872317 -4.37515,3.93065 -1.617486,0.947914 -3.517195,1.617839 -5.820481,1.786467 l -1.128183,-1.267531 -1.127125,1.266825 C 19.838911,51.249415 17.912391,50.557971 16.276561,49.58254 13.551705,47.957293 11.640003,45.483263 10.434208,43.388115 9.8309581,42.34354 9.4048026,41.401624 9.134222,40.732051 8.9991081,40.397265 8.902447,40.131271 8.8414165,39.954529 8.8107248,39.865982 8.7892053,39.800013 8.7761526,39.75909 l -0.013053,-0.04233 -0.00212,-0.006 L 8.374688,38.405835 H 0.87287218 v 3.653366 H 5.7302693 c 0.5323417,1.327503 1.5515166,3.495323 3.2441444,5.720645 1.3409083,1.757539 3.1143223,3.553177 5.4257223,4.937477 1.423105,0.854428 3.055761,1.541992 4.884914,1.960034 l -1.365956,1.534936 -2.751314,3.091744 4.069998,4.741686 c -1.8415,0.426861 -3.481212,1.128536 -4.909256,1.995664 -3.439936,2.087739 -5.6744305,5.06095 -7.0735472,7.485944 -0.7094361,1.234017 -1.2043833,2.33292 -1.5250583,3.129845 H 0.87287218 v 3.653366 H 8.3746915 l 0.3869972,-1.306689 c 0.017992,-0.07479 0.9574388,-3.071988 3.1996943,-5.959122 1.120775,-1.448505 2.556934,-2.865966 4.343753,-3.928886 1.594908,-0.94615 3.464983,-1.623483 5.726994,-1.814336 l 1.276703,1.486959 1.276703,-1.486959 c 2.304697,0.193675 4.202641,0.89147 5.8166,1.865136 2.703336,1.631245 4.598811,4.097514 5.794022,6.182431 0.597605,1.039283 1.01988,1.975908 1.288344,2.640894 0.134056,0.33267 0.229658,0.597253 0.289983,0.772583 0.02999,0.08784 0.05151,0.153459 0.06456,0.194028 l 0.01305,0.04198 0.0014,0.0056 0.385939,1.306336 h 7.502877 V 76.657176 H 40.885985 C 40.357172,75.336729 39.347522,73.18549 37.673944,70.973573 36.344677,69.222384 34.586433,67.430273 32.294788,66.041387 30.865333,65.173554 29.224563,64.47082 27.3813,64.044312 L 31.450238,59.304037 28.698572,56.21194 27.33438,54.679121 c 1.829153,-0.417336 3.461456,-1.104195 4.884208,-1.957917 3.46957,-2.081036 5.72135,-5.0673 7.129639,-7.505347 0.716845,-1.244953 1.216025,-2.353733 1.538111,-3.156656 h 4.855986 v -3.653366 h -7.502877 z"
|
||||
id="path12"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"productName": "Bocken",
|
||||
"identifier": "org.bocken.app",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.3",
|
||||
"build": {
|
||||
"devUrl": "http://192.168.1.4:5173",
|
||||
"frontendDist": "https://bocken.org"
|
||||
|
||||
@@ -464,6 +464,99 @@ a:focus-visible {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HIKES TRANSITIONS
|
||||
Cards + filter fly in/out vertically, clicked card morphs into the hero
|
||||
map (cross-fade between thumbnail and map), and the whole below-map panel
|
||||
(an opaque sheet) slides up from the bottom. Page chrome under the hero
|
||||
cross-fades so nothing snaps in at transition end. Lives in app.css (not
|
||||
the page component) so the rules are still loaded on the OLD side of a
|
||||
nav AWAY from /hikes.
|
||||
============================================ */
|
||||
|
||||
@keyframes hikes-fly-up {
|
||||
from { transform: translateY(100vh); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes hikes-fly-down {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(100vh); }
|
||||
}
|
||||
@keyframes hikes-root-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes hikes-root-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit):
|
||||
* kill UA's default fade, switch blend mode so the custom fly animation
|
||||
* shows clean motion against the rest of the page. */
|
||||
::view-transition-old(.hike-fly-in):only-child,
|
||||
::view-transition-new(.hike-fly-in):only-child {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* Paired (card ↔ hero): keep UA cross-fade so the card thumbnail dissolves
|
||||
* into the hero map — otherwise the new image would just cover the old one
|
||||
* and the thumbnail would vanish silently at t=0. Stretch the duration to
|
||||
* match the group so the fade ends exactly when the morph does. */
|
||||
::view-transition-old(.hike-fly-in):not(:only-child),
|
||||
::view-transition-new(.hike-fly-in):not(:only-child) {
|
||||
animation-duration: 550ms;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Group (the morphing bbox) timing. */
|
||||
::view-transition-group(.hike-fly-in) {
|
||||
animation-duration: 550ms;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Cards + filter rise from below the viewport on enter. */
|
||||
html.vt-enter-hikes::view-transition-new(.hike-fly-in):only-child {
|
||||
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* Cards + filter drop off the bottom on exit. */
|
||||
html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child {
|
||||
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
|
||||
}
|
||||
|
||||
/* Everything below the hero map on a detail page — stage nav, photo strip,
|
||||
* metrics, tags, elevation chart, scroll area, meta footer — slides up from
|
||||
* the bottom on enter and back down on any exit, as one panel. The wrapper
|
||||
* carries `view-transition-name: hike-below-map` and an opaque background, so
|
||||
* the whole sheet (background included) moves; the hero map morphs separately
|
||||
* above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
|
||||
html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
|
||||
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
html.vt-enter-hikes::view-transition-old(hike-below-map):only-child,
|
||||
html.vt-exit-hike-detail::view-transition-old(hike-below-map):only-child {
|
||||
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
|
||||
}
|
||||
|
||||
/* Cross-fade the rest of the page (root pseudo) during hike transitions so
|
||||
* the destination's chrome — metrics + content + footer on the detail page,
|
||||
* overview hero + credit on the index — phases in instead of snapping in
|
||||
* at the end of the morph. Overrides the global rule above; scope keeps
|
||||
* other routes' transitions on their existing instant-swap behavior. */
|
||||
html.vt-enter-hike-detail::view-transition-old(root),
|
||||
html.vt-enter-hikes::view-transition-old(root),
|
||||
html.vt-exit-hikes::view-transition-old(root),
|
||||
html.vt-exit-hike-detail::view-transition-old(root) {
|
||||
animation: hikes-root-fade-out 450ms ease-out both;
|
||||
}
|
||||
html.vt-enter-hike-detail::view-transition-new(root),
|
||||
html.vt-enter-hikes::view-transition-new(root),
|
||||
html.vt-exit-hikes::view-transition-new(root),
|
||||
html.vt-exit-hike-detail::view-transition-new(root) {
|
||||
animation: hikes-root-fade-in 450ms ease-out both;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RECIPE GRID
|
||||
Responsive card grid used across recipe pages
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="%lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Flims Gletschermühlen
|
||||
date: 2024-07-14
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [Graubünden, Flims, Sommer]
|
||||
seasons: 5-8
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
import Private from '$lib/components/Private.svelte';
|
||||
</script>
|
||||
|
||||
## Anreise
|
||||
|
||||
Start bei Bargis. Anreise am besten via Bus von Flims.
|
||||
<JourneyPlanner from="<current location>" to="Fidaz, Bargis" toFixed time="07:00" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
<HikeImage idx={48} />
|
||||
<HikeImage idx={49} />
|
||||
<HikeImage idx={50} />
|
||||
<HikeImage idx={51} />
|
||||
<HikeImage idx={52} />
|
||||
<HikeImage idx={53} />
|
||||
<HikeImage idx={54} />
|
||||
<HikeImage idx={55} />
|
||||
<HikeImage idx={56} />
|
||||
<HikeImage idx={57} />
|
||||
<HikeImage idx={58} />
|
||||
<HikeImage idx={59} />
|
||||
<HikeImage idx={60} />
|
||||
<HikeImage idx={61} />
|
||||
<HikeImage idx={62} />
|
||||
<HikeImage idx={63} />
|
||||
<HikeImage idx={64} />
|
||||
<HikeImage idx={65} />
|
||||
<HikeImage idx={66} />
|
||||
<HikeImage idx={67} />
|
||||
<HikeImage idx={68} />
|
||||
<HikeImage idx={69} />
|
||||
<HikeImage idx={70} />
|
||||
<HikeImage idx={71} />
|
||||
<HikeImage idx={72} />
|
||||
<HikeImage idx={73} />
|
||||
<HikeImage idx={74} />
|
||||
<HikeImage idx={75} />
|
||||
|
||||
## Abreise
|
||||
Via Bus oder Auto wieder nach Hause. Wenn man nicht abgeholt wird wie wir, muss man noch etwas weiter laufen bis nach Trin.
|
||||
<JourneyPlanner from="Trin, Quadris" fromFixed to="<current location>" time="15:30" target="departure"/>
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Schlittelausflug Brün
|
||||
date: 2024-12-25
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Graubünden, Flims, Winter, Schlitteln]
|
||||
seasons: 12-2
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
import Private from '$lib/components/Private.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Ein netter Ausflug zum Schlitteln, wenn man bereits in Flims ist.
|
||||
Aufstieg ca. 1 Stunde mit wunderschöner Winterlandschaft.
|
||||
|
||||
## Anreise
|
||||
|
||||
Start direkt in Brün. Eine Anreise mit Bus (Linie 404) ist möglich, ein direktes Anfahren mit Auto wäre jedoch zu empfehlen.
|
||||
Es empfiehlt sich ca. um 11 Uhr in Brün anzukommen, da durch die Nähe zum Piz Riein ausserhalb der Mittagszeit es schnell schattig werden kann.
|
||||
<JourneyPlanner from="<current location>" to="Valendas, Brün Dorf" toFixed time="11:00" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
|
||||
<HikeImage src="PXL_20241225_121635285.jpg" alt="Anna auf dem Weg runter" private />
|
||||
<HikeImage src="PXL_20241225_122938851.jpg" alt="Wieder in Brün" private />
|
||||
<HikeImage src="PXL_20241225_122942649.jpg" alt="Wieder in Brün" />
|
||||
|
||||
## Abreise
|
||||
Via Bus (Linie 404) oder Auto.
|
||||
<JourneyPlanner from="Valendas, Brün Dorf" fromFixed to="<current location>" time="12:30" target="departure"/>
|
||||
@@ -0,0 +1,917 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1" xmlns:bocken="https://bocken.org/gpx/v1">
|
||||
<wpt lat="46.778422" lon="9.30542">
|
||||
<ele>1289.9</ele>
|
||||
<time>2024-12-25T11:00:27.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="33736035" visibility="private"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781754" lon="9.304902">
|
||||
<ele>1321.3</ele>
|
||||
<time>2024-12-25T11:02:57.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="b50be014"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.78252" lon="9.305407">
|
||||
<ele>1327.1</ele>
|
||||
<time>2024-12-25T11:04:19.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="be3138c8"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780856" lon="9.307209">
|
||||
<ele>1356.5</ele>
|
||||
<time>2024-12-25T11:09:30.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="d4b01559"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780487" lon="9.307823">
|
||||
<ele>1363.9</ele>
|
||||
<time>2024-12-25T11:11:11.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="64b8ebe0"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781086" lon="9.310293">
|
||||
<ele>1444.3</ele>
|
||||
<time>2024-12-25T11:24:33.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="ace73886"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781736" lon="9.310631">
|
||||
<ele>1453.9</ele>
|
||||
<time>2024-12-25T11:27:15.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="2e3de268"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781776" lon="9.311906">
|
||||
<ele>1495.3</ele>
|
||||
<time>2024-12-25T11:33:44.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="e8cd91ea"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.77932" lon="9.314403">
|
||||
<ele>1540.4</ele>
|
||||
<time>2024-12-25T11:42:13.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="f03708bf"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.777217" lon="9.317179">
|
||||
<ele>1580.1</ele>
|
||||
<time>2024-12-25T11:50:16.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="0bf223b8" visibility="private"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780061" lon="9.317093">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:12.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="b0be80dd"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<trk>
|
||||
<name>Etappe 1</name>
|
||||
<trkseg>
|
||||
<trkpt lat="46.778422" lon="9.30542">
|
||||
<ele>1289.9</ele>
|
||||
<time>2024-12-25T11:00:27.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778416" lon="9.305474">
|
||||
<ele>1290.1</ele>
|
||||
<time>2024-12-25T11:00:28.504Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778416" lon="9.305543">
|
||||
<ele>1290.3</ele>
|
||||
<time>2024-12-25T11:00:30.402Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77843" lon="9.305573">
|
||||
<ele>1290.3</ele>
|
||||
<time>2024-12-25T11:00:31.400Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778532" lon="9.305683">
|
||||
<ele>1291.2</ele>
|
||||
<time>2024-12-25T11:00:36.492Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778563" lon="9.305731">
|
||||
<ele>1291.5</ele>
|
||||
<time>2024-12-25T11:00:38.307Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778709" lon="9.305979">
|
||||
<ele>1293.7</ele>
|
||||
<time>2024-12-25T11:00:47.301Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778762" lon="9.306037">
|
||||
<ele>1294.3</ele>
|
||||
<time>2024-12-25T11:00:49.961Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778818" lon="9.306061">
|
||||
<ele>1294.8</ele>
|
||||
<time>2024-12-25T11:00:52.305Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778869" lon="9.306064">
|
||||
<ele>1295.2</ele>
|
||||
<time>2024-12-25T11:00:54.355Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779004" lon="9.306009">
|
||||
<ele>1296.5</ele>
|
||||
<time>2024-12-25T11:00:59.983Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779116" lon="9.305952">
|
||||
<ele>1297.1</ele>
|
||||
<time>2024-12-25T11:01:04.747Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779221" lon="9.305905">
|
||||
<ele>1297.3</ele>
|
||||
<time>2024-12-25T11:01:09.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779305" lon="9.305897">
|
||||
<ele>1297.5</ele>
|
||||
<time>2024-12-25T11:01:12.538Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779651" lon="9.305939">
|
||||
<ele>1301.6</ele>
|
||||
<time>2024-12-25T11:01:26.481Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779773" lon="9.305926">
|
||||
<ele>1303.0</ele>
|
||||
<time>2024-12-25T11:01:31.394Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779851" lon="9.305896">
|
||||
<ele>1304.2</ele>
|
||||
<time>2024-12-25T11:01:34.633Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779954" lon="9.305841">
|
||||
<ele>1305.4</ele>
|
||||
<time>2024-12-25T11:01:39.037Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780225" lon="9.30561">
|
||||
<ele>1309.0</ele>
|
||||
<time>2024-12-25T11:01:51.639Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780626" lon="9.305371">
|
||||
<ele>1313.5</ele>
|
||||
<time>2024-12-25T11:02:09.033Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780671" lon="9.305355">
|
||||
<ele>1314.0</ele>
|
||||
<time>2024-12-25T11:02:10.893Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780816" lon="9.305363">
|
||||
<ele>1314.8</ele>
|
||||
<time>2024-12-25T11:02:16.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780929" lon="9.30533">
|
||||
<ele>1315.3</ele>
|
||||
<time>2024-12-25T11:02:21.348Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780989" lon="9.305304">
|
||||
<ele>1315.5</ele>
|
||||
<time>2024-12-25T11:02:23.861Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781243" lon="9.305123">
|
||||
<ele>1316.5</ele>
|
||||
<time>2024-12-25T11:02:35.212Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781504" lon="9.304991">
|
||||
<ele>1318.9</ele>
|
||||
<time>2024-12-25T11:02:46.304Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781617" lon="9.30491">
|
||||
<ele>1320.1</ele>
|
||||
<time>2024-12-25T11:02:51.360Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781668" lon="9.304888">
|
||||
<ele>1320.6</ele>
|
||||
<time>2024-12-25T11:02:53.495Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781706" lon="9.304886">
|
||||
<ele>1320.9</ele>
|
||||
<time>2024-12-25T11:02:55.022Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781754" lon="9.304902">
|
||||
<ele>1321.3</ele>
|
||||
<time>2024-12-25T11:02:57.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781813" lon="9.304937">
|
||||
<ele>1321.8</ele>
|
||||
<time>2024-12-25T11:03:02.723Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78206" lon="9.305146">
|
||||
<ele>1323.5</ele>
|
||||
<time>2024-12-25T11:03:28.379Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782117" lon="9.305184">
|
||||
<ele>1323.9</ele>
|
||||
<time>2024-12-25T11:03:34.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782334" lon="9.305263">
|
||||
<ele>1325.3</ele>
|
||||
<time>2024-12-25T11:03:54.110Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78248" lon="9.305283">
|
||||
<ele>1326.1</ele>
|
||||
<time>2024-12-25T11:04:07.290Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782509" lon="9.305298">
|
||||
<ele>1326.4</ele>
|
||||
<time>2024-12-25T11:04:10.055Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782535" lon="9.30533">
|
||||
<ele>1326.6</ele>
|
||||
<time>2024-12-25T11:04:13.111Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782546" lon="9.305368">
|
||||
<ele>1326.8</ele>
|
||||
<time>2024-12-25T11:04:15.650Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78252" lon="9.305407">
|
||||
<ele>1327.1</ele>
|
||||
<time>2024-12-25T11:04:19.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782529" lon="9.305446">
|
||||
<ele>1327.2</ele>
|
||||
<time>2024-12-25T11:04:22.651Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782505" lon="9.305472">
|
||||
<ele>1327.5</ele>
|
||||
<time>2024-12-25T11:04:26.523Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782475" lon="9.305481">
|
||||
<ele>1327.8</ele>
|
||||
<time>2024-12-25T11:04:30.491Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782437" lon="9.305473">
|
||||
<ele>1327.9</ele>
|
||||
<time>2024-12-25T11:04:35.466Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782332" lon="9.305419">
|
||||
<ele>1328.5</ele>
|
||||
<time>2024-12-25T11:04:49.890Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782273" lon="9.305398">
|
||||
<ele>1328.9</ele>
|
||||
<time>2024-12-25T11:04:57.758Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782008" lon="9.305388">
|
||||
<ele>1331.5</ele>
|
||||
<time>2024-12-25T11:05:32.106Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781851" lon="9.30536">
|
||||
<ele>1333.6</ele>
|
||||
<time>2024-12-25T11:05:52.600Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781795" lon="9.305358">
|
||||
<ele>1334.5</ele>
|
||||
<time>2024-12-25T11:05:59.858Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781736" lon="9.305378">
|
||||
<ele>1335.4</ele>
|
||||
<time>2024-12-25T11:06:07.706Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781714" lon="9.305402">
|
||||
<ele>1335.8</ele>
|
||||
<time>2024-12-25T11:06:11.264Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781688" lon="9.30546">
|
||||
<ele>1336.6</ele>
|
||||
<time>2024-12-25T11:06:17.415Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781662" lon="9.305689">
|
||||
<ele>1338.7</ele>
|
||||
<time>2024-12-25T11:06:38.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781641" lon="9.305796">
|
||||
<ele>1339.7</ele>
|
||||
<time>2024-12-25T11:06:47.887Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781615" lon="9.305873">
|
||||
<ele>1340.4</ele>
|
||||
<time>2024-12-25T11:06:55.505Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781592" lon="9.305919">
|
||||
<ele>1341.0</ele>
|
||||
<time>2024-12-25T11:07:00.559Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781448" lon="9.306123">
|
||||
<ele>1343.6</ele>
|
||||
<time>2024-12-25T11:07:26.554Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781411" lon="9.306188">
|
||||
<ele>1344.4</ele>
|
||||
<time>2024-12-25T11:07:34.054Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781245" lon="9.306508">
|
||||
<ele>1348.1</ele>
|
||||
<time>2024-12-25T11:08:09.674Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781134" lon="9.306686">
|
||||
<ele>1350.4</ele>
|
||||
<time>2024-12-25T11:08:31.035Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781063" lon="9.306817">
|
||||
<ele>1351.9</ele>
|
||||
<time>2024-12-25T11:08:45.859Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780882" lon="9.307183">
|
||||
<ele>1356.1</ele>
|
||||
<time>2024-12-25T11:09:25.916Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780856" lon="9.307209">
|
||||
<ele>1356.5</ele>
|
||||
<time>2024-12-25T11:09:30.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780806" lon="9.307293">
|
||||
<ele>1357.5</ele>
|
||||
<time>2024-12-25T11:09:43.673Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780634" lon="9.307515">
|
||||
<ele>1360.3</ele>
|
||||
<time>2024-12-25T11:10:24.855Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780549" lon="9.307703">
|
||||
<ele>1362.4</ele>
|
||||
<time>2024-12-25T11:10:52.532Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780487" lon="9.307823">
|
||||
<ele>1363.9</ele>
|
||||
<time>2024-12-25T11:11:11.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780461" lon="9.30794">
|
||||
<ele>1365.1</ele>
|
||||
<time>2024-12-25T11:11:21.979Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780403" lon="9.308115">
|
||||
<ele>1366.9</ele>
|
||||
<time>2024-12-25T11:11:39.333Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780344" lon="9.308364">
|
||||
<ele>1368.9</ele>
|
||||
<time>2024-12-25T11:12:02.851Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780309" lon="9.308474">
|
||||
<ele>1370.0</ele>
|
||||
<time>2024-12-25T11:12:13.678Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780172" lon="9.308786">
|
||||
<ele>1373.6</ele>
|
||||
<time>2024-12-25T11:12:46.761Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78013" lon="9.308907">
|
||||
<ele>1374.7</ele>
|
||||
<time>2024-12-25T11:12:58.870Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780088" lon="9.309049">
|
||||
<ele>1376.1</ele>
|
||||
<time>2024-12-25T11:13:12.676Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780027" lon="9.309317">
|
||||
<ele>1378.7</ele>
|
||||
<time>2024-12-25T11:13:37.885Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779979" lon="9.30956">
|
||||
<ele>1381.0</ele>
|
||||
<time>2024-12-25T11:14:00.460Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779951" lon="9.309722">
|
||||
<ele>1382.5</ele>
|
||||
<time>2024-12-25T11:14:15.373Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779923" lon="9.309842">
|
||||
<ele>1383.7</ele>
|
||||
<time>2024-12-25T11:14:26.689Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779794" lon="9.310237">
|
||||
<ele>1388.0</ele>
|
||||
<time>2024-12-25T11:15:05.752Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779698" lon="9.310591">
|
||||
<ele>1391.1</ele>
|
||||
<time>2024-12-25T11:15:39.737Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77967" lon="9.310656">
|
||||
<ele>1391.9</ele>
|
||||
<time>2024-12-25T11:15:46.592Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779627" lon="9.310722">
|
||||
<ele>1392.6</ele>
|
||||
<time>2024-12-25T11:15:54.723Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779532" lon="9.3108">
|
||||
<ele>1394.3</ele>
|
||||
<time>2024-12-25T11:16:08.929Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779468" lon="9.310839">
|
||||
<ele>1395.0</ele>
|
||||
<time>2024-12-25T11:16:17.969Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779411" lon="9.310862">
|
||||
<ele>1395.8</ele>
|
||||
<time>2024-12-25T11:16:25.677Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779353" lon="9.310881">
|
||||
<ele>1396.5</ele>
|
||||
<time>2024-12-25T11:16:33.425Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778964" lon="9.310958">
|
||||
<ele>1402.1</ele>
|
||||
<time>2024-12-25T11:17:24.593Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778797" lon="9.311025">
|
||||
<ele>1404.1</ele>
|
||||
<time>2024-12-25T11:17:47.167Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778656" lon="9.311056">
|
||||
<ele>1405.3</ele>
|
||||
<time>2024-12-25T11:18:05.753Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778631" lon="9.311068">
|
||||
<ele>1405.4</ele>
|
||||
<time>2024-12-25T11:18:09.183Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778612" lon="9.311084">
|
||||
<ele>1405.6</ele>
|
||||
<time>2024-12-25T11:18:12.042Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778598" lon="9.311108">
|
||||
<ele>1405.8</ele>
|
||||
<time>2024-12-25T11:18:14.856Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778598" lon="9.31116">
|
||||
<ele>1406.2</ele>
|
||||
<time>2024-12-25T11:18:19.498Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778609" lon="9.311186">
|
||||
<ele>1406.3</ele>
|
||||
<time>2024-12-25T11:18:22.226Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778629" lon="9.31121">
|
||||
<ele>1406.4</ele>
|
||||
<time>2024-12-25T11:18:25.600Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778651" lon="9.311221">
|
||||
<ele>1406.6</ele>
|
||||
<time>2024-12-25T11:18:28.631Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77884" lon="9.311188">
|
||||
<ele>1408.2</ele>
|
||||
<time>2024-12-25T11:18:53.442Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778933" lon="9.3112">
|
||||
<ele>1409.4</ele>
|
||||
<time>2024-12-25T11:19:05.611Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778978" lon="9.311215">
|
||||
<ele>1409.9</ele>
|
||||
<time>2024-12-25T11:19:11.628Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779154" lon="9.311334">
|
||||
<ele>1412.5</ele>
|
||||
<time>2024-12-25T11:19:36.908Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779327" lon="9.311421">
|
||||
<ele>1415.2</ele>
|
||||
<time>2024-12-25T11:20:00.758Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779497" lon="9.311468">
|
||||
<ele>1417.4</ele>
|
||||
<time>2024-12-25T11:20:23.310Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779841" lon="9.311512">
|
||||
<ele>1422.3</ele>
|
||||
<time>2024-12-25T11:21:08.321Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779942" lon="9.311522">
|
||||
<ele>1423.7</ele>
|
||||
<time>2024-12-25T11:21:21.516Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780057" lon="9.311506">
|
||||
<ele>1425.2</ele>
|
||||
<time>2024-12-25T11:21:36.573Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780112" lon="9.311482">
|
||||
<ele>1426.0</ele>
|
||||
<time>2024-12-25T11:21:44.056Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780204" lon="9.311423">
|
||||
<ele>1427.5</ele>
|
||||
<time>2024-12-25T11:21:57.153Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78027" lon="9.311369">
|
||||
<ele>1428.7</ele>
|
||||
<time>2024-12-25T11:22:07.014Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78032" lon="9.311315">
|
||||
<ele>1429.5</ele>
|
||||
<time>2024-12-25T11:22:15.120Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780368" lon="9.311245">
|
||||
<ele>1430.4</ele>
|
||||
<time>2024-12-25T11:22:23.962Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780629" lon="9.310815">
|
||||
<ele>1435.7</ele>
|
||||
<time>2024-12-25T11:23:15.251Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780899" lon="9.310423">
|
||||
<ele>1441.3</ele>
|
||||
<time>2024-12-25T11:24:04.878Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780968" lon="9.310341">
|
||||
<ele>1442.6</ele>
|
||||
<time>2024-12-25T11:24:16.474Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781011" lon="9.310305">
|
||||
<ele>1443.2</ele>
|
||||
<time>2024-12-25T11:24:22.934Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781048" lon="9.310287">
|
||||
<ele>1443.8</ele>
|
||||
<time>2024-12-25T11:24:28.018Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781086" lon="9.310293">
|
||||
<ele>1444.3</ele>
|
||||
<time>2024-12-25T11:24:33.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78109" lon="9.310279">
|
||||
<ele>1444.6</ele>
|
||||
<time>2024-12-25T11:24:35.394Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781125" lon="9.310282">
|
||||
<ele>1444.8</ele>
|
||||
<time>2024-12-25T11:24:43.475Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78119" lon="9.310304">
|
||||
<ele>1445.8</ele>
|
||||
<time>2024-12-25T11:24:58.853Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781608" lon="9.310537">
|
||||
<ele>1451.9</ele>
|
||||
<time>2024-12-25T11:26:41.977Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781736" lon="9.310631">
|
||||
<ele>1453.9</ele>
|
||||
<time>2024-12-25T11:27:15.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781992" lon="9.310887">
|
||||
<ele>1458.1</ele>
|
||||
<time>2024-12-25T11:27:54.242Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782265" lon="9.311167">
|
||||
<ele>1463.0</ele>
|
||||
<time>2024-12-25T11:28:36.436Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782316" lon="9.311231">
|
||||
<ele>1463.9</ele>
|
||||
<time>2024-12-25T11:28:44.941Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782351" lon="9.311289">
|
||||
<ele>1464.6</ele>
|
||||
<time>2024-12-25T11:28:51.636Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782389" lon="9.311378">
|
||||
<ele>1465.7</ele>
|
||||
<time>2024-12-25T11:29:00.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782576" lon="9.311913">
|
||||
<ele>1471.2</ele>
|
||||
<time>2024-12-25T11:29:52.743Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782683" lon="9.312188">
|
||||
<ele>1474.0</ele>
|
||||
<time>2024-12-25T11:30:20.137Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782742" lon="9.312381">
|
||||
<ele>1475.9</ele>
|
||||
<time>2024-12-25T11:30:38.442Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782786" lon="9.312543">
|
||||
<ele>1477.2</ele>
|
||||
<time>2024-12-25T11:30:53.536Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782798" lon="9.312602">
|
||||
<ele>1477.6</ele>
|
||||
<time>2024-12-25T11:30:58.867Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782798" lon="9.312636">
|
||||
<ele>1477.8</ele>
|
||||
<time>2024-12-25T11:31:01.812Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782789" lon="9.312662">
|
||||
<ele>1477.9</ele>
|
||||
<time>2024-12-25T11:31:04.335Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782746" lon="9.312686">
|
||||
<ele>1478.3</ele>
|
||||
<time>2024-12-25T11:31:10.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782721" lon="9.312689">
|
||||
<ele>1478.7</ele>
|
||||
<time>2024-12-25T11:31:13.330Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782699" lon="9.312678">
|
||||
<ele>1478.9</ele>
|
||||
<time>2024-12-25T11:31:16.271Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782678" lon="9.312654">
|
||||
<ele>1479.2</ele>
|
||||
<time>2024-12-25T11:31:19.643Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78246" lon="9.312333">
|
||||
<ele>1483.2</ele>
|
||||
<time>2024-12-25T11:31:58.799Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782285" lon="9.312031">
|
||||
<ele>1487.3</ele>
|
||||
<time>2024-12-25T11:32:33.063Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782246" lon="9.311979">
|
||||
<ele>1488.2</ele>
|
||||
<time>2024-12-25T11:32:39.743Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782196" lon="9.31192">
|
||||
<ele>1489.2</ele>
|
||||
<time>2024-12-25T11:32:47.873Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782137" lon="9.311873">
|
||||
<ele>1490.0</ele>
|
||||
<time>2024-12-25T11:32:56.373Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782088" lon="9.311841">
|
||||
<ele>1490.7</ele>
|
||||
<time>2024-12-25T11:33:03.162Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782035" lon="9.311823">
|
||||
<ele>1491.5</ele>
|
||||
<time>2024-12-25T11:33:10.045Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781976" lon="9.311818">
|
||||
<ele>1492.1</ele>
|
||||
<time>2024-12-25T11:33:17.519Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78192" lon="9.311833">
|
||||
<ele>1492.8</ele>
|
||||
<time>2024-12-25T11:33:24.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781776" lon="9.311906">
|
||||
<ele>1495.3</ele>
|
||||
<time>2024-12-25T11:33:44.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781729" lon="9.311954">
|
||||
<ele>1496.1</ele>
|
||||
<time>2024-12-25T11:33:53.503Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781335" lon="9.312383">
|
||||
<ele>1503.6</ele>
|
||||
<time>2024-12-25T11:35:14.942Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781145" lon="9.312623">
|
||||
<ele>1507.2</ele>
|
||||
<time>2024-12-25T11:35:56.570Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781083" lon="9.312733">
|
||||
<ele>1508.8</ele>
|
||||
<time>2024-12-25T11:36:12.736Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781002" lon="9.31291">
|
||||
<ele>1511.1</ele>
|
||||
<time>2024-12-25T11:36:36.893Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78095" lon="9.312999">
|
||||
<ele>1512.4</ele>
|
||||
<time>2024-12-25T11:36:50.168Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780902" lon="9.31306">
|
||||
<ele>1513.2</ele>
|
||||
<time>2024-12-25T11:37:00.712Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78086" lon="9.31309">
|
||||
<ele>1514.1</ele>
|
||||
<time>2024-12-25T11:37:08.460Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780694" lon="9.31326">
|
||||
<ele>1517.2</ele>
|
||||
<time>2024-12-25T11:37:42.057Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780601" lon="9.313378">
|
||||
<ele>1519.0</ele>
|
||||
<time>2024-12-25T11:38:02.472Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78048" lon="9.313565">
|
||||
<ele>1521.6</ele>
|
||||
<time>2024-12-25T11:38:31.666Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780447" lon="9.313604">
|
||||
<ele>1522.2</ele>
|
||||
<time>2024-12-25T11:38:38.701Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780285" lon="9.313719">
|
||||
<ele>1524.7</ele>
|
||||
<time>2024-12-25T11:39:08.549Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780238" lon="9.313764">
|
||||
<ele>1525.6</ele>
|
||||
<time>2024-12-25T11:39:17.862Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780095" lon="9.313951">
|
||||
<ele>1528.0</ele>
|
||||
<time>2024-12-25T11:39:49.670Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780029" lon="9.314036">
|
||||
<ele>1529.3</ele>
|
||||
<time>2024-12-25T11:40:04.252Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779972" lon="9.314074">
|
||||
<ele>1530.3</ele>
|
||||
<time>2024-12-25T11:40:14.635Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77993" lon="9.314088">
|
||||
<ele>1530.8</ele>
|
||||
<time>2024-12-25T11:40:21.774Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77964" lon="9.314135">
|
||||
<ele>1535.1</ele>
|
||||
<time>2024-12-25T11:41:10.123Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779527" lon="9.31417">
|
||||
<ele>1536.7</ele>
|
||||
<time>2024-12-25T11:41:29.265Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779487" lon="9.314194">
|
||||
<ele>1537.3</ele>
|
||||
<time>2024-12-25T11:41:36.431Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779441" lon="9.314237">
|
||||
<ele>1538.0</ele>
|
||||
<time>2024-12-25T11:41:45.481Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779396" lon="9.314295">
|
||||
<ele>1539.0</ele>
|
||||
<time>2024-12-25T11:41:55.427Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77932" lon="9.314403">
|
||||
<ele>1540.4</ele>
|
||||
<time>2024-12-25T11:42:13.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779313" lon="9.314438">
|
||||
<ele>1540.8</ele>
|
||||
<time>2024-12-25T11:42:16.830Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77928" lon="9.314478">
|
||||
<ele>1541.3</ele>
|
||||
<time>2024-12-25T11:42:23.410Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779212" lon="9.314531">
|
||||
<ele>1542.4</ele>
|
||||
<time>2024-12-25T11:42:35.235Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779139" lon="9.314551">
|
||||
<ele>1543.4</ele>
|
||||
<time>2024-12-25T11:42:46.630Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778993" lon="9.314513">
|
||||
<ele>1545.5</ele>
|
||||
<time>2024-12-25T11:43:09.381Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778945" lon="9.314511">
|
||||
<ele>1546.2</ele>
|
||||
<time>2024-12-25T11:43:16.748Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778818" lon="9.314557">
|
||||
<ele>1547.9</ele>
|
||||
<time>2024-12-25T11:43:36.823Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778609" lon="9.314607">
|
||||
<ele>1550.6</ele>
|
||||
<time>2024-12-25T11:44:09.314Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77849" lon="9.314662">
|
||||
<ele>1552.3</ele>
|
||||
<time>2024-12-25T11:44:28.463Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778114" lon="9.314981">
|
||||
<ele>1558.2</ele>
|
||||
<time>2024-12-25T11:45:35.177Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778062" lon="9.315019">
|
||||
<ele>1558.7</ele>
|
||||
<time>2024-12-25T11:45:44.097Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777899" lon="9.315109">
|
||||
<ele>1561.1</ele>
|
||||
<time>2024-12-25T11:46:10.832Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777872" lon="9.315135">
|
||||
<ele>1561.6</ele>
|
||||
<time>2024-12-25T11:46:15.794Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777853" lon="9.315164">
|
||||
<ele>1562.0</ele>
|
||||
<time>2024-12-25T11:46:20.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77783" lon="9.315238">
|
||||
<ele>1562.8</ele>
|
||||
<time>2024-12-25T11:46:28.548Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777777" lon="9.315595">
|
||||
<ele>1566.2</ele>
|
||||
<time>2024-12-25T11:47:06.927Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777752" lon="9.315719">
|
||||
<ele>1567.4</ele>
|
||||
<time>2024-12-25T11:47:20.508Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777724" lon="9.315821">
|
||||
<ele>1568.2</ele>
|
||||
<time>2024-12-25T11:47:32.053Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777662" lon="9.315984">
|
||||
<ele>1569.9</ele>
|
||||
<time>2024-12-25T11:47:51.643Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777446" lon="9.316396">
|
||||
<ele>1573.9</ele>
|
||||
<time>2024-12-25T11:48:46.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777371" lon="9.316581">
|
||||
<ele>1575.4</ele>
|
||||
<time>2024-12-25T11:49:08.744Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777344" lon="9.316674">
|
||||
<ele>1576.2</ele>
|
||||
<time>2024-12-25T11:49:19.357Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777256" lon="9.317067">
|
||||
<ele>1579.0</ele>
|
||||
<time>2024-12-25T11:50:02.799Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777217" lon="9.317179">
|
||||
<ele>1580.1</ele>
|
||||
<time>2024-12-25T11:50:16.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777218" lon="9.317208">
|
||||
<ele>1580.3</ele>
|
||||
<time>2024-12-25T11:50:20.041Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77726" lon="9.31725">
|
||||
<ele>1579.6</ele>
|
||||
<time>2024-12-25T11:50:30.385Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777316" lon="9.317282">
|
||||
<ele>1578.8</ele>
|
||||
<time>2024-12-25T11:50:42.606Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777683" lon="9.317418">
|
||||
<ele>1580.2</ele>
|
||||
<time>2024-12-25T11:51:59.551Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777914" lon="9.317508">
|
||||
<ele>1583.2</ele>
|
||||
<time>2024-12-25T11:52:48.136Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778206" lon="9.317509">
|
||||
<ele>1590.3</ele>
|
||||
<time>2024-12-25T11:53:47.475Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778592" lon="9.317407">
|
||||
<ele>1598.0</ele>
|
||||
<time>2024-12-25T11:55:07.191Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77922" lon="9.317416">
|
||||
<ele>1607.9</ele>
|
||||
<time>2024-12-25T11:57:14.818Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779428" lon="9.317344">
|
||||
<ele>1609.4</ele>
|
||||
<time>2024-12-25T11:57:58.258Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77965" lon="9.317225">
|
||||
<ele>1612.7</ele>
|
||||
<time>2024-12-25T11:58:46.316Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77985" lon="9.31718">
|
||||
<ele>1613.6</ele>
|
||||
<time>2024-12-25T11:59:27.439Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780045" lon="9.317101">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:08.563Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780061" lon="9.317093">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:12.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780092" lon="9.317082">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:02:09.000Z</time>
|
||||
</trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||