Compare commits

..

5 Commits

Author SHA1 Message Date
Peter Meier 44b991f7c1 Improve social media OG/Twitter meta for posts
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 1m2s
- Fix bug: post page was overriding og:image with 150x200 thumbnail
- og:type = "article" for posts (was always "website")
- article:published_time, article:modified_time, article:author, article:tag
- og:image:alt + twitter:image:alt (post headline)
- twitter:label1/data1 with reading time
- Remove thumbnail fallback from socialImage (falls back to site logo instead)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:35:32 +02:00
Peter Meier d7a84aff0d SearchableTextBlock: animated accordion, tag counts, empty state, focus-visible
- Replace <details> with Svelte-controlled accordion + slide transition (180ms)
- Close all items on search/tag filter change
- Live tag counts per filter showing matches for current search query
- Visual empty state with reset button when no results
- focus-visible ring on all interactive elements (was focus: — shows on click too)
- Tag.svelte: remove ring-0, add focus-visible:ring-2
- Card.svelte: add focus-visible ring to linked cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:31:38 +02:00
Peter Meier fa50617d3c Fix Tooltip overflow-hidden clipping via portal action
Portal the tooltip into document.body so overflow:hidden ancestors
can't clip it. Fixed positioning + getBoundingClientRect() for placement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:25:18 +02:00
Peter Meier cbf0e4aa51 Fix Tooltip clipping in overflow-hidden containers — use fixed positioning
Switches from absolute (clipped by ancestor overflow:hidden) to fixed
with viewport-relative coords from getBoundingClientRect().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:22:27 +02:00
Peter Meier 35a34eb300 Fix SearchableTextBlock h2 color — override global heading CSS with \!text-white
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:21:03 +02:00
7 changed files with 215 additions and 120 deletions
+1 -1
View File
@@ -47,7 +47,7 @@
variants: { variants: {
variant: { variant: {
callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100", callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100",
tile: "transition-[transform,box-shadow,border-color] duration-200 no-underline hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300", tile: "transition-[transform,box-shadow,border-color] duration-200 no-underline hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2",
}, },
}, },
defaultVariants: { variant: "tile" }, defaultVariants: { variant: "tile" },
+1 -1
View File
@@ -54,7 +54,7 @@
} }
const baseClass = const baseClass =
"inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none ring-0"; "inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-1";
const variantClass = $derived.by(() => { const variantClass = $derived.by(() => {
if (customColor?.trim()) { if (customColor?.trim()) {
return ""; return "";
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { marked } from "$lib/markdown-safe"; import { marked } from "$lib/markdown-safe";
import { slide } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { import type {
SearchableTextBlockData, SearchableTextBlockData,
@@ -27,7 +29,6 @@
return tStatic(translations ?? null, key, replacements); return tStatic(translations ?? null, key, replacements);
} }
const helpTooltipText = $derived(t(T.searchable_text_help)); const helpTooltipText = $derived(t(T.searchable_text_help));
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const fragments = $derived.by(() => { const fragments = $derived.by(() => {
@@ -64,6 +65,39 @@
let searchQuery = $state(""); let searchQuery = $state("");
let selectedTag = $state("all"); let selectedTag = $state("all");
let copiedIndex = $state<number | null>(null); let copiedIndex = $state<number | null>(null);
let openTitles = $state(new Set<string>());
function toggleItem(key: string) {
const s = new Set(openTitles);
if (s.has(key)) s.delete(key);
else s.add(key);
openTitles = s;
}
// Close all open items when search or tag filter changes
$effect(() => {
searchQuery;
selectedTag;
openTitles = new Set();
});
// Per-tag counts based on current search (ignoring tag filter) — updates live as user types
const tagCounts = $derived.by(() => {
const q = searchQuery.toLowerCase().trim().replace(/\s+/g, " ");
const counts = new Map<string, number>();
let allCount = 0;
for (const f of fragments) {
const titleNorm = f.title.toLowerCase().replace(/\s+/g, " ");
const textNorm = f.text.toLowerCase().replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
const matchesSearch = q === "" || titleNorm.includes(q) || textNorm.includes(q);
if (!matchesSearch) continue;
allCount++;
for (const tag of f.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return { counts, allCount };
});
async function copyFragmentContent( async function copyFragmentContent(
fragment: { title: string; text: string }, fragment: { title: string; text: string },
@@ -166,7 +200,7 @@
{#if block.title || descriptionHtml} {#if block.title || descriptionHtml}
<div class="px-4 md:px-6 py-5 bg-gradient-to-br from-wald-700 to-wald-800"> <div class="px-4 md:px-6 py-5 bg-gradient-to-br from-wald-700 to-wald-800">
{#if block.title} {#if block.title}
<h2 class="text-xl md:text-2xl font-bold text-white mb-1"> <h2 class="text-xl md:text-2xl font-bold !text-white mb-1">
{block.title} {block.title}
</h2> </h2>
{/if} {/if}
@@ -186,7 +220,7 @@
<Tooltip content={helpTooltipText} placement="top"> <Tooltip content={helpTooltipText} placement="top">
<button <button
type="button" type="button"
class="shrink-0 p-1.5 rounded-xs text-stein-400 hover:text-wald-600 hover:bg-wald-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1" class="shrink-0 p-1.5 rounded-xs text-stein-400 hover:text-wald-600 hover:bg-wald-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-1"
aria-label={t(T.searchable_text_help_aria)} aria-label={t(T.searchable_text_help_aria)}
> >
<Icon icon="mdi:help-circle-outline" class="size-5" aria-hidden="true" /> <Icon icon="mdi:help-circle-outline" class="size-5" aria-hidden="true" />
@@ -195,7 +229,7 @@
<input <input
type="search" type="search"
placeholder={t(T.searchable_text_placeholder)} placeholder={t(T.searchable_text_placeholder)}
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-xs focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-white text-stein-900 placeholder:text-stein-400" class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:border-wald-400 bg-white text-stein-900 placeholder:text-stein-400"
bind:value={searchQuery} bind:value={searchQuery}
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)} oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
aria-label={t(T.searchable_text_search_aria)} aria-label={t(T.searchable_text_search_aria)}
@@ -203,7 +237,7 @@
{#if searchQuery} {#if searchQuery}
<button <button
type="button" type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-xs hover:bg-stein-100 text-stein-400 hover:text-stein-700 transition-colors" class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-xs hover:bg-stein-100 text-stein-400 hover:text-stein-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
onclick={() => (searchQuery = "")} onclick={() => (searchQuery = "")}
aria-label={t(T.searchable_text_clear_search)} aria-label={t(T.searchable_text_clear_search)}
> >
@@ -220,14 +254,14 @@
<div class="overflow-x-auto -mx-1 px-1 pt-1 pb-1"> <div class="overflow-x-auto -mx-1 px-1 pt-1 pb-1">
<div class="flex gap-1.5 flex-wrap"> <div class="flex gap-1.5 flex-wrap">
<Tag <Tag
label={t(T.searchable_text_tag_all)} label={`${t(T.searchable_text_tag_all)} (${tagCounts.allCount})`}
variant={selectedTag === "all" ? "green" : "white"} variant={selectedTag === "all" ? "green" : "white"}
active={selectedTag === "all"} active={selectedTag === "all"}
onclick={() => (selectedTag = "all")} onclick={() => (selectedTag = "all")}
/> />
{#each allTags as tag} {#each allTags as tag}
<Tag <Tag
label={tag} label={`${tag} (${tagCounts.counts.get(tag) ?? 0})`}
variant={selectedTag === tag ? "green" : "white"} variant={selectedTag === tag ? "green" : "white"}
active={selectedTag === tag} active={selectedTag === tag}
onclick={() => (selectedTag = tag)} onclick={() => (selectedTag = tag)}
@@ -243,9 +277,6 @@
class="text-xs text-stein-500 px-4 md:px-6 py-2 bg-stein-50 border-b border-stein-100" class="text-xs text-stein-500 px-4 md:px-6 py-2 bg-stein-50 border-b border-stein-100"
aria-live="polite" aria-live="polite"
> >
{#if visibleFragments.length === 0}
<span>{t(T.searchable_text_no_results)}</span>
{:else}
<span> <span>
{visibleFragments.length === fragments.length {visibleFragments.length === fragments.length
? t(T.searchable_text_count_all, { count: visibleFragments.length }) ? t(T.searchable_text_count_all, { count: visibleFragments.length })
@@ -254,14 +285,44 @@
total: fragments.length, total: fragments.length,
})} })}
</span> </span>
{/if}
</div> </div>
<div class="flex flex-col divide-y divide-stein-100"> <div class="flex flex-col divide-y divide-stein-100">
{#if visibleFragments.length === 0}
<div class="flex flex-col items-center justify-center gap-3 py-12 px-6 text-center">
<div class="size-12 rounded-full bg-stein-100 flex items-center justify-center">
<Icon icon="mdi:magnify" class="size-6 text-stein-400" aria-hidden="true" />
</div>
<div>
<div class="text-sm font-medium text-stein-600">{t(T.searchable_text_no_results)}</div>
<div class="text-xs text-stein-400 mt-0.5">Suchbegriff oder Filter anpassen</div>
</div>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-xs border border-stein-200 bg-white text-stein-600 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
onclick={() => { searchQuery = ""; selectedTag = "all"; }}
>
Filter zurücksetzen
</button>
</div>
{:else}
{#each visibleFragments as fragment, i} {#each visibleFragments as fragment, i}
<details class="group border-l-4 border-l-transparent open:border-l-wald-500 transition-colors bg-white open:bg-wald-50/30"> {@const itemKey = fragment.title || String(i)}
<summary {@const isOpen = openTitles.has(itemKey)}
class="cursor-pointer list-none px-4 md:px-6 py-3.5 hover:bg-wald-50/60 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors" <div
class="group/item border-l-4 transition-colors {isOpen
? 'border-l-wald-500 bg-wald-50/30'
: 'border-l-transparent bg-white'}"
>
<div
role="button"
tabindex="0"
aria-expanded={isOpen}
class="cursor-pointer px-4 md:px-6 py-3.5 hover:bg-wald-50/60 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-wald-500"
onclick={() => toggleItem(itemKey)}
onkeydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
(e.preventDefault(), toggleItem(itemKey))}
> >
<div class="flex items-start gap-2 min-w-0"> <div class="flex items-start gap-2 min-w-0">
<div class="grow min-w-0"> <div class="grow min-w-0">
@@ -273,13 +334,12 @@
<div class="shrink-0 flex items-center gap-2"> <div class="shrink-0 flex items-center gap-2">
<button <button
type="button" type="button"
class="opacity-0 group-hover:opacity-100 inline-flex items-center gap-1 px-2 py-1 text-xs font-normal rounded-xs border border-stein-200 bg-white text-stein-600 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-all" class="opacity-0 group-hover/item:opacity-100 focus-visible:opacity-100 inline-flex items-center gap-1 px-2 py-1 text-xs font-normal rounded-xs border border-stein-200 bg-white text-stein-600 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
onclick={(e) => { onclick={(e) => {
e.preventDefault(); e.stopPropagation();
copyFragmentContent(fragment, i); copyFragmentContent(fragment, i);
}} }}
aria-label={t(T.searchable_text_copy_aria)} aria-label={t(T.searchable_text_copy_aria)}
tabindex="-1"
> >
{#if copiedIndex === i} {#if copiedIndex === i}
<Icon icon="mdi:check" class="size-3.5 text-wald-600" aria-hidden="true" /> <Icon icon="mdi:check" class="size-3.5 text-wald-600" aria-hidden="true" />
@@ -289,7 +349,7 @@
</button> </button>
<Icon <Icon
icon="mdi:chevron-down" icon="mdi:chevron-down"
class="text-stein-400 group-open:rotate-180 transition-transform size-5" class="text-stein-400 transition-transform size-5 {isOpen ? 'rotate-180' : ''}"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
@@ -299,12 +359,16 @@
<Tags tags={fragment.tags} variant="white" /> <Tags tags={fragment.tags} variant="white" />
</span> </span>
{/if} {/if}
</summary> </div>
<div class="px-4 md:px-6 py-4 bg-white border-t border-stein-100 markdown prose prose-zinc prose-sm max-w-none text-xs"> {#if isOpen}
<div
transition:slide={{ duration: 180, easing: cubicOut }}
class="px-4 md:px-6 py-4 bg-white border-t border-stein-100 markdown prose prose-zinc prose-sm max-w-none text-xs"
>
<div class="flex justify-end mb-3 -mt-1"> <div class="flex justify-end mb-3 -mt-1">
<button <button
type="button" type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xs border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-colors" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xs border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
onclick={(e) => { onclick={(e) => {
e.preventDefault(); e.preventDefault();
copyFragmentContent(fragment, i); copyFragmentContent(fragment, i);
@@ -322,7 +386,9 @@
</div> </div>
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())} {@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
</div> </div>
</details> {/if}
</div>
{/each} {/each}
{/if}
</div> </div>
</div> </div>
+20 -21
View File
@@ -13,7 +13,8 @@
let wrapperEl = $state<HTMLDivElement | null>(null); let wrapperEl = $state<HTMLDivElement | null>(null);
let showTooltip = $state(false); let showTooltip = $state(false);
let placement = $state<"top" | "bottom">("top"); let placement = $state<"top" | "bottom">("top");
let leftPx = $state<number | null>(null); // null = centered (CSS), number = clamped px from wrapper left let fixedLeft = $state(0);
let fixedTop = $state(0);
function updatePosition() { function updatePosition() {
if (!wrapperEl) return; if (!wrapperEl) return;
@@ -25,20 +26,13 @@
const spaceBelow = vh - rect.bottom; const spaceBelow = vh - rect.bottom;
placement = placement =
preferredPlacement === "top" preferredPlacement === "top"
? spaceAbove >= spaceBelow ? spaceAbove >= spaceBelow ? "top" : "bottom"
? "top" : spaceBelow >= spaceAbove ? "bottom" : "top";
: "bottom"
: spaceBelow >= spaceAbove
? "bottom"
: "top";
const iconCenterX = rect.left + rect.width / 2; const iconCenterX = rect.left + rect.width / 2;
const idealLeft = iconCenterX - TOOLTIP_MAX_WIDTH_PX / 2; const idealLeft = iconCenterX - TOOLTIP_MAX_WIDTH_PX / 2;
const clampedLeft = Math.max( fixedLeft = Math.max(TOOLTIP_GAP, Math.min(idealLeft, vw - TOOLTIP_MAX_WIDTH_PX - TOOLTIP_GAP));
TOOLTIP_GAP, fixedTop = placement === "top" ? rect.top - TOOLTIP_GAP : rect.bottom + TOOLTIP_GAP;
Math.min(idealLeft, vw - TOOLTIP_MAX_WIDTH_PX - TOOLTIP_GAP),
);
leftPx = clampedLeft - rect.left;
} }
function handleShow() { function handleShow() {
@@ -48,17 +42,21 @@
function handleHide() { function handleHide() {
showTooltip = false; showTooltip = false;
leftPx = null;
} }
const placementClasses = $derived( const tooltipStyle = $derived(
placement === "top" placement === "top"
? "bottom-full mb-2" ? `position:fixed;top:${fixedTop}px;left:${fixedLeft}px;transform:translateY(-100%);`
: "top-full mt-2", : `position:fixed;top:${fixedTop}px;left:${fixedLeft}px;`,
);
const horizontalStyle = $derived(
leftPx != null ? `left: ${leftPx}px; transform: none;` : "left: 50%; transform: translateX(-50%);",
); );
// Portal action: moves the node into document.body so overflow:hidden ancestors can't clip it.
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() { node.remove(); },
};
}
</script> </script>
<div <div
@@ -73,9 +71,10 @@
{@render children?.()} {@render children?.()}
{#if showTooltip} {#if showTooltip}
<div <div
use:portal
role="tooltip" role="tooltip"
class="absolute z-50 {placementClasses} px-3 py-2 text-xs font-normal text-stein-0 bg-stein-800 rounded-xs shadow-lg min-w-56 max-w-88 text-center pointer-events-none whitespace-normal" class="z-[9999] px-3 py-2 text-xs font-normal text-stein-0 bg-stein-800 rounded-xs shadow-lg min-w-56 max-w-88 text-center pointer-events-none whitespace-normal"
style={horizontalStyle} style={tooltipStyle}
> >
{content} {content}
</div> </div>
+31 -1
View File
@@ -52,6 +52,14 @@
seoTitle?: string; seoTitle?: string;
seoDescription?: string; seoDescription?: string;
socialImage?: string; socialImage?: string;
ogType?: string;
ogImageAlt?: string;
articlePublishedTime?: string | null;
articleModifiedTime?: string | null;
articleAuthor?: string | null;
articleTags?: string[];
twitterLabel1?: string;
twitterData1?: string;
robots?: string; robots?: string;
keywords?: string; keywords?: string;
preview?: boolean; preview?: boolean;
@@ -218,7 +226,7 @@
{/if} {/if}
<link rel="canonical" href={canonical} /> <link rel="canonical" href={canonical} />
<!-- Open Graph --> <!-- Open Graph -->
<meta property="og:type" content="website" /> <meta property="og:type" content={pageData?.ogType ?? "website"} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<meta property="og:title" content={seoTitle} /> <meta property="og:title" content={seoTitle} />
{#if seoDescription} {#if seoDescription}
@@ -228,9 +236,24 @@
<meta property="og:image" content={socialImageUrl} /> <meta property="og:image" content={socialImageUrl} />
<meta property="og:image:width" content={String(SOCIAL_IMAGE_DIMENSIONS.width)} /> <meta property="og:image:width" content={String(SOCIAL_IMAGE_DIMENSIONS.width)} />
<meta property="og:image:height" content={String(SOCIAL_IMAGE_DIMENSIONS.height)} /> <meta property="og:image:height" content={String(SOCIAL_IMAGE_DIMENSIONS.height)} />
{#if pageData?.ogImageAlt}
<meta property="og:image:alt" content={pageData.ogImageAlt} />
{/if}
{/if} {/if}
<meta property="og:locale" content="de_DE" /> <meta property="og:locale" content="de_DE" />
<meta property="og:site_name" content={data.siteName} /> <meta property="og:site_name" content={data.siteName} />
{#if pageData?.articlePublishedTime}
<meta property="article:published_time" content={pageData.articlePublishedTime} />
{/if}
{#if pageData?.articleModifiedTime}
<meta property="article:modified_time" content={pageData.articleModifiedTime} />
{/if}
{#if pageData?.articleAuthor}
<meta property="article:author" content={pageData.articleAuthor} />
{/if}
{#each pageData?.articleTags ?? [] as tag}
<meta property="article:tag" content={tag} />
{/each}
<!-- Twitter --> <!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonical} /> <meta name="twitter:url" content={canonical} />
@@ -240,6 +263,13 @@
{/if} {/if}
{#if socialImageUrl} {#if socialImageUrl}
<meta name="twitter:image" content={socialImageUrl} /> <meta name="twitter:image" content={socialImageUrl} />
{#if pageData?.ogImageAlt}
<meta name="twitter:image:alt" content={pageData.ogImageAlt} />
{/if}
{/if}
{#if pageData?.twitterLabel1 && pageData?.twitterData1}
<meta name="twitter:label1" content={pageData.twitterLabel1} />
<meta name="twitter:data1" content={pageData.twitterData1} />
{/if} {/if}
<!-- JSON-LD WebSite + Organization --> <!-- JSON-LD WebSite + Organization -->
{@html jsonLdScript} {@html jsonLdScript}
+12 -3
View File
@@ -303,13 +303,22 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
translations, translations,
seoTitle: post.seoTitle ?? postHeadline, seoTitle: post.seoTitle ?? postHeadline,
seoDescription: postDescription, seoDescription: postDescription,
socialImage: postSocialImageAbs ?? postImageUrl ?? undefined, socialImage: postSocialImageAbs ?? undefined,
ogType: 'article' as const,
ogImageAlt: postHeadline,
articlePublishedTime: postCreated,
articleModifiedTime: postUpdated,
articleAuthor: postAuthor ?? siteName,
articleTags: (() => {
const tags = (post as { postTag?: Array<{ name?: string }> }).postTag ?? [];
return tags.map((t) => (typeof t === 'string' ? t : (t?.name ?? ''))).filter(Boolean);
})(),
twitterLabel1: 'Lesezeit',
twitterData1: `${readingTimeMinutes} Min.`,
robots: (post as { seoMetaRobots?: string }).seoMetaRobots ?? undefined, robots: (post as { seoMetaRobots?: string }).seoMetaRobots ?? undefined,
keywords: (() => { keywords: (() => {
const explicit = ((post as { seoKeywords?: string }).seoKeywords ?? '').trim(); const explicit = ((post as { seoKeywords?: string }).seoKeywords ?? '').trim();
if (explicit) return explicit; if (explicit) return explicit;
// Fallback: join tag names so published posts without explicit keywords
// still ship a meaningful <meta name="keywords">.
const tags = (post as { postTag?: Array<{ name?: string }> }).postTag ?? []; const tags = (post as { postTag?: Array<{ name?: string }> }).postTag ?? [];
const names = tags const names = tags
.map((t) => (typeof t?.name === 'string' ? t.name.trim() : '')) .map((t) => (typeof t?.name === 'string' ? t.name.trim() : ''))
-9
View File
@@ -14,15 +14,6 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
</script> </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="my-4 md:flex md:items-start md:gap-4"> <div class="my-4 md:flex md:items-start md:gap-4">
{#if data.rawPostImageUrl} {#if data.rawPostImageUrl}