add: SEO for articles
SEO and SchemaORg for articles
This commit is contained in:
parent
f1cb4048a4
commit
d6859cdaad
22 changed files with 198 additions and 125 deletions
|
@ -3,14 +3,39 @@
|
|||
flex-direction: column;
|
||||
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 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-style: italic;
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
& .image {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border-radius: 1rem;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
|
||||
|
@ -19,6 +44,7 @@
|
|||
height: 100%;
|
||||
opacity: .8;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@
|
|||
& > .image {
|
||||
flex: 0 0 200px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
& img {
|
||||
height: 100%;
|
||||
|
|
|
@ -159,6 +159,15 @@ span.chip {
|
|||
width: max-content;
|
||||
transition: var(--transition-time);
|
||||
|
||||
&.interactive {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-orange-dark);
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.dark) {
|
||||
--background: var(--color-orange);
|
||||
--color: var(--color-black);
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
<template>
|
||||
<aside class="BlogAuthor">
|
||||
<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 class="meta">
|
||||
<span class="name">{{ name }}</span>
|
||||
|
@ -11,13 +19,14 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getImageSet, getInitialImage } from '../../utils/image'
|
||||
|
||||
type Props = {
|
||||
name: string
|
||||
image: string
|
||||
date: string
|
||||
}
|
||||
|
||||
const { date } = defineProps<Props>()
|
||||
const { name, date } = defineProps<Props>()
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
|
@ -26,4 +35,7 @@ const formatter = new Intl.DateTimeFormat('de-DE', {
|
|||
})
|
||||
|
||||
const dateFormatted = computed(() => formatter.format(new Date(date)))
|
||||
|
||||
const imageSet = getImageSet('/img/blog/authors/', name)
|
||||
const initialImage = getInitialImage('/img/blog/authors/', name)
|
||||
</script>
|
|
@ -10,15 +10,11 @@
|
|||
</header>
|
||||
<main>
|
||||
<p>
|
||||
{{ generatePlainText(excerpt.value).at(0)?.text ?? '' }}
|
||||
{{ description }}
|
||||
</p>
|
||||
</main>
|
||||
<footer>
|
||||
<BlogAuthor :name="author.name" :image="author.image" :date="date"/>
|
||||
<div class="tags">
|
||||
<span>tags</span>
|
||||
<span class="tag" v-for="tag in tags">{{ tag }}</span>
|
||||
</div>
|
||||
<BlogAuthor :name="author.name" :date="date"/>
|
||||
</footer>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
@ -26,20 +22,16 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from './types'
|
||||
import type { MinimalNode } from '@nuxt/content'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
date: string
|
||||
excerpt: { type: string, value: MinimalNode[], children?: any }
|
||||
link: string
|
||||
tags: string[]
|
||||
category: Category
|
||||
author: {
|
||||
name: string
|
||||
image: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,9 @@
|
|||
<span class="my-name-wrapper">Ich bin <Highlight>Fiona</Highlight>.</span>
|
||||
</h1>
|
||||
<h2>
|
||||
Component <Highlight>&</Highlight> API Entwicklerin
|
||||
Component
|
||||
<Highlight>&</Highlight>
|
||||
API Entwicklerin
|
||||
</h2>
|
||||
<p class="fulltext">
|
||||
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.
|
||||
</p>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
<div class="intro-img">
|
||||
<picture>
|
||||
<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" />
|
||||
<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"/>
|
||||
</picture>
|
||||
</div>
|
||||
</section>
|
||||
|
|
20
app/error.vue
Normal file
20
app/error.vue
Normal 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>
|
|
@ -5,29 +5,54 @@
|
|||
Zurück zur Übersicht
|
||||
</NuxtLink>
|
||||
|
||||
<p v-if="!article">
|
||||
Sorry bro, aber der Artikel existiert einfach nicht.
|
||||
</p>
|
||||
|
||||
<div v-else>
|
||||
<main v-if="article" class="article-content z-3">
|
||||
<div class="image z-2">
|
||||
<img :src="article.image" alt="Artikelbild" aria-hidden="true"/>
|
||||
</div>
|
||||
<header>
|
||||
<div class="image z-2">
|
||||
<img :src="article.image" alt="Artikelbild" aria-hidden="true"/>
|
||||
<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>
|
||||
<h2>{{ article.description }}</h2>
|
||||
</header>
|
||||
<div class="flex-col gap-default">
|
||||
<div class="flex-col gap-default article-text">
|
||||
<ContentRenderer v-if="article" :value="article" :style="{ display: 'contents' }"/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '../../components/Blog/types'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
<template>
|
||||
<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">
|
||||
<li>
|
||||
<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>
|
||||
</li>
|
||||
<li v-for="(count, category) in allCategoriesAndCount">
|
||||
|
@ -15,6 +22,8 @@
|
|||
</ul>
|
||||
<div class="grid margin-top-middle article-overview">
|
||||
<BlogCard v-for="article in firstTen" v-bind="makeBlogCard(article)"/>
|
||||
<div v-if="firstTen.length < 2"/>
|
||||
<div v-if="firstTen.length < 3"/>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
@ -33,8 +42,8 @@ const { data: articles } = await useAsyncData('articles', () => queryCollection(
|
|||
)
|
||||
|
||||
const firstTen = computed(() => {
|
||||
if (route.query.category) {
|
||||
return articles.value?.filter(article => article.meta.category === route.query.category).slice(0, 10) ?? []
|
||||
if (route.query.category && Object.keys(allCategoriesAndCount.value).includes(route.query.category as Category)) {
|
||||
return articles.value?.filter(article => article.category === route.query.category).slice(0, 10) ?? []
|
||||
}
|
||||
return articles.value?.slice(0, 10) ?? []
|
||||
})
|
||||
|
@ -42,7 +51,7 @@ const firstTen = computed(() => {
|
|||
const allCategoriesAndCount = computed(() => {
|
||||
const categories = {} as Record<Category, number>
|
||||
articles.value?.forEach(article => {
|
||||
const category = article.meta.category as Category
|
||||
const category = article.category as Category
|
||||
if (category) {
|
||||
categories[category] = (categories[category] ?? 0) + 1
|
||||
}
|
||||
|
@ -55,9 +64,7 @@ const makeBlogCard = (article: BlogCollectionItem) => ({
|
|||
description: article.description,
|
||||
image: article.thumbnail as string,
|
||||
date: article.date as string,
|
||||
excerpt: article.excerpt as any,
|
||||
link: article.path,
|
||||
tags: article.tags as string[],
|
||||
category: article.category as Category,
|
||||
author: article.author as { name: string, image: string },
|
||||
})
|
||||
|
|
15
app/utils/error.ts
Normal file
15
app/utils/error.ts
Normal 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.'
|
|
@ -1,8 +1,9 @@
|
|||
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
||||
import { asSchemaOrgCollection } from 'nuxt-schema-org/content'
|
||||
|
||||
export default defineContentConfig({
|
||||
collections: {
|
||||
blog: defineCollection({
|
||||
blog: defineCollection(asSchemaOrgCollection({
|
||||
type: 'page',
|
||||
source: 'blog/*.md',
|
||||
schema: z.object({
|
||||
|
@ -10,17 +11,12 @@ export default defineContentConfig({
|
|||
image: z.string(),
|
||||
thumbnail: z.string(),
|
||||
category: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
author: z.object({
|
||||
name: z.string(),
|
||||
image: z.string(),
|
||||
}),
|
||||
excerpt: z.object({
|
||||
type: z.string(),
|
||||
children: z.any(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
|
||||
skills: defineCollection({
|
||||
type: 'page',
|
||||
|
|
|
@ -1,20 +1,21 @@
|
|||
---
|
||||
image: 'https://picsum.photos/1920/450?random=4'
|
||||
thumbnail: 'https://picsum.photos/750/250?random=4'
|
||||
title: 'Blogfussel - für mehr Fussel im Blog'
|
||||
image: '/img/blog/posts/0000.start.webp'
|
||||
thumbnail: '/img/blog/posts/0000.start_thumb.webp'
|
||||
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
|
||||
date: 2025-06-11
|
||||
category: 'story'
|
||||
tags: [ 'start', 'story' ]
|
||||
author:
|
||||
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
|
||||
Warum ich mich dazu entschlossen habe jetzt doch wieder mit dem Bloggen anzufangen? Da muss ich etwas ausholen.
|
||||
::
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Noch mehr Text
|
||||
Irgendein Text
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
BIN
public/img/blog/authors/webfussel@1x.webp
Normal file
BIN
public/img/blog/authors/webfussel@1x.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 458 B |
BIN
public/img/blog/authors/webfussel@2x.webp
Normal file
BIN
public/img/blog/authors/webfussel@2x.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 1 KiB |
BIN
public/img/blog/authors/webfussel@3x.webp
Normal file
BIN
public/img/blog/authors/webfussel@3x.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 KiB |
BIN
public/img/blog/posts/0000.start.webp
Normal file
BIN
public/img/blog/posts/0000.start.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.3 KiB |
BIN
public/img/blog/posts/0000.start_thumb.webp
Normal file
BIN
public/img/blog/posts/0000.start_thumb.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.3 KiB |
39
server/routes/blog/posts.json.ts
Normal file
39
server/routes/blog/posts.json.ts
Normal 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,
|
||||
}
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue