feat(images): responsive <Image>, gated private images + prose
Build-time image optimization plus auth-gated private content. - <Image> (src/lib/components/Image.svelte): wraps @sveltejs/enhanced-img for public images under src/lib/assets/images/ (AVIF/WebP, multiple widths, lazy by default), plus a `private` mode for auth-gated images. - Private images: scripts/build-private-images.ts encodes sources from src/lib/assets/private-images/ into private-assets/ (outside the bundle) and a manifest; served only via the auth-checked /private-images/ endpoint (X-Accel-Redirect in prod, disk read in dev). - HikeImage gains a `src` prose mode: build-hikes encodes non-waypoint images referenced in .svx and exposes them by filename (imagesByName); a `private` attr routes them through the gated /hikes/<slug>/private/ path. - <Private> (src/lib/components/Private.svelte): renders prose only to logged-in viewers (cosmetic gating — text still ships in the bundle). - deploy.sh rsyncs private-assets/; prod needs an nginx internal /protected-images/ location.
This commit is contained in:
@@ -19,6 +19,12 @@ static/shopping/cumulus.svg
|
|||||||
static/hikes/
|
static/hikes/
|
||||||
hikes-assets/
|
hikes-assets/
|
||||||
src/lib/data/hikes.generated.ts
|
src/lib/data/hikes.generated.ts
|
||||||
|
# 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, ...)
|
# Build-script disk caches (Swisstopo identify, BRouter responses, ...)
|
||||||
scripts/.cache/
|
scripts/.cache/
|
||||||
# Loose working-tree scratch files (notes, photos, prototypes) that aren't
|
# Loose working-tree scratch files (notes, photos, prototypes) that aren't
|
||||||
|
|||||||
+3
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.86.2",
|
"version": "1.87.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"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 && 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",
|
"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",
|
"build": "vite build",
|
||||||
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
|
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.56.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
|
"@sveltejs/enhanced-img": "^0.10.4",
|
||||||
"@sveltejs/kit": "^2.56.1",
|
"@sveltejs/kit": "^2.56.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tauri-apps/cli": "^2.10.1",
|
"@tauri-apps/cli": "^2.10.1",
|
||||||
|
|||||||
Generated
+50
@@ -69,6 +69,9 @@ importers:
|
|||||||
'@sveltejs/adapter-auto':
|
'@sveltejs/adapter-auto':
|
||||||
specifier: ^7.0.1
|
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)))
|
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':
|
'@sveltejs/kit':
|
||||||
specifier: ^2.56.1
|
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))
|
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))
|
||||||
@@ -925,6 +928,13 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@sveltejs/kit': ^2.4.0
|
'@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':
|
'@sveltejs/kit@2.56.1':
|
||||||
resolution: {integrity: sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==}
|
resolution: {integrity: sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==}
|
||||||
engines: {node: '>=18.13'}
|
engines: {node: '>=18.13'}
|
||||||
@@ -1448,6 +1458,10 @@ packages:
|
|||||||
ieee754@1.2.1:
|
ieee754@1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
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:
|
indent-string@4.0.0:
|
||||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1914,6 +1928,11 @@ packages:
|
|||||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||||
typescript: '>=5.0.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:
|
svelte@5.55.1:
|
||||||
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
|
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2010,6 +2029,10 @@ packages:
|
|||||||
vfile-message@2.0.4:
|
vfile-message@2.0.4:
|
||||||
resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==}
|
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:
|
vite-node@6.0.0:
|
||||||
resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
|
resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -2744,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))
|
'@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
|
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))':
|
'@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:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.0.0
|
'@standard-schema/spec': 1.0.0
|
||||||
@@ -3238,6 +3274,8 @@ snapshots:
|
|||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
|
|
||||||
|
imagetools-core@9.1.0: {}
|
||||||
|
|
||||||
indent-string@4.0.0: {}
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
ip@2.0.1:
|
ip@2.0.1:
|
||||||
@@ -3734,6 +3772,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- picomatch
|
- picomatch
|
||||||
|
|
||||||
|
svelte-parse-markup@0.1.5(svelte@5.55.1):
|
||||||
|
dependencies:
|
||||||
|
svelte: 5.55.1
|
||||||
|
|
||||||
svelte@5.55.1:
|
svelte@5.55.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
@@ -3838,6 +3880,14 @@ snapshots:
|
|||||||
'@types/unist': 2.0.11
|
'@types/unist': 2.0.11
|
||||||
unist-util-stringify-position: 2.0.3
|
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):
|
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:
|
dependencies:
|
||||||
cac: 7.0.0
|
cac: 7.0.0
|
||||||
|
|||||||
+60
-17
@@ -38,7 +38,8 @@ import type {
|
|||||||
HikeStage,
|
HikeStage,
|
||||||
HikesOverview,
|
HikesOverview,
|
||||||
ImagePoint,
|
ImagePoint,
|
||||||
ImageVariant
|
ImageVariant,
|
||||||
|
NamedHikeImage
|
||||||
} from '../src/types/hikes.js';
|
} from '../src/types/hikes.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -399,7 +400,9 @@ async function processImage(
|
|||||||
srcPath: string,
|
srcPath: string,
|
||||||
slug: string,
|
slug: string,
|
||||||
alt: string,
|
alt: string,
|
||||||
gpxImageRefs: Record<string, GpxImageRef>
|
gpxImageRefs: Record<string, GpxImageRef>,
|
||||||
|
/** Visibility for a non-waypoint prose image, or null when it isn't one. */
|
||||||
|
forceVisibility: 'public' | 'private' | null
|
||||||
): Promise<
|
): Promise<
|
||||||
| { variant: ImageVariant; thumbnailRelUrl: string; largestRelUrl: string; hash: string; visibility: 'public' | 'private'; cached: boolean; outNames: string[] }
|
| { variant: ImageVariant; thumbnailRelUrl: string; largestRelUrl: string; hash: string; visibility: 'public' | 'private'; cached: boolean; outNames: string[] }
|
||||||
| { skipped: true; hash: string }
|
| { skipped: true; hash: string }
|
||||||
@@ -407,14 +410,19 @@ async function processImage(
|
|||||||
const buffer = await fs.readFile(srcPath);
|
const buffer = await fs.readFile(srcPath);
|
||||||
const hash = shortHashOfBuffer(buffer);
|
const hash = shortHashOfBuffer(buffer);
|
||||||
const ref = gpxImageRefs[hash];
|
const ref = gpxImageRefs[hash];
|
||||||
if (!ref) {
|
if (!ref && forceVisibility === null) {
|
||||||
// Not referenced by any waypoint in track.gpx — drop it entirely (no
|
// Not a track.gpx waypoint and not referenced in the prose — drop it
|
||||||
// encode, no manifest entry, no static output). Authors who want an
|
// entirely (no encode, no manifest entry, no static output). Authors
|
||||||
// image published must place it on the route via the route-builder
|
// publish an image either by placing it on the route via the route-builder
|
||||||
// (which writes a `<bocken:image hash>` waypoint into track.gpx).
|
// (writes a `<bocken:image hash>` waypoint) or by referencing its filename
|
||||||
|
// inline with `<HikeImage src="…">`.
|
||||||
return { skipped: true, hash };
|
return { skipped: true, hash };
|
||||||
}
|
}
|
||||||
const visibility: 'public' | 'private' = ref.visibility === 'private' ? 'private' : 'public';
|
// Waypoints carry their own visibility; a prose image takes the visibility
|
||||||
|
// requested by its `<HikeImage>` tag (public unless marked `private`).
|
||||||
|
const visibility: 'public' | 'private' = ref
|
||||||
|
? ref.visibility === 'private' ? 'private' : 'public'
|
||||||
|
: (forceVisibility ?? 'public');
|
||||||
// Public images go under `images/` (served directly by nginx); private ones
|
// Public images go under `images/` (served directly by nginx); private ones
|
||||||
// under `private/` (proxied through Node for the auth check, then handed off
|
// under `private/` (proxied through Node for the auth check, then handed off
|
||||||
// via X-Accel-Redirect). The encode itself is shared with the cover image.
|
// via X-Accel-Redirect). The encode itself is shared with the cover image.
|
||||||
@@ -1167,11 +1175,29 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
} catch {
|
} catch {
|
||||||
// no images dir is fine
|
// no images dir is fine
|
||||||
}
|
}
|
||||||
// Images whose content hash isn't in gpxImageRefs are dropped before
|
// Images addressed inline with `<HikeImage src="…">` in the prose, keyed by
|
||||||
// encoding (see processImage). Count for the log line below.
|
// source basename → requested visibility. These are encoded and exposed via
|
||||||
|
// `imagesByName` even when they aren't track.gpx waypoints; a `private`
|
||||||
|
// attribute on the tag routes the image into the gated `private/` segment.
|
||||||
|
// Everything else that isn't a waypoint is still dropped.
|
||||||
|
const proseImages = new Map<string, 'public' | 'private'>();
|
||||||
|
for (const m of svxSource.matchAll(/<HikeImage\b[^>]*?\/?>/g)) {
|
||||||
|
const tag = m[0];
|
||||||
|
const srcMatch = tag.match(/\bsrc\s*=\s*["']([^"']+)["']/);
|
||||||
|
if (!srcMatch) continue; // idx-mode tag, no filename
|
||||||
|
const name = srcMatch[1].split('/').pop();
|
||||||
|
if (!name) continue;
|
||||||
|
// `private` as a boolean attr (`private` or `private={true}`), excluding
|
||||||
|
// the src value so a "private" substring in a filename doesn't count.
|
||||||
|
const isPrivate = /\bprivate\b/.test(tag.replace(srcMatch[0], ''));
|
||||||
|
proseImages.set(name, isPrivate ? 'private' : 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-waypoint, non-prose images are dropped before encoding (see
|
||||||
|
// processImage). Count for the log line below.
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`[build-hikes:${slug}] processing ${imageFiles.length} image(s) — ${Object.keys(gpxImageRefs).length} referenced in track.gpx (concurrency=${IMAGE_CONCURRENCY})…`
|
`[build-hikes:${slug}] processing ${imageFiles.length} image(s) — ${Object.keys(gpxImageRefs).length} on route, ${proseImages.size} named in prose (concurrency=${IMAGE_CONCURRENCY})…`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,6 +1212,7 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ImageResult = {
|
type ImageResult = {
|
||||||
|
name: string;
|
||||||
variant: ImageVariant | null;
|
variant: ImageVariant | null;
|
||||||
point: ImagePoint | null;
|
point: ImagePoint | null;
|
||||||
outNames: string[];
|
outNames: string[];
|
||||||
@@ -1197,25 +1224,33 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
IMAGE_CONCURRENCY,
|
IMAGE_CONCURRENCY,
|
||||||
async (imgPath, i) => {
|
async (imgPath, i) => {
|
||||||
const imgT0 = Date.now();
|
const imgT0 = Date.now();
|
||||||
|
const name = path.basename(imgPath);
|
||||||
// Hero alt only applies to the first image; later ones get a generic
|
// Hero alt only applies to the first image; later ones get a generic
|
||||||
// label (image basenames usually encode date/camera info that we don't
|
// label (image basenames usually encode date/camera info that we don't
|
||||||
// want to leak into alt text or hover tooltips).
|
// want to leak into alt text or hover tooltips).
|
||||||
const alt = i === 0 && typeof fm.heroAlt === 'string'
|
const alt = i === 0 && typeof fm.heroAlt === 'string'
|
||||||
? fm.heroAlt
|
? fm.heroAlt
|
||||||
: `Bild ${i + 1}`;
|
: `Bild ${i + 1}`;
|
||||||
const processed = await processImage(imgPath, slug, alt, gpxImageRefs);
|
// Encode if it's a route waypoint OR named in the prose (with the
|
||||||
|
// visibility that tag requested).
|
||||||
|
const processed = await processImage(imgPath, slug, alt, gpxImageRefs, proseImages.get(name) ?? null);
|
||||||
if ('skipped' in processed) {
|
if ('skipped' in processed) {
|
||||||
console.log(
|
console.log(
|
||||||
`[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${path.basename(imgPath)} · ${processed.hash} · skipped (not in track.gpx)`
|
`[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${name} · ${processed.hash} · skipped (not on route, not in prose)`
|
||||||
);
|
);
|
||||||
return { variant: null, point: null, outNames: [], visibility: 'public' as const };
|
return { name, variant: null, point: null, outNames: [], visibility: 'public' as const };
|
||||||
}
|
}
|
||||||
const point = extractImagePoint(processed, alt, gpxImageRefs[processed.hash]);
|
// Only waypoint images get a map ImagePoint; prose-only ones have no
|
||||||
|
// position, so they're exposed by name (imagesByName) instead.
|
||||||
|
const ref = gpxImageRefs[processed.hash];
|
||||||
|
const point = ref ? extractImagePoint(processed, alt, ref) : null;
|
||||||
const cacheTag = processed.cached ? ' · cached' : '';
|
const cacheTag = processed.cached ? ' · cached' : '';
|
||||||
|
const kind = ref ? processed.visibility : 'prose';
|
||||||
console.log(
|
console.log(
|
||||||
`[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${path.basename(imgPath)} · ${processed.hash} · ${processed.visibility}${cacheTag} (${Date.now() - imgT0}ms)`
|
`[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${name} · ${processed.hash} · ${kind}${cacheTag} (${Date.now() - imgT0}ms)`
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
name,
|
||||||
variant: processed.variant,
|
variant: processed.variant,
|
||||||
point,
|
point,
|
||||||
outNames: processed.outNames,
|
outNames: processed.outNames,
|
||||||
@@ -1224,6 +1259,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Images addressable by source filename via `<HikeImage src="…">`. Only the
|
||||||
|
// prose-referenced ones — keyed by basename, carrying the full srcset.
|
||||||
|
const imagesByName: Record<string, NamedHikeImage> = {};
|
||||||
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.variant !== null) {
|
if (r.variant !== null) {
|
||||||
// Fallback cover when there's no explicit `cover.*`: the first PUBLIC
|
// Fallback cover when there's no explicit `cover.*`: the first PUBLIC
|
||||||
@@ -1233,6 +1272,9 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
if (cover === null && r.visibility === 'public') cover = r.variant;
|
if (cover === null && r.visibility === 'public') cover = r.variant;
|
||||||
const segment = r.visibility === 'private' ? 'private' : 'images';
|
const segment = r.visibility === 'private' ? 'private' : 'images';
|
||||||
for (const name of r.outNames) keepFiles[segment].add(name);
|
for (const name of r.outNames) keepFiles[segment].add(name);
|
||||||
|
if (proseImages.has(r.name)) {
|
||||||
|
imagesByName[r.name] = { ...r.variant, visibility: r.visibility };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (r.point) imagePoints.push(r.point);
|
if (r.point) imagePoints.push(r.point);
|
||||||
}
|
}
|
||||||
@@ -1393,7 +1435,8 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
heroMapUrlDarkNarrow,
|
heroMapUrlDarkNarrow,
|
||||||
heroMapZoomNarrow,
|
heroMapZoomNarrow,
|
||||||
heroMapCenterNarrow,
|
heroMapCenterNarrow,
|
||||||
imagePoints
|
imagePoints,
|
||||||
|
...(Object.keys(imagesByName).length > 0 ? { imagesByName } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[build-hikes:${slug}] done in ${((Date.now() - hikeStart) / 1000).toFixed(1)}s`);
|
console.log(`[build-hikes:${slug}] done in ${((Date.now() - hikeStart) / 1000).toFixed(1)}s`);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+16
-1
@@ -23,6 +23,12 @@ ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
|
|||||||
# rsync that tree to the path nginx serves from.
|
# rsync that tree to the path nginx serves from.
|
||||||
HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}"
|
HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}"
|
||||||
HIKES_ASSETS_OWNER="${HIKES_ASSETS_OWNER:-http:http}"
|
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=""
|
DRY=""
|
||||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||||
@@ -89,13 +95,22 @@ else
|
|||||||
echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync"
|
echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync"
|
||||||
fi
|
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
|
if [[ -n "$DRY" ]]; then
|
||||||
echo ":: Dry run complete — no service restart"
|
echo ":: Dry run complete — no service restart"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ":: Fixing ownership on server"
|
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"
|
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"
|
echo ":: Restarting $SERVICE"
|
||||||
ssh "$REMOTE" "systemctl restart $SERVICE"
|
ssh "$REMOTE" "systemctl restart $SERVICE"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Public responsive image assets
|
||||||
|
|
||||||
|
Drop public source images here, then render them with `$lib/components/Image.svelte`.
|
||||||
|
|
||||||
|
At build time `@sveltejs/enhanced-img` (vite-imagetools + sharp) processes every
|
||||||
|
raster image in this folder into AVIF/WebP at multiple widths and strips EXIF.
|
||||||
|
Output is a public, hashed, immutable build asset.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import Image from '$lib/components/Image.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- lazy by default; `src` is relative to this folder -->
|
||||||
|
<Image src="hero.jpg" alt="…" />
|
||||||
|
|
||||||
|
<!-- above-the-fold / LCP image: load eagerly -->
|
||||||
|
<Image src="hero.jpg" alt="…" lazy={false} />
|
||||||
|
|
||||||
|
<!-- full-width image: pass `sizes` so smaller screens fetch smaller files -->
|
||||||
|
<Image src="banner.jpg" alt="…" sizes="min(1280px, 100vw)" />
|
||||||
|
|
||||||
|
<!-- subfolders work too -->
|
||||||
|
<Image src="blog/cover.png" alt="…" />
|
||||||
|
```
|
||||||
|
|
||||||
|
For **private, auth-gated** images use `<Image src="…" private />` and put the
|
||||||
|
source in `../private-images/` instead — see that folder's README.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Provide images at ~2× the displayed size so HiDPI screens stay sharp;
|
||||||
|
processing only ever scales **down**.
|
||||||
|
- SVGs are not processed here — import them directly instead.
|
||||||
|
- First build is slow (encoding); results are cached in
|
||||||
|
`node_modules/.cache/imagetools`.
|
||||||
|
- These sources are committed (they're public site assets).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Private (auth-gated) image sources
|
||||||
|
|
||||||
|
Drop **private** source images here, then render them with
|
||||||
|
`<Image src="…" private />` from `$lib/components/Image.svelte`.
|
||||||
|
|
||||||
|
These can't use `@sveltejs/enhanced-img` — its output is a public asset. Instead
|
||||||
|
`scripts/build-private-images.ts` (runs at `prebuild`) encodes each image into
|
||||||
|
AVIF/WebP at multiple widths into `private-assets/` (gitignored, outside the
|
||||||
|
client bundle) and writes `src/lib/data/privateImages.generated.ts`. The bytes
|
||||||
|
are served only through the auth-gated endpoint
|
||||||
|
`src/routes/private-images/[...file]/+server.ts`.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import Image from '$lib/components/Image.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- `src` is relative to THIS folder; shows a lock badge -->
|
||||||
|
<Image src="receipt.jpg" private alt="…" />
|
||||||
|
|
||||||
|
<!-- gate rendering behind your own auth check too -->
|
||||||
|
{#if data.session}
|
||||||
|
<Image src="family/2024.jpg" private alt="…" sizes="min(1000px, 100vw)" />
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
Setup / notes:
|
||||||
|
|
||||||
|
- **Dev:** run `pnpm exec vite-node scripts/build-private-images.ts` once (and
|
||||||
|
after adding/changing images) so the manifest + `private-assets/` exist. You
|
||||||
|
must be logged in for the gated endpoint to serve the bytes.
|
||||||
|
- **Prod (one-time):** add an nginx `internal` location so the bytes are only
|
||||||
|
reachable via the endpoint's `X-Accel-Redirect`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /protected-images/ {
|
||||||
|
internal;
|
||||||
|
alias /var/www/static/private-images/;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/deploy.sh` rsyncs `private-assets/` → `/var/www/static/private-images/`.
|
||||||
|
- These source images are **gitignored** (private + large). Back them up
|
||||||
|
separately.
|
||||||
|
- SVGs are not processed here.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import type { Picture } from '@sveltejs/enhanced-img';
|
||||||
|
|
||||||
|
// Build-time map of every PUBLIC raster image under src/lib/assets/images/.
|
||||||
|
// `query: { enhanced: true }` routes each match through @sveltejs/enhanced-img
|
||||||
|
// (vite-imagetools + sharp), which generates AVIF/WebP at multiple widths and
|
||||||
|
// returns a Picture that <enhanced:img> renders as a <picture>. Eager so the
|
||||||
|
// lookup below stays synchronous. SVGs are excluded — enhanced-img only
|
||||||
|
// supports them statically, and they need no rasterising anyway.
|
||||||
|
const sources = import.meta.glob(
|
||||||
|
'/src/lib/assets/images/**/*.{avif,gif,heif,jpeg,jpg,png,tiff,webp}',
|
||||||
|
{ eager: true, query: { enhanced: true } }
|
||||||
|
) as Record<string, { default: Picture }>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
|
// PRIVATE images can't use enhanced-img (its output is public). They go
|
||||||
|
// through the parallel sharp pipeline (scripts/build-private-images.ts) and
|
||||||
|
// are served by the auth-gated /private-images/ endpoint. The manifest is
|
||||||
|
// generated at prebuild; run `vite-node scripts/build-private-images.ts` once
|
||||||
|
// for dev.
|
||||||
|
import { PRIVATE_IMAGES } from '$lib/data/privateImages.generated';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Path to the source image. Public: relative to src/lib/assets/images/.
|
||||||
|
* Private: relative to src/lib/assets/private-images/. e.g. "hero.jpg"
|
||||||
|
* or "blog/cover.png". A leading slash is tolerated. */
|
||||||
|
src: string;
|
||||||
|
/** Alt text. Always provide one for non-decorative images. */
|
||||||
|
alt?: string;
|
||||||
|
/** Lazy-load below the fold (default). Set false for above-the-fold /
|
||||||
|
* LCP images, which should load eagerly. */
|
||||||
|
lazy?: boolean;
|
||||||
|
/** Auth-gate this image: served only to logged-in users via the
|
||||||
|
* /private-images/ endpoint, with a lock badge. The bytes are never a
|
||||||
|
* public asset. Render these behind your own auth check too — anonymous
|
||||||
|
* viewers get a "locked" placeholder instead of the image. */
|
||||||
|
private?: boolean;
|
||||||
|
/** Responsive `sizes`. When set, smaller screens fetch smaller files;
|
||||||
|
* omit for a plain 1x/2x pair (public) or the full ladder (private). */
|
||||||
|
sizes?: string;
|
||||||
|
/** Extra class(es) forwarded to the underlying <img>. */
|
||||||
|
class?: string;
|
||||||
|
/** Any other <img> attribute (width, height, fetchpriority, style, …). */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
lazy = true,
|
||||||
|
private: isPrivate = false,
|
||||||
|
sizes,
|
||||||
|
class: className,
|
||||||
|
...rest
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const key = $derived(src.replace(/^\/+/, ''));
|
||||||
|
|
||||||
|
// Public: enhanced-img Picture, looked up by root-relative glob key.
|
||||||
|
const picture = $derived(isPrivate ? undefined : sources[`/src/lib/assets/images/${key}`]?.default);
|
||||||
|
// Private: responsive variant with auth-gated /private-images/ URLs.
|
||||||
|
const variant = $derived(isPrivate ? PRIVATE_IMAGES[key] : undefined);
|
||||||
|
|
||||||
|
// Anonymous viewers get a 401 from /private-images/; swap the broken image
|
||||||
|
// for a locked placeholder when that happens.
|
||||||
|
let locked = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!dev) return;
|
||||||
|
if (isPrivate && !variant) {
|
||||||
|
console.warn(
|
||||||
|
`[Image] No private build-time asset for "${src}". Place it under ` +
|
||||||
|
`src/lib/assets/private-images/ and re-run scripts/build-private-images.ts.`
|
||||||
|
);
|
||||||
|
} else if (!isPrivate && !picture) {
|
||||||
|
console.warn(
|
||||||
|
`[Image] No build-time asset for "${src}". ` +
|
||||||
|
`Place it under src/lib/assets/images/ (path relative to that dir).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isPrivate}
|
||||||
|
{#if variant}
|
||||||
|
<span class="g-private-image" class:locked>
|
||||||
|
<picture>
|
||||||
|
<source type="image/avif" srcset={variant.srcsetAvif} {sizes} />
|
||||||
|
<source type="image/webp" srcset={variant.srcsetWebp} {sizes} />
|
||||||
|
<img
|
||||||
|
src={variant.src}
|
||||||
|
{alt}
|
||||||
|
width={variant.width}
|
||||||
|
height={variant.height}
|
||||||
|
class={className}
|
||||||
|
loading={lazy ? 'lazy' : 'eager'}
|
||||||
|
decoding="async"
|
||||||
|
onerror={() => (locked = true)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
<span class="g-private-badge" title="Privates Bild — nur für eingeloggte Benutzer sichtbar">
|
||||||
|
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
privat
|
||||||
|
</span>
|
||||||
|
{#if locked}
|
||||||
|
<span class="g-private-locked">
|
||||||
|
<Lock size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
Anmeldung erforderlich
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{:else if picture}
|
||||||
|
<enhanced:img
|
||||||
|
src={picture}
|
||||||
|
{alt}
|
||||||
|
{sizes}
|
||||||
|
class={className}
|
||||||
|
loading={lazy ? 'lazy' : 'eager'}
|
||||||
|
decoding="async"
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* The colon in the tag name must be escaped in a selector. enhanced-img
|
||||||
|
* rewrites this to target the generated <img>. */
|
||||||
|
enhanced\:img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-private-image {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-private-image picture,
|
||||||
|
.g-private-image img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lock badge — mirrors HikeImage's `.private`. */
|
||||||
|
.g-private-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shown when the gated request 401s (anonymous viewer). */
|
||||||
|
.g-private-image.locked img {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-private-locked {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Show the small "privat" lock chip above the content (default true). */
|
||||||
|
badge?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { badge = true, children }: Props = $props();
|
||||||
|
|
||||||
|
// Visible only to logged-in viewers. Pages that use this should be rendered
|
||||||
|
// per request (e.g. the hike detail page is `prerender = false`) so the
|
||||||
|
// session is live and, for anonymous visitors, the content is omitted from
|
||||||
|
// the SSR HTML.
|
||||||
|
//
|
||||||
|
// NOTE: this is *cosmetic* gating, not byte-gating like a private image.
|
||||||
|
// The prose is compiled into the page's JS chunk, which ships to every
|
||||||
|
// visitor — a determined anonymous user can read it in the bundle. Use it
|
||||||
|
// for "members-only" notes, never for secrets.
|
||||||
|
const canSee = $derived(!!page.data.session?.user);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if canSee}
|
||||||
|
<div class="private-prose">
|
||||||
|
{#if badge}
|
||||||
|
<span class="badge" title="Privat — nur für eingeloggte Benutzer sichtbar">
|
||||||
|
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
privat
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.private-prose {
|
||||||
|
position: relative;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trim the first/last rendered block's margins so the box hugs its content. */
|
||||||
|
.private-prose :global(> :first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.private-prose :global(> :last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,24 +2,57 @@
|
|||||||
import { getHikeContext } from './hikeContext.svelte';
|
import { getHikeContext } from './hikeContext.svelte';
|
||||||
import { focused } from './focusedImageStore.svelte';
|
import { focused } from './focusedImageStore.svelte';
|
||||||
import { addScrollAnchor } from './scrollAnchors';
|
import { addScrollAnchor } from './scrollAnchors';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
import Lock from '@lucide/svelte/icons/lock';
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Position in the hike's full chronological image list (0-indexed,
|
/** Position in the hike's full chronological image list (0-indexed,
|
||||||
* stable across viewers because it refers to the unfiltered list). */
|
* stable across viewers because it refers to the unfiltered list).
|
||||||
idx: number;
|
* Use this for route photos — it carries the map sync + elapsed time. */
|
||||||
|
idx?: number;
|
||||||
|
/** Source filename of an image in the hike's `images/` dir, for an
|
||||||
|
* inline prose photo that isn't a route waypoint. Mutually exclusive
|
||||||
|
* with `idx`. A path is accepted; only the basename is used. */
|
||||||
|
src?: string;
|
||||||
|
/** Alt text override for `src` mode. Falls back to the build-time alt. */
|
||||||
|
alt?: string;
|
||||||
|
/** Marks a `src`-mode prose image as private (auth-gated + lock badge).
|
||||||
|
* Read at BUILD time from the prose by build-hikes — it encodes the image
|
||||||
|
* into the gated `private/` segment. At runtime the component takes the
|
||||||
|
* visibility from the manifest, so this prop is declarative only. */
|
||||||
|
private?: boolean;
|
||||||
/** Optional caption shown under the image — narrative blurb, not a
|
/** Optional caption shown under the image — narrative blurb, not a
|
||||||
* machine-derived label. Elapsed time is shown automatically. */
|
* machine-derived label. Elapsed time is shown automatically (idx mode). */
|
||||||
caption?: string;
|
caption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { idx, caption }: Props = $props();
|
const { idx, src, alt, caption }: Props = $props();
|
||||||
const ctx = getHikeContext();
|
const ctx = getHikeContext();
|
||||||
|
|
||||||
const ip = $derived(ctx().images[idx]);
|
// Prose mode: resolve the named image, hiding private ones from viewers who
|
||||||
|
// may not see them (the gated endpoint would 401 anyway).
|
||||||
|
const named = $derived.by(() => {
|
||||||
|
if (!src) return undefined;
|
||||||
|
const name = src.split('/').pop() ?? src;
|
||||||
|
const n = ctx().imagesByName[name];
|
||||||
|
if (!n) return undefined;
|
||||||
|
if (n.visibility === 'private' && !ctx().showPrivate) return undefined;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (dev && src && !ctx().imagesByName[src.split('/').pop() ?? src]) {
|
||||||
|
console.warn(
|
||||||
|
`[HikeImage] No image named "${src}" in this hike. Put it in the hike's ` +
|
||||||
|
`images/ folder, reference it in the prose, and re-run build-hikes.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = $derived(idx === undefined ? undefined : ctx().images[idx]);
|
||||||
const visible = $derived(ip ? ctx().visibleImages.includes(ip) : false);
|
const visible = $derived(ip ? ctx().visibleImages.includes(ip) : false);
|
||||||
const visibleIdx = $derived(visible ? ctx().visibleImages.indexOf(ip) : -1);
|
const visibleIdx = $derived(visible && ip ? ctx().visibleImages.indexOf(ip) : -1);
|
||||||
const isActive = $derived(visibleIdx >= 0 && focused.index === visibleIdx);
|
const isActive = $derived(visibleIdx >= 0 && focused.index === visibleIdx);
|
||||||
|
|
||||||
// Find the track point closest in time to this image. Used by the
|
// Find the track point closest in time to this image. Used by the
|
||||||
@@ -80,7 +113,33 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if ip && visible}
|
{#if src}
|
||||||
|
{#if named}
|
||||||
|
<figure class="hike-image">
|
||||||
|
<picture>
|
||||||
|
<source type="image/avif" srcset={named.srcsetAvif} sizes="(max-width: 680px) 100vw, 680px" />
|
||||||
|
<source type="image/webp" srcset={named.srcsetWebp} sizes="(max-width: 680px) 100vw, 680px" />
|
||||||
|
<img
|
||||||
|
src={named.src}
|
||||||
|
alt={alt ?? named.alt}
|
||||||
|
width={named.width}
|
||||||
|
height={named.height}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
{#if named.visibility === 'private'}
|
||||||
|
<span class="private" title="Privates Bild — nur für eingeloggte Benutzer sichtbar">
|
||||||
|
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
privat
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if caption}
|
||||||
|
<figcaption>{caption}</figcaption>
|
||||||
|
{/if}
|
||||||
|
</figure>
|
||||||
|
{/if}
|
||||||
|
{:else if ip && visible}
|
||||||
<figure class="hike-image" class:active={isActive} bind:this={figure}>
|
<figure class="hike-image" class:active={isActive} bind:this={figure}>
|
||||||
<img
|
<img
|
||||||
src={ip.src}
|
src={ip.src}
|
||||||
@@ -129,6 +188,10 @@
|
|||||||
0 6px 14px -6px rgb(0 0 0 / 0.25);
|
0 6px 14px -6px rgb(0 0 0 / 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hike-image picture {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.hike-image img {
|
.hike-image img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getContext, setContext } from 'svelte';
|
import { getContext, setContext } from 'svelte';
|
||||||
import type { HikeTrackPoint, ImagePoint } from '$types/hikes';
|
import type { HikeTrackPoint, ImagePoint, NamedHikeImage } from '$types/hikes';
|
||||||
|
|
||||||
const KEY = Symbol('hike-context');
|
const KEY = Symbol('hike-context');
|
||||||
|
|
||||||
@@ -28,6 +28,12 @@ interface HikeContext {
|
|||||||
* inline `<HikeImage>` to compute the nearest-track-index for the
|
* inline `<HikeImage>` to compute the nearest-track-index for the
|
||||||
* scroll-progress pin on the map. */
|
* scroll-progress pin on the map. */
|
||||||
readonly track: HikeTrackPoint[] | null;
|
readonly track: HikeTrackPoint[] | null;
|
||||||
|
/** Images addressable by source filename for `<HikeImage src="…">`,
|
||||||
|
* keyed by source basename. */
|
||||||
|
readonly imagesByName: Record<string, NamedHikeImage>;
|
||||||
|
/** Whether the current viewer may see private images. Path-mode
|
||||||
|
* `<HikeImage src>` hides private images when this is false. */
|
||||||
|
readonly showPrivate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setHikeContext(ctx: () => HikeContext): void {
|
export function setHikeContext(ctx: () => HikeContext): void {
|
||||||
|
|||||||
@@ -159,7 +159,9 @@
|
|||||||
setHikeContext(() => ({
|
setHikeContext(() => ({
|
||||||
images: hike.imagePoints,
|
images: hike.imagePoints,
|
||||||
visibleImages: visibleImagePoints,
|
visibleImages: visibleImagePoints,
|
||||||
track
|
track,
|
||||||
|
imagesByName: hike.imagesByName ?? {},
|
||||||
|
showPrivate
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Continuous trail-position tracking. As the reader scrolls through the
|
// Continuous trail-position tracking. As the reader scrolls through the
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates `/private-images/<file>` requests behind an authenticated session.
|
||||||
|
* These are the encoded variants produced by scripts/build-private-images.ts
|
||||||
|
* and referenced from <Image private> via src/lib/data/privateImages.generated.ts.
|
||||||
|
*
|
||||||
|
* Production: returns `X-Accel-Redirect` so nginx serves the bytes from
|
||||||
|
* `/var/www/static/private-images/<file>` via an `internal` location. Node never
|
||||||
|
* touches the file. Add this once to the server's nginx config:
|
||||||
|
*
|
||||||
|
* location /protected-images/ {
|
||||||
|
* internal;
|
||||||
|
* alias /var/www/static/private-images/;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Dev (`vite dev`): no nginx in front, so stream the file from the local
|
||||||
|
* `private-assets/` tree. The auth check is identical either way.
|
||||||
|
*/
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||||
|
const session = locals.session ?? (await locals.auth());
|
||||||
|
if (!session?.user) {
|
||||||
|
throw error(401, 'Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = params.file;
|
||||||
|
// `[...file]` may contain `/` for nested sources; reject `..` traversal.
|
||||||
|
if (!file || file.includes('..')) {
|
||||||
|
throw error(400, 'Bad request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dev) {
|
||||||
|
const base = path.join(process.cwd(), 'private-assets');
|
||||||
|
const filePath = path.join(base, file);
|
||||||
|
// Defensive: ensure the resolved path stays inside private-assets/.
|
||||||
|
if (filePath !== base && !filePath.startsWith(base + path.sep)) {
|
||||||
|
throw error(400, 'Bad request.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(filePath);
|
||||||
|
const mime = MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
|
||||||
|
return new Response(new Uint8Array(data), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mime,
|
||||||
|
'Cache-Control': 'private, max-age=60'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw error(404, 'Image not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
// nginx replaces the body with the file from the `internal` location;
|
||||||
|
// the Content-Type it sets there wins, so we don't guess one here.
|
||||||
|
'X-Accel-Redirect': `/protected-images/${file}`,
|
||||||
|
'Cache-Control': 'no-store'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -24,6 +24,14 @@ export type ImagePoint = {
|
|||||||
visibility?: 'public' | 'private';
|
visibility?: 'public' | 'private';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A hike image addressable by its source filename, for `<HikeImage src="…">`
|
||||||
|
* used inline in the prose. Unlike `imagePoints` these need not be route
|
||||||
|
* waypoints (so no lat/lng/timestamp), but they carry the full responsive
|
||||||
|
* `srcset` so prose photos still get every quality level. */
|
||||||
|
export type NamedHikeImage = ImageVariant & {
|
||||||
|
visibility: 'public' | 'private';
|
||||||
|
};
|
||||||
|
|
||||||
// [lng, lat, elevation?, unixMs?]
|
// [lng, lat, elevation?, unixMs?]
|
||||||
export type HikeTrackPoint = [number, number, number?, number?];
|
export type HikeTrackPoint = [number, number, number?, number?];
|
||||||
|
|
||||||
@@ -134,6 +142,11 @@ export type HikeManifestEntry = {
|
|||||||
|
|
||||||
// Geo-tagged photos shown as map markers on the detail page:
|
// Geo-tagged photos shown as map markers on the detail page:
|
||||||
imagePoints: ImagePoint[];
|
imagePoints: ImagePoint[];
|
||||||
|
|
||||||
|
/** Images addressable by source filename for inline `<HikeImage src="…">`.
|
||||||
|
* Contains every encoded route photo plus any non-waypoint image referenced
|
||||||
|
* by name in the prose (index.svx). Keyed by source basename. */
|
||||||
|
imagesByName?: Record<string, NamedHikeImage>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Pre-rendered hero map for the `/hikes` index page. One image covers
|
/** Pre-rendered hero map for the `/hikes` index page. One image covers
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Responsive variant for a private (auth-gated) image, produced at build time
|
||||||
|
* by scripts/build-private-images.ts and consumed by Image.svelte.
|
||||||
|
*
|
||||||
|
* All URLs point at `/private-images/<file>` — the auth-checked endpoint in
|
||||||
|
* src/routes/private-images/[...file]/+server.ts, NOT a public asset. Public
|
||||||
|
* images take the opposite path (enhanced-img), so they have no manifest. */
|
||||||
|
export type PrivateImageVariant = {
|
||||||
|
/** Largest WebP, used as the <img> fallback `src`. */
|
||||||
|
src: string;
|
||||||
|
/** AVIF candidates as a `srcset` string (`url 480w, url 960w, …`). */
|
||||||
|
srcsetAvif: string;
|
||||||
|
/** WebP candidates as a `srcset` string. */
|
||||||
|
srcsetWebp: string;
|
||||||
|
/** Intrinsic width/height of the largest variant — set on the <img> so the
|
||||||
|
* browser reserves space and avoids layout shift. */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
+6
-1
@@ -1,4 +1,5 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { enhancedImages } from '@sveltejs/enhanced-img';
|
||||||
import { defineConfig, type Plugin } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
import { createReadStream, promises as fs } from 'node:fs';
|
import { createReadStream, promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -68,7 +69,11 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
allowedHosts: ["bocken.org"]
|
allowedHosts: ["bocken.org"]
|
||||||
},
|
},
|
||||||
plugins: [hikeImagesDevPlugin(), sveltekit()],
|
// enhancedImages() powers <Image> (src/lib/components/Image.svelte): it runs
|
||||||
|
// vite-imagetools (sharp) over every image under src/lib/assets/images/ at
|
||||||
|
// build time, emitting AVIF/WebP at multiple widths. Must come before the
|
||||||
|
// SvelteKit plugin.
|
||||||
|
plugins: [enhancedImages(), hikeImagesDevPlugin(), sveltekit()],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['barcode-detector']
|
exclude: ['barcode-detector']
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user