feat: Add marketing and use case pages, their data structures, and supporting UI components.

This commit is contained in:
Timo Knuth
2026-03-09 12:47:52 +01:00
parent f5f3979996
commit 3c8e6bd19f
22 changed files with 4047 additions and 184 deletions

View File

@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import Script from "next/script";
import { getPublishedPostBySlug, getAuthorBySlug, getRelatedPosts, getPublishedPosts } from "@/lib/content";
import { AnswerBox } from "@/components/aeo/AnswerBox";
import { StepList } from "@/components/aeo/StepList";
@@ -72,17 +71,17 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) {
return (
<main className="container mx-auto max-w-4xl py-12 px-4">
<Script id="ld-blogposting" type="application/ld+json" strategy="afterInteractive"
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{howtoLd && (
<Script id="ld-howto" type="application/ld+json" strategy="afterInteractive"
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(howtoLd) }} />
)}
{faqLd && (
<Script id="ld-faq" type="application/ld+json" strategy="afterInteractive"
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqLd) }} />
)}
<Script id="ld-breadcrumb" type="application/ld+json" strategy="afterInteractive"
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }} />
<header className="space-y-6 text-center max-w-3xl mx-auto mb-10">

View File

@@ -0,0 +1,145 @@
import { getPublishedPostBySlug } from '@/lib/content';
import sanitizeHtml from 'sanitize-html';
const RAW_ENABLED_SLUGS = new Set([
'dynamic-vs-static-qr-codes',
'qr-code-small-business',
'qr-code-tracking-guide-2025',
'utm-parameter-qr-codes',
'trackable-qr-codes',
]);
function decodeHtmlEntities(text: string): string {
return text
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&mdash;/g, '--')
.replace(/&ndash;/g, '-')
.replace(/&hellip;/g, '...')
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/')
.replace(/&#(\d+);/g, (_, code) => {
const value = Number.parseInt(code, 10);
return Number.isNaN(value) ? '' : String.fromCharCode(value);
});
}
function cleanHtmlToText(html: string): string {
const normalized = html
.replace(/<div\b[^>]*class=(['"])[^'"]*post-metadata[^'"]*\1[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div\b[^>]*class=(['"])[^'"]*blog-content[^'"]*\1[^>]*>/gi, '')
.replace(/<\/div>\s*$/i, '');
const withLinks = normalized.replace(
/<a\b[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi,
(_, __, href: string, text: string) => `[${cleanHtmlToText(text)}](${href})`,
);
const structured = withLinks
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<li\b[^>]*>/gi, '- ')
.replace(/<\/li>/gi, '\n')
.replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `${'#'.repeat(Number.parseInt(level, 10))} `)
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n\n')
.replace(/<\/section>/gi, '\n\n')
.replace(/<\/ul>/gi, '\n')
.replace(/<\/ol>/gi, '\n');
const stripped = sanitizeHtml(structured, {
allowedTags: [],
allowedAttributes: {},
});
return decodeHtmlEntities(stripped)
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.trim();
}
function renderRawPost(slug: string): string | null {
if (!RAW_ENABLED_SLUGS.has(slug)) {
return null;
}
const post = getPublishedPostBySlug(slug);
if (!post) {
return null;
}
const sections: string[] = [
`# ${post.title}`,
'',
post.description,
'',
`Canonical URL: https://www.qrmaster.net/blog/${post.slug}`,
`Published: ${post.datePublished}`,
`Updated: ${post.dateModified || post.updatedAt || post.datePublished}`,
];
if (post.quickAnswer) {
sections.push('', '## Quick Answer', '', cleanHtmlToText(post.quickAnswer));
}
if (post.keySteps?.length) {
sections.push('', '## Steps', '', ...post.keySteps.map((step, index) => `${index + 1}. ${step}`));
}
const mainText = cleanHtmlToText(post.content);
if (mainText) {
sections.push('', '## Article', '', mainText);
}
if (post.faq?.length) {
sections.push('', '## FAQ', '');
for (const item of post.faq) {
sections.push(`Q: ${cleanHtmlToText(item.question)}`);
sections.push(`A: ${cleanHtmlToText(item.answer)}`, '');
}
if (sections[sections.length - 1] === '') {
sections.pop();
}
}
if (post.sources?.length) {
sections.push('', '## Sources', '');
for (const source of post.sources) {
const accessDate = source.accessDate ? ` (accessed ${source.accessDate})` : '';
sections.push(`- ${source.name}: ${source.url}${accessDate}`);
}
}
return `${sections.join('\n').trim()}\n`;
}
export async function GET(
_request: Request,
{ params }: { params: { slug: string } },
) {
const content = renderRawPost(params.slug);
if (!content) {
return new Response('Not Found', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow',
},
});
}
return new Response(content, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
});
}