Skip to content

Commit d19341b

Browse files
committed
refactor: enhance SEO and site metadata
1 parent e353c34 commit d19341b

5 files changed

Lines changed: 285 additions & 30 deletions

File tree

src/app/layout.tsx

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import Header from "@/app/_components/header";
22
import Footer from "@/app/_components/footer";
3-
import { CMS_NAME, HOME_OG_IMAGE_URL } from "@/lib/constants";
3+
import {
4+
CMS_NAME,
5+
HOME_OG_IMAGE_URL,
6+
SITE_NAME,
7+
SITE_DESCRIPTION,
8+
SITE_URL,
9+
SITE_KEYWORDS,
10+
TWITTER_HANDLE
11+
} from "@/lib/constants";
412
import type { Metadata } from "next";
513
import { Inter, Poppins } from "next/font/google";
614
import cn from "classnames";
@@ -16,16 +24,52 @@ const poppins = Poppins({
1624
});
1725

1826
export const metadata: Metadata = {
19-
title: `OpenVoiceOS Blog`,
20-
description: `Official blog for OpenVoiceOS - The open-source voice operating system`,
27+
metadataBase: new URL(SITE_URL),
28+
title: {
29+
default: SITE_NAME,
30+
template: `%s | ${SITE_NAME}`,
31+
},
32+
description: SITE_DESCRIPTION,
33+
keywords: SITE_KEYWORDS,
34+
authors: [{ name: "OpenVoiceOS Team" }],
35+
creator: "OpenVoiceOS",
36+
publisher: "OpenVoiceOS",
37+
robots: {
38+
index: true,
39+
follow: true,
40+
googleBot: {
41+
index: true,
42+
follow: true,
43+
'max-video-preview': -1,
44+
'max-image-preview': 'large',
45+
'max-snippet': -1,
46+
},
47+
},
2148
openGraph: {
49+
type: "website",
50+
locale: "en_US",
51+
url: SITE_URL,
52+
title: SITE_NAME,
53+
description: SITE_DESCRIPTION,
54+
siteName: SITE_NAME,
55+
images: [
56+
{
57+
url: HOME_OG_IMAGE_URL,
58+
width: 1200,
59+
height: 630,
60+
alt: SITE_NAME,
61+
},
62+
],
63+
},
64+
twitter: {
65+
card: "summary_large_image",
66+
title: SITE_NAME,
67+
description: SITE_DESCRIPTION,
68+
site: TWITTER_HANDLE,
69+
creator: TWITTER_HANDLE,
2270
images: [HOME_OG_IMAGE_URL],
23-
title: "OpenVoiceOS Blog",
24-
description:
25-
"Official blog for OpenVoiceOS - The open-source voice operating system",
26-
url: "https://blog.openvoiceos.org",
27-
siteName: "OpenVoiceOS Blog",
2871
},
72+
category: "technology",
2973
};
3074

3175
export default function RootLayout({
@@ -70,8 +114,35 @@ export default function RootLayout({
70114
name="viewport"
71115
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
72116
/>
73-
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
117+
<link rel="canonical" href={SITE_URL} />
118+
<link rel="alternate" type="application/rss+xml" href="/feed.xml" title={`${SITE_NAME} RSS Feed`} />
74119
<link rel="stylesheet" href="/assets/css/highlight.css" />
120+
<script
121+
type="application/ld+json"
122+
dangerouslySetInnerHTML={{
123+
__html: JSON.stringify({
124+
"@context": "https://schema.org",
125+
"@type": "WebSite",
126+
"name": SITE_NAME,
127+
"description": SITE_DESCRIPTION,
128+
"url": SITE_URL,
129+
"potentialAction": {
130+
"@type": "SearchAction",
131+
"target": `${SITE_URL}/search?q={search_term_string}`,
132+
"query-input": "required name=search_term_string"
133+
},
134+
"publisher": {
135+
"@type": "Organization",
136+
"name": "OpenVoiceOS",
137+
"url": "https://openvoiceos.org",
138+
"logo": {
139+
"@type": "ImageObject",
140+
"url": HOME_OG_IMAGE_URL
141+
}
142+
}
143+
})
144+
}}
145+
/>
75146
</head>
76147
<body
77148
className={cn(

src/app/posts/[slug]/page.tsx

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Metadata } from "next";
22
import { notFound } from "next/navigation";
33
import { getAllPosts, getPostBySlug } from "@/lib/api";
4+
import { SITE_NAME, SITE_URL, TWITTER_HANDLE } from "@/lib/constants";
45
import markdownToHtml from "@/lib/markdownToHtml";
56
import Alert from "@/app/_components/alert";
67
import Container from "@/app/_components/container";
@@ -50,6 +51,44 @@ export default async function Post(props: Params) {
5051
return (
5152
<>
5253
{post.preview && <Alert preview={post.preview} />}
54+
55+
{/* Structured Data for Article */}
56+
<script
57+
type="application/ld+json"
58+
dangerouslySetInnerHTML={{
59+
__html: JSON.stringify({
60+
"@context": "https://schema.org",
61+
"@type": "BlogPosting",
62+
"headline": post.title,
63+
"description": post.excerpt,
64+
"image": post.coverImage ? `${SITE_URL}${post.coverImage}` : post.ogImage.url,
65+
"author": {
66+
"@type": "Person",
67+
"name": post.author.name,
68+
"image": post.author.picture,
69+
},
70+
"publisher": {
71+
"@type": "Organization",
72+
"name": "OpenVoiceOS",
73+
"logo": {
74+
"@type": "ImageObject",
75+
"url": `${SITE_URL}/logo.svg`
76+
}
77+
},
78+
"datePublished": new Date(post.date).toISOString(),
79+
"dateModified": new Date(post.date).toISOString(),
80+
"mainEntityOfPage": {
81+
"@type": "WebPage",
82+
"@id": `${SITE_URL}/posts/${post.slug}`
83+
},
84+
"url": `${SITE_URL}/posts/${post.slug}`,
85+
"wordCount": post.content ? post.content.split(' ').length : 0,
86+
"keywords": "OpenVoiceOS, OVOS, voice assistant, open source, voice AI, privacy",
87+
"articleSection": "Technology",
88+
"inLanguage": "en-US"
89+
})
90+
}}
91+
/>
5392

5493
<article className="mb-6 pt-14 sm:pt-24 sm:mb-16">
5594
<Container>
@@ -290,25 +329,80 @@ export async function generateMetadata(props: Params): Promise<Metadata> {
290329
return notFound();
291330
}
292331

293-
const title = `${post.title} | OpenVoiceOS Blog`;
294-
const description = post.excerpt || "";
332+
const title = post.title;
333+
const description = post.excerpt || `Read ${post.title} on the OpenVoiceOS blog - Your source for open-source voice AI insights.`;
334+
const publishedTime = new Date(post.date).toISOString();
335+
const url = `${SITE_URL}/posts/${post.slug}`;
336+
const images = [
337+
{
338+
url: post.coverImage ? `${SITE_URL}${post.coverImage}` : post.ogImage.url,
339+
width: 1200,
340+
height: 630,
341+
alt: post.title,
342+
},
343+
];
344+
345+
// Extract reading time estimate (approximate)
346+
const wordsPerMinute = 200;
347+
const wordCount = post.content ? post.content.split(' ').length : 0;
348+
const readingTime = Math.ceil(wordCount / wordsPerMinute);
295349

296350
return {
297351
title,
298352
description,
353+
keywords: [
354+
"OpenVoiceOS",
355+
"OVOS",
356+
"voice assistant",
357+
"open source",
358+
"voice AI",
359+
"privacy",
360+
"smart speaker",
361+
],
362+
authors: [{ name: post.author.name }],
363+
creator: post.author.name,
364+
publisher: SITE_NAME,
365+
category: "Technology",
366+
robots: {
367+
index: true,
368+
follow: true,
369+
nocache: false,
370+
googleBot: {
371+
index: true,
372+
follow: true,
373+
noimageindex: false,
374+
'max-video-preview': -1,
375+
'max-image-preview': 'large',
376+
'max-snippet': -1,
377+
},
378+
},
299379
openGraph: {
300380
title,
301381
description,
302-
images: [post.ogImage.url],
303-
type: "article",
304-
publishedTime: post.date,
382+
url,
383+
siteName: SITE_NAME,
384+
images,
385+
locale: 'en_US',
386+
type: 'article',
387+
publishedTime,
388+
modifiedTime: publishedTime,
305389
authors: [post.author.name],
390+
section: 'Technology',
306391
},
307392
twitter: {
308-
card: "summary_large_image",
393+
card: 'summary_large_image',
309394
title,
310395
description,
311-
images: [post.ogImage.url],
396+
creator: TWITTER_HANDLE,
397+
site: TWITTER_HANDLE,
398+
images,
399+
},
400+
alternates: {
401+
canonical: url,
402+
},
403+
other: {
404+
'article:reading_time': readingTime.toString(),
405+
'article:word_count': wordCount.toString(),
312406
},
313407
};
314408
}

src/app/robots.txt/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ export async function GET() {
66
const robotsTxt = `User-agent: *
77
Allow: /
88
9+
# Disallow crawling of any admin or private directories
10+
Disallow: /api/
11+
Disallow: /_next/
12+
Disallow: /admin/
13+
14+
# Allow crawling of RSS feed
15+
Allow: /feed.xml
16+
17+
# Crawl delay (be respectful)
18+
Crawl-delay: 1
19+
20+
# Sitemap location
921
Sitemap: https://openvoiceos.github.io/ovos-blogs/sitemap.xml`;
1022

1123
return new NextResponse(robotsTxt, {

src/app/sitemap.xml/route.ts

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
11
import { getAllPosts } from "@/lib/api";
22
import { NextResponse } from "next/server";
3+
import fs from "fs";
4+
import { join } from "path";
35

46
export const dynamic = "force-static";
57

8+
// Helper function to escape XML entities
9+
function escapeXml(unsafe: string): string {
10+
return unsafe
11+
.replace(/&/g, "&amp;")
12+
.replace(/</g, "&lt;")
13+
.replace(/>/g, "&gt;")
14+
.replace(/"/g, "&quot;")
15+
.replace(/'/g, "&#039;");
16+
}
17+
618
export async function GET() {
719
const posts = getAllPosts();
820
const baseUrl = "https://openvoiceos.github.io/ovos-blogs";
921

22+
// Get file modification dates for more accurate lastmod
23+
const getFileModDate = (slug: string) => {
24+
try {
25+
const fullPath = join(process.cwd(), "_posts", `${slug}.md`);
26+
const stats = fs.statSync(fullPath);
27+
return stats.mtime.toISOString();
28+
} catch (error) {
29+
return new Date().toISOString();
30+
}
31+
};
32+
33+
// Sort posts by date (newest first) for priority calculation
34+
const sortedPosts = posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
35+
1036
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
11-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
37+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
38+
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
39+
xmlns:xhtml="http://www.w3.org/1999/xhtml"
40+
xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0"
41+
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
1242
<url>
1343
<loc>${baseUrl}</loc>
1444
<lastmod>${new Date().toISOString()}</lastmod>
@@ -23,20 +53,46 @@ export async function GET() {
2353
</url>
2454
<url>
2555
<loc>${baseUrl}/archive</loc>
26-
<lastmod>${new Date().toISOString()}</lastmod>
27-
<changefreq>daily</changefreq>
56+
<lastmod>${sortedPosts[0] ? new Date(sortedPosts[0].date).toISOString() : new Date().toISOString()}</lastmod>
57+
<changefreq>weekly</changefreq>
2858
<priority>0.9</priority>
2959
</url>
30-
${posts
31-
.map(
32-
(post) => `
3360
<url>
34-
<loc>${baseUrl}/posts/${post.slug}</loc>
35-
<lastmod>${new Date(post.date).toISOString()}</lastmod>
36-
<changefreq>monthly</changefreq>
37-
<priority>0.7</priority>
38-
</url>`
39-
)
61+
<loc>${baseUrl}/feed.xml</loc>
62+
<lastmod>${sortedPosts[0] ? new Date(sortedPosts[0].date).toISOString() : new Date().toISOString()}</lastmod>
63+
<changefreq>daily</changefreq>
64+
<priority>0.8</priority>
65+
</url>
66+
${sortedPosts
67+
.map((post, index) => {
68+
// Calculate priority based on recency and position
69+
const daysSincePublished = Math.floor((Date.now() - new Date(post.date).getTime()) / (1000 * 60 * 60 * 24));
70+
let priority = 0.7;
71+
72+
// Boost priority for recent posts
73+
if (daysSincePublished <= 7) priority = 0.9;
74+
else if (daysSincePublished <= 30) priority = 0.8;
75+
else if (daysSincePublished <= 90) priority = 0.7;
76+
else priority = 0.6;
77+
78+
// Cap priority to reasonable bounds
79+
priority = Math.max(0.5, Math.min(1.0, priority));
80+
81+
const changefreq = daysSincePublished <= 30 ? 'weekly' : 'monthly';
82+
83+
return `
84+
<url>
85+
<loc>${baseUrl}/posts/${escapeXml(post.slug)}</loc>
86+
<lastmod>${getFileModDate(post.slug)}</lastmod>
87+
<changefreq>${changefreq}</changefreq>
88+
<priority>${priority.toFixed(1)}</priority>${post.coverImage ? `
89+
<image:image>
90+
<image:loc>${baseUrl}${escapeXml(post.coverImage)}</image:loc>
91+
<image:title>${escapeXml(post.title)}</image:title>
92+
<image:caption>${escapeXml(post.excerpt || post.title)}</image:caption>
93+
</image:image>` : ''}
94+
</url>`;
95+
})
4096
.join("")}
4197
</urlset>`;
4298

0 commit comments

Comments
 (0)