seo: BreadcrumbList + CollectionPage + wordCount, web-manifest/ikoner, FAQ i foerste person
Build & Deploy / deploy (push) Successful in 1m20s
Build & Deploy / deploy (push) Successful in 1m20s
BreadcrumbList paa blogposts (schema + synlig broedkrumme) og CollectionPage paa /blog (hoejeste-ROI rich results). BlogPosting beriget m. wordCount + articleSection. Web-manifest + apple-touch-icon/192/512 PNG-ikoner. FAQ-svar i foerste person (spoergsmaal beholdt i 3. person m. navn for SEO). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3ed486b775
commit
d8bfff1cc4
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Asger Weirsøe",
|
||||||
|
"short_name": "Asger Weirsøe",
|
||||||
|
"description": "Datalog & Technical Solution Architect — Peppol, NemHandel, e-faktura, selvhosting, Proxmox og AI.",
|
||||||
|
"lang": "da",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0b0f17",
|
||||||
|
"theme_color": "#0b0f17",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ const graphs = [baseGraph, ...jsonLd];
|
|||||||
<meta name="theme-color" content="#0b0f17" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0b0f17" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#f6f8fb" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#f6f8fb" media="(prefers-color-scheme: light)" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||||
<link rel="alternate" type="application/rss+xml" title={SITE.name} href="/rss.xml" />
|
<link rel="alternate" type="application/rss+xml" title={SITE.name} href="/rss.xml" />
|
||||||
<!-- Mastodon-verifikation: usynligt rel=me i head (kræver ingen JS) -->
|
<!-- Mastodon-verifikation: usynligt rel=me i head (kræver ingen JS) -->
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import { SITE } from '../../consts';
|
import { SITE } from '../../consts';
|
||||||
import { blogPosting } from '../../seo';
|
import { blogPosting, breadcrumb } from '../../seo';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getCollection('blog', ({ data }) => !data.draft);
|
const posts = await getCollection('blog', ({ data }) => !data.draft);
|
||||||
@@ -15,11 +15,18 @@ const d = post.data.date.toLocaleDateString('da-DK', { year: 'numeric', month: '
|
|||||||
const url = new URL(`/blog/${post.id}`, SITE.url).href;
|
const url = new URL(`/blog/${post.id}`, SITE.url).href;
|
||||||
const published = post.data.date.toISOString();
|
const published = post.data.date.toISOString();
|
||||||
const modified = (post.data.updated ?? post.data.date).toISOString();
|
const modified = (post.data.updated ?? post.data.date).toISOString();
|
||||||
|
const wordCount = (post.body?.trim().split(/\s+/).filter(Boolean).length) || undefined;
|
||||||
const ld = blogPosting({
|
const ld = blogPosting({
|
||||||
url, headline: post.data.title, description: post.data.description,
|
url, headline: post.data.title, description: post.data.description,
|
||||||
datePublished: published, dateModified: modified,
|
datePublished: published, dateModified: modified,
|
||||||
tags: post.data.tags, image: new URL('/og.png', SITE.url).href,
|
tags: post.data.tags, image: new URL('/og.png', SITE.url).href,
|
||||||
|
wordCount, section: post.data.tags[0] || 'Blog',
|
||||||
});
|
});
|
||||||
|
const crumbs = breadcrumb([
|
||||||
|
{ name: 'Forside', url: `${SITE.url}/` },
|
||||||
|
{ name: 'Blog', url: `${SITE.url}/blog` },
|
||||||
|
{ name: post.data.title, url },
|
||||||
|
]);
|
||||||
---
|
---
|
||||||
<Base
|
<Base
|
||||||
title={post.data.title}
|
title={post.data.title}
|
||||||
@@ -28,9 +35,13 @@ const ld = blogPosting({
|
|||||||
publishedTime={published}
|
publishedTime={published}
|
||||||
modifiedTime={modified}
|
modifiedTime={modified}
|
||||||
tags={post.data.tags}
|
tags={post.data.tags}
|
||||||
jsonLd={[ld]}
|
jsonLd={[ld, crumbs]}
|
||||||
>
|
>
|
||||||
<a href="/blog" class="text-sm text-muted hover:text-accent">← Tilbage til blog</a>
|
<nav aria-label="Brødkrumme" class="text-sm text-muted">
|
||||||
|
<a href="/" class="hover:text-accent">Forside</a>
|
||||||
|
<span class="px-1.5 text-muted/60">/</span>
|
||||||
|
<a href="/blog" class="hover:text-accent">Blog</a>
|
||||||
|
</nav>
|
||||||
<article class="mt-4">
|
<article class="mt-4">
|
||||||
<header class="border-b border-border pb-6">
|
<header class="border-b border-border pb-6">
|
||||||
<div class="text-sm text-muted">{d}</div>
|
<div class="text-sm text-muted">{d}</div>
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import PostCard from '../../components/PostCard.astro';
|
import PostCard from '../../components/PostCard.astro';
|
||||||
|
import { SITE } from '../../consts';
|
||||||
|
import { breadcrumb, collectionPage } from '../../seo';
|
||||||
|
|
||||||
const posts = (await getCollection('blog', ({ data }) => !data.draft))
|
const posts = (await getCollection('blog', ({ data }) => !data.draft))
|
||||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||||
|
|
||||||
|
const blogUrl = `${SITE.url}/blog`;
|
||||||
|
const jsonLd = [
|
||||||
|
collectionPage({ url: blogUrl, name: 'Blog · Asger Weirsøe',
|
||||||
|
description: 'Indlæg om selvhosting, Proxmox, AI og udvikling.' }),
|
||||||
|
breadcrumb([{ name: 'Forside', url: `${SITE.url}/` }, { name: 'Blog', url: blogUrl }]),
|
||||||
|
];
|
||||||
---
|
---
|
||||||
<Base title="Blog" description="Indlæg om selvhosting, Proxmox, AI og udvikling.">
|
<Base title="Blog" description="Indlæg om selvhosting, Proxmox, AI og udvikling." jsonLd={jsonLd}>
|
||||||
<div class="flex items-baseline justify-between">
|
<div class="flex items-baseline justify-between">
|
||||||
<h1 class="text-4xl font-bold tracking-tight">Blog</h1>
|
<h1 class="text-4xl font-bold tracking-tight">Blog</h1>
|
||||||
<a href="/tags" class="text-sm text-accent hover:underline">Tags</a>
|
<a href="/tags" class="text-sm text-accent hover:underline">Tags</a>
|
||||||
|
|||||||
@@ -16,22 +16,24 @@ const highlights = [
|
|||||||
{ title: 'Selvhosting, AI & fuld-stack', body: 'Et Proxmox-homelab med ~50 LXC/VM’er bag SSO (Authentik), og projekter fra Java/Go/Python til TypeScript og LLM-drevne workflows.' },
|
{ title: 'Selvhosting, AI & fuld-stack', body: 'Et Proxmox-homelab med ~50 LXC/VM’er bag SSO (Authentik), og projekter fra Java/Go/Python til TypeScript og LLM-drevne workflows.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Spørgsmålene står i tredje person med navnet (det er det folk og LLM'er søger på,
|
||||||
|
// og det fodrer FAQ-schema); svarene er i første person, så det er mig der skriver.
|
||||||
const faqs = [
|
const faqs = [
|
||||||
{
|
{
|
||||||
q: 'Hvem er Asger Weirsøe?',
|
q: 'Hvem er Asger Weirsøe?',
|
||||||
a: 'Asger Weirsøe er datalog (cand.scient. fra Aalborg Universitet) og Technical Solution Architect hos Digisense A/S. Han har en bred profil inden for elektronisk fakturering og forener teknisk indsigt med forretningsforståelse.',
|
a: 'Jeg er datalog (cand.scient. fra Aalborg Universitet) og Technical Solution Architect hos Digisense A/S. Jeg har en bred profil inden for elektronisk fakturering og forener teknisk indsigt med forretningsforståelse.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Hvad arbejder Asger Weirsøe med?',
|
q: 'Hvad arbejder Asger Weirsøe med?',
|
||||||
a: 'Løsningsarkitektur inden for elektronisk fakturering og e-procurement: Peppol (Access Point, eDelivery/AS4 og Oxalis), NemHandel, OIOUBL/UBL og EN 16931. Derudover selvhosting, Proxmox og AI.',
|
a: 'Jeg arbejder med løsningsarkitektur inden for elektronisk fakturering og e-procurement: Peppol (Access Point, eDelivery/AS4 og Oxalis), NemHandel, OIOUBL/UBL og EN 16931. Derudover selvhosting, Proxmox og AI.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Hvem kan rådgive om Peppol og NemHandel i Danmark?',
|
q: 'Hvem kan rådgive om Peppol og NemHandel i Danmark?',
|
||||||
a: 'Asger Weirsøe har arbejdet praktisk med både Peppol og NemHandel. Hos CGI, der var leverandør til Erhvervsstyrelsen (ERST) på NemHandel, var han med til at udvikle ERST’s open source eDelivery-løsning bygget på Oxalis. I dag arbejder han som Technical Solution Architect hos Digisense A/S med løsningsarkitektur inden for elektronisk fakturering.',
|
a: 'Det kan jeg. Jeg har arbejdet praktisk med både Peppol og NemHandel — hos CGI, der var leverandør til Erhvervsstyrelsen (ERST) på NemHandel, var jeg med til at udvikle ERST’s open source eDelivery-løsning bygget på Oxalis. I dag arbejder jeg som Technical Solution Architect hos Digisense A/S med løsningsarkitektur inden for elektronisk fakturering.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Hvad er Asger Weirsøes baggrund?',
|
q: 'Hvad er Asger Weirsøes baggrund?',
|
||||||
a: 'Cand.scient. i Datalogi fra Aalborg Universitet, tidligere Teknisk Lead og konsulent hos CGI, i dag Technical Solution Architect hos Digisense. Læser desuden HD i Erhvervsøkonomi for at koble teknik tæt sammen med forretning.',
|
a: 'Cand.scient. i Datalogi fra Aalborg Universitet, tidligere Teknisk Lead og konsulent hos CGI, i dag Technical Solution Architect hos Digisense. Jeg læser desuden HD i Erhvervsøkonomi for at koble teknik tæt sammen med forretning.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+31
@@ -91,6 +91,7 @@ export function faqPage(items: { q: string; a: string }[]) {
|
|||||||
export function blogPosting(opts: {
|
export function blogPosting(opts: {
|
||||||
url: string; headline: string; description: string;
|
url: string; headline: string; description: string;
|
||||||
datePublished: string; dateModified: string; tags: string[]; image: string;
|
datePublished: string; dateModified: string; tags: string[]; image: string;
|
||||||
|
wordCount?: number; section?: string;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -104,8 +105,38 @@ export function blogPosting(opts: {
|
|||||||
inLanguage: 'da-DK',
|
inLanguage: 'da-DK',
|
||||||
image: opts.image,
|
image: opts.image,
|
||||||
keywords: opts.tags.join(', '),
|
keywords: opts.tags.join(', '),
|
||||||
|
articleSection: opts.section || 'Blog',
|
||||||
|
...(opts.wordCount ? { wordCount: opts.wordCount } : {}),
|
||||||
author: { '@id': personId },
|
author: { '@id': personId },
|
||||||
publisher: { '@id': personId },
|
publisher: { '@id': personId },
|
||||||
isPartOf: { '@id': websiteId },
|
isPartOf: { '@id': websiteId },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BreadcrumbList — the highest-ROI rich result for content sites: a human-readable
|
||||||
|
// path (Forside › Blog › …) in the SERP instead of the raw URL. Needs ≥2 items.
|
||||||
|
export function breadcrumb(items: { name: string; url: string }[]) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: items.map((it, i) => ({
|
||||||
|
'@type': 'ListItem', position: i + 1, name: it.name, item: it.url,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionPage — tells search/AI that a listing page (the blog index, a tag) is a
|
||||||
|
// curated set authored by the Person, not a thin/duplicate page.
|
||||||
|
export function collectionPage(opts: { url: string; name: string; description: string }) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
'@id': opts.url,
|
||||||
|
url: opts.url,
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
inLanguage: 'da-DK',
|
||||||
|
isPartOf: { '@id': websiteId },
|
||||||
|
about: { '@id': personId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user