mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-11 20:08:57 +00:00
Updated:
- content/blog/raw-markdown-and-copy-improvements.md
- Changed title from 'v1.7 and v1.8' to 'v1.7 to v1.10'
- Added Fork configuration section (v1.10.0) with 9-file table
- Added Scroll-to-top section (v1.9.0) with configuration options
- Updated summary to include all features from v1.7 to v1.10
- Fixed image path to /images/v17.png
- Updated sync command guidance for dev vs prod
- TASK.md
- Added new To Do items for future features
- Removed duplicate Future Enhancements section
- content/pages/docs.md
- Added Mobile menu section
- Added Copy Page dropdown table with all options
- Added Markdown tables section
- content/pages/about.md
- Updated Features list with new v1.8.0 features
- content/blog/setup-guide.md
- Added image field to pages schema
- Updated Project structure with new directories
- Added /raw/{slug}.md to API endpoints
- Added Mobile Navigation and Copy Page Dropdown sections
- Added featured image documentation with ordering details
Documentation now covers all features from v1.7.0 through v1.10.0.
410 lines
12 KiB
TypeScript
410 lines
12 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Page frontmatter (for static pages like About, Projects, Contact)
|
|
interface PageFrontmatter {
|
|
title: string;
|
|
slug: string;
|
|
published: boolean;
|
|
order?: number; // Display order in navigation
|
|
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)
|
|
}
|
|
|
|
interface ParsedPage {
|
|
slug: string;
|
|
title: string;
|
|
content: string;
|
|
published: boolean;
|
|
order?: number;
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
};
|
|
} 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,
|
|
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
|
|
};
|
|
} 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 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"
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`Generated ${publishedPosts.length} post files and ${publishedPages.length} page files`
|
|
);
|
|
}
|
|
|
|
// Run the sync
|
|
syncPosts().catch(console.error);
|