mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
277 lines
7.7 KiB
TypeScript
277 lines
7.7 KiB
TypeScript
|
|
import { httpRouter } from "convex/server";
|
||
|
|
import { httpAction } from "./_generated/server";
|
||
|
|
import { api } from "./_generated/api";
|
||
|
|
import { rssFeed, rssFullFeed } from "./rss";
|
||
|
|
|
||
|
|
const http = httpRouter();
|
||
|
|
|
||
|
|
// Site configuration
|
||
|
|
const SITE_URL = process.env.SITE_URL || "https://your-blog.netlify.app";
|
||
|
|
const SITE_NAME = "Wayne Sutton";
|
||
|
|
|
||
|
|
// RSS feed endpoint (descriptions only)
|
||
|
|
http.route({
|
||
|
|
path: "/rss.xml",
|
||
|
|
method: "GET",
|
||
|
|
handler: rssFeed,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Full RSS feed endpoint (with complete content for LLMs)
|
||
|
|
http.route({
|
||
|
|
path: "/rss-full.xml",
|
||
|
|
method: "GET",
|
||
|
|
handler: rssFullFeed,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Sitemap.xml endpoint for search engines
|
||
|
|
http.route({
|
||
|
|
path: "/sitemap.xml",
|
||
|
|
method: "GET",
|
||
|
|
handler: httpAction(async (ctx) => {
|
||
|
|
const posts = await ctx.runQuery(api.posts.getAllPosts);
|
||
|
|
|
||
|
|
const urls = [
|
||
|
|
// Homepage
|
||
|
|
` <url>
|
||
|
|
<loc>${SITE_URL}/</loc>
|
||
|
|
<changefreq>daily</changefreq>
|
||
|
|
<priority>1.0</priority>
|
||
|
|
</url>`,
|
||
|
|
// All posts
|
||
|
|
...posts.map(
|
||
|
|
(post) => ` <url>
|
||
|
|
<loc>${SITE_URL}/${post.slug}</loc>
|
||
|
|
<lastmod>${post.date}</lastmod>
|
||
|
|
<changefreq>monthly</changefreq>
|
||
|
|
<priority>0.8</priority>
|
||
|
|
</url>`,
|
||
|
|
),
|
||
|
|
];
|
||
|
|
|
||
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||
|
|
${urls.join("\n")}
|
||
|
|
</urlset>`;
|
||
|
|
|
||
|
|
return new Response(xml, {
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "application/xml; charset=utf-8",
|
||
|
|
"Cache-Control": "public, max-age=3600, s-maxage=7200",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
// API endpoint: List all posts (JSON for LLMs/agents)
|
||
|
|
http.route({
|
||
|
|
path: "/api/posts",
|
||
|
|
method: "GET",
|
||
|
|
handler: httpAction(async (ctx) => {
|
||
|
|
const posts = await ctx.runQuery(api.posts.getAllPosts);
|
||
|
|
|
||
|
|
const response = {
|
||
|
|
site: SITE_NAME,
|
||
|
|
url: SITE_URL,
|
||
|
|
description: "Developer and writer. Building with Convex and AI.",
|
||
|
|
posts: posts.map((post) => ({
|
||
|
|
title: post.title,
|
||
|
|
slug: post.slug,
|
||
|
|
description: post.description,
|
||
|
|
date: post.date,
|
||
|
|
readTime: post.readTime,
|
||
|
|
tags: post.tags,
|
||
|
|
url: `${SITE_URL}/${post.slug}`,
|
||
|
|
markdownUrl: `${SITE_URL}/api/post?slug=${post.slug}`,
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
|
||
|
|
return new Response(JSON.stringify(response, null, 2), {
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "application/json; charset=utf-8",
|
||
|
|
"Cache-Control": "public, max-age=300, s-maxage=600",
|
||
|
|
"Access-Control-Allow-Origin": "*",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
// API endpoint: Get single post as markdown (for LLMs/agents)
|
||
|
|
http.route({
|
||
|
|
path: "/api/post",
|
||
|
|
method: "GET",
|
||
|
|
handler: httpAction(async (ctx, request) => {
|
||
|
|
const url = new URL(request.url);
|
||
|
|
const slug = url.searchParams.get("slug");
|
||
|
|
const format = url.searchParams.get("format") || "json";
|
||
|
|
|
||
|
|
if (!slug) {
|
||
|
|
return new Response(JSON.stringify({ error: "Missing slug parameter" }), {
|
||
|
|
status: 400,
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const post = await ctx.runQuery(api.posts.getPostBySlug, { slug });
|
||
|
|
|
||
|
|
if (!post) {
|
||
|
|
return new Response(JSON.stringify({ error: "Post not found" }), {
|
||
|
|
status: 404,
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Return raw markdown if requested
|
||
|
|
if (format === "markdown" || format === "md") {
|
||
|
|
const markdown = `# ${post.title}
|
||
|
|
|
||
|
|
> ${post.description}
|
||
|
|
|
||
|
|
**Published:** ${post.date}${post.readTime ? ` | **Read time:** ${post.readTime}` : ""}
|
||
|
|
**Tags:** ${post.tags.join(", ")}
|
||
|
|
**URL:** ${SITE_URL}/${post.slug}
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
${post.content}`;
|
||
|
|
|
||
|
|
return new Response(markdown, {
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "text/markdown; charset=utf-8",
|
||
|
|
"Cache-Control": "public, max-age=300, s-maxage=600",
|
||
|
|
"Access-Control-Allow-Origin": "*",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Default: JSON response
|
||
|
|
const response = {
|
||
|
|
title: post.title,
|
||
|
|
slug: post.slug,
|
||
|
|
description: post.description,
|
||
|
|
date: post.date,
|
||
|
|
readTime: post.readTime,
|
||
|
|
tags: post.tags,
|
||
|
|
url: `${SITE_URL}/${post.slug}`,
|
||
|
|
content: post.content,
|
||
|
|
};
|
||
|
|
|
||
|
|
return new Response(JSON.stringify(response, null, 2), {
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "application/json; charset=utf-8",
|
||
|
|
"Cache-Control": "public, max-age=300, s-maxage=600",
|
||
|
|
"Access-Control-Allow-Origin": "*",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Escape HTML characters to prevent XSS
|
||
|
|
function escapeHtml(text: string): string {
|
||
|
|
return text
|
||
|
|
.replace(/&/g, "&")
|
||
|
|
.replace(/</g, "<")
|
||
|
|
.replace(/>/g, ">")
|
||
|
|
.replace(/"/g, """)
|
||
|
|
.replace(/'/g, "'");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate Open Graph HTML for a post
|
||
|
|
function generatePostMetaHtml(post: {
|
||
|
|
title: string;
|
||
|
|
description: string;
|
||
|
|
slug: string;
|
||
|
|
date: string;
|
||
|
|
readTime?: string;
|
||
|
|
}): string {
|
||
|
|
const siteUrl = process.env.SITE_URL || "https://your-blog.netlify.app";
|
||
|
|
const siteName = "Wayne Sutton";
|
||
|
|
const defaultImage = `${siteUrl}/og-image.png`;
|
||
|
|
const canonicalUrl = `${siteUrl}/${post.slug}`;
|
||
|
|
|
||
|
|
const safeTitle = escapeHtml(post.title);
|
||
|
|
const safeDescription = escapeHtml(post.description);
|
||
|
|
|
||
|
|
return `<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
|
||
|
|
<!-- Basic SEO -->
|
||
|
|
<title>${safeTitle} | ${siteName}</title>
|
||
|
|
<meta name="description" content="${safeDescription}">
|
||
|
|
<link rel="canonical" href="${canonicalUrl}">
|
||
|
|
|
||
|
|
<!-- Open Graph -->
|
||
|
|
<meta property="og:title" content="${safeTitle}">
|
||
|
|
<meta property="og:description" content="${safeDescription}">
|
||
|
|
<meta property="og:image" content="${defaultImage}">
|
||
|
|
<meta property="og:url" content="${canonicalUrl}">
|
||
|
|
<meta property="og:type" content="article">
|
||
|
|
<meta property="og:site_name" content="${siteName}">
|
||
|
|
<meta property="article:published_time" content="${post.date}">
|
||
|
|
|
||
|
|
<!-- Twitter Card -->
|
||
|
|
<meta name="twitter:card" content="summary_large_image">
|
||
|
|
<meta name="twitter:title" content="${safeTitle}">
|
||
|
|
<meta name="twitter:description" content="${safeDescription}">
|
||
|
|
<meta name="twitter:image" content="${defaultImage}">
|
||
|
|
|
||
|
|
<!-- Redirect to actual page after a brief delay for crawlers -->
|
||
|
|
<script>
|
||
|
|
setTimeout(() => {
|
||
|
|
window.location.href = "${canonicalUrl}";
|
||
|
|
}, 100);
|
||
|
|
</script>
|
||
|
|
</head>
|
||
|
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 680px; margin: 50px auto; padding: 20px; color: #111;">
|
||
|
|
<h1 style="font-size: 32px; margin-bottom: 16px;">${safeTitle}</h1>
|
||
|
|
<p style="color: #666; margin-bottom: 24px;">${safeDescription}</p>
|
||
|
|
<p style="font-size: 14px; color: #999;">${post.date}${post.readTime ? ` · ${post.readTime}` : ""}</p>
|
||
|
|
<p style="margin-top: 24px;"><small>Redirecting to full article...</small></p>
|
||
|
|
</body>
|
||
|
|
</html>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// HTTP endpoint for Open Graph metadata
|
||
|
|
http.route({
|
||
|
|
path: "/meta/post",
|
||
|
|
method: "GET",
|
||
|
|
handler: httpAction(async (ctx, request) => {
|
||
|
|
const url = new URL(request.url);
|
||
|
|
const slug = url.searchParams.get("slug");
|
||
|
|
|
||
|
|
if (!slug) {
|
||
|
|
return new Response("Missing slug parameter", { status: 400 });
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const post = await ctx.runQuery(api.posts.getPostBySlug, { slug });
|
||
|
|
|
||
|
|
if (!post) {
|
||
|
|
return new Response("Post not found", { status: 404 });
|
||
|
|
}
|
||
|
|
|
||
|
|
const html = generatePostMetaHtml({
|
||
|
|
title: post.title,
|
||
|
|
description: post.description,
|
||
|
|
slug: post.slug,
|
||
|
|
date: post.date,
|
||
|
|
readTime: post.readTime,
|
||
|
|
});
|
||
|
|
|
||
|
|
return new Response(html, {
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "text/html; charset=utf-8",
|
||
|
|
"Cache-Control":
|
||
|
|
"public, max-age=60, s-maxage=300, stale-while-revalidate=600",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
return new Response("Internal server error", { status: 500 });
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
export default http;
|