mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-11 20:08:57 +00:00
Update version to 1.0.0 across package.json and changelog. Configure netlify.toml with Convex deployment URL (agreeable-trout-200.convex.site). Verify TypeScript type-safety for src and convex directories. Confirm Netlify build passes with SPA 404 fallback configured. Update TASK.md with deployment steps and files.md with complete file structure.
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;
|