Renders a single CMS entry as a block in isolation — no header, footer, or
preview banner. Lets us share a deep link to a single widget (e.g. the live
strommix gauge) for embedding or quick previews without exposing the rest of
the page.
- New BlockRenderer.svelte: switch over `_type` shared with ContentRows.
- Generic `getEntryBySlug(collection, slug, options)` in cms.ts so any
collection (live_strommix, deadline_banner, image, ...) is fetchable.
- Route group `(embed)/+layout@.svelte` resets the layout chain so no
Header/Footer/TopBanner inherit. `x-robots-tag: noindex`.
- Optional `?preview=<token>` for drafts. Cache: short s-maxage for live,
no-store for preview.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lead-paragraph rule matched footer MarkdownBlocks (copyright line)
and made it 1.25rem. Restrict to article descendants so footer +
sidebar Markdown stay at their natural size.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Palette: gray/zinc/slate/neutral → stein, red → fire, amber → erde,
sky → himmel, green → wald
- Footer: replaced raw rgb with stein tokens
- Radius: unified to rounded-xs across cards, buttons, inputs, pagination
- Buttons: .btn-primary/.btn-secondary now font-medium; added secondary
- Card hover: -translate-y-0.5 + shadow + wald-300 border
- Header active link: wald border-bottom + wald-700 text
- Mobile nav: bigger touch targets + wald active accent
- Pagination: prev/next text labels + wald-500 active state with
separated shape/color classes to avoid Tailwind conflicts
- Lead paragraph: scoped .markdown > p:first-child for top-level
MarkdownBlock only via [data-block-type="markdown"]
- Section dividers: subtle wald-200 gradient between content rows
- Global focus-visible ring (wald-500)
- Inline hex → palette tokens (Badge amber, Tag custom-color contrast)
- Font weights snapped to design system (300/400/500/600/700)
- transition-all → transition-colors where only color changes
- Removed em-dashes from user-visible templates
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add full/compact variants, prominent share, source badge, big
negative-price banner with conditional disclaimer, cards-style
context row, fade-in animation, and remove em-dashes from
visible strings.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Energy-Charts' /public_power endpoint started rejecting requests without
`start` + `end` query params ("HTTP 404: no content available"). The
CMS-cached `strommix_de` external schema cannot pass dynamic time
windows (external-collections v1 has no runtime-vars), so the per-field
fallback chain was effectively dead — when SMARD's load filter lagged
12+ h, both sources returned null and the widget surfaced "Lastwert
nicht verfügbar" with all zeros.
Aggregator now fetches /public_power directly with a 2-hour window,
picks the latest non-null per series, and feeds those values into the
existing per-field fallback. CMS strommix_de schema becomes obsolete
for the widget but stays available for admin debugging.
Same shape as the previous CMS payload, so the fallback merge is
unchanged. Picks `Wind onshore`, `Wind offshore`, `Solar`, `Biomass`,
`Hydro Run-of-River`, `Fossil brown coal / lignite`, `Fossil hard
coal`, `Fossil gas`, `Nuclear`, `Load` from production_types.
When SMARD's load filter (id 410) lags 12+ hours, the aligned-min
strategy in fetchSmardSnapshot produces a snapshot stamped with that
12-hour-old timestamp — and the widget surfaces it as the live "Stand
HH:MM" header. With Energy-Charts already returning fresher numbers
through the CMS path, falling back entirely is the right move.
Threshold is 90 min: tight enough to catch obvious lag, loose enough
to keep normal 30–60 min SMARD delay as the canonical source.
Two issues from the audit:
1. Hardcoded `lg:col-span-8 lg:col-start-3` collided with the layout
coming from `getBlockLayoutClasses(block.layout)`. Tailwind picked
`lg:col-start-3` from the hardcoded class (no opposing
`lg:col-start-1` in the layout output) so the widget started at
column 3 with span 12 → overflowed past the 12-col grid on
desktop, looking like it was "hanging right". Drop the hardcoded
constraints; the layout config from the CMS controls width
(mobile/tablet/desktop = 12 by default = full width).
2. Roughly 25 German strings were inline. Extracted them to
translation keys (T.strommix_*, registered in translations.ts) and
read via `useTranslate()` like ImageGalleryBlock /
PostOverviewBlock / YoutubeVideoBlock. Includes field labels for
the breakdown disclosure, source attribution variants, status
bucket markers, error / loading / fallback messages, and the
conventional formula text shown under the "Konventionell jetzt"
number.
Default DE strings live in cms_content/windwiderstand/translation_bundle/
app.json5 (separate CMS push).
The widget rendered "Keine gültigen Live-Werte" the moment SMARD's
load filter (id 410) lagged behind the production filters by even one
slot — the aggregator coalesced "filter unavailable" with "filter
returned 0" and the percent calculation refused to divide by zero.
Three changes:
1. SmardSnapshot.values now uses `number | null` so callers can tell
"filter genuinely missing" apart from "filter reported zero" (e.g.
solar at night, decommissioned nuclear).
2. Aggregator pulls Energy-Charts (CMS strommix_de) in parallel and
uses it as a per-field fallback for any SMARD null. New
`dataSource: "smard+energy-charts"` flag travels in the response so
the attribution stays honest. Pure SMARD when everything resolves;
pure Energy-Charts when SMARD is entirely down; the hybrid label
for the common case where only load drops out.
3. Widget renders the production breakdown + context row + price row
even when the percentage is unavailable, with a clear "Lastwert
von SMARD gerade nicht verfügbar" hint instead of going blank.
Audit at 22:15 MESZ flagged the conventional sum (lignite + hard coal
+ gas + nuclear) as 19.1 GW in the widget vs 15.65 GW in reality. The
breakdown showed the gap was Energy-Charts' `Fossil gas` (6.9 GW)
which bundles natural gas + GuD + similar, while SMARD's `Erdgas`
filter 4069 (3.8 GW) tracks just natural gas the way the widget label
implies. SMARD is the canonical German grid feed; switching the
primary source resolves the discrepancy directly.
- New `lib/smard.ts` fetches the 11 relevant filter IDs in parallel:
conventional (1223/4070/4069/1224), wind (4067/1225), solar (1226),
hydro (1228), biomass (4066), other-conventional + pumped storage
(1227/4071, surfaced separately), load (410). Two-step protocol
(index.json → archive.json) handled inside; output is MW (MWh × 4)
aligned to one common slot timestamp so every value on screen comes
from the same point in time.
- Aggregator route uses SMARD for production + load, Energy-Charts
only for cross-border saldo + day-ahead prices (where SMARD has no
convenient single-call endpoint). `dataSource: "smard" |
"energy-charts"` flag travels in the response so the widget can
attribute the numbers and fall back gracefully when SMARD is down.
- StrommixBlock gets a "Werte prüfen" disclosure that lists every
component (wind onshore + offshore, solar, the four conventional
buckets, biomass, hydro, load) with its MW value and the slot
timestamp + data source. Fact-check users can verify the addition
themselves now without asking.
Verified live: SMARD @ 21:00 UTC = lignite 8484 + hard coal 3480 +
gas 3811 + nuclear 0 = 15.78 GW conventional. Matches the user's
ground-truth 15.65 GW within sampling noise.
Audit against ground truth at 22:15 lokal exposed three real bugs:
1. Day-ahead price was wrong (widget 125, real 153 €/MWh). The CMS'
`last_non_null` transform always picks the last cell of the 96-slot
array — typically a slot in the future, not the slot covering "now".
Bypass the CMS for prices: aggregator fetches energy-charts /price
directly, walks unix_seconds, and picks the largest start ≤ now.
`priceMeasuredAtUnix` is surfaced so the widget can show the slot
timestamp as a sanity check.
2. Cross-border saldo (cbpf) typically lags 1.5–2 h. Widget labelled
it as "jetzt" even when the upstream's last datapoint was 90 min
old. Now the cross-border timestamp travels through the aggregator
and the widget renders "Stand 20:30 Uhr · verzögert" when the gap
to the production snapshot exceeds 30 min.
3. Bucket label "Dunkelflaute" misuses the term — the formal
definition needs ≥24 h history, not a single threshold crossing.
Updated the strommix-default content entry to use "Sehr niedrige
Einspeisung" / "Very low feed-in" instead, and explained the
reasoning in a comment so editors don't re-introduce the misnomer.
Aggregator response shape gained `crossborderMeasuredAtUnix` +
`priceMeasuredAtUnix`; the widget renders both as small timestamps
under their values, plus a "verzögert" hint when cross-border data is
older than the 30-min tolerance window.
Cache TTLs on the price external collections lowered from 30 min to
15 min so the admin-side cache flips at the same cadence as the
day-ahead slots (since 30.09.2025 prices come in 15-min buckets).
Editor-controlled live-strommix card that surfaces what share of
electricity demand is currently covered by weather-dependent
renewables (Wind+Solar / Load), plus the conventional generation that
fills the gap, the cross-border saldo, and the day-ahead price (DE/FR
side-by-side). Status bucket + label + argument come from the CMS so
editors can iterate copy without a deploy.
- LiveStrommixBlockData type next to the other block-types.
- New `/api/strommix` aggregator route bundles four CMS external
collections (strommix_de, crossborder_de, price_de, price_fr) into a
single payload, computes the renewable + conventional sums and the
cross-border saldo (Σ flow_xx_gw, sign convention: + = export from
DE), surfaces stale-on-error flags from the CMS response headers.
Cache-control 5min so a single node only hits energy-charts once
per window.
- StrommixBlock.svelte renders the card: percentage, bucket label,
bucket argument (markdown), context row (conventional + saldo with
↑/↓ arrow), price row with optional FR comparison + negative-price
badge, footer with disclaimer + "Wie wird das berechnet?" link +
stale-data hint when the CMS served fallback. Re-polls every 5min
client-side; bucket thresholds + labels + arguments come from the
block instance so multiple placements can carry different copy.
- ContentRows.svelte dispatcher branch on `_type === "live_strommix"`.
Wired against `cms_content/_types/core/live_strommix.json5` (added in
the rustycms repo today). content_layout.row{1,2,3}Content already
allow the new block-type via the same commit on the CMS side.
Security:
- cms-images / cms-files now share resolveCmsSource (cms-source.server.ts)
which enforces an allowlist: asset filename, absolute path on the CMS
host, or full URL exact-matching the configured CMS host+protocol.
Foreign hosts, protocol-relative URLs and path traversal return 400.
- markdown-safe.ts is the single configured marked entrypoint; it
disables raw HTML rendering globally (renderer.html() returns ''),
removing the inline script/iframe injection vector via markdown.
All 8 marked imports across components and loaders now go through it.
- sanitize-blocks.server.ts walks resolved page/post content and runs
HtmlBlock html fields through sanitize-html with a tight allowlist
(no script/style/on*, only youtube/vimeo/spotify iframes, mailto/tel
plus http(s) schemes, target=_blank gets rel=noopener).
- Webhook tokens (revalidate, cache/clear) compared via
crypto.timingSafeEqual through auth-token.server.ts.
- cms.ts wraps every fetch in cmsFetch(): 5 s default timeout via
AbortSignal.timeout, override per call or globally via
PUBLIC_CMS_FETCH_TIMEOUT_MS. Stops slow CMS responses from pinning
SSR workers indefinitely.
Observability:
- log.server.ts gives logWarn / logError / logInfo with scope + context;
swallowed catch {} blocks in hooks, layout.server, +page.server,
[...slug]/+page.server and sitemap now log instead of going silent.
Routing:
- Legacy /blog, /blog/page/:n, /blog/tag/:t and /p/:slug stub routes
deleted; their 301 redirects moved into hooks.server.ts so the
request never enters the SvelteKit page pipeline.
Tests (vitest, server-side):
- auth-token.server.test.ts (6 cases for constant-time compare)
- cms-source.server.test.ts (8 cases for SSRF allowlist)
- sanitize-blocks.server.test.ts (9 cases for sanitizer + walker)
All 23 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Toolchain hygiene:
- svelte-check is now a pinned devDep (was npx-fetched per CI run)
- vitest + coverage as devDeps; vitest.config.ts is separate from
vite.config.ts so vite build does not pull vitest
- $env stub for vitest (alias resolves $env/dynamic/{public,private}
to a process.env proxy) — keeps server modules testable without
booting SvelteKit
- npm scripts: check / check:watch / typecheck / test / test:watch
- drop yarn.lock + packageManager field; CI uses npm ci, single source
of truth is package-lock.json
- CI runs generate:icons + svelte-check + tsc --noEmit before deploy
Switch from raw process.env.X to $env/dynamic/{public,private} in
file-cache, image-cache, transform-cache and rusty-image.server. The
typed imports get tree-shaken correctly per server/client and fail
loudly at build time on accidental client-side use of private vars.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous setup loaded the full @iconify-json/mdi collection (7000+
icons, 2.95 MB) just to render the ~30 icons we actually use, producing a
2.99 MB client chunk.
generate-icons.mjs scans src/ for static `icon="mdi:..."` references,
extracts a minimal IconifyJSON containing only those icons (plus an
EXTRA_ICONS list for dynamic CMS-driven names) from
@iconify-json/mdi/icons.json, and writes it to a generated subset file.
iconify-offline.ts now registers that subset instead of the full pack.
build/client: 5.6 MB -> 1.6 MB (-71%)
build/server: 5.3 MB -> 2.4 MB (-55%)
largest client chunk: 2.99 MB -> 127 KB (-96%)
iconify subset: 2.95 MB -> 15.5 KB (-99.5%)
Hooked into predev/prebuild so the subset stays fresh; missing icons
fall back to the iconify API at runtime.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a self-contained maintenance page that the SvelteKit handle hook
serves before the first CMS call when `isCmsAvailable()` returns
false. The probe hits `/api-docs/openapi.json` with a short timeout
and caches the result (10s healthy, 3s unhealthy) so we don't pay a
round-trip per request. `/maintenance` and `?cms_down=1` simulate
the page without taking the CMS down — useful for previewing the
copy.
The maintenance HTML is fully inline (no CMS calls, no layout, no
external CSS) so it survives even when every dependency is down.
Returned with 503 + Retry-After:60 + noindex headers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Compares the link href against `$page.url.pathname` (with trailing-slash
normalization + subpath match), applies `font-bold underline` and
`aria-current="page"` on a match. Wired into both the desktop horizontal
nav and the mobile slide-down menu.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When EventBadges sits inside a `<a hover:underline>` (upcoming-events
list, post cards) the underline propagated through the badge spans on
hover. Force `text-decoration: none` on the date and location pills so
only the headline text underlines.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The plain `<time>` was visually weak next to the headline. Reuse the
`EventBadges` component from the post card so the date renders as a
proper wald-tinted pill — same shape as event posts. Drops the local
`formatEventDate` since EventBadges does the localization itself
(weekday + time included, matching post cards).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`main ul:not(.not-prose) li` injects a disc bullet on every list under
`main`, which the upcoming-events list inherited. Mark the ul `not-prose`
+ `list-none` so the rule doesn't match. Also bump the calendar-clock
icon from `h-4 w-4` to `size-5` so it visually matches the heading
ascender.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`?preview_draft=<sid>` on any page/post route forwards the signed
session id to the CMS, which renders the in-memory draft instead of
the persisted entry. Layout listens for `rustycms:reload` postMessages
from the admin iframe parent and re-runs `invalidateAll()` to pick up
fresh draft state. CSP `frame-ancestors` is set per-request when
`PUBLIC_PREVIEW_PARENT_ORIGIN` is configured so the admin iframe can
embed the response.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adopt RustyCMS' new resolve semantics (_depth, _fields,
POST /_batch) where they pay off:
- +layout.server.ts: 4 sequential GETs (navHeader, navSocial,
footer, page_config) collapsed into one batchFetch() call.
Legacy "default" page_config slug stays as fallback Single-
Batch only when the canonical slug is missing.
- New getPageStubs() / getPostStubs() use _fields= so the
Slug→Label/Slug→URL maps don't pull body/rows. Typically
10-50× smaller payload, eligible for the same TTL cache.
- loadPostsList() now requests posts with _depth=2 and a
sparse fieldset that keeps card-level fields plus
postImage.* and postTag.{name,icon,color,_slug}. Body
Markdown stays out of the listing entirely.
- Page + post detail routes set _depth=3 as a cycle/blast-radius
cap (page → block → image → img reaches in three levels).
- cms.ts: ContentGetOptions gains depth/fields, getPageBySlug /
getPostBySlug / getPosts thread them through (cache key
extended). New batchFetch() / batchData<T>() helpers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New block for `youtube_video_gallery` CMS type. Carousel renders
2 iframes side-by-side on md+ (1 on mobile) with prev/next/dots; grid
shows YT thumbnails opening modal embeds. Description supports
markdown via marked; long URLs break with overflow-wrap:anywhere.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Hides Header / HeaderOverlay / Footer / TopBanner / Breadcrumbs and
the comment accordion when printing. Action toolbar already had
`print:hidden`.
- Adds @media print rules in app.css: 1.5cm page margins, 11pt body,
forced black-on-white, expand link URLs after anchor text inside
articles, keep images and headings together at page breaks.
- PostCard now renders the count pill whenever a value is known (incl.
zero); previously hidden for `count === 0`, so posts whose comments
are still pending always looked empty.
- Extracts the bulk-fetch helper to `lib/comment-counts.ts`
(`fetchCommentCounts` + `postSlugForComments`) and uses it from both
`BlogOverview` and `PostOverviewBlock`. Homepage / any page with a
post_overview block now shows the badges too — previously only the
paginated /posts/* routes did.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- PostActions: every item is a rounded-full pill with subtle border;
the three icon-only actions (share/copy/print) cluster in one
segmented control with divider lines, count + reading-time stand
alone left and right
- Share button only renders when `navigator.share` is available so
desktop doesn't get a duplicate of "Copy link"
- All action buttons drop their inline labels and use `title=` +
`aria-label` for tooltip-only access; copy still flips icon to a
checkmark for 1.5s on success
- Empty comment state stacks vertically on mobile (icon above), goes
horizontal + left-aligned on ≥sm
- Post header rebuilt: image fixed at 150x150 (no more mobile blow-up),
right column is `flex flex-col gap-3` so date/tags/actions share one
rhythm instead of ad-hoc margins
- Reading-time now also walks `row*Content` and pulls `content`/`body`/
`text` strings — Termin-Posts with empty body but markdown rows now
show a meaningful estimate
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CommentCountBadge was a separate component rendered next to PostActions
— two adjacent strips of buttons that should have been one toolbar.
Merges the count fetch + click-to-scroll behaviour into PostActions
behind `commentPageId` / `commentTargetId` props. The standalone
CommentCountBadge.svelte is now redundant; deleted.
Layout under the post hero is now a single row:
[💬 3] [↗ Teilen] [📋 Link] [🖨️] [⏱ 4 min]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a small toolbar next to the comment-count badge with four actions:
- Share — uses `navigator.share()` on supporting devices, falls back
to copy-link otherwise
- Copy link — clipboard API with `<input>` + `execCommand` fallback,
flashes a checkmark for 1.5s on success
- Print — `window.print()`, hidden via `print:hidden` so it doesn't
show up in the printed output itself
- Reading time — server-computed from headline+body HTML at ~200 wpm,
hidden when 0
Reading time + canonical URL come from `+page.server.ts` so SSR
output is identical and shareable URLs render correctly.
All labels go through the translations layer (5 new
`post_action_*` keys).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pulls every German string out of Comments.svelte, PostCard.svelte and
CommentCountBadge.svelte and routes it through `useTranslate()` +
the `T.*` key registry. New keys (`comments_*`, `post_overview` count
aria, `comments_jump_to`) are registered in `lib/translations.ts`.
Default DE values land in
`cms_content/windwiderstand/de/translation_bundle/app.json5` (committed
separately) so the CMS-loaded bundle keeps the same look without each
component falling back to its raw key.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PostCard now shows a small comment-count pill in the top-left of the
image when count > 0. BlogOverview bulk-fetches counts for the visible
posts via the new `/api/comments/counts?ids=…` endpoint and passes
them down. One request per page render, regardless of card count.
- New helper `getCommentCounts()` in `cms.ts`
- `BlogOverview.svelte` derives slugs from posts, fires bulk fetch
in `$effect`, hands the result map to `PostCard`
- `PostCard.svelte` accepts `commentCount?: number | null`, renders
pill only when value is a positive number
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Repositions the CommentCountBadge out of the date row into its own
container below the post image. Removes hover colour-change + the
prose underline (`no-underline\!`) so the badge sits flat with the
post chrome.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`arValue` (height computation) and `arString` (URL `ar=` param) used
to read from different sources, so the URL could request a different
aspect than the `<img>` element reserved — caused a visible flicker
when the transformed image loaded. Both now derive from `arString`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Avatars with hash-derived hue, initials per author
- Relative dates (Intl.RelativeTimeFormat), absolute as title
- localStorage remembers author name for next visit
- Auto-resize textarea, char counter, Cmd+Enter submit
- Skeleton loading state, icon empty state, retry button on error
- Pending comments get amber card + status pill (visible only to author)
- Reply count in toggle ("3 Antworten")
- Edit/delete buttons fade in on hover (group-hover)
- DRY: meta + body rendering as snippets, used by top-level + replies
- Sends `client_age_ms` for backend's anti-instant-bot check
No content filtering — only timing / structure / re-use checks live in
the backend now (sha256 dedupe, session cooldown, banned IPs, author
char sanity).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Svelte rejected `{@const replies = repliesOf(c.id)}` because it sat
inside `<li>` rather than directly under `{#each}`. The Gitea deploy
typecheck (`svelte-check --threshold error`) failed on this, leaving
the previous Cusdis bundle live. Inlining the call costs one extra
filter per render — fine here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the Cusdis embed (postCommentSlot in pageConfig) in favour of the
new RustyCMS comment plugin. New Comments.svelte renders the thread,
form, replies and inline edit/delete via /api/comments. Count-badge near
the post date links to the accordion and uses /api/comments/:id/count.
Accordion gets a `lazy` mode so the comment iframe / fetch happens only
on first open (avoids the previous "empty on first render" issue with
hidden details).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The runtime default has been `["webp", "jpeg"]` for a while, but the doc
comments still referenced AVIF. Update them and add the rationale (server-side
AVIF encoding is 5-10× slower than WebP for marginal bandwidth gain).
No runtime change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
YoutubeVideoBlockData gains an optional `playlistId`. The block now
accepts either a video, a playlist, or both, and either field can be
filled with a raw ID *or* a full youtube.com / youtu.be URL — a
helper extracts the ?v= and ?list= parameters automatically.
Embed routing:
- video only → /embed/<id>?rel=0
- playlist only → /embed/videoseries?list=<plid>&rel=0
- video + playlist → /embed/<id>?list=<plid>&rel=0
- block.params merged via URLSearchParams
Also tweaks ImageGalleryBlock (grid variant): green padding/contain
fit, darker caption gradient, smaller left-aligned label that expands
on hover. Hides post_overview block when no posts and no intro text
(prevents a bare heading on /mediathek).
iconForExt now matches the backend's expanded ALLOWED_EXTENSIONS:
- tsv routes to the excel-box icon (alongside csv).
- json → mdi:code-json, xml/html → mdi:xml.
- py → mdi:language-python; js/ts/jsx/tsx → language-javascript;
rs → language-rust; sh/bash → console.
Visitors who download a CMS-hosted dataset, source dump or archive
now see a meaningful icon instead of the generic file-outline
fallback.
The post_overview block kept rendering its <h3> headline + the
"all posts" footer link even when both `postsResolved` was empty
and `text` was unset, leaving a heading with nothing under it on
pages like /mediathek that include the block defensively.
Wrap the whole render in `{#if hasContent}` so empty overviews
disappear; the heading reappears as soon as posts or intro text
are present.
- Render block.title as <h3> above the grid when set; the carousel
variant is unchanged.
- Download CTA in the modal shrunk to a 9×9 icon-only button with
text-white/80 hover-tint instead of the dark green link colour the
app's `main a` rule was forcing in. Adds `no-underline` so the
global underline doesn't sneak back in.
- All previously hardcoded German strings (Download, Schließen,
modal aria, open aria) now go through translations.ts → CMS
translation_bundle. Cms_content app.json5 ships the matching keys.
Open follow-up: per-image title/alt/caption is empty on the existing
windwiderstand mediathek gallery → editor needs to fill those if
hover labels are wanted.
The global typography rules in app.css (main ul li → list-style: disc)
ran independently of Tailwind Typography, so `not-prose` on the
gallery grid couldn't suppress the bullets. Adding :not(.not-prose)
to those selectors makes the same opt-out class work for both
prose-managed and globally-styled lists, so the gallery grid renders
without disc markers and any future grid-of-list use case can opt out
the same way.
The Tailwind Typography (`prose prose-zinc`) wrapper applied higher in
the page hierarchy injects `.prose ul` rules — list-style: disc, an
indent, and ::marker color — at a higher specificity than single-class
utilities, so list-none/p-0/m-0 silently lost.
`not-prose` is the official escape hatch from the typography plugin
and zaps every prose rule on the element plus its descendants. One
class instead of three \!important overrides.
ImageGalleryBlock now branches on `block.variant`:
- "standard" (default) → existing carousel.
- "grid" → 2/3/4-column thumbnail grid (1:1 squares, w=400/q=75
through /cms-images). Each tile scales 5% on hover and surfaces
the image's title/alt/caption/description as a gradient overlay
caption that fades in on hover and keyboard focus.
Click any tile → fullscreen lightbox (z-100 overlay + 90vh image).
The lightbox carries:
- A download button using the asset's filename (extracted from the
src URL).
- Prev/Next arrows when there's more than one image (keyboard:
←/→), Esc to close, click on the backdrop closes too.
- Position counter (n/total) when applicable.
Type updates in block-types.ts: ImageGalleryBlockData gains
`variant?: "standard"|"grid"` and `title?`. ImageGalleryImage gains
`alt`/`caption`/`title` so the schema's `image` field shape is
honoured for the hover label.
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.
Three CI typecheck failures from the previous microformat patch:
- post.created is the only date field in the schema (no _created on the
generated PostEntry). Drop the _created fallback.
- postTag items can be a slug string OR a tag object. Type narrowing
with `'name' in tag` widened to `never`; pull the name through an
explicit `{ name?: string }` cast in @const so the iteration stays
honest about the runtime shape.
- author isn't on PostEntry either; cast at the use-site for the
hidden h-card author block.
Two changes:
- New POST /api/cache/clear (auth: REVALIDATE_TOKEN). Wipes the
TTL content cache, the image disk + memory cache, and forwards to
the RustyCMS /api/transform/cache/invalidate endpoint when an
API key is configured. Single trigger to flush everything.
- /api/revalidate now also calls clearImageCache() on asset.* events
so a re-upload (focal change, alt edit, replacement) doesn't keep
serving the previous transformed image from disk.
clearImageCache() in image-cache.server.ts wipes the in-memory map
and removes every file under IMAGE_CACHE_DIR.
Posts whose row*Content contains a `youtube_video` block now ship a
schema.org VideoObject per video alongside the existing BlogPosting /
Event JSON-LD. Lets Google attach a video rich-result to the post URL
(thumbnail, duration where available, embed/content URLs) instead of
treating the page as plain text whose body happens to be a single
iframe.
Thumbnail uses YouTube's predictable maxresdefault + hqdefault URLs;
upload date falls back to post.created when the embed has none.
Microformats:
- Wrap the post <article> in h-entry with p-name on the headline,
p-summary on the subheadline (or sr-only fallback from excerpt),
e-content on the rendered body, dt-published on the created date
(hidden time element) and an invisible p-author h-card. Tag names
are mirrored as hidden p-category entries so crawlers see them
even though the visual Tags component already links them.
- itemscope/itemtype maps to schema.org BlogPosting (or Event when
`isEvent`), aligning the invisible microdata with the existing
JSON-LD.
Keywords:
- When post.seoKeywords is empty, fall back to the comma-joined tag
names so <meta name="keywords"> still ships something meaningful.
The article template only rendered the H1 it could pluck from the
markdown body (`contentHeadlineHtml`). Posts whose body opens with H2
(or no heading) silently lost both the schema-level `headline` and
`subheadline` — they were used in SEO/JSON-LD/breadcrumb only.
Fall back to the structured fields when no inline H1 is present.
- Accept dpr query param (clamped 1..4) and include it in cache key so
each density gets its own cached file.
- Accept format=auto and negotiate locally from the client's Accept
header (avif > webp > jpeg) before building the cache key — concrete
formats in the cache avoid cross-client leaks.
- Added support for a preview mode in the CMS, allowing draft content to be viewed without being indexed.
- Updated hooks and server logic to handle preview parameters, adjusting cache control and robots tags accordingly.
- Enhanced layout to display a preview notification when in preview mode, improving user experience for content editors.