Compare commits

..

8 Commits

Author SHA1 Message Date
960f7ff4d0 Post Archive v1: a first pass at a functional post-archive 2026-01-08 18:31:57 -08:00
1b66fc8a90 FIX: Use relative url, not hardcoded localhost to load profile picture 2026-01-08 16:07:33 -08:00
f46f4667a1 Complete WIP: Architecture refactor.
Mount JSX server side templating for blog posts. Send AppShell conditionally. Maintain support for HMR via HTMLbundles using Bun's native fullstack dev server under an /hmr path. This is only mounted in development and is supported by the onImport Bun plugin. Add DB creation on startup and load pages based on those records.
2026-01-08 05:35:59 -08:00
3abd97702d WIP: Architecture Refactor, add stub files and working blog post wrapped by AppShell 2025-12-28 00:21:13 -08:00
c34f11de00 WIP: Architecture Refactor, add additional demo content 2025-12-28 00:18:04 -08:00
16a441513e WIP: Architecture Refactor, add and rename plugins for handling content 2025-12-28 00:17:35 -08:00
df449cd3e7 WIP: Architecture Refactor, remove Elysia and only use Bun.serve 2025-12-27 22:51:48 -08:00
cd88f570a0 Replace Elysia with bun.serve for hot reloading and static serving 2025-10-22 19:56:38 -07:00
40 changed files with 3540 additions and 344 deletions

View File

@@ -1,31 +0,0 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1.0.25-alpine as base
WORKDIR /usr/src/app
# install dependencies into temp directory
# this will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lockb /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
FROM base AS release
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# [optional] tests & build
ENV NODE_ENV=production
RUN bun test
# run the app
USER bun
EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "run", "./src/index.ts" ]

View File

@@ -4,100 +4,57 @@
"": {
"name": "portfolio",
"dependencies": {
"@elysiajs/html": "1.4.0",
"@elysiajs/static": "1.4.4",
"elysia": "^1.4.11",
"gray-matter": "^4.0.3",
"marked": "^16.4.1",
"react-dom": "19.2",
},
"devDependencies": {
"@types/bun": "^1.3.0",
"@types/react-dom": "^19.2.3",
},
},
},
"packages": {
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
"@elysiajs/static": ["@elysiajs/static@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-PT/uGvBHQL5I+APAGiuRjhVfySe5YmrJdPtSc2QyM6CgNp4WDCmPfhPoVYkHNaH5QGWdP62hMq0HUnClNxR3zw=="],
"@kitajs/html": ["@kitajs/html@4.2.10", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-q9n2Ig7GlAYOdL+CeWxsIIZFIKna+eCJah15eK8PBIFHW3UcWayAMs8QYGJNYgP3uMucDimIAUBH26xnE7GILw=="],
"@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.3", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.6.2" }, "bin": { "ts-html-plugin": "dist/cli.js", "xss-scan": "dist/cli.js" } }, "sha512-NlYrID5yMxfRKiO1eiiSC4MWveKe0ffoCJOZm4idNOqwimmLXr0g1NmvCcquOU2XLRrgzynxZqw6rhwR5CY5Nw=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="],
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
"js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"marked": ["marked@16.4.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg=="],
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
}
}

View File

@@ -0,0 +1,52 @@
import type { BunPlugin } from 'bun';
import React from 'react';
import { renderToString } from "react-dom/server";
import matter from 'gray-matter';
import { marked } from 'marked';
import { addToDatabase } from "../src/db/index";
import { AppShell } from "../src/frontend/AppShell";
import { Post } from "../src/frontend/pages/post";
// TODO: Add better type handling for if Markdown parsing fails
const markdownLoader: BunPlugin = {
name: 'markdown-loader',
setup(build) {
// Plugin implementation
build.onLoad({filter: /\.md$/}, async args => {
console.log("Loading markdown file:", args.path);
const {data, content } = matter(await Bun.file(args.path).text());
// Remove the title from content if it matches the frontmatter title to avoid duplicate H1s
let processedContent = content;
if (data.title) {
const titleHeadingRegex = new RegExp(`^#\\s+${data.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
processedContent = content.replace(titleHeadingRegex, '').trim();
}
const bodyHtml = await marked.parse(processedContent);
// AppShell is required here for rendering. If used at route level
// Bun will only see an htmlBundle and fail to load anything
// Validate required fields
if (!data.title || !data.date) {
throw new Error(`Markdown files must include title and date in frontmatter: ${args.path}`);
}
const meta = {
title: data.title,
date: new Date(data.date),
readingTime: data.readingTime || `${Math.ceil(content.split(/\s+/).length / 200)} min read`
};
const renderedHtml = renderToString(<AppShell><Post meta={meta} children={bodyHtml} /></AppShell>);
addToDatabase(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying
// JSX Approach
return {
contents: renderedHtml,
loader: 'html',
};
});
},
};
export default markdownLoader;

View File

@@ -0,0 +1,29 @@
import matter from 'gray-matter';
import { marked } from 'marked';
import { addToDatabase } from "../src/db/index";
// When the server starts, import all the blog post metadata into the database
// Executed on startup because it's included in <root> ./bunfig.toml
// Only import if not running in production
(async () => {
if (process.env.NODE_ENV === 'production') return;
const glob = new Bun.Glob("**/*.md");
for await (const file of glob.scan("./content")) {
const {data, content } = matter(await Bun.file(`./content/${file}`).text());
const route = `/${file.replace(/\.md$/, "")}`;
// Remove the title from content if it matches the frontmatter title to avoid duplicate H1s
let processedContent = content;
if (data.title) {
const titleHeadingRegex = new RegExp(`^#\\s+${data.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
processedContent = content.replace(titleHeadingRegex, '').trim();
}
const bodyHtml = await marked.parse(processedContent);
addToDatabase(route, data, bodyHtml);
}
console.log('Posts have been imported into db');
})();

4
bunfig.toml Normal file
View File

@@ -0,0 +1,4 @@
preload = ["./bun_plugins/onStartup-post-importer.ts"]
[serve.static]
plugins = ["./bun_plugins/onImport-markdown-loader.tsx"]

34
content/.template.md Normal file
View File

@@ -0,0 +1,34 @@
---
title: Your Post Title
date: 2025-10-21
tags: [Web Development, TypeScript]
excerpt: A brief summary of your post (2-3 sentences). This will appear in post listings and search results.
draft: false
---
# Your Post Title
Your content here. You can use standard markdown syntax:
## Section Heading
Write your paragraphs with proper spacing.
### Subsection
- Bullet points
- Another point
- And another
**Bold text** and *italic text* are supported.
```typescript
// Code blocks work too
const example = "Hello World";
console.log(example);
```
> Blockquotes for important callouts or quotes.
This is just a template - delete this file or ignore it when writing your actual posts.

View File

@@ -0,0 +1,93 @@
---
title: 2024 Year in Review
date: 2024-12-28
tags: [Career, Productivity]
excerpt: Reflecting on 2024 - the projects I built, lessons I learned, and goals for 2025. A year of growth, challenges, and accomplishments.
draft: false
---
# 2024 Year in Review!
As 2024 comes to a close, I wanted to take some time to reflect on the year. It's been a year of significant growth, both professionally and personally.
## Professional Highlights
### Projects
This year, I worked on several exciting projects:
**E-commerce Platform**: Led the frontend architecture for a new e-commerce platform serving 100k+ daily users. We achieved a 40% improvement in page load times through careful optimization.
**Design System**: Built a comprehensive design system from scratch, complete with documentation and Storybook. This is now used across 5 different products.
**Open Source**: Made my first significant open source contributions! Contributed to several Bun-related projects and created a few small utilities that others found useful.
### Skills Developed
- **Performance Optimization**: Learned a ton about web performance, Core Web Vitals, and how to actually make sites fast
- **System Design**: Started thinking more about architecture and scalability
- **TypeScript**: Became much more proficient with advanced TypeScript patterns
- **Testing**: Finally built a solid testing practice
## Personal Growth
### Writing
Started this blog! Writing has helped me:
- Clarify my thinking
- Learn more deeply
- Connect with others in the community
### Work-Life Balance
Made a conscious effort to improve work-life balance:
- Implemented a hard stop at 6 PM
- Started exercising regularly (3x per week)
- Picked up reading again (finished 12 books!)
## Challenges
Not everything was smooth sailing:
**Burnout**: Hit a rough patch in Q2 where I was working too much. Learned the importance of rest and boundaries.
**Imposter Syndrome**: Still struggles with this occasionally, but getting better at recognizing it and pushing through.
**Saying No**: Learned that saying no to some opportunities is necessary to say yes to the right ones.
## Lessons Learned
1. **Quality > Quantity**: Better to do fewer things well than many things poorly
2. **Ask for Help**: Nobody knows everything, and asking for help is a strength
3. **Document Everything**: Future you will thank present you
4. **Take Breaks**: Rest isn't laziness - it's necessary for sustained performance
5. **Community Matters**: Connecting with other developers has been invaluable
## Goals for 2025
Looking ahead to 2025:
### Professional
- Contribute to a major open source project
- Speak at a local meetup or conference
- Learn Rust (for real this time)
- Build and launch a side project
### Personal
- Write 24 blog posts (2 per month)
- Read 20 books
- Maintain exercise routine
- Learn to cook 10 new recipes
### Learning
- Deep dive into system design
- Master web performance
- Learn more about databases
- Get better at writing
## Thank You
Thanks to everyone who supported me this year - colleagues, friends, family, and the online tech community. Looking forward to 2025!
What were your highlights from 2024? What are you looking forward to in 2025?

View File

@@ -0,0 +1,108 @@
---
title: 5 TypeScript Tips I Wish I Knew Earlier
date: 2025-09-18
readingTime: 10 minutes
tags: [TypeScript, JavaScript, Productivity]
excerpt: Five practical TypeScript tips that will make your code more type-safe and your development experience smoother. From utility types to const assertions.
draft: false
---
# 5 TypeScript Tips I Wish I Knew Earlier
TypeScript is an amazing tool, but it takes time to learn all its features. Here are five tips that significantly improved my TypeScript development experience.
## 1. Use `satisfies` for Type Checking
The `satisfies` keyword (added in TS 4.9) lets you validate that a value matches a type without widening its type:
```typescript
type RGB = { r: number; g: number; b: number };
// Before: Type is widened to RGB
const color: RGB = { r: 255, g: 0, b: 0 };
// After: Type is preserved as literal values
const color = { r: 255, g: 0, b: 0 } satisfies RGB;
```
This is especially useful when you want both type safety and precise types.
## 2. Const Assertions
Adding `as const` to an object or array makes all properties readonly and infers literal types:
```typescript
// Regular array: type is string[]
const fruits = ['apple', 'banana'];
// With const assertion: type is readonly ['apple', 'banana']
const fruits = ['apple', 'banana'] as const;
```
This is perfect for configuration objects and enum-like values.
## 3. Utility Type: `Awaited<T>`
Need to get the resolved type of a Promise? Use `Awaited`:
```typescript
async function fetchUser() {
return { id: 1, name: 'Alice' };
}
// Gets the return type without the Promise wrapper
type User = Awaited<ReturnType<typeof fetchUser>>;
// User is { id: number; name: string }
```
## 4. Template Literal Types
Create types based on string patterns:
```typescript
type EventName = 'click' | 'focus' | 'blur';
type EventHandler = `on${Capitalize<EventName>}`;
// Result: 'onClick' | 'onFocus' | 'onBlur'
```
This is incredibly powerful for creating type-safe APIs.
## 5. Discriminated Unions
Use a common property to narrow union types:
```typescript
type Success = { status: 'success'; data: string };
type Error = { status: 'error'; error: string };
type Result = Success | Error;
function handle(result: Result) {
if (result.status === 'success') {
// TypeScript knows this is Success
console.log(result.data);
} else {
// TypeScript knows this is Error
console.log(result.error);
}
}
```
This pattern eliminates entire classes of runtime errors.
## Bonus: Non-Null Assertion Operator
While I generally avoid the `!` operator, it's useful when you know better than TypeScript:
```typescript
// When you're certain the element exists
const button = document.getElementById('submit')!;
button.click();
```
Use sparingly and only when you're absolutely sure.
## Wrapping Up
These tips have made my TypeScript code more robust and easier to maintain. The key is to leverage TypeScript's type system to catch errors at compile time rather than runtime.
What are your favorite TypeScript features? Let me know!

View File

@@ -0,0 +1,82 @@
---
title: Modern Development Workflow
date: 2025-10-21
tags: [Web Development, TypeScript, Productivity]
excerpt: Explore modern development workflows that boost productivity and code quality. Learn about hot module replacement, automated testing, and modern tooling.
draft: false
---
# Modern Development Workflow
In today's fast-paced development environment, having an efficient workflow is crucial for maintaining productivity and code quality.
## Hot Module Replacement
Hot Module Replacement (HMR) revolutionizes the development experience:
```typescript
// Vite configuration with HMR
export default {
server: {
hmr: {
overlay: true
}
},
plugins: [
reactRefresh()
]
}
// During development, changes appear instantly
const Component = () => {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
};
```
## Tool Chain Essentials
A modern tool chain should include:
- **Fast builds**: Vite or Bun for lightning-fast compilation
- **Type safety**: TypeScript for catching errors at compile time
- **Code formatting**: Prettier for consistent code style
- **Linting**: ESLint for maintaining code quality
## Automated Testing
Integrate testing into your workflow:
```typescript
// Component testing with React Testing Library
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
test('increment works correctly', () => {
render(<Counter />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
```
## Best Practices
- **Git hooks**: Pre-commit hooks for code quality checks
- **CI/CD**: Automated testing and deployment
- **Documentation**: Keep README files up to date
- **Code reviews**: Peer reviews for knowledge sharing
## Conclusion
A modern development workflow combines the right tools with good practices. Invest time in setting up your environment properly, and it will pay dividends in productivity throughout your project.
---
*This post demonstrates the power of modern development tools and workflows.*

View File

@@ -0,0 +1,101 @@
---
title: Building a Modern Blog with Bun and TypeScript
date: 2025-10-20
tags: [Web Development, TypeScript, JavaScript]
excerpt: A deep dive into building a performant, modern blog using Bun's runtime, TypeScript, and server-side rendering. Learn about the architecture decisions and tradeoffs.
draft: false
---
# Building a Modern Blog with Bun and TypeScript
## Why Bun?
Bun is an all-in-one JavaScript runtime that's significantly faster than Node.js for many operations. It includes:
- A blazing-fast JavaScript/TypeScript runtime
- Built-in bundler
- Native TypeScript support
- Package manager
- Test runner
For this blog, the combination of native TypeScript support and the built-in bundler made Bun an obvious choice.
## Architecture Overview
The blog uses a hybrid approach:
### Server-Side Rendering
All HTML is generated on the server using JSX as a templating language. This means:
- **Fast initial page loads** - No waiting for JavaScript to download and execute
- **SEO friendly** - Search engines see fully rendered HTML
- **Works without JavaScript** - Core functionality doesn't depend on client-side JS
### AppShell Pattern
The blog implements an "AppShell" pattern where:
1. First visit loads the full page with AppShell (sidebar, header, etc.)
2. Navigation replaces only the `<main>` content
3. Subsequent requests send a custom header to indicate AppShell is already loaded
4. Server returns just the content, not the full shell
This gives us SPA-like navigation speed with SSR benefits.
## Markdown Processing
Blog posts are written in Markdown and processed at build time:
```typescript
// Simplified example
import { marked } from 'marked';
const html = marked.parse(markdownContent);
```
The plugin:
- Scans the content directory
- Parses frontmatter (title, date, tags, etc.)
- Converts markdown to HTML
- Stores everything in SQLite for fast queries
## Performance Results
The result? Page loads under 128KB including:
- All HTML
- All CSS (inlined)
- Minimal JavaScript for interactivity
First contentful paint happens in under 100ms on a fast connection.
## Developer Experience
With Bun's `--watch` flag, the entire development workflow is seamless:
```bash
bun run --watch ./index.tsx
```
This watches for changes and hot-reloads the server. Combined with the markdown plugin, changing a blog post immediately reflects in the browser.
## Lessons Learned
### What Worked Well
- **JSX for templating** - Familiar, type-safe, and no new syntax to learn
- **SQLite for search** - Fast, embedded, and perfect for a small blog
- **Bun's speed** - Development is incredibly fast
### What Could Be Better
- **Hot reloading** - Would love client-side HMR for styles
- **Build step** - Currently all processing happens at runtime
- **TypeScript types** - Virtual modules need proper type definitions
## Conclusion
Building with Bun has been a great experience. The performance is excellent, and the developer experience is top-notch. If you're building a new project and want to try something modern, I highly recommend giving Bun a shot.
The code for this blog is open source - check it out on GitHub!

View File

@@ -0,0 +1,51 @@
---
title: Welcome to My Blog
date: 2025-10-15
tags: [Career, Web Development, Another Tag, Blogging]
excerpt: Starting a new blog to share my thoughts on web development, programming, and building great software. Here's why I decided to start writing.
draft: false
---
# Welcome to My Blog
I'm excited to finally launch my personal blog! After years of thinking about it, I've decided it's time to start sharing my experiences, learnings, and thoughts about web development and software engineering.
## Why Start a Blog?
There are a few reasons I decided to finally take the plunge:
**Learning in Public**: Writing about what I learn helps solidify my understanding. Teaching is one of the best ways to learn, and writing blog posts forces me to really understand a topic deeply.
**Building a Knowledge Base**: I often solve problems and then forget the solution months later. This blog will serve as my personal reference guide for future me.
**Contributing to the Community**: The developer community has given me so much through blog posts, tutorials, and open source. This is my way of giving back.
## What to Expect
I plan to write about:
- Web development best practices
- TypeScript and JavaScript tips
- Architecture and design patterns
- Career advice and lessons learned
- Tools and productivity
## The Tech Stack
This blog itself is built with some fun technologies:
- **Bun** - The all-in-one JavaScript runtime
- **TypeScript** - For type safety
- **Elysia** - Fast web framework for Bun
- **SQLite** - For blog post search and metadata
I'll be writing more about the technical implementation in future posts.
## Let's Connect
If you enjoy the content, feel free to reach out! You can find me on Twitter, GitHub, and LinkedIn (links in the sidebar).
Thanks for reading, and I hope you find something useful here!
Test change at Wed Oct 22 06:20:32 PDT 2025
<!-- test change -->

158
index.tsx
View File

@@ -1,28 +1,136 @@
import { Elysia } from "elysia";
import { html } from "@elysiajs/html";
import { staticPlugin } from "@elysiajs/static";
import React from "react";
// Database connection is now handled by the centralized db module
import { renderToString } from "react-dom/server";
import { AppShell } from "./src/frontend/AppShell";
import { app } from "./src/backend";
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 } from "./src/db/index";
const index = new Elysia()
.use(html())
.onRequest(({ request }) => {
console.log(`Request ${request.method} ${request.url}`);
})
.onAfterHandle(({ request, responseValue }) => {
if (request.headers.get("shell-loaded") === "true") {
return responseValue; // Return the <main> element if the AppShell has already been loaded
async function blogPosts(hmr: boolean) {
const glob = new Bun.Glob("**/*.md");
const blogPosts: Record<string, any> = {};
for await (const file of glob.scan("./content")) {
const post = await import(`./content/${file}`, { with: { type: "html" } });
const route = `/${file.replace(/\.md$/, "")}`;
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) => {
const path = new URL(req.url).pathname;
const post = getPostWithTags(path);
if (!post)
return new Response(renderToString(<NotFound />), {
status: 404,
headers: { "Content-Type": "text/html" },
});
const data = {
title: post.title,
summary: post.summary,
date: new Date(post.date),
readingTime: post.reading_time,
tags: post.tags || [],
};
// AppShell is already loaded, just send the <main> content
if (req.headers.get("shell-loaded") === "true") {
return new Response(
renderToString(<Post meta={data} children={post.content} />),
{
headers: {
"Content-Type": "text/html",
},
},
);
}
// AppShell is not loaded, send the <AppShell> with the <main> content inside
return new Response(
renderToString(
<AppShell>
<Post meta={data} children={post.content} />
</AppShell>,
),
{
headers: {
"Content-Type": "text/html",
},
},
);
};
}
return AppShell(responseValue); // Return the <main> element wrapped by the AppShell
})
.use(staticPlugin({
assets: './src/public',
prefix: '/public'
}))
.use(app)
.listen(3000);
console.log(
`🦊 Elysia is running at ${index.server?.hostname}:${index.server?.port}`
);
}
Object.keys(blogPosts).map((route) => {
console.info(route);
});
return blogPosts;
}
Bun.serve({
development: {
hmr: true,
console: true,
},
routes: {
// standard mounting of blog posts
...(await blogPosts(false)),
// hot module replacement in development mode
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : []),
// Home page
"/": (req: Request) => {
// Extract URL parameters from the request to pass to the component
const url = new URL(req.url);
const searchParams = Object.fromEntries(url.searchParams.entries());
if (req.headers.get("shell-loaded") === "true") {
return new Response(renderToString(<Home searchParams={searchParams} />), {
headers: {
"Content-Type": "text/html",
},
});
}
return new Response(
renderToString(
<AppShell>
<Home searchParams={searchParams} />
</AppShell>,
),
{
headers: {
"Content-Type": "text/html",
},
},
);
},
"/target": demo,
"/profile-picture.webp": () => {
return new Response(Bun.file("./src/public/profile-picture.webp"), {
headers: {
"Content-Type": "image/webp",
},
});
},
"/*": (req) => {
if(req.headers.get("shell-loaded") === "true") {
return new Response(renderToString(<NotFound />), {
status: 404,
headers: { "Content-Type": "text/html" },
});
}
return new Response(renderToString(<AppShell><NotFound /></AppShell>), {
status: 404,
headers: { "Content-Type": "text/html" },
});
}
},
});

4
markdown.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.md" {
const content: import("html").HtmlString;
export default content;
}

View File

@@ -6,12 +6,13 @@
"dev": "bun run --watch ./index.tsx"
},
"dependencies": {
"@elysiajs/html": "1.4.0",
"@elysiajs/static": "1.4.4",
"elysia": "^1.4.11"
"gray-matter": "^4.0.3",
"marked": "^16.4.1",
"react-dom": "19.2"
},
"devDependencies": {
"@types/bun": "^1.3.0"
"@types/bun": "^1.3.0",
"@types/react-dom": "^19.2.3"
},
"module": "src/index.js"
}

View File

@@ -1,22 +0,0 @@
import { Html } from "@elysiajs/html";
import { Elysia, NotFoundError } from "elysia";
import { Home } from "../frontend/home";
import { Blog } from "../frontend/blog";
import { NotFound } from "../frontend/not-found";
export const app = new Elysia()
.onError(({ error }) => {
if(error instanceof NotFoundError) {
return <NotFound />;
}
return error;
})
.get("/", () => { return <Home /> })
.get("/:path", ({ path }) => {
if(path === "/blog") {
return <Blog />;
}
throw new NotFoundError();
})

66
src/db/db.ts Normal file
View File

@@ -0,0 +1,66 @@
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();

8
src/db/index.ts Normal file
View File

@@ -0,0 +1,8 @@
// Import the database connection
import { db } from './db';
export { db } from './db';
// Export all functions related to database operations
export * from './posts';
export * from './tags';
export * from './queries';

165
src/db/posts.ts Normal file
View File

@@ -0,0 +1,165 @@
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)
const values = [
filePath ? String(filePath) : null,
title ? String(title) : null,
date ? 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() {
const queryCount = db.query('SELECT COUNT(*) AS count FROM posts');
const numPosts = queryCount.get() as { count: number };
return numPosts.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) {
const query = db.query(`
SELECT * FROM posts
WHERE path NOT LIKE '%.md'
ORDER BY date DESC
LIMIT ? OFFSET ?
`);
const posts = query.all(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 {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}

191
src/db/queries.ts Normal file
View File

@@ -0,0 +1,191 @@
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<string, ArchiveYear>();
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)
);
}

77
src/db/tags.ts Normal file
View File

@@ -0,0 +1,77 @@
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");
}

View File

@@ -1,76 +1,33 @@
import { Html } from "@elysiajs/html";
import React, { ReactNode } from 'react';
import { minifyCSS, minifyJS } from './utils';
// Helper: Minify CSS using simple but effective regex
async function minifyCSS(css: string): Promise<string> {
return css
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove comments
.replace(/\s+/g, ' ') // Collapse whitespace
.replace(/\s*([{}:;,])\s*/g, '$1') // Remove space around delimiters
.trim();
}
import styles from './styles.css' with { type: "text" };
import headScript from './onLoad' with { type: "text" };
// Helper: Minify JS/TS using Bun.Transpiler
async function minifyJS(code: string): Promise<string> {
const transpiler = new Bun.Transpiler({
loader: 'ts',
minifyWhitespace: true,
});
return transpiler.transformSync(code);
}
import { ThemePicker } from './components/theme-picker';
import { ProfileBadge } from './components/profile-badge';
import { TagPicker } from './components/tag-picker';
import { PostArchive } from './components/post-archive';
// Read and minify files at module load time (runs once)
console.log('[AppShell] Loading and minifying assets...');
const rawStyles = await Bun.file("./src/public/styles.css").text();
const rawHeadScript = await Bun.file("./src/public/head.ts").text();
const rawOnLoadScript = await Bun.file("./src/public/onLoad.ts").text();
// Minify all assets
const styles = await minifyCSS(rawStyles);
const headScript = await minifyJS(rawHeadScript);
const onLoadScript = await minifyJS(rawOnLoadScript);
console.log('[AppShell] Assets minified and cached in memory');
console.log(` CSS: ${rawStyles.length}${styles.length} bytes (-${Math.round((1 - styles.length / rawStyles.length) * 100)}%)`);
console.log(` head.ts: ${rawHeadScript.length}${headScript.length} bytes (-${Math.round((1 - headScript.length / rawHeadScript.length) * 100)}%)`);
console.log(` onLoad.ts: ${rawOnLoadScript.length}${onLoadScript.length} bytes (-${Math.round((1 - onLoadScript.length / rawOnLoadScript.length) * 100)}%)`);
export function AppShell(responseValue: any) {
return (
<html>
export function AppShell({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Caleb's Blog</title>
<style>
{styles}
</style>
<script>
{headScript}
</script>
<script defer>
{onLoadScript}
</script>
<style dangerouslySetInnerHTML={{ __html: minifyCSS(styles) }} />
</head>
<body>
{responseValue}
{children}
<aside>
<h3>About Me</h3>
<p>I'm a software engineer</p>
<ul>
<li>Twitter</li>
<li>GitHub</li>
<li>LinkedIn</li>
</ul>
<h3>Categories</h3>
<ul>
<li>Web Development</li>
<li>UI/UX Design</li>
<li>Productivity</li>
<li>Career</li>
</ul>
<ThemePicker />
<ProfileBadge />
<TagPicker />
<PostArchive />
</aside>
</body>
<script dangerouslySetInnerHTML={{ __html: minifyJS(headScript) }} />
</html>
)
}

View File

@@ -1,10 +0,0 @@
import { Html } from "@elysiajs/html";
export function Blog() {
return (
<main>
<h1>Blog</h1>
<a href="/">Home</a>
</main>
)
}

View File

@@ -0,0 +1,247 @@
// Post Archive toggle functionality
function setupArchiveToggles() {
document.querySelectorAll('.archive-year, .archive-month').forEach(toggle => {
toggle.addEventListener('click', function(e) {
e.stopPropagation();
const toggleIcon = this.querySelector('.archive-toggle');
const content = this.nextElementSibling;
// Toggle expanded state
if (toggleIcon) toggleIcon.classList.toggle('expanded');
if (content) content.classList.toggle('expanded');
// Update aria-expanded
const isExpanded = content.classList.contains('expanded');
this.setAttribute('aria-expanded', String(isExpanded));
// Update tabIndex for nested elements
updateTabIndex(content, isExpanded);
});
});
// Remove active class from all post links
document.querySelectorAll('.archive-post a').forEach(link => {
link.classList.remove('active');
});
// Add click handlers for post links to toggle active class
document.querySelectorAll('.archive-post a').forEach(link => {
link.addEventListener('click', function() {
// Remove active class from all post links
document.querySelectorAll('.archive-post a').forEach(postLink => {
postLink.classList.remove('active');
});
// Add active class to clicked link
this.classList.add('active');
});
});
// Initialize the archive state with a slight delay to ensure DOM is ready
setTimeout(() => {
initializeArchiveState();
// Update the active post indicator after initializing the archive state
updateActivePost();
}, 100);
}
function updateTabIndex(content: Element, isExpanded: boolean) {
if (!content) return;
const tabIndex = isExpanded ? '0' : '-1';
// Update focusable elements based on visibility
content.querySelectorAll('a, button, [tabindex="0"], [tabindex="-1"]').forEach(el => {
el.setAttribute('tabindex', tabIndex);
});
}
function initializeArchiveState() {
// Get the current path
const currentPath = window.location.pathname;
// Check if we're currently viewing a blog post (pattern: /YYYY/MM/slug)
const pathParts = currentPath.split('/').filter(part => part);
const isPostView = pathParts.length >= 3 &&
/^\d{4}$/.test(pathParts[0]) &&
/^\d{1,2}$/.test(pathParts[1]);
// Get all year and month elements
const years = Array.from(document.querySelectorAll('.archive-year')) as HTMLElement[];
const months = Array.from(document.querySelectorAll('.archive-month')) as HTMLElement[];
// Find the target year and month to keep expanded
let targetYear: HTMLElement | null = null;
let targetMonth: HTMLElement | null = null;
if (isPostView) {
// Try to find the post link in multiple ways
let postLink: HTMLAnchorElement | null = null;
// Try to find the post link with exact path or with/without trailing slash
postLink = document.querySelector(`a[href="${currentPath}"]`) as HTMLAnchorElement;
if (!postLink) {
const altPath = currentPath.endsWith('/') ?
currentPath.slice(0, -1) : currentPath + '/';
postLink = document.querySelector(`a[href="${altPath}"]`) as HTMLAnchorElement;
}
// If still not found, try a partial match using the slug
if (!postLink && pathParts.length >= 3) {
const slug = pathParts[pathParts.length - 1];
document.querySelectorAll('.archive-post a').forEach((link) => {
const href = link.getAttribute('href');
if (href && href.endsWith(slug)) {
postLink = link as HTMLAnchorElement;
return;
}
});
}
// If we found the post link, get its parent month and year
if (postLink) {
const postLi = postLink.parentElement; // li.archive-post
const postsUl = postLi?.parentElement; // ul.archive-posts
const contentDiv = postsUl?.parentElement; // div.archive-content
const monthLi = contentDiv?.parentElement; // li containing the month
// Get the month toggle element
const monthElement = monthLi?.querySelector('.archive-month') as HTMLElement;
if (monthElement) {
targetMonth = monthElement;
// Get the parent year
const yearLi = monthLi?.parentElement; // ul containing the month
const yearContentDiv = yearLi?.parentElement; // div.archive-content
const yearMainLi = yearContentDiv?.parentElement; // li containing the year
const yearElement = yearMainLi?.querySelector('.archive-year') as HTMLElement;
targetYear = yearElement;
}
}
}
// If we didn't find a specific target year, fall back to the most recent year
if (!targetYear && years.length > 0) {
targetYear = years[0];
}
// If we didn't find a specific target month, fall back to the most recent month in that year
if (!targetMonth && targetYear) {
const yearContent = targetYear.nextElementSibling as HTMLElement;
if (yearContent) {
const yearMonths = yearContent.querySelectorAll('.archive-month') as NodeListOf<HTMLElement>;
if (yearMonths.length > 0) {
targetMonth = yearMonths[0];
}
}
}
// Helper function to collapse an element
function collapse(element: HTMLElement) {
const toggleIcon = element.querySelector('.archive-toggle');
const content = element.nextElementSibling;
if (toggleIcon) toggleIcon.classList.remove('expanded');
if (content) {
content.classList.remove('expanded');
updateTabIndex(content, false);
}
element.setAttribute('aria-expanded', 'false');
}
// Helper function to expand an element
function expand(element: HTMLElement) {
const toggleIcon = element.querySelector('.archive-toggle');
const content = element.nextElementSibling;
if (toggleIcon) toggleIcon.classList.add('expanded');
if (content) {
content.classList.add('expanded');
updateTabIndex(content, true);
}
element.setAttribute('aria-expanded', 'true');
}
// Collapse all years except our target
years.forEach(year => {
if (year !== targetYear) {
collapse(year);
}
});
// Collapse all months except our target
months.forEach(month => {
if (month !== targetMonth) {
collapse(month);
}
});
// Expand the target year if we have one
if (targetYear) {
expand(targetYear);
}
// Expand the target month if we have one
if (targetMonth) {
expand(targetMonth);
}
}
// Function to update the active post link based on the current URL
function updateActivePost() {
// Remove active class from all post links
document.querySelectorAll('.archive-post a').forEach(link => {
link.classList.remove('active');
});
// Get the current path
const currentPath = window.location.pathname;
// Find the post link matching the current path
let postLink = document.querySelector(`a[href="${currentPath}"]`) as HTMLAnchorElement;
// Try with/without trailing slash if not found
if (!postLink) {
const altPath = currentPath.endsWith('/') ?
currentPath.slice(0, -1) : currentPath + '/';
postLink = document.querySelector(`a[href="${altPath}"]`) as HTMLAnchorElement;
}
// If found, add active class
if (postLink) {
postLink.classList.add('active');
} else {
// Try partial match using the slug
const pathParts = currentPath.split('/').filter(part => part);
if (pathParts.length >= 3) {
const slug = pathParts[pathParts.length - 1];
document.querySelectorAll('.archive-post a').forEach((link) => {
const href = link.getAttribute('href');
if (href && href.endsWith(slug)) {
link.classList.add('active');
}
});
}
}
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setupArchiveToggles();
});
} else {
setupArchiveToggles();
}
// Listen for browser navigation events (back/forward buttons)
window.addEventListener('popstate', () => {
// Re-initialize the archive state for the new URL
initializeArchiveState();
// Update the active post indicator after initializing the archive state
setTimeout(updateActivePost, 10); // Small delay to ensure DOM is updated
});

View File

@@ -0,0 +1,102 @@
// Client-side tag picker toggle functionality
class TagPickerManager {
constructor() {
this.init();
}
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, ' '));
}
// 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 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;
}
// Attach click listeners to tag links
attachTagClickListeners() {
const tagLinks = document.querySelectorAll('.tag-pill, .post-tag');
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 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 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();
// 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();
}
export { TagPickerManager };

View File

@@ -0,0 +1,165 @@
// Theme switching functionality
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
const THEME_NAMES = {
latte: 'Catppuccin Latte',
frappe: 'Catppuccin Frappe',
macchiato: 'Catppuccin Macchiato',
mocha: 'Catppuccin Mocha',
'solarized-light': 'Solarized Light',
'solarized-dark': 'Solarized Dark',
'gruvbox-light': 'Gruvbox Light',
'gruvbox-dark': 'Gruvbox Dark',
nord: 'Nord',
dracula: 'Dracula',
'one-dark': 'One Dark',
'tokyo-night': 'Tokyo Night'
};
let currentMode = '';
let THEMES = { light: 'latte', dark: 'mocha' };
function initializeTheme() {
const savedLightTheme = localStorage.getItem('theme-light') || 'latte';
const savedDarkTheme = localStorage.getItem('theme-dark') || 'mocha';
const savedMode = localStorage.getItem('theme-mode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
THEMES.light = savedLightTheme;
THEMES.dark = savedDarkTheme;
currentMode = savedMode || (prefersDark ? 'dark' : 'light');
updateModeToggle();
updateThemeDropdown();
applyTheme();
}
function applyTheme() {
const theme = THEMES[currentMode];
document.documentElement.setAttribute('data-theme', theme);
updateCurrentThemeDisplay();
}
function updateThemeDropdown() {
const menu = document.getElementById('themeDropdownMenu');
const themes = currentMode === 'light' ? LIGHT_THEMES : DARK_THEMES;
if (menu) {
menu.innerHTML = themes.map(theme =>
'<div class="theme-option" data-theme="' + theme + '">' +
' <div class="theme-name">' + THEME_NAMES[theme] + '</div>' +
' <button class="theme-selector-btn" data-theme="' + theme + '"></button>' +
'</div>'
).join('');
attachThemeOptionListeners();
updateUI();
}
}
function attachThemeOptionListeners() {
document.querySelectorAll('.theme-option').forEach(option => {
option.addEventListener('click', function(e) {
const theme = this.getAttribute('data-theme');
if (theme) selectTheme(theme);
});
});
}
function selectTheme(theme: string) {
THEMES[currentMode] = theme;
localStorage.setItem('theme-' + currentMode, theme);
updateUI();
applyTheme();
}
function updateCurrentThemeDisplay() {
const theme = THEMES[currentMode];
const themeName = THEME_NAMES[theme];
const display = document.getElementById('currentThemeDisplay');
if (display) {
display.textContent = themeName;
}
}
function updateUI() {
const currentTheme = THEMES[currentMode];
document.querySelectorAll('.theme-selector-btn').forEach(function(btn) {
const theme = btn.getAttribute('data-theme');
if (theme) {
btn.classList.toggle('selected', currentTheme === theme);
}
});
}
function updateModeToggle() {
const lightBtn = document.getElementById('lightModeBtn');
const darkBtn = document.getElementById('darkModeBtn');
if (lightBtn) lightBtn.classList.toggle('active', currentMode === 'light');
if (darkBtn) darkBtn.classList.toggle('active', currentMode === 'dark');
}
// Event listeners
function setupEventListeners() {
const lightBtn = document.getElementById('lightModeBtn');
const darkBtn = document.getElementById('darkModeBtn');
const trigger = document.getElementById('themeDropdownTrigger');
const menu = document.getElementById('themeDropdownMenu');
if (lightBtn) {
lightBtn.addEventListener('click', function() {
currentMode = 'light';
localStorage.setItem('theme-mode', 'light');
updateModeToggle();
updateThemeDropdown();
applyTheme();
});
}
if (darkBtn) {
darkBtn.addEventListener('click', function() {
currentMode = 'dark';
localStorage.setItem('theme-mode', 'dark');
updateModeToggle();
updateThemeDropdown();
applyTheme();
});
}
if (trigger && menu) {
trigger.addEventListener('click', function() {
trigger.classList.toggle('open');
menu.classList.toggle('open');
});
}
document.addEventListener('click', function(e) {
const target = e.target as Element;
if (!target.closest('.theme-dropdown-wrapper')) {
if (trigger) trigger.classList.remove('open');
if (menu) menu.classList.remove('open');
}
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (localStorage.getItem('theme-mode') !== 'light' && localStorage.getItem('theme-mode') !== 'dark') {
currentMode = e.matches ? 'dark' : 'light';
updateModeToggle();
updateThemeDropdown();
applyTheme();
}
});
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
initializeTheme();
setupEventListeners();
});
} else {
initializeTheme();
setupEventListeners();
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
import { getPostsByYearAndMonth } from '../../db';
import { minifyJS } from '../utils';
// Read posts from database once and store in a closure
const getArchiveData = (() => {
let cachedData: ReturnType<typeof getPostsByYearAndMonth> | null = null;
return () => {
if (!cachedData) {
cachedData = getPostsByYearAndMonth();
}
return cachedData;
};
})();
export function PostArchive() {
const archiveData = getArchiveData();
return (
<div className="postList sheet-background">
<h3>Posts</h3>
<ul className="post-archive">
{archiveData.map((yearData) => (
<li key={yearData.year}>
<div className="archive-year" tabIndex={0} role="button" aria-expanded="true">
<span className="archive-toggle expanded"></span>
<span>{yearData.year}</span>
<span className="post-count">{yearData.count}</span>
</div>
<div className="archive-content expanded">
<ul className="archive-months">
{yearData.months.map((monthData) => (
<li key={monthData.name}>
<div className="archive-month" tabIndex={0} role="button" aria-expanded="true">
<span className="archive-toggle expanded"></span>
<span>{monthData.name}</span>
<span className="post-count">{monthData.count}</span>
</div>
<div className="archive-content expanded">
<ul className="archive-posts">
{monthData.posts.map((post) => (
<li key={post.href} className="archive-post">
<a href={post.href} tabIndex={-1}>{post.title}</a>
</li>
))}
</ul>
</div>
</li>
))}
</ul>
</div>
</li>
))}
</ul>
<script dangerouslySetInnerHTML={{ __html: minifyJS(postArchiveScript) }} />
</div>
)
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
const urlLinkedIn = 'https://linkedin.com/in/CalebBraaten';
const urlX = 'https://x.com/CalebBraaten';
const urlGit = 'https://git.cbraaten.dev/Caleb';
export function ProfileBadge() {
return (
<div className="aboutMe sheet-background">
<div className="profile-header">
<img src="/profile-picture.webp" alt="Caleb Braaten" className="profile-picture" />
<h3 className="profile-name">Caleb Braaten</h3>
</div>
<ul className="social-links">
<li>
<a href={urlLinkedIn} data-external target="_blank" rel="noopener noreferrer" aria-label="LinkedIn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</li>
<li>
<a href={urlX} data-external target="_blank" rel="noopener noreferrer" aria-label="X (Twitter)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
</li>
<li>
<a href={urlGit} data-external target="_blank" rel="noopener noreferrer" aria-label="GitHub / Gitea">
<div className="git-icon-wrapper">
<svg className="icon-github" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
</svg>
<svg className="icon-gitea" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<g>
<path id="teabag" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8
c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4
c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
<g>
<g>
<path d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2
c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5
c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5
c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3
c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1
C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4
c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7
S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55
c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8
l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
<path d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4
c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1
c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9
c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3
c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3
c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29
c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8
C343.2,346.5,335,363.3,326.8,380.1z"/>
</g>
</g>
</g>
</svg>
</div>
</a>
</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import React from 'react';
import { minifyJS } from '../utils';
export function TagPicker({ availableTags = [], activeTags = [] }: {
availableTags?: { name: string; post_count: number }[],
activeTags?: string[]
}) {
return (
<div className="tags sheet-background">
<h3>Tags</h3>
{availableTags.length > 0 ? (
<ul className="tag-pills">
{availableTags.map(tag => (
<li key={tag.name}>
<a
href={`?tag=${tag.name.toLowerCase().replace(/\s+/g, '-')}`}
className="tag-pill"
title={`${tag.post_count} post${tag.post_count !== 1 ? 's' : ''} (click to toggle)`}
>
{tag.name}
</a>
</li>
))}
</ul>
) : (
<p className="no-tags-available">No tags available</p>
)}
<div className="tag-actions" style={{ display: 'none' }}>
<a href="/" className="clear-tags-btn">
Clear all filters
</a>
</div>
<script dangerouslySetInnerHTML={{ __html: minifyJS(tagToggleScript) }} />
</div>
)
}
// Client-side script for tag toggle functionality
const tagToggleScript = `
(function() {
'use strict';
class TagPickerManager {
constructor() {
this.init();
}
init() {
this.attachTagClickListeners();
this.updateTagVisualState();
}
// Parse current URL to get active tags
getActiveTags() {
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) {
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) {
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];
// 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');
tagLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
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 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 clear filters button visibility
const tagActions = document.querySelector('.tag-actions');
if (tagActions) {
tagActions.style.display = activeTags.length > 0 ? 'block' : 'none';
}
}
}
// Initialize on DOM ready
function initializeTagPicker() {
// Remove any existing instance
if (window.tagPickerManager) {
delete window.tagPickerManager;
}
// Create new instance
window.tagPickerManager = new TagPickerManager();
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeTagPicker);
} else {
initializeTagPicker();
}
})();
`;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import themePickerScript from '../clientJS/theme-picker' with { type: "text" };
import { minifyJS } from '../utils';
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
const THEME_NAMES: Record<string, string> = {
latte: 'Catppuccin Latte',
frappe: 'Catppuccin Frappe',
macchiato: 'Catppuccin Macchiato',
mocha: 'Catppuccin Mocha',
'solarized-light': 'Solarized Light',
'solarized-dark': 'Solarized Dark',
'gruvbox-light': 'Gruvbox Light',
'gruvbox-dark': 'Gruvbox Dark',
nord: 'Nord',
dracula: 'Dracula',
'one-dark': 'One Dark',
'tokyo-night': 'Tokyo Night'
};
export function ThemePicker() {
return (
<div className="themePicker sheet-background">
<label htmlFor="theme" className="hidden">Theme</label>
<div className="theme-controls">
<div className="theme-mode-toggle">
<button
className="mode-btn active"
data-mode="light"
id="lightModeBtn"
>
Light
</button>
<button
className="mode-btn"
data-mode="dark"
id="darkModeBtn"
>
Dark
</button>
</div>
<div className="theme-dropdown-wrapper">
<button
className="theme-dropdown-trigger"
id="themeDropdownTrigger"
>
<span id="currentThemeDisplay">Catppuccin Latte</span>
<span className="dropdown-arrow"></span>
</button>
<div className="theme-dropdown-menu" id="themeDropdownMenu">
{LIGHT_THEMES.map(theme => (
<div key={theme} className="theme-option" data-theme={theme}>
<div className="theme-name">{THEME_NAMES[theme]}</div>
<button className="theme-selector-btn" data-theme={theme}></button>
</div>
))}
</div>
</div>
</div>
<script dangerouslySetInnerHTML={{ __html: minifyJS(themePickerScript) }} />
</div>
)
}

View File

@@ -1,10 +0,0 @@
import { Html } from "@elysiajs/html";
export function Home() {
return (
<main>
<h1>Home</h1>
<a href="/blog">Blog</a>
</main>
)
}

View File

@@ -17,9 +17,8 @@ async function loadContent(url: string) {
}
function attachLinkHandlers() {
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a');
console.log('Found links:', links.length);
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
links.forEach(link => {
console.log('Attaching listener to:', link.href);
link.onclick = async (e) => {

145
src/frontend/pages/home.tsx Normal file
View File

@@ -0,0 +1,145 @@
import React from 'react';
import { getRecentPosts, formatDate, calculateReadTime, getNumOfPosts } from '../../db/posts';
import { parseTags } from '../../db/tags';
import { type BlogPost } from '../../db/queries';
export function Home({ searchParams }: { searchParams: Record<string, string> }) {
const currentPage = parseInt(searchParams.page || "1", 10);
const postsPerPage = 10;
const totalPages = Math.ceil(getNumOfPosts() / postsPerPage);
const offset = (currentPage - 1) * postsPerPage;
const posts = getRecentPosts(postsPerPage, offset); // Get posts for the current page
return (
<main>
<div className="posts-list">
{posts.length > 0 ? (
posts.map((post) => (
<PostCard key={post.id} post={post} />
))
) : (
<div className="empty-state">
<h2>No posts yet</h2>
<p>Check back soon for new blog posts!</p>
</div>
)}
</div>
<Pagination currentPage={currentPage} totalPages={totalPages} />
</main>
);
}
interface PostCardProps {
post: BlogPost;
}
function PostCard({ post }: PostCardProps) {
const tags = parseTags(post.tags);
const formattedDate = formatDate(post.date);
return (
<article className="post-card">
<header className="post-card-header">
<h2 className="post-card-title">
<a href={`${post.path}`} className="post-card-link">{post.title}</a>
</h2>
<div className="post-card-meta">
<time dateTime={post.date}>{formattedDate}</time>
<span className="meta-separator"></span>
<span className="read-time">5 min read</span>
</div>
{tags.length > 0 && (
<div className="post-card-tags">
{tags.map((tag, index) => (
<a
key={index}
href={`/?tag=${tag.toLowerCase().replace(/\s+/g, '-')}`}
className="post-tag"
>
{tag}
</a>
))}
</div>
)}
</header>
{post.summary && (
<div className="post-card-summary">
<p>{post.summary}</p>
</div>
)}
<footer className="post-card-footer">
<a href={`${post.path}`} className="read-more-link">Read more </a>
</footer>
</article>
);
}
function Pagination({ currentPage, totalPages }: { currentPage: number; totalPages: number }) {
// Calculate the range of page numbers to show
let startPage: number;
let endPage: number;
if (totalPages <= 9) {
// If there are fewer than 9 pages, show all of them
startPage = 1;
endPage = totalPages;
} else {
// If we have more than 9 pages, calculate the window
if (currentPage <= 5) {
// When we're at the start, show pages 1-9
startPage = 1;
endPage = 9;
} else if (currentPage >= totalPages - 4) {
// When we're near the end, show the last 9 pages
startPage = totalPages - 8;
endPage = totalPages;
} else {
// Otherwise, center the window around the current page
startPage = currentPage - 4;
endPage = currentPage + 4;
}
}
const pages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<nav className="pagination">
{/* Previous button - always visible, disabled on first page */}
<a
href={`/?page=${currentPage - 1}`}
className={`pagination-link ${currentPage === 1 ? 'disabled' : ''}`}
style={{
opacity: currentPage === 1 ? 0.5 : 1,
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
pointerEvents: currentPage === 1 ? 'none' : 'auto'
}}
>
Previous
</a>
{/* Page numbers */}
{pages.map((page) => (
<a
key={page}
href={`/?page=${page}`}
className={`pagination-link ${page === currentPage ? 'active' : ''}`}
>
{page}
</a>
))}
{/* Next button - always visible, disabled on last page */}
<a
href={`/?page=${currentPage + 1}`}
className={`pagination-link ${currentPage === totalPages ? 'disabled' : ''}`}
style={{
opacity: currentPage === totalPages ? 0.5 : 1,
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
pointerEvents: currentPage === totalPages ? 'none' : 'auto'
}}
>
Next
</a>
</nav>
);
}

View File

@@ -1,4 +1,4 @@
import { Html } from "@elysiajs/html";
import React from 'react';
export function NotFound() {
return (
@@ -6,4 +6,4 @@ export function NotFound() {
<h1>404 Not Found</h1>
</main>
)
}
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
interface PostProps {
// HTML string for the blog post body
children: string;
meta: {
title: string;
date: Date;
readingTime: string;
};
}
export function Post({ children, meta }: PostProps) {
return (
<main>
<article className="blog-post">
<header className="post-header">
<h1>{meta.title}</h1>
<div className="post-meta">
{meta.date && meta.date instanceof Date &&
<>
<time dateTime={meta.date.toISOString()}>
{meta.date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</>
}
{meta.readingTime &&
<>
<span className="meta-separator"></span>
<span>{meta.readingTime}</span>
</>
}
</div>
</header>
<div className="post-content" dangerouslySetInnerHTML={{ __html: children }} />
</article>
</main>
)
}

1222
src/frontend/styles.css Normal file

File diff suppressed because it is too large Load Diff

18
src/frontend/utils.ts Normal file
View File

@@ -0,0 +1,18 @@
// Minify CSS using simple but effective regex
export function minifyCSS(css: string): string {
return css
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove comments
.replace(/\s+/g, ' ') // Collapse whitespace
.replace(/\s*([{}:;,])\s*/g, '$1') // Remove space around delimiters
.trim();
}
// Minify TypeScript/JavaScript code for client-side injection
export function minifyJS(code: string): string {
const transpiler = new Bun.Transpiler({
loader: 'ts',
minifyWhitespace: true,
});
return transpiler.transformSync(code);
}

View File

@@ -1,11 +0,0 @@
// Client-side script that runs in <head>
// Example: TypeScript with DOM types
(() => {
const logPageInfo = (): void => {
console.log('Page loaded in <head>');
};
if (document.readyState === 'loading') {
logPageInfo();
}
})();

File diff suppressed because one or more lines are too long

View File

@@ -1,97 +0,0 @@
body, a {
margin: 0px;
text-decoration: none;
color: black;
}
header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: #f2f2f2;
padding: 15px;
height: 50px;
}
header span {
font-size: 30px;
font-weight: bold;
}
nav {
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
gap: 10px;
}
nav a {
text-decoration: none;
color: black;
font-size: 20px;
}
hr {
margin: 0px;
opacity: 75%;
}
.post-spotlight {
display: flex;
flex-direction: column;
align-items: center;
margin: 30px;
padding: 4em;
}
article {
font-family: 'Arial Narrow Bold', sans-serif;
}
article > h2 {
font-size: 3em;
font-weight: bold;
margin: 15px;
}
article > div {
color: gray;
}
article > a {
text-decoration: none;
border: 1px solid black;
padding: 10px;
border-radius: 5px;
background-color: #f2f2f2;
color: black;
text-align: end;
}
aside {
display: flex;
flex-direction: row;
justify-content: space-evenly;
align-items: center;
margin: 15px;
}
aside h3 {
font-size: 2em;
font-weight: bold;
}
aside ul {
list-style-type: none;
padding: 0px;
}
.about ul {
display: flex;
flex-direction: row;
gap: 10px;
}

View File

@@ -16,8 +16,8 @@
"jsx": "react", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
"jsxFactory": "Html.createElement", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
"jsxFragmentFactory": "Html.Fragment", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
"jsxFactory": "React.createElement", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
"jsxFragmentFactory": "React.Fragment", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
@@ -25,7 +25,7 @@
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2022", /* Specify what module code is generated. */
"module": "esnext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */