diff --git a/bun_plugins/onImport-markdown-loader.tsx b/bun_plugins/onImport-markdown-loader.tsx index 57d42fc..5063a74 100644 --- a/bun_plugins/onImport-markdown-loader.tsx +++ b/bun_plugins/onImport-markdown-loader.tsx @@ -4,7 +4,7 @@ import { renderToString } from "react-dom/server"; import matter from 'gray-matter'; import { marked } from 'marked'; -import { addToDatabase } from "../src/db/index"; +import { dbConnection } from "../src/db/index"; import { AppShell } from "../src/frontend/AppShell"; import { Post } from "../src/frontend/pages/post"; @@ -38,7 +38,7 @@ const markdownLoader: BunPlugin = { readingTime: data.readingTime || `${Math.ceil(content.split(/\s+/).length / 200)} min read` }; const renderedHtml = renderToString(); - addToDatabase(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying + dbConnection.addPost(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying // JSX Approach return { diff --git a/bun_plugins/onStartup-post-importer.ts b/bun_plugins/onStartup-post-importer.ts index d1286f9..9d407b4 100644 --- a/bun_plugins/onStartup-post-importer.ts +++ b/bun_plugins/onStartup-post-importer.ts @@ -1,6 +1,6 @@ import matter from 'gray-matter'; import { marked } from 'marked'; -import { addToDatabase } from "../src/db/index"; +import { dbConnection } from "../src/db"; // When the server starts, import all the blog post metadata into the database // Executed on startup because it's included in ./bunfig.toml @@ -22,7 +22,7 @@ import { addToDatabase } from "../src/db/index"; } const bodyHtml = await marked.parse(processedContent); - addToDatabase(route, data, bodyHtml); + dbConnection.addPost(route, data, bodyHtml); } console.log('Posts have been imported into db'); diff --git a/index.tsx b/index.tsx index 7bfa6e9..08be061 100644 --- a/index.tsx +++ b/index.tsx @@ -6,7 +6,7 @@ import { Home } from "./src/frontend/pages/home"; import { NotFound } from "./src/frontend/pages/not-found"; import demo from "./temp/appshell.html"; import { Post } from "./src/frontend/pages/post"; -import { getPostWithTags, getAdjacentPosts } from "./src/db/index"; +import { dbConnection } from "./src/db"; async function blogPosts(hmr: boolean) { const glob = new Bun.Glob("**/*.md"); @@ -14,15 +14,16 @@ async function blogPosts(hmr: boolean) { for await (const file of glob.scan("./content")) { const post = await import(`./content/${file}`, { with: { type: "html" } }); const route = `/${file.replace(/\.md$/, "")}`; + dbConnection.getAllTags(); if (hmr) { // Use Bun Importer plugin for hot reloading in the browser blogPosts[`/hmr${route}`] = post.default; } else { // Use the Database for sending just the HTML or the HTML and AppShell - blogPosts[route] = (req: Request) => { + blogPosts[route] = async (req: Request) => { const path = new URL(req.url).pathname; - const post = getPostWithTags(path); + const post = dbConnection.getPost(path); if (!post) return new Response(renderToString(), { status: 404, @@ -30,7 +31,7 @@ async function blogPosts(hmr: boolean) { }); // Get adjacent posts for navigation - const { previousPost, nextPost } = getAdjacentPosts(post.path); + const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path); const data = { title: post.title, @@ -91,7 +92,7 @@ Bun.serve({ ...(await blogPosts(false)), // hot module replacement in development mode - ...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : []), + ...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : {}), // Home page "/": (req: Request) => { @@ -108,7 +109,7 @@ Bun.serve({ return new Response( renderToString( - + , ), diff --git a/src/db/db.ts b/src/db/db.ts deleted file mode 100644 index 8e940e7..0000000 --- a/src/db/db.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Database } from 'bun:sqlite'; -import path from 'path'; - -// Singleton database connection -class DatabaseConnection { - private static instance: DatabaseConnection; - private db: Database; - - private constructor() { - // Initialize the database if it doesn't exist - const dbPath = path.join(process.cwd(), 'blog.sqlite'); - this.db = new Database(dbPath); - - // Initialize database schema - this.initializeDatabase(); - } - - public static getInstance(): DatabaseConnection { - if (!DatabaseConnection.instance) { - DatabaseConnection.instance = new DatabaseConnection(); - } - return DatabaseConnection.instance; - } - - public getDatabase(): Database { - return this.db; - } - - private initializeDatabase() { - // Create the posts table if it doesn't exist - this.db.run(` - CREATE TABLE IF NOT EXISTS posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT UNIQUE NOT NULL, - title TEXT, - date TEXT, - reading_time TEXT, - summary TEXT, - content TEXT - ) - `); - - // Create the tags table if it doesn't exist - this.db.run(` - CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL - ) - `); - - // Create the post_tags junction table for many-to-many relationship - this.db.run(` - CREATE TABLE IF NOT EXISTS post_tags ( - post_id INTEGER, - tag_id INTEGER, - PRIMARY KEY (post_id, tag_id), - FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE, - FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE - ) - `); - } -} - -// Get the singleton database instance and export it -const dbConnection = DatabaseConnection.getInstance(); -export const db = dbConnection.getDatabase(); \ No newline at end of file diff --git a/src/db/index.ts b/src/db/index.ts index 258659b..5ebbdd2 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,8 +1,425 @@ -// Import the database connection -import { db } from './db'; -export { db } from './db'; +import { Database } from 'bun:sqlite'; +import path from 'path'; -// Export all functions related to database operations -export * from './posts'; -export * from './tags'; -export * from './queries'; \ No newline at end of file +/** + * Singleton database connection class for managing blog posts and tags + */ +class DatabaseConnection { + private static instance: DatabaseConnection; + private db: Database; + + /** + * Private constructor to initialize database connection and schema + */ + private constructor() { + // Initialize the database if it doesn't exist + const dbPath = path.join(process.cwd(), 'blog.sqlite'); + this.db = new Database(dbPath); + + // Initialize database schema + this.initializeDatabase(); + } + + /** + * Gets the singleton instance of the DatabaseConnection + * + * @returns The DatabaseConnection singleton instance + */ + public static getInstance(): DatabaseConnection { + if (!DatabaseConnection.instance) { + DatabaseConnection.instance = new DatabaseConnection(); + } + return DatabaseConnection.instance; + } + + /** + * Gets the database connection + * + * @returns The SQLite database instance + */ + public getDatabase(): Database { + return this.db; + } + + /** + * Initializes the database schema with posts, tags, and post_tags tables + */ + private initializeDatabase() { + // Create the posts table if it doesn't exist + this.db.run(` + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE NOT NULL, + title TEXT, + date TEXT, + reading_time TEXT, + summary TEXT, + content TEXT + ) + `); + + // Create the tags table if it doesn't exist + this.db.run(` + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + url_normalized TEXT GENERATED ALWAYS AS (lower(replace(name, ' ', '-'))) STORED + ) + `); + + // Create the post_tags junction table for many-to-many relationship + this.db.run(` + CREATE TABLE IF NOT EXISTS post_tags ( + post_id INTEGER, + tag_id INTEGER, + PRIMARY KEY (post_id, tag_id), + FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE + ) + `); + } + + /** + * Adds a post to the database with its metadata and content + * + * @param filePath - The path to the post file + * @param data - An object containing the post metadata (title, date, tags, etc.) + * @param content - The full content of the post + */ + public addPost(filePath: string, data: { [key: string]: any }, content: string) { + if (!data) return; + + // Use a transaction to ensure data consistency + const transaction = this.db.transaction(() => { + try { + // Extract common fields + const { title, date, readingTime, tags, excerpt } = data; + + // Convert all values to strings or null explicitly (except tags) + // Ensure date is stored in ISO format for consistent sorting + const values = [ + filePath ? String(filePath) : null, + title ? String(title) : null, + date ? (date instanceof Date ? date.toISOString().split('T')[0] : String(date)) : null, + readingTime ? String(readingTime) : null, + excerpt ? String(excerpt) : null, + content ? String(content) : null + ]; + + // Query to insert or replace metadata (without tags) + const insertPost = this.db.query(` + INSERT OR REPLACE INTO posts + (path, title, date, reading_time, summary, content) + VALUES (?, ?, ?, ?, ?, ?) + `); + + insertPost.run(...values); + + // Get the post ID + const getPostId = this.db.query('SELECT id FROM posts WHERE path = ?'); + const postResult = getPostId.get(filePath) as { id: number } | undefined; + if (!postResult) { + throw new Error(`Failed to retrieve post ID for ${filePath}`); + } + + // Delete existing tag associations for this post + const deleteExistingTags = this.db.query('DELETE FROM post_tags WHERE post_id = ?'); + deleteExistingTags.run(postResult.id); + + // If tags exist, process them + if (tags && Array.isArray(tags)) { + // Insert into junction table + const insertPostTag = this.db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)'); + + // Create or get tag and associate with post + const createOrGetTag = this.db.query(` + INSERT OR IGNORE INTO tags (name) VALUES (?) + RETURNING id + `); + + const getTag = this.db.query('SELECT id FROM tags WHERE name = ?'); + + for (const tag of tags) { + let tagResult = createOrGetTag.get(String(tag)) as { id: number } | undefined; + + // If no result from insert, the tag already exists, so get it + if (!tagResult) { + tagResult = getTag.get(String(tag)) as { id: number } | undefined; + } + + if (!tagResult) { + throw new Error(`Failed to retrieve tag ID for ${tag}`); + } + + insertPostTag.run(postResult.id, tagResult.id); + } + } + + } catch (error) { + console.error(`Failed to store ${filePath}:`, error); + throw error; // Re-throw to make the transaction fail + } + }); + + // Execute the transaction + try { + transaction(); + } catch (error) { + console.error(`Transaction failed for ${filePath}:`, error); + } + } + + /** + * Gets the total number of posts in the database + * + * @param tags - Optional array of tag names to filter posts by + * @returns The number of posts, optionally filtered by tags + */ + public getNumOfPosts(tags: string[] = []) { + if (tags.length === 0) { + const queryCount = this.db.query('SELECT COUNT(*) AS count FROM posts WHERE path NOT LIKE \'%.md\''); + const numPosts = queryCount.get() as { count: number }; + return numPosts.count; + } else { + // Use url_normalized field for tag filtering + const placeholders = tags.map(() => '?').join(','); + + // Get count of posts that have ALL the specified tags using url_normalized + const countQuery = this.db.query(` + SELECT COUNT(*) AS count FROM ( + SELECT p.id, COUNT(DISTINCT t.id) as tag_count + FROM posts p + JOIN post_tags pt ON p.id = pt.post_id + JOIN tags t ON pt.tag_id = t.id + WHERE t.url_normalized IN (${placeholders}) + AND p.path NOT LIKE '%.md' + GROUP BY p.id + HAVING COUNT(DISTINCT t.id) = ? + ) + `); + + const result = countQuery.get(...tags, tags.length) as { count: number }; + return result.count; + } + } + + /** + * Retrieves a paginated list of posts + * + * @param limit - Maximum number of posts to return (default: 10) + * @param offset - Number of posts to skip for pagination (default: 0) + * @param tags - Optional array of tag names to filter posts by + * @returns Array of posts with metadata and cleaned paths + */ + public getPosts(limit: number = 10, offset: number = 0, tags: string[] = []): + Array<{ + id: number, + path: string, + title: string, + date: string, + readingTime: string, + summary: string, + content: string, + tags: string[], + }> + { + let posts; + + if (tags.length === 0) { + // No tags specified, use the original query + const query = this.db.query(` + SELECT * FROM posts + WHERE path NOT LIKE '%.md' + ORDER BY date DESC + LIMIT ? OFFSET ? + `); + + posts = query.all(limit, offset) as any[]; + } else { + // Filter by tags using url_normalized field + // and ensure posts have ALL the specified tags + const placeholders = tags.map(() => '?').join(','); + const tagFilter = this.db.query(` + SELECT p.* FROM posts p + JOIN post_tags pt ON p.id = pt.post_id + JOIN tags t ON pt.tag_id = t.id + WHERE t.url_normalized IN (${placeholders}) + AND p.path NOT LIKE '%.md' + GROUP BY p.id + HAVING COUNT(DISTINCT t.id) = ? + ORDER BY p.date DESC + LIMIT ? OFFSET ? + `); + + // Use the tags directly since we're now comparing with url_normalized + posts = tagFilter.all(...tags, tags.length, limit, offset) as any[]; + } + + // Helper function to get tags for a post + const getPostTags = (postId: number): string[] => { + const query = this.db.query(` + SELECT t.name FROM tags t + JOIN post_tags pt ON t.id = pt.tag_id + WHERE pt.post_id = ? + `); + + const results = query.all(postId) as { name: string }[]; + return results.map(row => row.name); + }; + + // Add tags to each post and clean up paths + return posts.map(post => { + const basePost = post satisfies { + id: number, + path: string, + title: string, + date: string, + readingTime: string, + summary: string, + content: string + }; + + return { + ...basePost, + tags: getPostTags(post.id), + path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '') + }; + }); + } + + /** + * Retrieves a single post by its file path + * + * @param path - The file path of the post to retrieve + * @returns The post object with metadata and tags, or null if not found + */ + public getPost(path: string) { + const query = this.db.query(` + SELECT * FROM posts + WHERE path = ? + `); + + const post = query.get(path) as any; + if (!post) return null; + + // Helper function to get tags for a post + const getPostTags = (postId: number): string[] => { + const query = this.db.query(` + SELECT t.name FROM tags t + JOIN post_tags pt ON t.id = pt.tag_id + WHERE pt.post_id = ? + `); + + const results = query.all(postId) as { name: string }[]; + return results.map(row => row.name); + }; + + // Get tags for this post + post.tags = getPostTags(post.id); + + return post; + } + + /** + * Finds the chronologically adjacent posts (previous and next) for navigation + * + * @param currentPostPath - The file path of the current post + * @returns Object with previousPost and nextPost properties, each containing title and path, or null if no adjacent post exists + */ + public getAdjacentPosts(currentPostPath: string) { + const allPostsQuery = this.db.query(` + SELECT path, title, date + FROM posts + WHERE path NOT LIKE '%.md' + ORDER BY date DESC + `); + + const allPosts = allPostsQuery.all() as any[]; + + // Find the current post index + const currentIndex = allPosts.findIndex(post => post.path === currentPostPath); + + // If not found, return empty navigation + if (currentIndex === -1) { + return { previousPost: null, nextPost: null }; + } + + // Get previous post (newer post, which comes before current in reverse chronological order) + let previousPost = null; + if (currentIndex > 0) { + const prevPost = allPosts[currentIndex - 1]; + // Clean up the path to match the URL structure + const cleanPath = prevPost.path.replace(/^.*\/content\//, '/').replace(/\.md$/, ''); + previousPost = { + title: prevPost.title, + path: cleanPath + }; + } + + // Get next post (older post, which comes after current in reverse chronological order) + let nextPost = null; + if (currentIndex < allPosts.length - 1) { + const post = allPosts[currentIndex + 1]; + // Clean up the path to match the URL structure + const cleanPath = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, ''); + nextPost = { + title: post.title, + path: cleanPath + }; + } + + return { previousPost, nextPost }; + } + + /** + * Retrieves all unique tags from the database with their respective post counts. + * + * This method implements caching to avoid repeated database queries. After the first + * call, subsequent calls will return the cached results. Be aware that on db build, this + * likely won't be accurate because of the natural race condition and the server should be restarted. + * + * @returns {Array<{ name: string; post_count: number; urlNormalized: string }>} + * An array of tag objects containing: + * - name: The display name of the tag + * - post_count: Number of posts associated with this tag + * - urlNormalized: The URL-safe version of the tag name + */ + public getAllTags(): { name: string; post_count: number; urlNormalized: string }[] { + if (!this.getAllTagsCache) { + this.getAllTagsCache = this.initGetAllTags(); + } + return this.getAllTagsCache; + } + + private getAllTagsCache: { name: string; post_count: number; urlNormalized: string }[] | null = null + + private initGetAllTags() { + const query = db.query(` + SELECT t.name, t.url_normalized, COUNT(pt.post_id) as post_count + FROM tags t + JOIN post_tags pt ON t.id = pt.tag_id + JOIN posts p ON pt.post_id = p.id + GROUP BY t.id, t.name, t.url_normalized + ORDER BY post_count DESC, t.name ASC + `); + + const results = query.all() as { name: string; url_normalized: string; post_count: number }[]; + return results.map(row => ({ + name: row.name, + urlNormalized: row.url_normalized, + post_count: row.post_count + })); + } +} + +/** + * The SQLite database connection class + * Use this by default for using helper query functions + */ +export const dbConnection = DatabaseConnection.getInstance(); + +/** + * The SQLite database connection instance + * Use this exported instance for direct database operations + */ +export const db = dbConnection.getDatabase(); diff --git a/src/db/posts.ts b/src/db/posts.ts deleted file mode 100644 index 20e3d6f..0000000 --- a/src/db/posts.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { db } from './db'; -import { getOrCreateTag } from './tags'; -import { getPostTags } from './tags'; - -// Load the blog post into a SQLite database -// This allows us to index and make queries against the metadata -// to support functions like search, filtering, and sorting -// as well as return either the post or the full AppShell with the post content -export function addToDatabase(filePath: string, data: { [key: string]: any }, content: string) { - if (!data) return; - - // Use a transaction to ensure data consistency - const transaction = db.transaction(() => { - try { - // Extract common fields - const { title, date, readingTime, tags, excerpt } = data; - - // Convert all values to strings or null explicitly (except tags) - // Ensure date is stored in ISO format for consistent sorting - const values = [ - filePath ? String(filePath) : null, - title ? String(title) : null, - date ? (date instanceof Date ? date.toISOString().split('T')[0] : String(date)) : null, - readingTime ? String(readingTime) : null, - excerpt ? String(excerpt) : null, - content ? String(content) : null - ]; - - // Query to insert or replace metadata (without tags) - const insertPost = db.query(` - INSERT OR REPLACE INTO posts - (path, title, date, reading_time, summary, content) - VALUES (?, ?, ?, ?, ?, ?) - `); - - insertPost.run(...values); - - // Get the post ID - const getPostId = db.query('SELECT id FROM posts WHERE path = ?'); - const postResult = getPostId.get(filePath) as { id: number } | undefined; - if (!postResult) { - throw new Error(`Failed to retrieve post ID for ${filePath}`); - } - - // Delete existing tag associations for this post - const deleteExistingTags = db.query('DELETE FROM post_tags WHERE post_id = ?'); - deleteExistingTags.run(postResult.id); - - // If tags exist, process them - if (tags && Array.isArray(tags)) { - // Insert into junction table - const insertPostTag = db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)'); - - for (const tag of tags) { - const tagId = getOrCreateTag(String(tag)); - insertPostTag.run(postResult.id, tagId); - } - } - - } catch (error) { - console.error(`Failed to store ${filePath}:`, error); - throw error; // Re-throw to make the transaction fail - } - }); - - // Execute the transaction - try { - transaction(); - } catch (error) { - console.error(`Transaction failed for ${filePath}:`, error); - } -} - -// Returns the total number of posts - -export function getNumOfPosts(tags: string[] = []) { - if (tags.length === 0) { - const queryCount = db.query('SELECT COUNT(*) AS count FROM posts WHERE path NOT LIKE \'%.md\''); - const numPosts = queryCount.get() as { count: number }; - return numPosts.count; - } else { - // Filter by tags - convert URL-format tags (lowercase with hyphens) - // to database format (lowercase with spaces) for case-insensitive comparison - const placeholders = tags.map(() => '?').join(','); - const databaseFormatTags = tags.map(tag => tag.toLowerCase().replace(/-/g, ' ')); - - const queryCount = db.query(` - SELECT COUNT(DISTINCT p.id) AS count FROM posts p - JOIN post_tags pt ON p.id = pt.post_id - JOIN tags t ON pt.tag_id = t.id - WHERE LOWER(t.name) IN (${placeholders}) - AND p.path NOT LIKE '%.md' - `); - - // Get all posts that match the tags - const matchedPosts = queryCount.all(...databaseFormatTags) as { count: number }[]; - - if (matchedPosts.length === 0) return 0; - - // Now count how many posts have ALL the specified tags - const countQuery = db.query(` - SELECT COUNT(*) AS count FROM ( - SELECT p.id, COUNT(DISTINCT t.id) as tag_count - FROM posts p - JOIN post_tags pt ON p.id = pt.post_id - JOIN tags t ON pt.tag_id = t.id - WHERE LOWER(t.name) IN (${placeholders}) - AND p.path NOT LIKE '%.md' - GROUP BY p.id - HAVING COUNT(DISTINCT t.id) = ? - ) - `); - - const result = countQuery.get(...databaseFormatTags, tags.length) as { count: number }; - return result.count; - } -} - -// Helper function to get post data with tags -export function getPostWithTags(postPath: string) { - const getPost = db.query(` - SELECT * FROM posts WHERE path = ? - `); - - const post = getPost.get(postPath) as any; - if (!post) return null; - - // Get tags for this post - if (post.id) { - post.tags = getPostTags(post.id); - } - - return post; -} - -// Get recent posts -export function getRecentPosts(limit: number = 10, offset: number = 0, tags: string[] = []) { - let posts; - - if (tags.length === 0) { - // No tags specified, use the original query - const query = db.query(` - SELECT * FROM posts - WHERE path NOT LIKE '%.md' - ORDER BY date DESC - LIMIT ? OFFSET ? - `); - - posts = query.all(limit, offset) as any[]; - } else { - // Filter by tags - join directly with post_tags and tags - // and ensure posts have ALL the specified tags - const placeholders = tags.map(() => '?').join(','); - const tagFilter = db.query(` - SELECT p.* FROM posts p - JOIN post_tags pt ON p.id = pt.post_id - JOIN tags t ON pt.tag_id = t.id - WHERE LOWER(t.name) IN (${placeholders}) - AND p.path NOT LIKE '%.md' - GROUP BY p.id - HAVING COUNT(DISTINCT t.id) = ? - ORDER BY p.date DESC - LIMIT ? OFFSET ? - `); - - // Convert URL-format tags (lowercase with hyphens) to database format (lowercase with spaces) - // and then use that for case-insensitive comparison - const databaseFormatTags = tags.map(tag => tag.toLowerCase().replace(/-/g, ' ')); - posts = tagFilter.all(...databaseFormatTags, tags.length, limit, offset) as any[]; - } - - // Add tags to each post and clean up paths - return posts.map(post => ({ - ...post, - tags: getPostTags(post.id), - path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '') - })); -} - -// Get all posts -export function getAllPosts() { - const query = db.query(` - SELECT * FROM posts - ORDER BY date DESC - `); - - const posts = query.all() as any[]; - - // Add tags to each post - return posts.map(post => ({ - ...post, - tags: getPostTags(post.id) - })); -} - -// Helper function to get a post by its path -export function getPostByPath(path: string) { - const query = db.query(` - SELECT * FROM posts - WHERE path = ? - `); - - const post = query.get(path) as any; - if (!post) return null; - - // Get tags for this post - post.tags = getPostTags(post.id); - - return post; -} - -// Helper function to calculate read time -export function calculateReadTime(content: string): number { - const wordsPerMinute = 200; - const words = content.split(/\s+/).length; - return Math.max(1, Math.ceil(words / wordsPerMinute)); -} - -// Helper function to format date for display -export function formatDate(dateString: string): string { - // Parse ISO date string (YYYY-MM-DD) - const date = new Date(dateString + 'T00:00:00'); - - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }); -} - -// Get adjacent posts (previous and next) chronologically for a given post -export function getAdjacentPosts(currentPostPath: string) { - const allPostsQuery = db.query(` - SELECT path, title, date - FROM posts - WHERE path NOT LIKE '%.md' - ORDER BY date DESC - `); - - const allPosts = allPostsQuery.all() as any[]; - - // Find the current post index - const currentIndex = allPosts.findIndex(post => post.path === currentPostPath); - - // If not found, return empty navigation - if (currentIndex === -1) { - return { previousPost: null, nextPost: null }; - } - - // Get previous post (newer post, which comes before current in reverse chronological order) - let previousPost = null; - if (currentIndex > 0) { - const prevPost = allPosts[currentIndex - 1]; - // Clean up the path to match the URL structure - const cleanPath = prevPost.path.replace(/^.*\/content\//, '/').replace(/\.md$/, ''); - previousPost = { - title: prevPost.title, - path: cleanPath - }; - } - - // Get next post (older post, which comes after current in reverse chronological order) - let nextPost = null; - if (currentIndex < allPosts.length - 1) { - const post = allPosts[currentIndex + 1]; - // Clean up the path to match the URL structure - const cleanPath = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, ''); - nextPost = { - title: post.title, - path: cleanPath - }; - } - - return { previousPost, nextPost }; -} diff --git a/src/db/queries.ts b/src/db/queries.ts deleted file mode 100644 index e4244e7..0000000 --- a/src/db/queries.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { db } from './db'; -import { parseTags } from './tags'; - -// Interface for archive data structure -export interface ArchiveMonth { - name: string; - count: string; - posts: Array<{ - title: string; - href: string; - }>; -} - -export interface ArchiveYear { - year: string; - count: string; - months: ArchiveMonth[]; -} - -// Interface for blog post -export interface BlogPost { - id: number; - path: string; - title: string; - date: string; - author?: string; - tags: string | string[]; - summary?: string; - reading_time?: string; - content?: string; -} - -// Get posts by a single tag -export function getPostsByTag(tag: string): BlogPost[] { - const query = db.query(` - SELECT p.* FROM posts p - JOIN post_tags pt ON p.id = pt.post_id - JOIN tags t ON pt.tag_id = t.id - WHERE t.name = ? - ORDER BY p.date DESC - `); - - return query.all(tag) as BlogPost[]; -} - -// Get posts by multiple tags (AND logic - posts must contain ALL tags) -export function getPostsByTags(tags: string[]): BlogPost[] { - if (tags.length === 0) { - return getRecentPosts(); - } - - // Build query for multiple tags using JOIN and GROUP BY - const placeholders = tags.map(() => '?').join(','); - const query = db.query(` - SELECT p.* FROM posts p - JOIN post_tags pt ON p.id = pt.post_id - JOIN tags t ON pt.tag_id = t.id - WHERE t.name IN (${placeholders}) - AND p.path NOT LIKE '%.md' - GROUP BY p.id - HAVING COUNT(DISTINCT t.id) = ? - ORDER BY p.date DESC - `); - - return query.all(...tags, tags.length) as BlogPost[]; -} - -// Get recent posts with optional tag filtering -export function getRecentPostsByTags(tags: string[], limit: number = 10): BlogPost[] { - if (tags.length === 0) { - return getRecentPosts(limit); - } - - // Build query for posts with all specified tags - const placeholders = tags.map(() => '?').join(','); - const query = db.query(` - SELECT p.* FROM posts p - JOIN post_tags pt ON p.id = pt.post_id - JOIN tags t ON pt.tag_id = t.id - WHERE t.name IN (${placeholders}) - AND p.path NOT LIKE '%.md' - GROUP BY p.id - HAVING COUNT(DISTINCT t.id) = ? - ORDER BY p.date DESC - LIMIT ? - `); - - return query.all(...tags, tags.length, limit) as BlogPost[]; -} - -// Helper function to get recent posts -function getRecentPosts(limit: number = 10): BlogPost[] { - const query = db.query(` - SELECT * FROM posts - WHERE path NOT LIKE '%.md' - ORDER BY date DESC - LIMIT ? - `); - - return query.all(limit) as BlogPost[]; -} - -// Search posts by title or content -export function searchPosts(query: string, limit: number = 20): BlogPost[] { - const searchQuery = db.query(` - SELECT * FROM posts - WHERE (title LIKE ? OR content LIKE ?) - ORDER BY date DESC - LIMIT ? - `); - - const searchPattern = `%${query}%`; - return searchQuery.all(searchPattern, searchPattern, limit) as BlogPost[]; -} - -// Get posts by date range -export function getPostsByDateRange(startDate: string, endDate: string): BlogPost[] { - const dateQuery = db.query(` - SELECT * FROM posts - WHERE date BETWEEN ? AND ? - ORDER BY date DESC - `); - - return dateQuery.all(startDate, endDate) as BlogPost[]; -} - -// Function to get posts organized by year and month for the archive -export function getPostsByYearAndMonth(): ArchiveYear[] { - const query = db.query(` - SELECT * FROM posts - WHERE path NOT LIKE '%.md' - ORDER BY date DESC - `); - - const posts = query.all() as any[]; - - // Group posts by year and month - const yearMap = new Map(); - - const monthNames = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December" - ]; - - // Process each post - posts.forEach(post => { - const date = new Date(post.date); - const year = String(date.getFullYear()); - const monthName = monthNames[date.getMonth()]; - - // Create clean post href from path - const href = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, ''); - - // Initialize year if it doesn't exist - if (!yearMap.has(year)) { - yearMap.set(year, { - year, - count: "(0)", - months: [] - }); - } - - const yearData = yearMap.get(year)!; - - // Find or create month data - let monthData = yearData.months.find(m => m.name === monthName); - if (!monthData) { - monthData = { - name: monthName, - count: "(0)", - posts: [] - }; - yearData.months.push(monthData); - } - - // Add post to the month - monthData.posts.push({ - title: post.title, - href - }); - - // Update counts - monthData.count = `(${monthData.posts.length})`; - yearData.count = `(${yearData.months.reduce((total, m) => total + m.posts.length, 0)})`; - }); - - // Convert map to array and sort - return Array.from(yearMap.values()).sort((a, b) => - parseInt(b.year) - parseInt(a.year) - ); -} \ No newline at end of file diff --git a/src/db/tags.ts b/src/db/tags.ts deleted file mode 100644 index 3c20cdb..0000000 --- a/src/db/tags.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { db } from './db'; - -// Helper function to get or create a tag and return its ID -export function getOrCreateTag(tagName: string): number { - // Try to find existing tag - const findTag = db.query('SELECT id FROM tags WHERE name = ?'); - const existingTag = findTag.get(tagName) as { id: number } | undefined; - - if (existingTag) { - return existingTag.id; - } - - // Create new tag if it doesn't exist - const insertTag = db.query('INSERT INTO tags (name) VALUES (?) RETURNING id'); - const result = insertTag.get(tagName) as { id: number }; - return result.id; -} - -// Helper function to get tags for a post -export function getPostTags(postId: number): string[] { - const query = db.query(` - SELECT t.name FROM tags t - JOIN post_tags pt ON t.id = pt.tag_id - WHERE pt.post_id = ? - `); - - const results = query.all(postId) as { name: string }[]; - return results.map(row => row.name); -} - -// Helper function to parse tags -export function parseTags(tags: string | string[]): string[] { - // If tags is already an array, return it directly - if (Array.isArray(tags)) { - return tags; - } - - // If tags is a string, try to parse as JSON - try { - return JSON.parse(tags || '[]'); - } catch { - // If parsing fails, assume it's a comma-separated string - if (typeof tags === 'string' && tags.trim()) { - return tags.split(',').map(tag => tag.trim()).filter(Boolean); - } - return []; - } -} - -// Get all unique tags from database -export function getAllTags(): { name: string; post_count: number }[] { - const query = db.query(` - SELECT t.name, COUNT(pt.post_id) as post_count - FROM tags t - JOIN post_tags pt ON t.id = pt.tag_id - JOIN posts p ON pt.post_id = p.id - GROUP BY t.id, t.name - ORDER BY post_count DESC, t.name ASC - `); - - return query.all() as { name: string; post_count: number }[]; -} - -// Update tag counts after changes (no longer needed with the new structure) -// The post_count is calculated dynamically in getAllTags() -// Keeping the function for backward compatibility -export function updateTagCounts() { - console.log("Tag counts are now calculated dynamically in getAllTags()"); -} - -// Initialize tags table and populate with existing tags -// This function is deprecated as we've restructured the database -// The new database structure is initialized in index.ts -// Kept for backward compatibility -export function initializeTagsTable() { - console.log("Tags table is now initialized in index.ts with the new structure"); -} \ No newline at end of file diff --git a/src/frontend/AppShell.tsx b/src/frontend/AppShell.tsx index b5aafda..9b7dba2 100644 --- a/src/frontend/AppShell.tsx +++ b/src/frontend/AppShell.tsx @@ -1,7 +1,9 @@ import React, { ReactNode } from 'react'; import { minifyCSS, minifyJS } from './utils'; +// @ts-expect-error - Importing as text, not a module import styles from './styles.css' with { type: "text" }; +// @ts-expect-error - Importing as text, not a module import headScript from './onLoad' with { type: "text" }; import { ThemePicker } from './components/theme-picker'; @@ -9,7 +11,7 @@ import { ProfileBadge } from './components/profile-badge'; import { TagPicker } from './components/tag-picker'; import { PostArchive } from './components/post-archive'; -export function AppShell({ children }: { children: ReactNode }) { +export function AppShell({ children, searchParams }: { children: ReactNode, searchParams?: URLSearchParams }) { return ( @@ -23,7 +25,7 @@ export function AppShell({ children }: { children: ReactNode }) { diff --git a/src/frontend/clientJS/tag-picker.ts b/src/frontend/clientJS/tag-picker.ts index 4daa924..817d460 100644 --- a/src/frontend/clientJS/tag-picker.ts +++ b/src/frontend/clientJS/tag-picker.ts @@ -1,100 +1,100 @@ -// Client-side tag picker toggle functionality -class TagPickerManager { - constructor() { - this.init(); - } +// // Client-side tag picker toggle functionality +// class TagPickerManager { +// constructor() { +// this.init(); +// } - init() { - this.attachTagClickListeners(); - } +// init() { +// this.attachTagClickListeners(); +// } - // Parse current URL to get active tags - getActiveTags(): string[] { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.getAll('tag').map(tag => decodeURIComponent(tag).replace(/-/g, ' ')); - } +// // Parse current URL to get active tags +// getActiveTags(): string[] { +// const urlParams = new URLSearchParams(window.location.search); +// return urlParams.getAll('tag').map(tag => decodeURIComponent(tag).replace(/-/g, ' ')); +// } - // Generate query string from tags array - generateTagQueryString(tags: string[]): string { - if (tags.length === 0) return ''; - const params = new URLSearchParams(); - tags.forEach(tag => { - params.append('tag', tag.toLowerCase().replace(/\s+/g, '-')); - }); - return params.toString(); - } +// // Generate query string from tags array +// generateTagQueryString(tags: string[]): string { +// if (tags.length === 0) return ''; +// const params = new URLSearchParams(); +// tags.forEach(tag => { +// params.append('tag', tag.toLowerCase().replace(/\s+/g, '-')); +// }); +// return params.toString(); +// } - // Toggle a tag and update the page - toggleTag(tagName: string) { - const currentTags = this.getActiveTags(); +// // Toggle a tag and update the page +// toggleTag(tagName: string) { +// const currentTags = this.getActiveTags(); - // Toggle logic: if tag is active, remove it; otherwise add it - const newTags = currentTags.includes(tagName) - ? currentTags.filter(t => t !== tagName) - : [...currentTags, tagName]; +// // Toggle logic: if tag is active, remove it; otherwise add it +// const newTags = currentTags.includes(tagName) +// ? currentTags.filter(t => t !== tagName) +// : [...currentTags, tagName]; - // Navigate to new URL - const queryString = this.generateTagQueryString(newTags); - const newUrl = queryString ? `/?${queryString}` : '/'; - window.location.href = newUrl; - } +// // Navigate to new URL +// const queryString = this.generateTagQueryString(newTags); +// const newUrl = queryString ? `/?${queryString}` : '/'; +// window.location.href = newUrl; +// } - // Attach click listeners to tag links - attachTagClickListeners() { - const tagLinks = document.querySelectorAll('.tag-pill, .post-tag'); +// // Attach click listeners to tag links +// attachTagClickListeners() { +// const tagLinks = document.querySelectorAll('[data-taglink], .post-tag'); - tagLinks.forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - const tagName = link.textContent?.trim(); - if (tagName) { - this.toggleTag(tagName); - } - }); - }); - } +// tagLinks.forEach(link => { +// link.addEventListener('click', (e) => { +// e.preventDefault(); +// const tagName = link.textContent?.trim(); +// if (tagName) { +// this.toggleTag(tagName); +// } +// }); +// }); +// } - // Update visual state of tags based on current URL - updateTagVisualState() { - const activeTags = this.getActiveTags(); +// // Update visual state of tags based on current URL +// updateTagVisualState() { +// const activeTags = this.getActiveTags(); - // Update tag pills in sidebar - document.querySelectorAll('.tag-pill').forEach(link => { - const tagName = link.textContent?.trim(); - if (tagName && activeTags.includes(tagName)) { - link.classList.add('active'); - } else { - link.classList.remove('active'); - } - }); +// // Update tag pills in sidebar +// document.querySelectorAll('.tag-pill').forEach(link => { +// const tagName = link.textContent?.trim(); +// if (tagName && activeTags.includes(tagName)) { +// link.classList.add('active'); +// } else { +// link.classList.remove('active'); +// } +// }); - // Update post tags - document.querySelectorAll('.post-tag').forEach(link => { - const tagName = link.textContent?.trim(); - if (tagName && activeTags.includes(tagName)) { - link.classList.add('active'); - } else { - link.classList.remove('active'); - } - }); +// // Update post tags +// document.querySelectorAll('.post-tag').forEach(link => { +// const tagName = link.textContent?.trim(); +// if (tagName && activeTags.includes(tagName)) { +// link.classList.add('active'); +// } else { +// link.classList.remove('active'); +// } +// }); - // Update clear filters button visibility - const tagActions = document.querySelector('.tag-actions'); - if (tagActions) { - tagActions.style.display = activeTags.length > 0 ? 'block' : 'none'; - } - } -} +// // Update clear filters button visibility +// const tagActions = document.querySelector('.tag-actions'); +// if (tagActions) { +// tagActions.style.display = activeTags.length > 0 ? 'block' : 'none'; +// } +// } +// } -// Initialize on page load -const tagPickerManager = new TagPickerManager(); +// // Initialize on page load +// const tagPickerManager = new TagPickerManager(); -// Update visual state after page loads -document.addEventListener('DOMContentLoaded', () => { - tagPickerManager.updateTagVisualState(); -}); +// // Update visual state after page loads +// document.addEventListener('DOMContentLoaded', () => { +// tagPickerManager.updateTagVisualState(); +// }); -// Also call immediately if DOM is already loaded -if (document.readyState === 'interactive' || document.readyState === 'complete') { - tagPickerManager.updateTagVisualState(); -} +// // Also call immediately if DOM is already loaded +// if (document.readyState === 'interactive' || document.readyState === 'complete') { +// tagPickerManager.updateTagVisualState(); +// } diff --git a/src/frontend/components/post-archive.tsx b/src/frontend/components/post-archive.tsx index a68ec99..3ef0b98 100644 --- a/src/frontend/components/post-archive.tsx +++ b/src/frontend/components/post-archive.tsx @@ -1,24 +1,41 @@ import React from 'react'; -import postArchiveScript from '../clientJS/post-archive' with { type: "text" }; -import { getPostsByYearAndMonth } from '../../db'; import { minifyJS } from '../utils'; +import { db } from '../../db'; + +// @ts-expect-error - Importing as text, not a module +import postArchiveScript from '../clientJS/post-archive' with { type: "text" }; + +// Interface for archive data structure +export interface ArchiveMonth { + name: string; + count: string; + posts: Array<{ + title: string; + href: string; + }>; +} + +export interface ArchiveYear { + year: string; + count: string; + months: ArchiveMonth[]; +} // Read posts from database once and store in a closure const getArchiveData = (() => { let cachedData: ReturnType | null = null; - + return () => { if (!cachedData) { cachedData = getPostsByYearAndMonth(); } - return cachedData; + return cachedData; }; })(); - export function PostArchive() { const archiveData = getArchiveData(); - + return (

Posts

@@ -59,3 +76,69 @@ export function PostArchive() {
) } + +// Function to get posts organized by year and month for the archive +export function getPostsByYearAndMonth(): ArchiveYear[] { + const query = db.query(` + SELECT * FROM posts + WHERE path NOT LIKE '%.md' + ORDER BY date DESC + `); + + const posts = query.all() as any[]; + + // Group posts by year and month + const yearMap = new Map(); + + const monthNames = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ]; + + // Process each post + posts.forEach(post => { + const date = new Date(post.date); + const year = String(date.getFullYear()); + const monthName = monthNames[date.getMonth()]; + + // Create clean post href from path + const href = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, ''); + + // Initialize year if it doesn't exist + if (!yearMap.has(year)) { + yearMap.set(year, { + year, + count: "(0)", + months: [] + }); + } + + const yearData = yearMap.get(year)!; + + // Find or create month data + let monthData = yearData.months.find(m => m.name === monthName); + if (!monthData) { + monthData = { + name: monthName, + count: "(0)", + posts: [] + }; + yearData.months.push(monthData); + } + + // Add post to the month + monthData.posts.push({ + title: post.title, + href + }); + + // Update counts + monthData.count = `(${monthData.posts.length})`; + yearData.count = `(${yearData.months.reduce((total, m) => total + m.posts.length, 0)})`; + }); + + // Convert map to array and sort + return Array.from(yearMap.values()).sort((a, b) => + parseInt(b.year) - parseInt(a.year) + ); +} diff --git a/src/frontend/components/tag-picker.tsx b/src/frontend/components/tag-picker.tsx index 884e0ed..79fbf02 100644 --- a/src/frontend/components/tag-picker.tsx +++ b/src/frontend/components/tag-picker.tsx @@ -1,38 +1,49 @@ import React from 'react'; -import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" }; import { minifyJS } from '../utils'; +import { dbConnection } from '../../db/index.js'; -import { getAllTags } from '../../db/tags'; +// @ts-expect-error - Importing as text, not a module +import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" }; -const tags = getAllTags(); +const tags = dbConnection.getAllTags().map(tag => ({ + name: tag.name, + post_count: tag.post_count, + urlNormalized: tag.urlNormalized +})); + +export function TagPicker({ searchParams }: { searchParams?: URLSearchParams }) { + const selectedTags = typeof searchParams === 'object' ? searchParams.getAll('tag') : []; -export function TagPicker() { return (

Tags

- {tags.length > 0 ? ( - - ) : ( -

No tags available

+
    + {tags.map(tag => { + const active = selectedTags.includes(tag.urlNormalized) + + return ( + < li key={tag.name} > + + {tag.name} + + + ) + })} +
+ + {/* Show clear tags button if there are selected tags */} + {selectedTags.length > 0 && ( + )} -