Something went wrong. Try again.
A CLI for publishing standard.site documents to ATProto forked from stevedylan.dev/sequoia
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222import * as path from "path";import { Glob } from "bun";import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): { frontmatter: PostFrontmatter; body: string;} { // Support multiple frontmatter delimiters: // --- (YAML) - Jekyll, Astro, most SSGs // +++ (TOML) - Hugo // *** - Alternative format const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; const match = content.match(frontmatterRegex);
if (!match) { throw new Error("Could not parse frontmatter"); }
const delimiter = match[1]; const frontmatterStr = match[2] ?? ""; const body = match[3] ?? "";
// Determine format based on delimiter: // +++ uses TOML (key = value) // --- and *** use YAML (key: value) const isToml = delimiter === "+++"; const separator = isToml ? "=" : ":";
// Parse frontmatter manually const raw: Record<string, unknown> = {}; const lines = frontmatterStr.split("\n");
for (const line of lines) { const sepIndex = line.indexOf(separator); if (sepIndex === -1) continue;
const key = line.slice(0, sepIndex).trim(); let value = line.slice(sepIndex + 1).trim();
// Handle quoted strings if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); }
// Handle arrays (simple case for tags) if (value.startsWith("[") && value.endsWith("]")) { const arrayContent = value.slice(1, -1); raw[key] = arrayContent .split(",") .map((item) => item.trim().replace(/^["']|["']$/g, "")); } else if (value === "true") { raw[key] = true; } else if (value === "false") { raw[key] = false; } else { raw[key] = value; } }
// Apply field mappings to normalize to standard PostFrontmatter fields const frontmatter: Record<string, unknown> = {};
// Title mapping const titleField = mapping?.title || "title"; frontmatter.title = raw[titleField] || raw.title;
// Description mapping const descField = mapping?.description || "description"; frontmatter.description = raw[descField] || raw.description;
// Publish date mapping - check custom field first, then fallbacks const dateField = mapping?.publishDate; if (dateField && raw[dateField]) { frontmatter.publishDate = raw[dateField]; } else if (raw.publishDate) { frontmatter.publishDate = raw.publishDate; } else { // Fallback to common date field names const dateFields = ["pubDate", "date", "createdAt", "created_at"]; for (const field of dateFields) { if (raw[field]) { frontmatter.publishDate = raw[field]; break; } } }
// Cover image mapping const coverField = mapping?.coverImage || "ogImage"; frontmatter.ogImage = raw[coverField] || raw.ogImage;
// Tags mapping const tagsField = mapping?.tags || "tags"; frontmatter.tags = raw[tagsField] || raw.tags;
// Always preserve atUri (internal field) frontmatter.atUri = raw.atUri;
return { frontmatter: frontmatter as unknown as PostFrontmatter, body };}
export function getSlugFromFilename(filename: string): string { return filename .replace(/\.mdx?$/, "") .toLowerCase() .replace(/\s+/g, "-");}
export async function getContentHash(content: string): Promise<string> { const encoder = new TextEncoder(); const data = encoder.encode(content); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");}
function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean { for (const pattern of ignorePatterns) { const glob = new Glob(pattern); if (glob.match(relativePath)) { return true; } } return false;}
export async function scanContentDirectory( contentDir: string, frontmatterMapping?: FrontmatterMapping, ignorePatterns: string[] = []): Promise<BlogPost[]> { const patterns = ["**/*.md", "**/*.mdx"]; const posts: BlogPost[] = [];
for (const pattern of patterns) { const glob = new Glob(pattern);
for await (const relativePath of glob.scan({ cwd: contentDir, absolute: false, })) { // Skip files matching ignore patterns if (shouldIgnore(relativePath, ignorePatterns)) { continue; }
const filePath = path.join(contentDir, relativePath); const file = Bun.file(filePath); const rawContent = await file.text();
try { const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping); const filename = path.basename(relativePath); const slug = getSlugFromFilename(filename);
posts.push({ filePath, slug, frontmatter, content: body, rawContent, }); } catch (error) { console.error(`Error parsing ${relativePath}:`, error); } } }
// Sort by publish date (newest first) posts.sort((a, b) => { const dateA = new Date(a.frontmatter.publishDate); const dateB = new Date(b.frontmatter.publishDate); return dateB.getTime() - dateA.getTime(); });
return posts;}
export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string { // Detect which delimiter is used (---, +++, or ***) const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); const delimiter = delimiterMatch?.[1] ?? "---"; const isToml = delimiter === "+++";
// Format the atUri entry based on frontmatter type const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
// Check if atUri already exists in frontmatter (handle both formats) if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { // Replace existing atUri (match both YAML and TOML formats) return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`); }
// Insert atUri before the closing delimiter const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); if (frontmatterEndIndex === -1) { throw new Error("Could not find frontmatter end"); }
const beforeEnd = rawContent.slice(0, frontmatterEndIndex); const afterEnd = rawContent.slice(frontmatterEndIndex);
return `${beforeEnd}${atUriEntry}\n${afterEnd}`;}
export function stripMarkdownForText(markdown: string): string { return markdown .replace(/#{1,6}\s/g, "") // Remove headers .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold .replace(/\*([^*]+)\*/g, "$1") // Remove italic .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines .trim();}