Thalia is a Bun-first web framework for hosting one or many websites from a single repo. It’s optimised for “internal tools + content-rich sites”: Handlebars templates, Markdown pages, on-demand SCSS compilation, and simple controller-based routing.
This repository is the framework and includes a few example websites under websites/.
- Runtime: Bun (ESM, TypeScript-first)
- Templating: Handlebars (
.hbs) - Content pages: Markdown (
.md) rendered to HTML - Styling: SCSS compiled on demand (
src/css/*.scss→/css/*.css) - DB layer: Drizzle ORM (commonly MySQL/MariaDB via
mysql2) - Auth: Optional security subsystem (
thalia/security) used bywebsites/example-auth
The framework does not ship a webpack toolchain; browser TypeScript and SCSS are handled on demand by the server (tryTypescript, tryScss) or by bun run build:scss for static CSS.
.
├── server/ # Framework runtime (server, routing, request handler chain)
├── models/ # Drizzle schemas used by the framework (optional)
├── bin/ # Framework helper CLIs (dev, sitemap, SCSS build)
├── websites/ # Example / deployed sites (each has its own config + src + public)
│ ├── example-minimal/
│ ├── example-src/
│ └── example-auth/ # Auth + DB-backed example (heavier integration tests)
├── tests/ # Unit + integration + E2E tests (Bun)
└── src/ # Framework-shipped assets/partials (served as fallback)
Each website typically looks like:
websites/<site>/
├── config/config.ts
├── src/ # templates, markdown, scss, (optional) TS for browser
├── public/ # static assets served directly
├── dist/ # optional prebuilt assets (if you precompile)
└── models/ # optional site-specific Drizzle tables
bun installThalia can run in two main modes:
- Standalone (single project): run from a website directory that does not contain a
websites/folder. - Multiplex (many projects): run from the Thalia root (this repo) and serve projects out of
websites/.
bun run startbun server/cli.ts --project=example-srcRuns the Thalia server with bun --hot. Use the app URL printed in the logs (same PORT as the child process).
bun run dev example-srcThalia’s request handler is a chain. In broad strokes it tries:
- path exploit checks / route guard
- controllers
dist/(optional)- SCSS (
src/css) - TypeScript-to-browser-JS (
src/js) - Handlebars templates (
src/**/*.hbs) - Markdown (
src/**/*.md) public/, thendocs/, thendata/- framework
public/+ directory index + 404
bun testTargeted runs:
bun run test:unit
bun run test:integrationThe default bun run test script sets SKIP_EXAMPLE_AUTH_TESTS=1 so the suite passes without MySQL and seeded users (same idea as CI). To run only the request-handler integration file with example-auth enabled (needs DB + schema push + seed users — see websites/example-auth/README.md):
bun websites/example-auth/scripts/seed-test-users.ts
bun run test:integration:example-authTo fail if the example-auth server starts but logins return no cookies (catch silent no-ops in CI that does run example-auth):
REQUIRE_EXAMPLE_AUTH_LOGIN=1 bun run test:integration:example-authDatabase “online” integration (tests/Integration/database-online.test.ts) exercises example-auth against a live MySQL/MariaDB (Crud /json counts, seeded logins, profile updates). The suite runs only when SKIP_DATABASE_TESTS=0; the default bun run test sets SKIP_DATABASE_TESTS=1 so CI and laptop runs stay green without a database. From Thalia root:
bun websites/example-auth/scripts/seed-test-users.ts # upserts test users (see websites/example-auth/README.md)
bun run test:integration:database # same as SKIP_DATABASE_TESTS=0 bun test tests/Integration/database-online.test.tsIf MySQL is down or seed users are missing, those tests fail (they do not pass by skipping).
Golden OAuth/signing fixtures live under tests/fixtures/smugmug/; unit coverage is in tests/Unit/smugmug-*.test.ts. Use tests/helpers/smugmug-fixtures.ts (sampleSmugImageInsertRow()) when seeding local MariaDB rows from the sample upload + AlbumImage payloads.
For uploads, smugmug.album / SMUGMUG_ALBUM / config.smugmug.album accepts a bare album key, an /api/v2/album/… API path (with or without a leading slash), or a https://api.smugmug.com/api/v2/album/… URL; gallery webpage URLs alone are rejected for the upload header (normalizeSmugMugAlbumUri in server/smugmug/album-uri.ts).
/uploadPhoto supports application/json bodies (UploadThing-style): provide uploadThingUrl, fileUrl, or url (first non-empty wins) plus optional caption, title, keywords, filename, mimeType. The server GETs the HTTPS URL with manual redirects and SSRF guards (server/smugmug/remote-image-fetch.ts), then uploads bytes to SmugMug the same way as the legacy multipart form. Multipart fileToUpload behaviour is unchanged.
Optional signed GET smoke against a sandbox account (no uploads, no writes via this path):
# Never set SMUGMUG_WRITE in CI — it is intentionally ignored here; uploads stay out of default automation.
SMUGMUG_READ_CI=1 \
SMUGMUG_CONSUMER_KEY="…" \
SMUGMUG_CONSUMER_SECRET="…" \
SMUGMUG_OAUTH_TOKEN="…" \
SMUGMUG_OAUTH_TOKEN_SECRET="…" \
bun test tests/Integration/smugmug-read-live.test.tsThe default bun test / GitHub Actions job does not set SMUGMUG_READ_CI (see .github/workflows/tests.yml).
SmugMug upload/API paths emit one JSON line per request to stdout/stderr (service: "smugmug", operation, durationMs, httpStatus, website, …) via server/smugmug/log.ts — never oauth_* values; free-text msg fields are passed through redactLogText.
The websites/example-auth fixture exercises Thalia’s auth + route guard + mail flows and expects external services in some scenarios (database; MailCatcher for an end-to-end password-reset test).
For fast, deterministic runs you can skip those (also the default for bun run test):
SKIP_EXAMPLE_AUTH_TESTS=1 SKIP_MAILCATCHER_TESTS=1 bun testGitHub Actions runs the fast test suite by default (skipping example-auth + MailCatcher dependent tests). See .github/workflows/tests.yml.
GPLv3 (see LICENSE).