2025-12-14 11:30:22 -08:00
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
2025-12-21 22:24:12 -08:00
const SITE_URL = process . env . SITE_URL || "https://markdown.fast" ;
2025-12-20 20:46:34 -08:00
const SITE_NAME = "markdown sync framework" ;
2025-12-14 11:30:22 -08:00
// 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 ,
2025-12-21 22:24:12 -08:00
description :
"An open-source publishing framework for AI agents and developers. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify." ,
2025-12-14 11:30:22 -08:00
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" : "*" ,
} ,
} ) ;
} ) ,
} ) ;
2025-12-18 12:28:25 -08:00
// 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 ) = > {
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 ,
2025-12-21 22:24:12 -08:00
description :
"An open-source publishing framework for AI agents and developers. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify." ,
2025-12-18 12:28:25 -08:00
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" : "*" ,
} ,
} ) ;
} ) ,
} ) ;
2025-12-14 11:30:22 -08:00
// Escape HTML characters to prevent XSS
function escapeHtml ( text : string ) : string {
return text
. replace ( /&/g , "&" )
. replace ( /</g , "<" )
. replace ( />/g , ">" )
. replace ( /"/g , """ )
. replace ( /'/g , "'" ) ;
}
2025-12-20 16:34:48 -08:00
// Generate Open Graph HTML for a post or page
function generateMetaHtml ( content : {
2025-12-14 11:30:22 -08:00
title : string ;
description : string ;
slug : string ;
2025-12-20 16:34:48 -08:00
date? : string ;
2025-12-14 11:30:22 -08:00
readTime? : string ;
2025-12-20 16:34:48 -08:00
image? : string ;
type ? : "post" | "page" ;
2025-12-14 11:30:22 -08:00
} ) : string {
2025-12-21 22:24:12 -08:00
const siteUrl = process . env . SITE_URL || "https://markdown.fast" ;
2025-12-20 20:46:34 -08:00
const siteName = "markdown sync framework" ;
2025-12-20 16:34:48 -08:00
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" ;
2025-12-14 11:30:22 -08:00
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}" >
2025-12-20 16:34:48 -08:00
< meta property = "og:image" content = "${ogImage}" >
2025-12-14 11:30:22 -08:00
< meta property = "og:url" content = "${canonicalUrl}" >
2025-12-20 16:34:48 -08:00
< meta property = "og:type" content = "${ogType}" >
2025-12-21 22:24:12 -08:00
< meta property = "og:site_name" content = "${siteName}" > $ {
content . date
? `
< meta property = "article:published_time" content = "${content.date}" > `
: ""
}
2025-12-14 11:30:22 -08:00
<!-- Twitter Card -->
< meta name = "twitter:card" content = "summary_large_image" >
< meta name = "twitter:title" content = "${safeTitle}" >
< meta name = "twitter:description" content = "${safeDescription}" >
2025-12-20 16:34:48 -08:00
< meta name = "twitter:image" content = "${ogImage}" >
2025-12-14 11:30:22 -08:00
<!-- 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 >
2025-12-21 22:24:12 -08:00
< 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 > `
: ""
}
2025-12-20 16:34:48 -08:00
< p style = "margin-top: 24px;" > < small > Redirecting to full $ { contentType } . . . < / small > < / p >
2025-12-14 11:30:22 -08:00
< / body >
< / html > ` ;
}
2025-12-20 16:34:48 -08:00
// HTTP endpoint for Open Graph metadata (supports both posts and pages)
2025-12-14 11:30:22 -08:00
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 {
2025-12-20 16:34:48 -08:00
// First try to find a post
2025-12-14 11:30:22 -08:00
const post = await ctx . runQuery ( api . posts . getPostBySlug , { slug } ) ;
2025-12-20 16:34:48 -08:00
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" ,
} ,
} ) ;
2025-12-14 11:30:22 -08:00
}
2025-12-20 16:34:48 -08:00
// If no post found, try to find a page
const page = await ctx . runQuery ( api . pages . getPageBySlug , { slug } ) ;
2025-12-14 11:30:22 -08:00
2025-12-20 16:34:48 -08:00
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 } ) ;
2025-12-14 11:30:22 -08:00
} catch {
return new Response ( "Internal server error" , { status : 500 } ) ;
}
} ) ,
} ) ;
export default http ;