mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
SEO improvements for better search engine optimization: 1. Canonical URL - Added client-side dynamic canonical link tags for posts and pages 2. Single H1 per page - Markdown H1s demoted to H2 with .blog-h1-demoted class (maintains H1 visual styling) 3. DOM order fix - Article now loads before sidebar in DOM for SEO (CSS order property maintains visual layout) 4. X-Robots-Tag - HTTP header added via netlify.toml (index, follow for public; noindex for dashboard/api routes) 5. Hreflang tags - Self-referencing hreflang (en, x-default) for language targeting 6. og:url consistency - Uses same canonicalUrl variable as canonical link tag 7. twitter:site - New TwitterConfig in siteConfig.ts for Twitter Cards meta tags Files modified: - src/config/siteConfig.ts: Added TwitterConfig interface with site/creator fields - src/pages/Post.tsx: SEO meta tags for posts/pages, DOM order optimization - src/components/BlogPost.tsx: H1 to H2 demotion in markdown renderer - src/styles/global.css: .blog-h1-demoted class, CSS order properties - convex/http.ts: hreflang and twitter:site in generateMetaHtml() - netlify.toml: X-Robots-Tag headers for public, dashboard, API routes - index.html: canonical, hreflang, twitter:site placeholder tags - fork-config.json.example: twitter configuration fields Closes #4 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
403 lines
12 KiB
TypeScript
403 lines
12 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 - update these for your site (or run npm run configure)
|
|
const SITE_URL = process.env.SITE_URL || "https://www.markdown.fast";
|
|
const SITE_NAME = "markdown sync framework";
|
|
|
|
// 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 (includes posts, pages, and tag pages)
|
|
http.route({
|
|
path: "/sitemap.xml",
|
|
method: "GET",
|
|
handler: httpAction(async (ctx) => {
|
|
const posts = await ctx.runQuery(api.posts.getAllPosts);
|
|
const pages = await ctx.runQuery(api.pages.getAllPages);
|
|
const tags = await ctx.runQuery(api.posts.getAllTags);
|
|
const authors = await ctx.runQuery(api.posts.getAllAuthors);
|
|
|
|
const urls = [
|
|
// Homepage
|
|
` <url>
|
|
<loc>${SITE_URL}/</loc>
|
|
<changefreq>daily</changefreq>
|
|
<priority>1.0</priority>
|
|
</url>`,
|
|
// All posts
|
|
...posts.map(
|
|
(post: { slug: string; date: string }) => ` <url>
|
|
<loc>${SITE_URL}/${post.slug}</loc>
|
|
<lastmod>${post.date}</lastmod>
|
|
<changefreq>monthly</changefreq>
|
|
<priority>0.8</priority>
|
|
</url>`,
|
|
),
|
|
// All pages
|
|
...pages.map(
|
|
(page: { slug: string }) => ` <url>
|
|
<loc>${SITE_URL}/${page.slug}</loc>
|
|
<changefreq>monthly</changefreq>
|
|
<priority>0.7</priority>
|
|
</url>`,
|
|
),
|
|
// All tag pages
|
|
...tags.map(
|
|
(tagInfo: { tag: string }) => ` <url>
|
|
<loc>${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())}</loc>
|
|
<changefreq>weekly</changefreq>
|
|
<priority>0.6</priority>
|
|
</url>`,
|
|
),
|
|
// All author pages
|
|
...authors.map(
|
|
(author: { slug: string }) => ` <url>
|
|
<loc>${SITE_URL}/author/${encodeURIComponent(author.slug)}</loc>
|
|
<changefreq>weekly</changefreq>
|
|
<priority>0.6</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:
|
|
"An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.",
|
|
posts: posts.map((post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => ({
|
|
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": "*",
|
|
},
|
|
});
|
|
}),
|
|
});
|
|
|
|
// API endpoint: Export all posts with full content (batch for LLMs)
|
|
http.route({
|
|
path: "/api/export",
|
|
method: "GET",
|
|
handler: httpAction(async (ctx) => {
|
|
const posts = await ctx.runQuery(api.posts.getAllPosts);
|
|
|
|
// Fetch full content for each post
|
|
const fullPosts = await Promise.all(
|
|
posts.map(async (post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => {
|
|
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
|
|
slug: post.slug,
|
|
});
|
|
return {
|
|
title: post.title,
|
|
slug: post.slug,
|
|
description: post.description,
|
|
date: post.date,
|
|
readTime: post.readTime,
|
|
tags: post.tags,
|
|
url: `${SITE_URL}/${post.slug}`,
|
|
content: fullPost?.content || "",
|
|
};
|
|
}),
|
|
);
|
|
|
|
const response = {
|
|
site: SITE_NAME,
|
|
url: SITE_URL,
|
|
description:
|
|
"An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.",
|
|
exportedAt: new Date().toISOString(),
|
|
totalPosts: fullPosts.length,
|
|
posts: fullPosts,
|
|
};
|
|
|
|
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 or page
|
|
function generateMetaHtml(content: {
|
|
title: string;
|
|
description: string;
|
|
slug: string;
|
|
date?: string;
|
|
readTime?: string;
|
|
image?: string;
|
|
type?: "post" | "page";
|
|
}): string {
|
|
const siteUrl = process.env.SITE_URL || "https://markdown.fast";
|
|
const siteName = "markdown sync framework";
|
|
const defaultImage = `${siteUrl}/images/og-default.svg`;
|
|
const canonicalUrl = `${siteUrl}/${content.slug}`;
|
|
|
|
// Resolve image URL: use post image if available, otherwise default
|
|
let ogImage = defaultImage;
|
|
if (content.image) {
|
|
// Handle both absolute URLs and relative paths
|
|
ogImage = content.image.startsWith("http")
|
|
? content.image
|
|
: `${siteUrl}${content.image}`;
|
|
}
|
|
|
|
const safeTitle = escapeHtml(content.title);
|
|
const safeDescription = escapeHtml(content.description);
|
|
const contentType = content.type || "post";
|
|
const ogType = contentType === "post" ? "article" : "website";
|
|
|
|
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="${ogImage}">
|
|
<meta property="og:url" content="${canonicalUrl}">
|
|
<meta property="og:type" content="${ogType}">
|
|
<meta property="og:site_name" content="${siteName}">${
|
|
content.date
|
|
? `
|
|
<meta property="article:published_time" content="${content.date}">`
|
|
: ""
|
|
}
|
|
|
|
<!-- Hreflang for language/region targeting -->
|
|
<link rel="alternate" hreflang="en" href="${canonicalUrl}">
|
|
<link rel="alternate" hreflang="x-default" href="${canonicalUrl}">
|
|
|
|
<!-- 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="${ogImage}">
|
|
<meta name="twitter:site" content="">
|
|
<meta name="twitter:creator" content="">
|
|
|
|
<!-- 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>${
|
|
content.date
|
|
? `
|
|
<p style="font-size: 14px; color: #999;">${content.date}${content.readTime ? ` · ${content.readTime}` : ""}</p>`
|
|
: ""
|
|
}
|
|
<p style="margin-top: 24px;"><small>Redirecting to full ${contentType}...</small></p>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// HTTP endpoint for Open Graph metadata (supports both posts and pages)
|
|
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 {
|
|
// First try to find a post
|
|
const post = await ctx.runQuery(api.posts.getPostBySlug, { slug });
|
|
|
|
if (post) {
|
|
const html = generateMetaHtml({
|
|
title: post.title,
|
|
description: post.description,
|
|
slug: post.slug,
|
|
date: post.date,
|
|
readTime: post.readTime,
|
|
image: post.image,
|
|
type: "post",
|
|
});
|
|
|
|
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",
|
|
},
|
|
});
|
|
}
|
|
|
|
// If no post found, try to find a page
|
|
const page = await ctx.runQuery(api.pages.getPageBySlug, { slug });
|
|
|
|
if (page) {
|
|
const html = generateMetaHtml({
|
|
title: page.title,
|
|
description: page.excerpt || `${page.title} - ${SITE_NAME}`,
|
|
slug: page.slug,
|
|
image: page.image,
|
|
type: "page",
|
|
});
|
|
|
|
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",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Neither post nor page found
|
|
return new Response("Content not found", { status: 404 });
|
|
} catch {
|
|
return new Response("Internal server error", { status: 500 });
|
|
}
|
|
}),
|
|
});
|
|
|
|
export default http;
|