Files
wiki/scripts/sync-posts.ts

505 lines
15 KiB
TypeScript

import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import dotenv from "dotenv";
// Load environment variables based on SYNC_ENV
const isProduction = process.env.SYNC_ENV === "production";
if (isProduction) {
// Production: load .env.production.local first
dotenv.config({ path: ".env.production.local" });
console.log("Syncing to PRODUCTION deployment...\n");
} else {
// Development: load .env.local
dotenv.config({ path: ".env.local" });
}
dotenv.config();
const CONTENT_DIR = path.join(process.cwd(), "content", "blog");
const PAGES_DIR = path.join(process.cwd(), "content", "pages");
const RAW_OUTPUT_DIR = path.join(process.cwd(), "public", "raw");
interface PostFrontmatter {
title: string;
description: string;
date: string;
slug: string;
published: boolean;
tags: string[];
readTime?: string;
image?: string; // Header/OG image URL
excerpt?: string; // Short excerpt for card view
featured?: boolean; // Show in featured section
featuredOrder?: number; // Order in featured section (lower = first)
authorName?: string; // Author display name
authorImage?: string; // Author avatar image URL (round)
layout?: string; // Layout type: "sidebar" for docs-style layout
}
interface ParsedPost {
slug: string;
title: string;
description: string;
content: string;
date: string;
published: boolean;
tags: string[];
readTime?: string;
image?: string; // Header/OG image URL
excerpt?: string; // Short excerpt for card view
featured?: boolean; // Show in featured section
featuredOrder?: number; // Order in featured section (lower = first)
authorName?: string; // Author display name
authorImage?: string; // Author avatar image URL (round)
layout?: string; // Layout type: "sidebar" for docs-style layout
}
// Page frontmatter (for static pages like About, Projects, Contact)
interface PageFrontmatter {
title: string;
slug: string;
published: boolean;
order?: number; // Display order in navigation
showInNav?: boolean; // Show in navigation menu (default: true)
excerpt?: string; // Short excerpt for card view
image?: string; // Thumbnail/OG image URL for featured cards
featured?: boolean; // Show in featured section
featuredOrder?: number; // Order in featured section (lower = first)
authorName?: string; // Author display name
authorImage?: string; // Author avatar image URL (round)
layout?: string; // Layout type: "sidebar" for docs-style layout
}
interface ParsedPage {
slug: string;
title: string;
content: string;
published: boolean;
order?: number;
showInNav?: boolean; // Show in navigation menu (default: true)
excerpt?: string; // Short excerpt for card view
image?: string; // Thumbnail/OG image URL for featured cards
featured?: boolean; // Show in featured section
featuredOrder?: number; // Order in featured section (lower = first)
authorName?: string; // Author display name
authorImage?: string; // Author avatar image URL (round)
layout?: string; // Layout type: "sidebar" for docs-style layout
}
// Calculate reading time based on word count
function calculateReadTime(content: string): string {
const wordsPerMinute = 200;
const wordCount = content.split(/\s+/).length;
const minutes = Math.ceil(wordCount / wordsPerMinute);
return `${minutes} min read`;
}
// Parse a single markdown file
function parseMarkdownFile(filePath: string): ParsedPost | null {
try {
const fileContent = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(fileContent);
const frontmatter = data as Partial<PostFrontmatter>;
// Validate required fields
if (!frontmatter.title || !frontmatter.date || !frontmatter.slug) {
console.warn(`Skipping ${filePath}: missing required frontmatter fields`);
return null;
}
return {
slug: frontmatter.slug,
title: frontmatter.title,
description: frontmatter.description || "",
content: content.trim(),
date: frontmatter.date,
published: frontmatter.published ?? true,
tags: frontmatter.tags || [],
readTime: frontmatter.readTime || calculateReadTime(content),
image: frontmatter.image, // Header/OG image URL
excerpt: frontmatter.excerpt, // Short excerpt for card view
featured: frontmatter.featured, // Show in featured section
featuredOrder: frontmatter.featuredOrder, // Order in featured section
authorName: frontmatter.authorName, // Author display name
authorImage: frontmatter.authorImage, // Author avatar image URL
layout: frontmatter.layout, // Layout type: "sidebar" for docs-style layout
};
} catch (error) {
console.error(`Error parsing ${filePath}:`, error);
return null;
}
}
// Get all markdown files from the content directory
function getAllMarkdownFiles(): string[] {
if (!fs.existsSync(CONTENT_DIR)) {
console.log(`Creating content directory: ${CONTENT_DIR}`);
fs.mkdirSync(CONTENT_DIR, { recursive: true });
return [];
}
const files = fs.readdirSync(CONTENT_DIR);
return files
.filter((file) => file.endsWith(".md"))
.map((file) => path.join(CONTENT_DIR, file));
}
// Parse a single page markdown file
function parsePageFile(filePath: string): ParsedPage | null {
try {
const fileContent = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(fileContent);
const frontmatter = data as Partial<PageFrontmatter>;
// Validate required fields
if (!frontmatter.title || !frontmatter.slug) {
console.warn(
`Skipping page ${filePath}: missing required frontmatter fields`,
);
return null;
}
return {
slug: frontmatter.slug,
title: frontmatter.title,
content: content.trim(),
published: frontmatter.published ?? true,
order: frontmatter.order,
showInNav: frontmatter.showInNav, // Show in navigation menu (default: true)
excerpt: frontmatter.excerpt, // Short excerpt for card view
image: frontmatter.image, // Thumbnail/OG image URL for featured cards
featured: frontmatter.featured, // Show in featured section
featuredOrder: frontmatter.featuredOrder, // Order in featured section
authorName: frontmatter.authorName, // Author display name
authorImage: frontmatter.authorImage, // Author avatar image URL
layout: frontmatter.layout, // Layout type: "sidebar" for docs-style layout
};
} catch (error) {
console.error(`Error parsing page ${filePath}:`, error);
return null;
}
}
// Get all page markdown files from the pages directory
function getAllPageFiles(): string[] {
if (!fs.existsSync(PAGES_DIR)) {
// Pages directory is optional, don't create it automatically
return [];
}
const files = fs.readdirSync(PAGES_DIR);
return files
.filter((file) => file.endsWith(".md"))
.map((file) => path.join(PAGES_DIR, file));
}
// Main sync function
async function syncPosts() {
console.log("Starting post sync...\n");
// Get Convex URL from environment
const convexUrl = process.env.VITE_CONVEX_URL || process.env.CONVEX_URL;
if (!convexUrl) {
console.error(
"Error: VITE_CONVEX_URL or CONVEX_URL environment variable is not set",
);
process.exit(1);
}
// Initialize Convex client
const client = new ConvexHttpClient(convexUrl);
// Get all markdown files
const markdownFiles = getAllMarkdownFiles();
console.log(`Found ${markdownFiles.length} markdown files\n`);
if (markdownFiles.length === 0) {
console.log("No markdown files found. Creating sample post...");
createSamplePost();
// Re-read files after creating sample
const newFiles = getAllMarkdownFiles();
markdownFiles.push(...newFiles);
}
// Parse all markdown files
const posts: ParsedPost[] = [];
for (const filePath of markdownFiles) {
const post = parseMarkdownFile(filePath);
if (post) {
posts.push(post);
console.log(`Parsed: ${post.title} (${post.slug})`);
}
}
console.log(`\nSyncing ${posts.length} posts to Convex...\n`);
// Sync posts to Convex
try {
const result = await client.mutation(api.posts.syncPostsPublic, { posts });
console.log("Sync complete!");
console.log(` Created: ${result.created}`);
console.log(` Updated: ${result.updated}`);
console.log(` Deleted: ${result.deleted}`);
} catch (error) {
console.error("Error syncing posts:", error);
process.exit(1);
}
// Sync pages if pages directory exists
const pageFiles = getAllPageFiles();
const pages: ParsedPage[] = [];
if (pageFiles.length > 0) {
console.log(`\nFound ${pageFiles.length} page files\n`);
for (const filePath of pageFiles) {
const page = parsePageFile(filePath);
if (page) {
pages.push(page);
console.log(`Parsed page: ${page.title} (${page.slug})`);
}
}
if (pages.length > 0) {
console.log(`\nSyncing ${pages.length} pages to Convex...\n`);
try {
const pageResult = await client.mutation(api.pages.syncPagesPublic, {
pages,
});
console.log("Pages sync complete!");
console.log(` Created: ${pageResult.created}`);
console.log(` Updated: ${pageResult.updated}`);
console.log(` Deleted: ${pageResult.deleted}`);
} catch (error) {
console.error("Error syncing pages:", error);
process.exit(1);
}
}
}
// Generate static raw markdown files in public/raw/
generateRawMarkdownFiles(posts, pages);
}
// Create a sample post if none exist
function createSamplePost() {
const samplePost = `---
title: "Hello World"
description: "Welcome to my blog. This is my first post."
date: "${new Date().toISOString().split("T")[0]}"
slug: "hello-world"
published: true
tags: ["introduction", "blog"]
---
# Hello World
Welcome to my blog! This is my first post.
## What to Expect
I'll be writing about:
- **Development**: Building applications with modern tools
- **AI**: Exploring artificial intelligence and machine learning
- **Productivity**: Tips and tricks for getting things done
## Code Example
Here's a simple TypeScript example:
\`\`\`typescript
function greet(name: string): string {
return \`Hello, \${name}!\`;
}
console.log(greet("World"));
\`\`\`
## Stay Tuned
More posts coming soon. Thanks for reading!
`;
const filePath = path.join(CONTENT_DIR, "hello-world.md");
fs.writeFileSync(filePath, samplePost);
console.log(`Created sample post: ${filePath}`);
}
// Generate static markdown file in public/raw/ directory
function generateRawMarkdownFile(
slug: string,
title: string,
description: string,
content: string,
date: string,
tags: string[],
readTime?: string,
type: "post" | "page" = "post"
): void {
// Ensure raw output directory exists
if (!fs.existsSync(RAW_OUTPUT_DIR)) {
fs.mkdirSync(RAW_OUTPUT_DIR, { recursive: true });
}
// Build metadata section
const metadataLines: string[] = [];
metadataLines.push(`Type: ${type}`);
metadataLines.push(`Date: ${date}`);
if (readTime) metadataLines.push(`Reading time: ${readTime}`);
if (tags && tags.length > 0) metadataLines.push(`Tags: ${tags.join(", ")}`);
// Build the full markdown document
let markdown = `# ${title}\n\n`;
// Add description if available
if (description) {
markdown += `> ${description}\n\n`;
}
// Add metadata block
markdown += `---\n${metadataLines.join("\n")}\n---\n\n`;
// Add main content
markdown += content;
// Write to file
const filePath = path.join(RAW_OUTPUT_DIR, `${slug}.md`);
fs.writeFileSync(filePath, markdown);
}
// Generate homepage index markdown file listing all posts
function generateHomepageIndex(
posts: ParsedPost[],
pages: ParsedPage[]
): void {
const publishedPosts = posts.filter((p) => p.published);
const publishedPages = pages.filter((p) => p.published);
// Sort posts by date (newest first)
const sortedPosts = [...publishedPosts].sort((a, b) => {
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
// Build markdown content
let markdown = `# Homepage\n\n`;
markdown += `This is the homepage index of all published content.\n\n`;
// Add posts section
if (sortedPosts.length > 0) {
markdown += `## Blog Posts (${sortedPosts.length})\n\n`;
for (const post of sortedPosts) {
markdown += `- **[${post.title}](/raw/${post.slug}.md)**`;
if (post.description) {
markdown += ` - ${post.description}`;
}
markdown += `\n - Date: ${post.date}`;
if (post.readTime) {
markdown += ` | Reading time: ${post.readTime}`;
}
if (post.tags && post.tags.length > 0) {
markdown += ` | Tags: ${post.tags.join(", ")}`;
}
markdown += `\n`;
}
markdown += `\n`;
}
// Add pages section
if (publishedPages.length > 0) {
markdown += `## Pages (${publishedPages.length})\n\n`;
// Sort pages by order if available, otherwise alphabetically
const sortedPages = [...publishedPages].sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
if (a.order !== undefined) return -1;
if (b.order !== undefined) return 1;
return a.title.localeCompare(b.title);
});
for (const page of sortedPages) {
markdown += `- **[${page.title}](/raw/${page.slug}.md)**`;
if (page.excerpt) {
markdown += ` - ${page.excerpt}`;
}
markdown += `\n`;
}
markdown += `\n`;
}
// Add summary
markdown += `---\n\n`;
markdown += `**Total Content:** ${sortedPosts.length} posts, ${publishedPages.length} pages\n`;
markdown += `\nAll content is available as raw markdown files at \`/raw/{slug}.md\`\n`;
// Write index.md file
const indexPath = path.join(RAW_OUTPUT_DIR, "index.md");
fs.writeFileSync(indexPath, markdown);
console.log("Generated homepage index: index.md");
}
// Generate all raw markdown files during sync
function generateRawMarkdownFiles(
posts: ParsedPost[],
pages: ParsedPage[]
): void {
console.log("\nGenerating static markdown files in public/raw/...");
// Clear existing raw files
if (fs.existsSync(RAW_OUTPUT_DIR)) {
const existingFiles = fs.readdirSync(RAW_OUTPUT_DIR);
for (const file of existingFiles) {
if (file.endsWith(".md")) {
fs.unlinkSync(path.join(RAW_OUTPUT_DIR, file));
}
}
}
// Generate files for published posts
const publishedPosts = posts.filter((p) => p.published);
for (const post of publishedPosts) {
generateRawMarkdownFile(
post.slug,
post.title,
post.description,
post.content,
post.date,
post.tags,
post.readTime,
"post"
);
}
// Generate files for published pages
const publishedPages = pages.filter((p) => p.published);
for (const page of publishedPages) {
generateRawMarkdownFile(
page.slug,
page.title,
"", // pages don't have description
page.content,
new Date().toISOString().split("T")[0], // pages don't have date
[], // pages don't have tags
undefined,
"page"
);
}
// Generate homepage index markdown file
generateHomepageIndex(posts, pages);
console.log(
`Generated ${publishedPosts.length} post files, ${publishedPages.length} page files, and 1 index file`
);
}
// Run the sync
syncPosts().catch(console.error);