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 (