Files
windwiderstand/src/routes/post/[slug]/+page.svelte
T
Peter Meier ecff7b0bbf
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m1s
feat(images): cache-bust /cms-images URLs by post._updated
Browser caches /cms-images responses immutably for a year. When the
editor changes a focal point or alt text, the underlying transform
output changes too, but the URL stays the same — so the browser keeps
serving the previous bytes. Manual hard-reloads and /api/cache/clear
help once but not for visitors who already have the file.

- RustyImageTransformParams gains an optional `version` that
  serializes as `_v` in the /cms-images URL.
- RustyImage.svelte takes a matching `version` prop and forwards it
  through `baseParams`, so every <picture> source carries the tag.
- The post thumbnail passes `post._updated` (truncated to seconds)
  as version. Each save bumps `_updated`, the URL changes, browsers
  re-fetch.

`_v` is intentionally not consumed by `/cms-images` — its sole job is
to break the browser cache key. The server-side image-cache and the
RustyCMS transform cache key both ignore it, so disk usage doesn't
balloon when the version tag changes.
2026-04-25 09:43:34 +02:00

158 lines
5.0 KiB
Svelte

<script lang="ts">
import ContentRows from '$lib/components/ContentRows.svelte';
import Tag from '$lib/components/Tag.svelte';
import Tags from '$lib/components/Tags.svelte';
import EventBadges from '$lib/components/EventBadges.svelte';
import EventMap from '$lib/components/EventMap.svelte';
import Accordion from '$lib/components/Accordion.svelte';
import RustyImage from '$lib/components/RustyImage.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function executeScripts(node: HTMLElement) {
const scripts = node.querySelectorAll("script");
scripts.forEach((old) => {
const s = document.createElement("script");
for (const { name, value } of old.attributes) s.setAttribute(name, value);
s.text = old.textContent ?? "";
old.replaceWith(s);
});
}
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
{#if data.postImageUrl}
<meta property="og:image" content={data.postImageUrl} />
{/if}
</svelte:head>
<div class="md:flex gap-2 items-end">
{#if data.rawPostImageUrl}
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
<RustyImage
src={data.rawPostImageUrl}
focal={data.postImageFocal}
width={150}
height={200}
fit="cover"
alt=""
class="block object-cover"
loading="eager"
fetchpriority="high"
version={(data.post as { _updated?: string })._updated?.slice(0, 19)}
/>
</div>
{:else if data.postImageUrl}
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
<img
src={data.postImageUrl}
alt=""
class="block object-cover"
width="150"
height="200"
style="aspect-ratio: 1.3333"
loading="eager"
/>
</div>
{/if}
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
{#if data.postDate}
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
<Tag label={data.postDate} variant="date" />
</div>
{/if}
<div class="flex flex-nowrap gap-2 items-center shrink-0 p-1 pb-4 -ml-1 overflow-x-auto overflow-y-hidden">
<Tags tags={data.post.postTag ?? []} variant="green" tagBase="/posts/tag" />
</div>
</div>
</div>
<article
class="mt-8 h-entry"
itemscope
itemtype={data.isEvent ? 'https://schema.org/Event' : 'https://schema.org/BlogPosting'}
>
<a class="u-url" href="#" aria-hidden="true" hidden></a>
{#if data.post.created}
<time
class="dt-published"
datetime={String(data.post.created)}
hidden
>{data.post.created}</time>
{/if}
{#if data.contentHeadlineHtml}
<div class="content markdown max-w-none prose prose-zinc p-name">
{@html data.contentHeadlineHtml}
</div>
{:else if data.post.headline || data.post.subheadline}
<header class="content markdown max-w-none prose prose-zinc mb-4">
{#if data.post.headline}
<h1 class="p-name">{data.post.headline}</h1>
{/if}
{#if data.post.subheadline}
<p class="text-zinc-600 lead p-summary">{data.post.subheadline}</p>
{/if}
</header>
{/if}
{#if !data.post.subheadline && data.post.excerpt}
<p class="p-summary sr-only">{data.post.excerpt}</p>
{/if}
{#if data.isEvent && (data.eventDateLabel || data.eventLocationText)}
<div class="mb-6">
<EventBadges
eventDate={data.eventDateRaw}
locationText={data.eventLocationText}
locationHref={(data.mapEmbedUrl || data.mapLinkUrl) ? '#map-section' : null}
/>
</div>
{/if}
{#if data.contentBodyHtml}
<div class="content markdown max-w-none prose prose-zinc e-content">
{@html data.contentBodyHtml}
</div>
{/if}
{#if data.hasRowContent}
<ContentRows layout={data.post} translations={data.translations} />
{/if}
{#if (data.post.postTag ?? []).length > 0}
<div hidden>
{#each (data.post.postTag ?? []) as tag}
{@const tagName =
tag && typeof tag === 'object'
? ((tag as { name?: string }).name ?? '')
: ''}
{#if tagName}
<span class="p-category">{tagName}</span>
{/if}
{/each}
</div>
{/if}
<div class="p-author h-card" hidden>
<span class="p-name">{(data.post as { author?: string }).author ?? 'Bürgerinitiative Vachdorf'}</span>
</div>
</article>
{#if data.isEvent && (data.mapEmbedUrl || data.mapLinkUrl)}
<EventMap
embedUrl={data.mapEmbedUrl}
linkUrl={data.mapLinkUrl}
locationText={data.eventLocationText}
label={t(data.translations, T.post_map)}
translations={data.translations}
/>
{/if}
{#if data.commentSlot}
<Accordion label={t(data.translations, T.post_comments)} class="mt-6">
<div class="p-8 border border-zinc-200 rounded-lg" use:executeScripts>
{@html data.commentSlot}
</div>
</Accordion>
{/if}