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

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.
</p>
<div v-else>
<header>
<div class="image z-2"> <div class="image z-2">
<img :src="article.image" alt="Artikelbild" aria-hidden="true"/> <img :src="article.image" alt="Artikelbild" aria-hidden="true"/>
</div> </div>
<header>
<div class="meta">
<NuxtLink :to="`/blog/?category=${article.category}`">
<span class="chip interactive"><BlogCategory :name="article.category as Category"/></span>
</NuxtLink>
</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,
}
})