add: SEO for articles

SEO and SchemaORg for articles
This commit is contained in:
webfussel 2025-06-14 10:23:46 +02:00
parent f1cb4048a4
commit d6859cdaad
22 changed files with 198 additions and 125 deletions

View file

@ -3,14 +3,39 @@
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
& .meta {
display: flex;
gap: 1rem;
align-items: center;
}
& .article-content {
background: var(--color-black);
border-radius: 1rem;
}
& .article-text {
padding: 2rem;
}
& header {
padding: 2rem 2rem 0;
}
h1 { h1 {
font-size: 2rem; font-size: 2rem;
} }
h2 {
font-size: 1.5rem;
font-style: italic;
font-weight: lighter;
}
& .image { & .image {
width: 100%; width: 100%;
height: 450px; height: 450px;
border-radius: 1rem; border-radius: 1rem 1rem 0 0;
overflow: hidden; overflow: hidden;
background: #000; background: #000;
@ -19,6 +44,7 @@
height: 100%; height: 100%;
opacity: .8; opacity: .8;
object-fit: cover; object-fit: cover;
object-position: center;
} }
} }
} }

View file

@ -15,6 +15,7 @@
& > .image { & > .image {
flex: 0 0 200px; flex: 0 0 200px;
width: 100%; width: 100%;
overflow: hidden;
& img { & img {
height: 100%; height: 100%;

View file

@ -159,6 +159,15 @@ span.chip {
width: max-content; width: max-content;
transition: var(--transition-time); transition: var(--transition-time);
&.interactive {
cursor: pointer;
&:hover {
background: var(--color-orange-dark);
color: var(--color-white);
}
}
&:not(.dark) { &:not(.dark) {
--background: var(--color-orange); --background: var(--color-orange);
--color: var(--color-black); --color: var(--color-black);

View file

@ -1,7 +1,15 @@
<template> <template>
<aside class="BlogAuthor"> <aside class="BlogAuthor">
<div class="image"> <div class="image">
<img :src="image" :alt="`Bild von ${name}`"/> <img
loading="lazy"
width="50"
height="50"
:srcset="imageSet.join(', ')"
:src="initialImage"
aria-hidden="true"
:alt="`Profilbild von ${name}`"
/>
</div> </div>
<div class="meta"> <div class="meta">
<span class="name">{{ name }}</span> <span class="name">{{ name }}</span>
@ -11,13 +19,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getImageSet, getInitialImage } from '../../utils/image'
type Props = { type Props = {
name: string name: string
image: string
date: string date: string
} }
const { date } = defineProps<Props>() const { name, date } = defineProps<Props>()
const formatter = new Intl.DateTimeFormat('de-DE', { const formatter = new Intl.DateTimeFormat('de-DE', {
year: 'numeric', year: 'numeric',
@ -26,4 +35,7 @@ const formatter = new Intl.DateTimeFormat('de-DE', {
}) })
const dateFormatted = computed(() => formatter.format(new Date(date))) const dateFormatted = computed(() => formatter.format(new Date(date)))
const imageSet = getImageSet('/img/blog/authors/', name)
const initialImage = getInitialImage('/img/blog/authors/', name)
</script> </script>

View file

@ -10,15 +10,11 @@
</header> </header>
<main> <main>
<p> <p>
{{ generatePlainText(excerpt.value).at(0)?.text ?? '' }} {{ description }}
</p> </p>
</main> </main>
<footer> <footer>
<BlogAuthor :name="author.name" :image="author.image" :date="date"/> <BlogAuthor :name="author.name" :date="date"/>
<div class="tags">
<span>tags</span>
<span class="tag" v-for="tag in tags">{{ tag }}</span>
</div>
</footer> </footer>
</div> </div>
</NuxtLink> </NuxtLink>
@ -26,20 +22,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Category } from './types' import type { Category } from './types'
import type { MinimalNode } from '@nuxt/content'
type Props = { type Props = {
title: string title: string
description: string description: string
image: string image: string
date: string date: string
excerpt: { type: string, value: MinimalNode[], children?: any }
link: string link: string
tags: string[]
category: Category category: Category
author: { author: {
name: string name: string
image: string
} }
} }

View file

@ -6,7 +6,9 @@
<span class="my-name-wrapper">Ich bin <Highlight>Fiona</Highlight>.</span> <span class="my-name-wrapper">Ich bin <Highlight>Fiona</Highlight>.</span>
</h1> </h1>
<h2> <h2>
Component <Highlight>&</Highlight> API Entwicklerin Component
<Highlight>&</Highlight>
API Entwicklerin
</h2> </h2>
<p class="fulltext"> <p class="fulltext">
Ich unterstütze Unternehmen dabei, ihre Daten von verschiedenen Endpunkten sauber aufzubereiten Ich unterstütze Unternehmen dabei, ihre Daten von verschiedenen Endpunkten sauber aufzubereiten
@ -21,14 +23,14 @@
Ich biete dir genau das, was du brauchst, um eine individuelle WebApp in Fahrt zu bringen, deren Inhalte einfach zu verändern sind. Ich biete dir genau das, was du brauchst, um eine individuelle WebApp in Fahrt zu bringen, deren Inhalte einfach zu verändern sind.
</p> </p>
<Button class="cta" href="#skills"> <Button class="cta" href="#skills">
<Icon name="ph:lightbulb-duotone" size="1.5em" mode="svg" /> <Icon name="ph:lightbulb-duotone" size="1.5em" mode="svg"/>
Fussel erklärt's dir Fussel erklärt's dir
</Button> </Button>
</div> </div>
<div class="intro-img"> <div class="intro-img">
<picture> <picture>
<source width="750" height="866" media="(min-width: 431px)" srcset="/img/profile_big.webp" /> <source width="750" height="866" media="(min-width: 431px)" srcset="/img/profile_big.webp"/>
<img width="430" height="866" src="/img/profile_small.webp" alt="Bild von Fiona Urban" /> <img width="430" height="866" src="/img/profile_small.webp" alt="Bild von Fiona Urban"/>
</picture> </picture>
</div> </div>
</section> </section>

20
app/error.vue Normal file
View file

@ -0,0 +1,20 @@
<template>
<section class="bg-radial">
<NuxtLayout>
<section class="content">
<h1>{{ error?.statusCode }}</h1>
<p>{{ message }}</p>
</section>
</NuxtLayout>
</section>
</template>
<script setup lang="ts">
import type { NuxtError } from 'nuxt/app'
type Props = {
error: NuxtError
}
const { error } = defineProps<Props>()
const message = getErrorMessage(error)
</script>

View file

@ -5,29 +5,54 @@
Zurück zur Übersicht Zurück zur Übersicht
</NuxtLink> </NuxtLink>
<p v-if="!article"> <main v-if="article" class="article-content z-3">
Sorry bro, aber der Artikel existiert einfach nicht. <div class="image z-2">
</p> <img :src="article.image" alt="Artikelbild" aria-hidden="true"/>
</div>
<div v-else>
<header> <header>
<div class="image z-2"> <div class="meta">
<img :src="article.image" alt="Artikelbild" aria-hidden="true"/> <NuxtLink :to="`/blog/?category=${article.category}`">
<span class="chip interactive"><BlogCategory :name="article.category as Category"/></span>
</NuxtLink>
</div> </div>
<h1 class="margin-top">{{ article.title }}</h1> <h1 class="margin-top">{{ article.title }}</h1>
<h2>{{ article.description }}</h2> <h2>{{ article.description }}</h2>
</header> </header>
<div class="flex-col gap-default"> <div class="flex-col gap-default article-text">
<ContentRenderer v-if="article" :value="article" :style="{ display: 'contents' }"/> <ContentRenderer v-if="article" :value="article" :style="{ display: 'contents' }"/>
</div> </div>
</div> </main>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Category } from '../../components/Blog/types'
const route = useRoute() const route = useRoute()
const { data: article } = await useAsyncData('article', () => queryCollection('blog').path(route.path).where('date', '<', tomorrow(new Date())).first()) const { data: article } = await useAsyncData('article', () => queryCollection('blog').path(route.path).where('date', '<', tomorrow(new Date())).first())
if (!article.value) {
throw createError({
statusCode: 404,
statusMessage: 'Page Not Found',
})
} else {
useHead(article.value.head || {})
useSeoMeta({
...(article.value.seo || {}),
ogTitle: article.value.title,
ogDescription: article.value.description,
ogImage: article.value.image,
ogImageAlt: article.value.description,
ogUrl: `https://webfussel.de${article.value.path}`,
twitterTitle: article.value.title,
twitterDescription: article.value.description,
twitterImage: article.value.image,
twitterImageAlt: article.value.description,
twitterUrl: `https://webfussel.de${article.value.path}`,
})
}
</script> </script>
<style scoped> <style scoped>

View file

@ -1,10 +1,17 @@
<template> <template>
<section id="blog" class="BlogOverview content"> <section id="blog" class="BlogOverview content">
<main> <h1>Blogfussel - für mehr Fussel im Blog</h1>
<p>Hey! Hier sammel ich alles, was mir so durch den Kopf geht - von handfesten Tutorials und Code-Snippets über Freelancing-Stories
bis hin zu News meiner Apps und random Gedanken. Manchmal ausführlich, manchmal nur kurz angerissen.</p>
<main class="margin-top">
<ul class="category-list"> <ul class="category-list">
<li> <li>
<NuxtLink class="inline-flex-row gap-sm side" to="/blog"> <NuxtLink class="inline-flex-row gap-sm side" to="/blog">
<span class="chip" :class="{ 'dark' : route.query.category}">Alle {{ articles?.length }}</span> <span class="chip"
:class="{ 'dark' : route.query.category && Object.keys(allCategoriesAndCount).includes(route.query.category as string)}">Alle {{
articles?.length
}}</span>
</NuxtLink> </NuxtLink>
</li> </li>
<li v-for="(count, category) in allCategoriesAndCount"> <li v-for="(count, category) in allCategoriesAndCount">
@ -15,6 +22,8 @@
</ul> </ul>
<div class="grid margin-top-middle article-overview"> <div class="grid margin-top-middle article-overview">
<BlogCard v-for="article in firstTen" v-bind="makeBlogCard(article)"/> <BlogCard v-for="article in firstTen" v-bind="makeBlogCard(article)"/>
<div v-if="firstTen.length < 2"/>
<div v-if="firstTen.length < 3"/>
</div> </div>
</main> </main>
</section> </section>
@ -33,8 +42,8 @@ const { data: articles } = await useAsyncData('articles', () => queryCollection(
) )
const firstTen = computed(() => { const firstTen = computed(() => {
if (route.query.category) { if (route.query.category && Object.keys(allCategoriesAndCount.value).includes(route.query.category as Category)) {
return articles.value?.filter(article => article.meta.category === route.query.category).slice(0, 10) ?? [] return articles.value?.filter(article => article.category === route.query.category).slice(0, 10) ?? []
} }
return articles.value?.slice(0, 10) ?? [] return articles.value?.slice(0, 10) ?? []
}) })
@ -42,7 +51,7 @@ const firstTen = computed(() => {
const allCategoriesAndCount = computed(() => { const allCategoriesAndCount = computed(() => {
const categories = {} as Record<Category, number> const categories = {} as Record<Category, number>
articles.value?.forEach(article => { articles.value?.forEach(article => {
const category = article.meta.category as Category const category = article.category as Category
if (category) { if (category) {
categories[category] = (categories[category] ?? 0) + 1 categories[category] = (categories[category] ?? 0) + 1
} }
@ -55,9 +64,7 @@ const makeBlogCard = (article: BlogCollectionItem) => ({
description: article.description, description: article.description,
image: article.thumbnail as string, image: article.thumbnail as string,
date: article.date as string, date: article.date as string,
excerpt: article.excerpt as any,
link: article.path, link: article.path,
tags: article.tags as string[],
category: article.category as Category, category: article.category as Category,
author: article.author as { name: string, image: string }, author: article.author as { name: string, image: string },
}) })

15
app/utils/error.ts Normal file
View file

@ -0,0 +1,15 @@
import type { NuxtError } from 'nuxt/app'
const codes: Record<number, string> = {
400: 'Hey, deine Anfrage war irgendwie kaputt. Check nochmal!',
404: 'Sorry bro, aber diese Seite existiert einfach nicht.',
408: 'Alter, das hat viel zu lange gedauert. Timeout!',
410: 'Diese Seite ist für immer weg. Gone!',
500: 'Crap, da ging was im Hintergrund schief. Sorry!',
501: 'Das haben wir noch nicht implementiert. Oops!',
502: 'Der Server dahinter spinnt gerade rum.',
503: 'Service ist gerade down. Versuch es später nochmal.',
504: 'Gateway Timeout - da hängt was fest.',
}
export const getErrorMessage = (error: NuxtError) => codes[error.statusCode] ?? 'Sorry, da ist etwas schief gelaufen.'

View file

@ -1,8 +1,9 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content' import { defineContentConfig, defineCollection, z } from '@nuxt/content'
import { asSchemaOrgCollection } from 'nuxt-schema-org/content'
export default defineContentConfig({ export default defineContentConfig({
collections: { collections: {
blog: defineCollection({ blog: defineCollection(asSchemaOrgCollection({
type: 'page', type: 'page',
source: 'blog/*.md', source: 'blog/*.md',
schema: z.object({ schema: z.object({
@ -10,17 +11,12 @@ export default defineContentConfig({
image: z.string(), image: z.string(),
thumbnail: z.string(), thumbnail: z.string(),
category: z.string(), category: z.string(),
tags: z.array(z.string()),
author: z.object({ author: z.object({
name: z.string(), name: z.string(),
image: z.string(), image: z.string(),
}), }),
excerpt: z.object({
type: z.string(),
children: z.any(),
}),
}), }),
}), })),
skills: defineCollection({ skills: defineCollection({
type: 'page', type: 'page',

View file

@ -1,20 +1,21 @@
--- ---
image: 'https://picsum.photos/1920/450?random=4' image: '/img/blog/posts/0000.start.webp'
thumbnail: 'https://picsum.photos/750/250?random=4' thumbnail: '/img/blog/posts/0000.start_thumb.webp'
title: 'Blogfussel - für mehr Fussel im Blog' title: 'Blogfussel - schon wieder'
description: 'Warum ich mich dazu entschlossen habe, jetzt doch wieder mit dem Bloggen anzufangen? Da muss ich etwas ausholen.'
navigation: true navigation: true
date: 2025-06-11 date: 2025-06-11
category: 'story' category: 'story'
tags: [ 'start', 'story' ]
author: author:
name: 'webfussel' name: 'webfussel'
image: '/img/og.webp' image: '/img/blog/authors/webfussel@3x.webp'
schemaOrg:
type: 'BlogPosting'
headline: 'Warum es wieder einen blogfussel gibt'
author:
type: 'Person'
name: 'Fiona Urban aka webfussel'
datePublished: '2025-06-11'
--- ---
::blog-excerpt Irgendein Text
Warum ich mich dazu entschlossen habe jetzt doch wieder mit dem Bloggen anzufangen? Da muss ich etwas ausholen.
::
<!-- more -->
Noch mehr Text

View file

@ -1,18 +0,0 @@
---
image: 'https://picsum.photos/600/250?random=3'
title: 'Post 1'
description: 'Blablabla'
navigation: true
date: 2025-05-20
category: 'tutorial'
tags: [ 'test', 'wasd' ]
author:
name: 'webfussel'
image: '/img/og.webp'
---
Blablabla test 1 2 3
<!-- more -->
Noch mehr Text

View file

@ -1,18 +0,0 @@
---
image: 'https://picsum.photos/600/250?random=2'
title: 'Post 2'
description: 'Blablabla'
navigation: true
date: 2025-05-16
category: 'snippet'
tags: [ 'test', 'asdf' ]
author:
name: 'webfussel'
image: '/img/og.webp'
---
Blablabla test 1 2 3
<!-- more -->
Noch mehr Text

View file

@ -1,18 +0,0 @@
---
image: 'https://picsum.photos/600/250?random=1'
title: 'Post 3'
description: 'Blablabla'
navigation: true
date: 2025-05-25
category: 'freelancing'
tags: [ 'test', '123' ]
author:
name: 'webfussel'
image: '/img/og.webp'
---
Blablabla test 1 2 3
<!-- more -->
Noch mehr Text

View file

@ -1,18 +0,0 @@
---
image: 'https://picsum.photos/600/250?random=5'
title: 'Post 4'
description: 'Blablabla'
navigation: true
date: 2025-05-25
category: 'news'
tags: [ 'test', '123' ]
author:
name: 'webfussel'
image: '/img/og.webp'
---
Blablabla test 1 2 3
<!-- more -->
Noch mehr Text

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -0,0 +1,39 @@
import { BlogCollectionItem } from '@nuxt/content'
const simpleDate = (date: Date) => {
date.setDate(date.getDate() + 1)
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}-${date.getDate()}`
}
const author = (baseUrl: string) => ({
name: 'webfussel',
img: `${baseUrl}/img/blog/authors/webfussel@3x.webp`,
img1x: `${baseUrl}/img/blog/authors/webfussel@1x.webp`,
img2x: `${baseUrl}/img/blog/authors/webfussel@2x.webp`,
img3x: `${baseUrl}/img/blog/authors/webfussel@3x.webp`,
})
export default defineEventHandler(async event => {
const url = getRequestURL(event)
const baseUrl = `${url.protocol}//${url.host}`
const articles = await queryCollection(event, 'blog')
.where('date', '<', simpleDate(new Date()))
.order('date', 'DESC')
.all()
const entries = articles.map(article => ({
url: `${baseUrl}${article.path}`,
title: article.title,
excerpt: article.description,
date: article.date,
thumbnail: `${baseUrl}${article.thumbnail}`,
category: article.category,
}))
return {
author: author(baseUrl),
posts: entries,
}
})