Jannick Knudsen 1 year ago
parent
commit
f8e73828cc

+ 171 - 0
realworld-htmlx/realworld-htmx/.gitignore

@@ -0,0 +1,171 @@
+# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+
+# Logs
+
+logs
+_.log
+npm-debug.log_
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# Runtime data
+
+pids
+_.pid
+_.seed
+\*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+lib-cov
+
+# Coverage directory used by tools like istanbul
+
+coverage
+\*.lcov
+
+# nyc test coverage
+
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+
+bower_components
+
+# node-waf configuration
+
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+build/Release
+
+# Dependency directories
+
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+web_modules/
+
+# TypeScript cache
+
+\*.tsbuildinfo
+
+# Optional npm cache directory
+
+.npm
+
+# Optional eslint cache
+
+.eslintcache
+
+# Optional stylelint cache
+
+.stylelintcache
+
+# Microbundle cache
+
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+
+.node_repl_history
+
+# Output of 'npm pack'
+
+\*.tgz
+
+# Yarn Integrity file
+
+.yarn-integrity
+
+# dotenv environment variable files
+
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+
+.cache
+.parcel-cache
+
+# Next.js build output
+
+.next
+out
+
+# Nuxt.js build / generate output
+
+.nuxt
+dist
+
+# Gatsby files
+
+.cache/
+
+# Comment in the public line in if your project uses Gatsby and not Next.js
+
+# https://nextjs.org/blog/next-9-1#public-directory-support
+
+# public
+
+# vuepress build output
+
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+
+.temp
+.cache
+
+# Docusaurus cache and generated files
+
+.docusaurus
+
+# Serverless directories
+
+.serverless/
+
+# FuseBox cache
+
+.fusebox/
+
+# DynamoDB Local files
+
+.dynamodb/
+
+# TernJS port file
+
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.\*
+
+*.sqlite

+ 33 - 0
realworld-htmlx/realworld-htmx/README.md

@@ -0,0 +1,33 @@
+# realworld-htmx
+
+# Grok it
+
+This realworld implementation uses the BASH Stack (Bun + Andale + Sqlite + Htmx).
+
+Subsequently it's a server side rendered app with a rather flat structure that _does_ make use of `fp-ts` (scary monads ahead) and `zod`.
+
+If you want to get started I'd suggest starting with `Routes` - the public asset route in particular.
+
+The high level concept is always the same:
+
+> incoming request -> wrap it into a context object -> extend the context as needed -> transform the context into a response -> return the response
+
+# Run it
+
+To install dependencies:
+
+```bash
+bun install
+```
+
+To run:
+
+```bash
+bun start
+```
+
+This project was created using `bun init` in bun v1.0.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
+
+# Further notes
+
+Codebase only allows github repos, I'm primarily using gitlab however. I'll do my best to sync changes but the github repo might be out of date.

BIN
realworld-htmlx/realworld-htmx/bun.lockb


+ 28 - 0
realworld-htmlx/realworld-htmx/package.json

@@ -0,0 +1,28 @@
+{
+  "name": "realworld-htmx",
+  "type": "module",
+  "scripts": {
+    "start": "bun --watch src/index.ts"
+  },
+  "devDependencies": {
+    "@faker-js/faker": "^8.0.2",
+    "@types/dompurify": "^3.0.2",
+    "@types/jsonwebtoken": "^9.0.3",
+    "bun-types": "latest"
+  },
+  "peerDependencies": {
+    "typescript": "^5.0.0"
+  },
+  "dependencies": {
+    "andale": "^0.0.12",
+    "date-fns": "^2.30.0",
+    "dompurify": "^3.0.5",
+    "drizzle-orm": "^0.28.6",
+    "fp-ts": "^2.16.1",
+    "htmx-tsx": "^0.0.15",
+    "marked": "^9.0.3",
+    "typescript": "^5.2.2",
+    "ulid": "^2.3.0",
+    "zod": "^3.22.2"
+  }
+}

+ 273 - 0
realworld-htmlx/realworld-htmx/src/Components.tsx

@@ -0,0 +1,273 @@
+/**
+ * standalone components that can be used from wherever
+ */
+
+import * as DateFns from "date-fns/fp";
+import { ArticlePreview, Comment, User } from "./Db";
+import { Url, url } from "./Routes";
+
+const formatDate = DateFns.format("MMMM do");
+
+export function FormErrors({ errors }: { errors: string[] }) {
+  return (
+    <ul class="error-messages">
+      {errors.map((error) => (
+        <li>{error}</li>
+      ))}
+    </ul>
+  );
+}
+
+export function Avatar({ user }: { user: User }) {
+  return (
+    <Link url={["GET /profile", { id: user.id }]}>
+      <img src={user.avatar ?? undefined} class="comment-author-img" />
+    </Link>
+  );
+}
+
+export function Link(props: JSX.Element & { url: Url }) {
+  return (
+    <a href={url(props.url)} hx-target="main#app-root">
+      {props.children}
+    </a>
+  );
+}
+
+export function ButtonThatIsActuallyALink({ children }: JSX.Element) {
+  return (
+    <button hx-target="main#app-root" hx-push-url="true">
+      {children}
+    </button>
+  );
+}
+
+export function Shell({
+  children,
+  user,
+  currentUrl,
+}: JSX.Element & { user: User | undefined; currentUrl: string }) {
+  const htmxVersion = "1.9.4";
+
+  const NavLink = (props: JSX.Element & { url: Url }) => (
+    <Link
+      class={`nav-link ${currentUrl !== url(props.url) ? "" : "active"}`}
+      url={props.url}
+    >
+      {props.children}
+    </Link>
+  );
+
+  return (
+    <html _="on load set global page to location.pathname + location.search then send navigation to <a[hx-target='main#app-root']/>">
+      <head>
+        <base href="/" />
+
+        <title>Conduit</title>
+        <link
+          href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
+          rel="stylesheet"
+          type="text/css"
+        />
+        <link
+          href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
+          rel="stylesheet"
+          type="text/css"
+        />
+        <link rel="stylesheet" href="//demo.productionready.io/main.css" />
+
+        <script
+          src={`https://unpkg.com/htmx.org@${htmxVersion}`}
+          integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
+          crossOrigin="anonymous"
+          defer
+        ></script>
+        <script src="https://unpkg.com/hyperscript.org@0.9.11" defer></script>
+      </head>
+      <body
+        hx-boost="true"
+        class="hx-indicator"
+        _="
+        on htmx:beforeRequest add .htmx-request to <.htmx-indicator/> in me end
+        on htmx:afterRequest remove .htmx-request from <.htmx-indicator/> in me end"
+      >
+        <div
+          class="htmx-indicator"
+          style={{
+            position: "fixed",
+            zIndex: "999",
+            top: "0",
+            left: "0",
+            height: "100vh",
+            width: "100vw",
+            pointerEvents: "none",
+            backdropFilter: "blur(2px)",
+            background: "rgba(255, 255, 255, 0.5)",
+          }}
+        ></div>
+        <nav class="navbar navbar-light">
+          <div class="container">
+            <Link class="navbar-brand" url={["GET /"]}>
+              conduit
+            </Link>
+            <ul class="nav navbar-nav pull-xs-right">
+              <li class="nav-item">
+                <NavLink url={["GET /"]}>Home</NavLink>
+              </li>
+              {user == null ? (
+                <>
+                  <li class="nav-item">
+                    <NavLink url={["GET /login"]}>Sign in</NavLink>
+                  </li>
+                  <li class="nav-item">
+                    <NavLink url={["GET /register"]}>Sign up</NavLink>
+                  </li>
+                </>
+              ) : (
+                <>
+                  <li class="nav-item">
+                    <NavLink url={["GET /article/editor", {}]}>
+                      New Article
+                    </NavLink>
+                  </li>
+                  <li class="nav-item">
+                    <NavLink url={["GET /profile/settings"]}>Settings</NavLink>
+                  </li>
+                </>
+              )}
+            </ul>
+          </div>
+        </nav>
+
+        <main
+          id="app-root"
+          style={{
+            ...({
+              "view-transition-name": "card",
+            } as unknown as CSSStyleDeclaration),
+          }}
+        >
+          {children}
+        </main>
+
+        <footer>
+          <div class="container">
+            <Link url={["GET /"]} class="logo-font">
+              conduit
+            </Link>
+            <span class="attribution">
+              An interactive learning project from{" "}
+              <a href="https://thinkster.io">Thinkster</a>. Code &amp; design
+              licensed under MIT.
+            </span>
+          </div>
+        </footer>
+      </body>
+    </html>
+  );
+}
+
+export function ArticlePreview({ article }: { article: ArticlePreview }) {
+  return (
+    <div class="article-preview">
+      <div class="article-meta">
+        <Avatar user={article.author} />
+        <div class="info">
+          <Link
+            url={["GET /profile", { id: article.author.id }]}
+            class="author"
+          >
+            {article.author.username}
+          </Link>
+          <span class="date">{formatDate(new Date(article.createdAt))}</span>
+        </div>
+        <button
+          hx-post={url(["POST /favorite", { id: article.id }])}
+          hx-target="find .counter"
+          class="btn btn-outline-primary btn-sm pull-xs-right"
+        >
+          <i class="ion-heart"></i>{" "}
+          <span class="counter">{article.favoritedBy.length}</span>
+        </button>
+      </div>
+      <Link url={["GET /article", { id: article.id }]} class="preview-link">
+        <h1>{article.title}</h1>
+        <p>{article.description}</p>
+        <span>Read more...</span>
+        <ul class="tag-list">
+          {article.tags.map((tag) => (
+            <li class="tag-default tag-pill tag-outline">{tag.name}</li>
+          ))}
+        </ul>
+      </Link>
+    </div>
+  );
+}
+
+export function Comment({
+  comment,
+  currentUser,
+}: {
+  comment: Comment & { author: User };
+  currentUser: User | undefined;
+}) {
+  return (
+    <div class="card">
+      <div class="card-block">
+        <p class="card-text">{comment.content}</p>
+      </div>
+      <div class="card-footer">
+        <Avatar user={comment.author} />
+        &nbsp;
+        <Link
+          url={["GET /profile", { id: comment.author.id }]}
+          class="comment-author"
+        >
+          {comment.author.username}
+        </Link>
+        <span class="date-posted">
+          {formatDate(new Date(comment.createdAt))}
+        </span>
+        {currentUser?.id !== comment.author.id ? undefined : (
+          <span class="mod-options">
+            <i class="ion-trash-a"></i>
+          </span>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function Feed({
+  articles,
+  pagination: { page, size },
+  getPaginationUrl,
+}: {
+  articles: ArticlePreview[];
+  pagination: { page: number; size: number };
+  getPaginationUrl: (index: number) => Url;
+}) {
+  return (
+    <article>
+      {articles.map((article) => (
+        <ArticlePreview article={article} />
+      ))}
+      <ul class="pagination">
+        {Array.from({
+          length: Math.ceil(articles.length / size),
+        }).map((_, index) => (
+          <li class={`page-item ${index === page ? "active" : ""}`}>
+            <a
+              hx-get={url(getPaginationUrl(index))}
+              hx-target="closest article"
+              hx-swap="outerHTML"
+              class={`page-link ${index !== page ? "" : "active"}`}
+            >
+              {index + 1}
+            </a>
+          </li>
+        ))}
+      </ul>
+    </article>
+  );
+}

+ 696 - 0
realworld-htmlx/realworld-htmx/src/Db.ts

@@ -0,0 +1,696 @@
+/**
+ * Persistence operations
+ */
+
+import Database from "bun:sqlite";
+import {
+  SQL,
+  Table,
+  and,
+  desc,
+  eq,
+  inArray,
+  relations,
+  sql,
+} from "drizzle-orm";
+import { drizzle } from "drizzle-orm/bun-sqlite";
+import {
+  BaseSQLiteDatabase,
+  ForeignKey,
+  Index,
+  PrimaryKey,
+  SQLiteColumn,
+  SQLiteSyncDialect,
+  SQLiteTableWithColumns,
+  getTableConfig,
+  index,
+  primaryKey,
+  sqliteTable,
+  text,
+} from "drizzle-orm/sqlite-core";
+import { notNullish } from "./Utils";
+import { flow, pipe } from "fp-ts/lib/function";
+import { array, either, reader, string } from "fp-ts";
+import { ulid } from "ulid";
+
+const users = sqliteTable("users", {
+  id: text("id").primaryKey(),
+  username: text("username").notNull(),
+  password: text("password").notNull(),
+  email: text("email").notNull(),
+  bio: text("bio"),
+  avatar: text("avatar"),
+});
+export type User = typeof users.$inferSelect;
+
+const follows = sqliteTable(
+  "follows",
+  {
+    userId: text("userId").notNull(),
+    followsId: text("followsId").notNull(),
+  },
+  (follows) => ({
+    pk: primaryKey(follows.userId, follows.followsId),
+  }),
+);
+export type Follows = typeof follows.$inferSelect;
+
+const articles = sqliteTable("articles", {
+  id: text("id").primaryKey(),
+  slug: text("slug").notNull(),
+  title: text("title").notNull(),
+  description: text("description").notNull(),
+  body: text("body").notNull(),
+  createdAt: text("createdAt").notNull(),
+  updatedAt: text("updatedAt"),
+  authorId: text("authorId")
+    .notNull()
+    .references(() => users.id),
+});
+export type Article = typeof articles.$inferSelect;
+
+const comments = sqliteTable("comments", {
+  id: text("id").primaryKey(),
+  articleId: text("articleId")
+    .notNull()
+    .references(() => articles.id),
+  authorId: text("authorId")
+    .notNull()
+    .references(() => users.id),
+  content: text("content").notNull(),
+  createdAt: text("createdAt").notNull(),
+});
+export type Comment = typeof comments.$inferSelect;
+
+const favorites = sqliteTable(
+  "favorites",
+  {
+    userId: text("userId").notNull(),
+    articleId: text("articleId").notNull(),
+  },
+  (favorites) => ({
+    pk: primaryKey(favorites.userId, favorites.articleId),
+    favoriteUserId: index("favoriteUserId").on(favorites.userId),
+    favoriteArticleId: index("favoriteArticleId").on(favorites.articleId),
+  }),
+);
+export type Favorites = typeof favorites.$inferSelect;
+
+const tags = sqliteTable("tags", {
+  name: text("name").notNull().primaryKey(),
+});
+export type Tag = typeof tags.$inferSelect;
+
+const tagged = sqliteTable(
+  "tagged",
+  {
+    articleId: text("articleId").notNull(),
+    tag: text("tag").notNull(),
+  },
+  (tagged) => ({
+    pk: primaryKey(tagged.articleId, tagged.tag),
+  }),
+);
+export type Tagged = typeof tagged.$inferSelect;
+
+const userRelations = relations(users, ({ many }) => ({
+  follows: many(follows, { relationName: "follows" }),
+  followers: many(follows, { relationName: "followers" }),
+  favorites: many(favorites),
+}));
+
+const articleRelations = relations(articles, ({ one, many }) => ({
+  favoritedBy: many(favorites),
+  tags: many(tagged),
+  author: one(users, {
+    fields: [articles.authorId],
+    references: [users.id],
+  }),
+  comments: many(comments),
+}));
+
+const commentRelations = relations(comments, ({ one }) => ({
+  article: one(articles, {
+    fields: [comments.articleId],
+    references: [articles.id],
+  }),
+  author: one(users, {
+    fields: [comments.authorId],
+    references: [users.id],
+  }),
+}));
+
+const tagRelations = relations(tags, ({ many }) => ({
+  tagged: many(tagged),
+}));
+
+const taggedRelations = relations(tagged, ({ one }) => ({
+  tag: one(tags, {
+    fields: [tagged.tag],
+    references: [tags.name],
+  }),
+  article: one(articles, {
+    fields: [tagged.articleId],
+    references: [articles.id],
+  }),
+}));
+
+const followsRelations = relations(follows, ({ one }) => ({
+  user: one(users, {
+    fields: [follows.userId],
+    references: [users.id],
+  }),
+  follows: one(users, {
+    fields: [follows.followsId],
+    references: [users.id],
+  }),
+}));
+
+const favoritesRelations = relations(favorites, ({ one }) => ({
+  article: one(articles, {
+    fields: [favorites.articleId],
+    references: [articles.id],
+  }),
+  user: one(users, {
+    fields: [favorites.userId],
+    references: [users.id],
+  }),
+}));
+
+/**
+ * creates a new database table from its drizzle config
+ *
+ * Why isn't this part of drizzle...
+ */
+const createTableDDL =
+  (db: BaseSQLiteDatabase<any, any, any, any>) =>
+  (table: SQLiteTableWithColumns<any>) => {
+    const dialect = new SQLiteSyncDialect();
+    const toString = (x: { getSQL: () => SQL }) =>
+      dialect.sqlToQuery(x.getSQL()).sql;
+    const removeQualifier = (identifier: string) =>
+      identifier.replace(/.*\./, "");
+
+    const { name, columns, indexes, primaryKeys, foreignKeys } =
+      getTableConfig(table);
+
+    const createColumn = (column: SQLiteColumn) => {
+      const fragments = [
+        column.name,
+        column.dataType,
+        !column.primary ? undefined : "PRIMARY KEY",
+        !column.notNull ? undefined : "NOT NULL",
+      ];
+      return fragments.filter(notNullish).join(" ");
+    };
+    const createPrimaryKey = (key: PrimaryKey) => {
+      const fragments = [
+        "PRIMARY KEY",
+        `(${key.columns.map((col) => col.name).join(",")})`,
+      ];
+      return fragments.filter(notNullish).join(" ");
+    };
+    const createForeignKey = (key: ForeignKey) => {
+      const { foreignTable, foreignColumns, columns } = key.reference();
+      const fragments = [
+        "FOREIGN KEY",
+        `(${columns.map(flow(toString, removeQualifier))})`,
+        "REFERENCES",
+        toString(foreignTable),
+        `(${foreignColumns.map(flow(toString, removeQualifier))})`,
+        key.onUpdate == null ? undefined : `ON UPDATE ${key.onUpdate}`,
+        key.onDelete == null ? undefined : `ON DELETE ${key.onDelete}`,
+      ];
+
+      return fragments.filter(notNullish).join(" ");
+    };
+    const createIndex = ({ config }: Index) => {
+      const fragments = [
+        "CREATE",
+        !config.unique ? undefined : "UNIQUE",
+        "INDEX",
+        "IF NOT EXISTS",
+        config.name,
+        "ON",
+        toString(config.table),
+        `(${config.columns.map(flow(toString, removeQualifier))})`,
+      ];
+      return fragments.filter(notNullish).join(" ");
+    };
+    const query = `
+    CREATE TABLE IF NOT EXISTS ${name} (\n\t${[
+      ...columns.map(createColumn),
+      ...primaryKeys.map(createPrimaryKey),
+      ...foreignKeys.map(createForeignKey),
+    ].join(",\n\t")}
+    )`;
+
+    const indices = indexes.map(createIndex);
+
+    db.run(sql.raw(query));
+    indices.forEach((index) => db.run(sql.raw(index)));
+  };
+
+const withDb = <T>(fn: (db: Db) => T) => reader.asks(fn);
+
+export const create = (
+  handle = ":memory:",
+  seed?: {
+    users: User[];
+    articles: Article[];
+    comments: Comment[];
+    tags: Tag[];
+    follows: Follows[];
+    favorites: Favorites[];
+    tagged: Tagged[];
+  },
+) => {
+  const raw = new Database(handle);
+  const db = drizzle(raw, {
+    schema: {
+      users,
+      userRelations,
+
+      articles,
+      articleRelations,
+
+      comments,
+      commentRelations,
+
+      tags,
+      tagRelations,
+
+      follows,
+      followsRelations,
+
+      favorites,
+      favoritesRelations,
+
+      tagged,
+      taggedRelations,
+    },
+  });
+
+  const defineTable = createTableDDL(db);
+
+  defineTable(users);
+  defineTable(articles);
+  defineTable(comments);
+  defineTable(follows);
+  defineTable(favorites);
+  defineTable(tags);
+  defineTable(tagged);
+
+  if (seed != null) {
+    db.transaction((db) => {
+      const setupTable = <T>(table: Table, values: T[]) => {
+        db.delete(table).run();
+
+        pipe(values, array.chunksOf(10_000)).forEach((chunk) =>
+          db.insert(table).values(chunk).onConflictDoNothing().run(),
+        );
+      };
+
+      setupTable(users, seed.users);
+      setupTable(tags, seed.tags);
+      setupTable(articles, seed.articles);
+      setupTable(comments, seed.comments);
+      setupTable(follows, seed.follows);
+      setupTable(tagged, seed.tagged);
+      setupTable(favorites, seed.favorites);
+    });
+  }
+
+  return db;
+};
+export type Db = ReturnType<typeof create>;
+export type Seed = Parameters<typeof create>[1];
+
+export const getArticlePreviews = ({
+  page,
+  size,
+  tag,
+  fromAuthor,
+  forUser,
+  favoritedBy,
+}: {
+  page: number;
+  size: number;
+  tag?: string;
+  fromAuthor?: Pick<User, "id">;
+  forUser?: Pick<User, "id">;
+  favoritedBy?: Pick<User, "id">;
+}) =>
+  withDb((db) =>
+    db.transaction((db) => {
+      const userFollows =
+        forUser == null
+          ? undefined
+          : inArray(
+              articles.authorId,
+              pipe(db, getFollowers(forUser)).map(({ id }) => id),
+            );
+
+      const tagEquals = tag == null ? undefined : eq(tagged.tag, tag);
+
+      const authorEquals =
+        fromAuthor == null ? undefined : eq(articles.authorId, fromAuthor.id);
+
+      const limitToFavorites =
+        favoritedBy == null ? undefined : eq(favorites.userId, favoritedBy.id);
+
+      const statement = db.query.articles.findMany({
+        with: {
+          author: true,
+          tags: {
+            where: tagEquals,
+            with: {
+              tag: true,
+            },
+          },
+          favoritedBy: {
+            where: limitToFavorites,
+            with: {
+              user: true,
+            },
+          },
+        },
+        where: and(
+          authorEquals,
+          userFollows,
+          tagEquals == null ? undefined : sql`json_array_length(tags) > 0`,
+          limitToFavorites == null
+            ? undefined
+            : sql`json_array_length(favoritedBy) > 0`,
+        ),
+        orderBy: desc(articles.id),
+        limit: size,
+        offset: page * size,
+      });
+
+      const totalHits = (() => {
+        const query = statement.toSQL();
+        let index = 0;
+        const raw = query.sql
+          .replaceAll("?", () => {
+            const param = query.params.at(index++)!;
+            return typeof param === "string" ? `'${param}'` : (param as string);
+          })
+          .replace(/select/i, "select count(*) count,")
+          .replace(/(.*)limit \d+/i, "$1")
+          .replace(/(.)offset \d+/i, "$1");
+        const result = db.all(sql.raw(raw)).at(0) as { count: number };
+        return result.count;
+      })();
+
+      const result = statement.sync();
+
+      return Object.assign(
+        result.map((entry) => ({
+          ...entry,
+          tags: entry.tags.map(({ tag }) => tag),
+          favoritedBy: entry.favoritedBy.map(({ user }) => user),
+        })),
+        { length: totalHits },
+      );
+    }),
+  );
+export type ArticlePreview = ReturnType<
+  ReturnType<typeof getArticlePreviews>
+>[number];
+
+export const getProfile = (user: Pick<User, "id">) =>
+  withDb((db) =>
+    db.transaction((db) => {
+      const found = db.query.users
+        .findMany({
+          where: eq(users.id, user.id),
+          limit: 1,
+        })
+        .sync()
+        .at(0);
+
+      if (found == null) {
+        return undefined;
+      }
+
+      const followers = pipe(db, getFollowers(user));
+
+      return Object.assign(found, { followers });
+    }),
+  );
+export type Profile = NonNullable<ReturnType<ReturnType<typeof getProfile>>>;
+
+export const getFollowers = (user: Pick<User, "id">) =>
+  withDb((db) =>
+    db.query.follows
+      .findMany({
+        with: {
+          user: true,
+        },
+        where: eq(follows.followsId, user.id),
+      })
+      .sync()
+      .map(({ user }) => user),
+  );
+
+export const getTags = () => withDb((db) => db.select().from(tags).all());
+
+export const favoriteArticle = (favorite: Favorites) =>
+  withDb((db) =>
+    db.transaction((db) => {
+      const userLikesArticleAlready =
+        db
+          .select()
+          .from(favorites)
+          .where(eq(favorites.userId, favorite.userId))
+          .all().length > 0;
+
+      if (userLikesArticleAlready) {
+        db.delete(favorites).where(eq(favorites.userId, favorite.userId)).run();
+      } else {
+        db.insert(favorites).values(favorite).run();
+      }
+
+      return db.query.favorites
+        .findMany({
+          where: eq(favorites.articleId, favorite.articleId),
+          columns: {},
+          with: {
+            user: true,
+          },
+        })
+        .sync()
+        .map(({ user }) => user);
+    }),
+  );
+
+export const getArticle = (article: Pick<Article, "id">) =>
+  withDb((db) =>
+    db.transaction((db) => {
+      const found = db.query.articles
+        .findMany({
+          where: eq(articles.id, article.id),
+          with: {
+            author: true,
+            favoritedBy: true,
+            tags: {
+              with: {
+                tag: true,
+              },
+            },
+            comments: {
+              with: {
+                author: true,
+              },
+            },
+          },
+        })
+        .sync()
+        .at(0);
+
+      if (found == null) {
+        return undefined;
+      }
+
+      const authorFollowers = db.query.follows
+        .findMany({
+          where: eq(follows.followsId, found.author.id),
+          with: {
+            user: true,
+          },
+        })
+        .sync()
+        .map(({ user }) => user);
+
+      return {
+        ...found,
+        author: {
+          ...found.author,
+          followers: authorFollowers,
+        },
+        tags: found.tags.map(({ tag }) => tag),
+      };
+    }),
+  );
+export type ArticleDetail = NonNullable<
+  ReturnType<ReturnType<typeof getArticle>>
+>;
+
+export const followUser = (follower: Follows) =>
+  withDb((db) =>
+    db.transaction((db) => {
+      const userFollowsUser = and(
+        eq(follows.userId, follower.userId),
+        eq(follows.followsId, follower.followsId),
+      );
+
+      const userFollowsAlready =
+        db.select().from(follows).where(userFollowsUser).all().length > 0;
+
+      if (userFollowsAlready) {
+        db.delete(follows).where(userFollowsUser).run();
+      } else {
+        db.insert(follows).values(follower).run();
+      }
+
+      return {
+        newFollowerAmount: db
+          .select({
+            count: sql<number>`count(*)`,
+          })
+          .from(follows)
+          .where(eq(follows.followsId, follower.followsId))
+          .all()
+          .at(0)!.count,
+        /**
+         * whether or not the given user now follows the given other user
+         */
+        userFollowsUser: !userFollowsAlready,
+      };
+    }),
+  );
+
+export const getUserByCredentials = (
+  credentials: Pick<User, "username" | "password">,
+) =>
+  withDb((db) =>
+    db
+      .select()
+      .from(users)
+      .where(
+        and(
+          eq(users.username, credentials.username),
+          eq(users.password, credentials.password),
+        ),
+      )
+      .all()
+      .at(0),
+  );
+
+export const addUser = (
+  addUserRequest: Pick<User, "username" | "password" | "email">,
+) =>
+  withDb((db) =>
+    either.tryCatch(
+      () =>
+        db
+          .insert(users)
+          .values({
+            ...addUserRequest,
+            id: ulid(),
+          })
+          .returning()
+          .all()
+          .at(0)!,
+      () => "email already taken",
+    ),
+  );
+
+export const getUserById = (user: Pick<User, "id">) =>
+  withDb((db) =>
+    db.select().from(users).where(eq(users.id, user.id)).all().at(0),
+  );
+
+export const updateUser = (user: Partial<User> & Pick<User, "id">) =>
+  withDb((db) => db.update(users).set(user).where(eq(users.id, user.id)).run());
+
+export const upsertArticle = ({
+  article,
+  author,
+  tags,
+}: {
+  article:
+    | (Partial<Article> & Pick<Article, "id">)
+    | Omit<Article, "id" | "createdAt" | "updatedAt" | "slug" | "authorId">;
+  author: Pick<User, "id">;
+  tags: Tag[];
+}) =>
+  withDb((db) =>
+    db.transaction((db) => {
+      const upserted = (() => {
+        if ("id" in article) {
+          return db
+            .update(articles)
+            .set(article)
+            .where(eq(articles.id, article.id))
+            .returning()
+            .all()
+            .at(0);
+        } else {
+          const createdAt = new Date();
+          return db
+            .insert(articles)
+            .values({
+              ...article,
+              slug: article.title.toLowerCase().replaceAll(" ", "-"),
+              createdAt: createdAt.toISOString(),
+              updatedAt: null,
+              authorId: author.id,
+              id: ulid(createdAt.valueOf()),
+            })
+            .returning()
+            .all()
+            .at(0)!;
+        }
+      })();
+
+      if (upserted == null) {
+        return undefined;
+      }
+
+      const existingTags = db.query.tagged
+        .findMany({
+          where:
+            tags.length === 0
+              ? undefined
+              : inArray(
+                  tagged.tag,
+                  tags.map(({ name }) => name),
+                ),
+        })
+        .sync()
+        .map(({ tag }) => tag);
+      const nextTags = tags.map(({ name }) => name);
+
+      const stringDiff = array.difference(string.Eq);
+
+      const toAdd = stringDiff(nextTags, existingTags);
+      const toRemove = stringDiff(existingTags, nextTags);
+
+      if (toRemove.length > 0) {
+        db.delete(tagged).where(inArray(tagged.tag, toRemove)).run();
+      }
+      if (toAdd.length > 0) {
+        db.insert(tagged)
+          .values(toAdd.map((name) => ({ articleId: upserted.id, tag: name })))
+          .onConflictDoNothing()
+          .run();
+      }
+
+      return pipe(db, getArticle(upserted));
+    }),
+  );
+
+export const deleteArticle = (article: Pick<Article, "id">) =>
+  withDb((db) => db.delete(articles).where(eq(articles.id, article.id)).run());

+ 552 - 0
realworld-htmlx/realworld-htmx/src/Handlers.tsx

@@ -0,0 +1,552 @@
+/**
+ * the "business logic" if you will. Here we pull data from the database,
+ * set preconditions on incoming requests and choose _what_ to render
+ */
+
+import { flow, pipe } from "fp-ts/lib/function";
+import {
+  createRootContext,
+  createSecuredContext,
+  sign,
+  tryGetUser,
+} from "./Middlewares";
+import { A } from "andale";
+import { toHtml } from "htmx-tsx";
+import { z } from "zod";
+import * as Db from "./Db";
+import { array, either } from "fp-ts";
+import {
+  ArticleDetail,
+  Editor,
+  Home,
+  Login,
+  Profile,
+  Register,
+  Settings,
+} from "./Pages";
+import { Feed } from "./Components";
+import { eitherFromZodResult, formatZodError, tap } from "./Utils";
+import { Url, url } from "./Routes";
+
+//#region UTILS
+
+const {
+  Validate: { query, body },
+  Context: { withCookies, withCaching },
+  context,
+  handle,
+} = A.HTTP;
+
+const jsx = (jsx: JSX.Element) =>
+  new Response(toHtml(jsx), {
+    headers: {
+      "Content-Type": "text/html",
+    },
+  });
+
+const text = (text: string | number) => new Response(text.toString());
+
+const json = (text: string) =>
+  new Response(text, {
+    headers: {
+      "Content-Type": "application/json",
+    },
+  });
+
+const empty = () => new Response(undefined, { status: 204 });
+
+const notFound = ({ children = "not found" }: JSX.Element = {}) =>
+  new Response(toHtml(<div>{children}</div>), {
+    status: 404,
+    headers: {
+      "Content-Type": "text/html",
+    },
+  });
+
+const redirect = (
+  location: Url,
+  {
+    hx = true,
+    status = hx ? 200 : 302,
+  }: { hx?: boolean; status?: number } = {},
+) =>
+  new Response(undefined, {
+    status,
+    headers: hx
+      ? {
+          "hx-redirect": url(location),
+        }
+      : {
+          Location: url(location),
+        },
+  });
+
+const pagination = z.object({
+  page: z.coerce.number().optional().default(0),
+  size: z.coerce.number().optional().default(20),
+});
+
+//#endregion
+
+export const getPublicAsset = pipe(
+  context(),
+  withCaching(),
+  handle(({ request, cache }) => {
+    const url = new URL(request.url);
+    const file = Bun.file(url.pathname.slice(1));
+    return cache.etag(
+      new Response(file),
+      file.lastModified.toString().slice(-5),
+    );
+  }),
+);
+
+export const getArticle = flow(
+  createRootContext(),
+  query(z.object({ id: z.string() })),
+  handle(({ query, withDb, user, Shell, cache }) => {
+    const article = withDb(Db.getArticle(query));
+    if (article == null) {
+      return notFound();
+    }
+    const cached = cache.etag(
+      jsx(
+        <Shell>
+          <ArticleDetail article={article} currentUser={user} />
+        </Shell>,
+      ),
+      (article.updatedAt ?? article.createdAt).slice(-5),
+    );
+
+    cached.headers.set("Vary", "Hx-Request");
+
+    return cached;
+  }),
+);
+
+export const deleteArticle = flow(
+  createSecuredContext(),
+  query(z.object({ id: z.string() })),
+  handle(({ query, withDb, user }): Response => {
+    const article = withDb(Db.getArticle(query));
+    if (article == null || user.id !== article.authorId) {
+      return notFound();
+    }
+    withDb(Db.deleteArticle(query));
+    return redirect(["GET /"]);
+  }),
+);
+
+export const getProfile = flow(
+  createRootContext(),
+  query(z.object({ id: z.string() })),
+  handle(({ withDb, query, Shell, user }) => {
+    const profile = withDb(Db.getProfile(query));
+    if (profile == null) {
+      return notFound();
+    }
+    return jsx(
+      <Shell>
+        <Profile profile={profile} currentUser={user} />
+      </Shell>,
+    );
+  }),
+);
+
+export const getGlobalFeed = flow(
+  createRootContext(),
+  tryGetUser(),
+  query(pagination.extend({ tag: z.string().optional() })),
+  handle(({ query, withDb, cache }) => {
+    const articles = withDb(Db.getArticlePreviews(query));
+    const latestArticle = articles.at(0);
+
+    const res = jsx(
+      <Feed
+        articles={articles}
+        pagination={query}
+        getPaginationUrl={(index) => [
+          "GET /feed/global",
+          { ...query, page: index },
+        ]}
+      />,
+    );
+
+    return latestArticle == null
+      ? res
+      : cache.etag(
+          res,
+          (latestArticle.updatedAt ?? latestArticle.createdAt).slice(-5),
+        );
+  }),
+);
+
+export const getPersonalFeed = flow(
+  createSecuredContext(),
+  query(pagination),
+  handle(({ query, withDb, user, cache }) => {
+    const articles = withDb(Db.getArticlePreviews({ ...query, forUser: user }));
+    const latestArticle = articles.at(0);
+
+    const res = jsx(
+      <Feed
+        articles={articles}
+        pagination={query}
+        getPaginationUrl={(index) => [
+          "GET /feed/personal",
+          { ...query, page: index },
+        ]}
+      />,
+    );
+
+    return latestArticle == null
+      ? res
+      : cache.etag(
+          res,
+          (latestArticle.updatedAt ?? latestArticle.createdAt).slice(-5),
+        );
+  }),
+);
+
+export const getHome = flow(
+  createRootContext(),
+  tryGetUser(),
+  handle(({ withDb, Shell, user, cache }) => {
+    const popularTags = withDb(Db.getTags());
+
+    const tagsHash = Buffer.from(
+      popularTags.map(({ name }) => name).join(","),
+    ).toString("base64");
+
+    const res = cache.etag(
+      jsx(
+        <Shell>
+          <Home user={user} popularTags={popularTags} />
+        </Shell>,
+      ),
+      tagsHash,
+    );
+
+    res.headers.append("Vary", "Cookie");
+
+    return res;
+  }),
+);
+
+export const favoriteArticle = flow(
+  createSecuredContext(),
+  tryGetUser(),
+  query(z.object({ id: z.string() })),
+  handle(({ withDb, user, query }) => {
+    const nextUsers = withDb(
+      Db.favoriteArticle({
+        userId: user.id,
+        articleId: query.id,
+      }),
+    );
+    return text(nextUsers.length);
+  }),
+);
+
+export const followProfile = flow(
+  createSecuredContext(),
+  query(z.object({ id: z.string() })),
+  handle(({ withDb, user, query }) => {
+    const { newFollowerAmount, userFollowsUser } = withDb(
+      Db.followUser({
+        userId: user.id,
+        followsId: query.id,
+      }),
+    );
+    return text(newFollowerAmount);
+  }),
+);
+
+export const getLogin = flow(
+  createRootContext(),
+  handle(({ Shell }) => {
+    return jsx(
+      <Shell>
+        <Login />
+      </Shell>,
+    );
+  }),
+);
+
+export const login = flow(
+  createRootContext(),
+  body(z.object({ username: z.string(), password: z.string() })),
+  withCookies(),
+  handle(({ withDb, body, setCookie }): Response => {
+    const user = withDb(Db.getUserByCredentials(body));
+    if (user == null) {
+      return new Response(
+        toHtml(
+          <ul class="error-messages">
+            <li>invalid username/password</li>
+          </ul>,
+        ),
+        {
+          status: 400,
+        },
+      );
+    }
+    const jwt = sign({ id: user.id }, {});
+
+    return pipe(redirect(["GET /"]), setCookie("jwt", jwt, { httpOnly: true }));
+  }),
+);
+
+export const logout = pipe(
+  context(),
+  withCookies(),
+  handle(({ setCookie }): Response => {
+    return pipe(
+      redirect(["GET /"]),
+      setCookie("jwt", "", {
+        httpOnly: true,
+        maxAge: 0,
+      }),
+    );
+  }),
+);
+
+export const getRegister = flow(
+  createRootContext(),
+  handle(({ Shell }) => {
+    return jsx(
+      <Shell>
+        <Register />
+      </Shell>,
+    );
+  }),
+);
+
+export const register = flow(
+  createRootContext(),
+  body(
+    z.object({
+      username: z.string(),
+      password: z.string(),
+      email: z.string(),
+    }),
+  ),
+  handle(({ withDb, body, bodySchema }): Response => {
+    return pipe(
+      bodySchema
+        .extend({
+          email: z.string().email(),
+        })
+        .safeParse(body),
+      eitherFromZodResult,
+      either.mapLeft(formatZodError),
+      either.flatMap(flow(Db.addUser, withDb, either.mapLeft(array.of))),
+      either.match(
+        (errors) => jsx(<Register values={body} errors={errors} />),
+        () => redirect(["GET /login"]),
+      ),
+    );
+  }),
+);
+
+export const getSettings = flow(
+  createSecuredContext(),
+  handle(({ user, Shell }) => {
+    return jsx(
+      <Shell>
+        <Settings values={user} />
+      </Shell>,
+    );
+  }),
+);
+
+export const updateSettings = flow(
+  createSecuredContext(),
+  query(z.object({ id: z.string() })),
+  body(
+    z.object({
+      username: z.string(),
+      password: z.string(),
+      email: z.string(),
+      bio: z.string(),
+      avatar: z.string(),
+    }),
+  ),
+  handle(({ withDb, body, bodySchema, user }) => {
+    return pipe(
+      bodySchema
+        .extend({
+          username: z.string().min(1, "username is required"),
+          password: z.string().min(1, "password is required"),
+          email: z.string().email(),
+          avatar: z.string().url(),
+        })
+        .safeParse(body),
+      eitherFromZodResult,
+      either.mapLeft(formatZodError),
+      either.match(
+        (errors) =>
+          jsx(<Settings values={{ id: user.id, ...body }} errors={errors} />),
+        (parsed) => {
+          const updated = { id: user.id, ...parsed };
+          withDb(Db.updateUser(updated));
+          return jsx(<Settings values={updated} />);
+        },
+      ),
+    );
+  }),
+);
+
+export const getOwnArticles = flow(
+  createRootContext(),
+  query(pagination.extend({ id: z.string() })),
+  handle(({ query, withDb, cache }) => {
+    const articles = withDb(
+      Db.getArticlePreviews({
+        ...query,
+        fromAuthor: query,
+      }),
+    );
+    const latestArticle = articles.at(0);
+
+    const res = jsx(
+      <Feed
+        articles={articles}
+        pagination={query}
+        getPaginationUrl={(index) => [
+          "GET /profile/articles/own",
+          { ...query, page: index },
+        ]}
+      />,
+    );
+
+    return latestArticle == null
+      ? res
+      : cache.etag(
+          res,
+          (latestArticle.updatedAt ?? latestArticle.createdAt).slice(-5),
+        );
+  }),
+);
+
+export const getFavoritedArticles = flow(
+  createRootContext(),
+  query(pagination.extend({ id: z.string() })),
+  handle(({ query, withDb, cache }) => {
+    const articles = withDb(
+      Db.getArticlePreviews({ ...query, favoritedBy: query }),
+    );
+
+    const latestArticle = articles.at(0);
+
+    const res = jsx(
+      <Feed
+        articles={articles}
+        pagination={query}
+        getPaginationUrl={(index) => [
+          "GET /profile/articles/favorited",
+          { ...query, page: index },
+        ]}
+      />,
+    );
+
+    return latestArticle == null
+      ? res
+      : cache.etag(
+          res,
+          (latestArticle.updatedAt ?? latestArticle.createdAt).slice(-5),
+        );
+  }),
+);
+
+export const getEditor = flow(
+  createSecuredContext(),
+  query(z.object({ id: z.string().optional() })),
+  handle(({ query, withDb, user, Shell }) => {
+    const article =
+      query.id == null ? undefined : withDb(Db.getArticle({ id: query.id }));
+    if (article != null && article?.authorId !== user.id) {
+      return notFound();
+    }
+    return jsx(
+      <Shell>
+        <Editor values={article} />
+      </Shell>,
+    );
+  }),
+);
+
+export const upsertArticle = flow(
+  createSecuredContext(),
+  body(
+    z
+      .object({
+        id: z.string(),
+        body: z.string({
+          invalid_type_error: "body must be a string",
+        }),
+        description: z.string(),
+        title: z.string(),
+        tags: z
+          .string()
+          .default("")
+          .transform((value) => value.trim())
+          .transform((value) =>
+            value == "" ? [] : value.split(",").map((name) => ({ name })),
+          ),
+      })
+      .partial(),
+  ),
+  handle(
+    ({ body, withDb, user }): Response =>
+      pipe(
+        z
+          .union([
+            z.object({
+              id: z.string({
+                required_error: "id is required",
+              }),
+            }),
+            z.object({
+              title: z.string().min(1, "title cannot be empty"),
+              description: z.string().min(1, "description cannot be empty"),
+              body: z.string().min(1, "body cannot be empty"),
+              tags: z.array(z.object({ name: z.string() })),
+            }),
+          ])
+          .safeParse(body),
+        eitherFromZodResult,
+        either.mapLeft((error) => {
+          const { unionErrors } =
+            error.errors.find(
+              (
+                error,
+              ): error is Extract<z.ZodIssue, { code: "invalid_union" }> =>
+                error.code === "invalid_union",
+            ) ?? {};
+          if (unionErrors == null) {
+            return error;
+          }
+          return "id" in body ? unionErrors.at(0)! : unionErrors.at(1)!;
+        }),
+        either.mapLeft(formatZodError),
+        either.map((data) => ("id" in data ? { ...body, ...data } : data)),
+        either.match(
+          (errors) => jsx(<Editor values={body} errors={errors} />),
+          (parsed) => {
+            const upserted = withDb(
+              Db.upsertArticle({
+                article: parsed,
+                author: user,
+                tags: parsed.tags ?? [],
+              }),
+            );
+            if (upserted == null) {
+              return notFound({ children: `could not find article` });
+            }
+            return redirect(["GET /article", { id: upserted.id }]);
+          },
+        ),
+      ),
+  ),
+);

+ 108 - 0
realworld-htmlx/realworld-htmx/src/Middlewares.tsx

@@ -0,0 +1,108 @@
+/**
+ * set up common logic
+ */
+
+import { A } from "andale";
+import * as JWT from "./NaiveJWT";
+import { apply, flow, pipe } from "fp-ts/lib/function";
+import { Db, getUserById } from "./Db";
+import { either, option, taskEither } from "fp-ts";
+import { Shell } from "./Components";
+import { z } from "zod";
+
+const createBaseContext = (db: Db) =>
+  pipe(
+    A.HTTP.context(),
+    A.HTTP.Context.withCookies(),
+    A.HTTP.Context.withCaching({
+      defaultHeaders: {
+        Vary: "hx-request",
+      },
+    }),
+    A.Context.add({
+      withDb: apply(db),
+    }),
+  );
+
+type BaseContext = A.Middleware.Output<ReturnType<typeof createBaseContext>>;
+
+const secret = "oh so secret secret";
+export const verify = JWT.verify(secret);
+export const sign = JWT.sign(secret);
+
+export const tryGetUser = <Current extends BaseContext>() =>
+  A.Context.extend(({ withDb, cookies }: Current) => {
+    return pipe(
+      pipe(option.fromNullable(cookies["jwt"]), option.map(decodeURIComponent)),
+      either.fromOption(() => undefined),
+      either.flatMap(verify),
+      either.flatMap(({ payload }) =>
+        either.tryCatch(
+          () => z.object({ id: z.string() }).parse(payload),
+          () => undefined,
+        ),
+      ),
+      either.map(({ id }) => withDb(getUserById({ id }))),
+      either.getOrElseW(() => undefined),
+      (maybeUser) => ({
+        user: maybeUser,
+      }),
+    );
+  });
+
+export const createRootContext = () =>
+  flow(
+    createBaseContext,
+    tryGetUser(),
+    A.Context.extend(({ request, user }) => {
+      const url = new URL(request.url);
+      const isHxRequest = request.headers.get("HX-Request") != null;
+      const currentUrl = request.headers.get("HX-Current-URL") ?? request.url;
+      return {
+        url,
+        isHxRequest,
+        currentUrl,
+        Shell: ({ children }: JSX.Element) =>
+          isHxRequest ? (
+            <>{children}</>
+          ) : (
+            <Shell currentUrl={currentUrl} user={user}>
+              {children}
+            </Shell>
+          ),
+      };
+    }),
+  );
+
+export const createSecuredContext = ({
+  redirectToLogin = true,
+}: {
+  redirectToLogin?: boolean;
+} = {}) =>
+  flow(
+    createRootContext(),
+    A.Middleware.mapTaskEither((context) => {
+      if (context.user == null) {
+        return taskEither.left(
+          redirectToLogin
+            ? new Response(undefined, {
+                status: context.isHxRequest ? 200 : 302,
+                headers: {
+                  "hx-redirect": "/login",
+                  location: "/login",
+                },
+              })
+            : new Response("unauthorized", {
+                status: 401,
+                headers: {
+                  "WWW-Authenticate": "Bearer",
+                },
+              }),
+        );
+      }
+      return taskEither.right({
+        ...context,
+        user: context.user,
+      });
+    }),
+  );

+ 31 - 0
realworld-htmlx/realworld-htmx/src/NaiveJWT.spec.ts

@@ -0,0 +1,31 @@
+import { describe, expect, it } from "bun:test";
+import * as JWT from "./NaiveJWT";
+import { pipe } from "fp-ts/lib/function";
+import * as Either from "fp-ts/Either";
+
+const secret = "foo";
+const sign = JWT.sign(secret);
+const verify = JWT.verify(secret);
+
+describe("sign", () => {
+  it("should return a string", () => {
+    expect(typeof sign("bar", {}) === "string").toBe(true);
+  });
+
+  it("should have three parts denoted by a .", () => {
+    const parts = sign("bar", {}).split(".");
+    expect(parts.length === 3).toBe(true);
+  });
+});
+
+it("should be possible to retrieve the payload", () => {
+  const payload = { email: "foo@bar.com" };
+  const jwt = sign(payload, {});
+  const decoded = pipe(
+    verify(jwt),
+    Either.getOrElseW((error) => {
+      throw error;
+    }),
+  );
+  expect(decoded.payload).toEqual(payload);
+});

+ 87 - 0
realworld-htmlx/realworld-htmx/src/NaiveJWT.ts

@@ -0,0 +1,87 @@
+/**
+ * just a fill-in implementation until bun supports `jsonwebtoken`
+ *
+ * As the name implies, this should _NOT_ be used in production
+ */
+
+import * as Crypto from "node:crypto";
+import * as Either from "fp-ts/Either";
+import { pipe } from "fp-ts/lib/function";
+
+type Headers = Record<string, string>;
+
+const toBase64 = (string: string) =>
+  Buffer.from(string, "latin1").toString("base64");
+
+const fromBase64 = (string: string) =>
+  Buffer.from(string, "base64").toString("latin1");
+
+const createSignature = (secret: string, payload: string) =>
+  Crypto.createHash("sha256").update(secret).update(payload).digest("base64");
+
+export const sign = (secret: string) => (payload: any, headers: Headers) => {
+  const stringifiedHeaders = JSON.stringify(headers);
+  const stringifiedPayload = JSON.stringify(payload);
+  const signature = createSignature(
+    secret,
+    stringifiedHeaders + stringifiedPayload,
+  );
+  return `${toBase64(stringifiedHeaders)}.${toBase64(
+    stringifiedPayload,
+  )}.${signature}`;
+};
+
+export const verify =
+  (secret: string) =>
+  (
+    encoded: string,
+  ): Either.Either<string, { headers: Headers; payload: any }> => {
+    const [stringifiedHeaders, stringifiedPayload, signature] =
+      encoded.split(".");
+
+    const tryDecode = (encoded: string) =>
+      Either.tryCatch(
+        () => fromBase64(encoded),
+        () => "could not decode",
+      );
+
+    const tryParse = (stringified: string) =>
+      Either.tryCatch(
+        () => JSON.parse(stringified),
+        () => `could not parse '${stringified}'`,
+      );
+
+    return pipe(
+      Either.Do,
+      Either.apS(
+        "headers",
+        pipe(
+          stringifiedHeaders,
+          Either.fromNullable("headers are null"),
+          Either.chain(tryDecode),
+        ),
+      ),
+      Either.apS(
+        "payload",
+        pipe(
+          stringifiedPayload,
+          Either.fromNullable("payload is null"),
+          Either.chain(tryDecode),
+        ),
+      ),
+      Either.chain(
+        Either.fromPredicate(
+          ({ headers, payload }) =>
+            createSignature(secret, headers + payload) === signature,
+          () => `signature does not match`,
+        ),
+      ),
+      Either.chain(({ headers, payload }) =>
+        pipe(
+          Either.Do,
+          Either.apS("headers", tryParse(headers)),
+          Either.apS("payload", tryParse(payload)),
+        ),
+      ),
+    );
+  };

+ 708 - 0
realworld-htmlx/realworld-htmx/src/Pages.tsx

@@ -0,0 +1,708 @@
+/**
+ * All "Components" that the user can directly navigate to
+ */
+
+import * as DateFns from "date-fns/fp";
+import * as Db from "./Db";
+import {
+  ButtonThatIsActuallyALink,
+  Comment,
+  FormErrors,
+  Link,
+} from "./Components";
+import { url } from "./Routes";
+import { marked } from "marked";
+import { fromHtml } from "htmx-tsx";
+
+type Form<T> = (T extends undefined ? { values?: T } : { values: T }) & {
+  errors?: string[] | undefined;
+};
+
+export function ArticleDetail({
+  article,
+  currentUser,
+}: {
+  article: Db.ArticleDetail;
+  currentUser: Db.User | undefined;
+}) {
+  const formatDate = DateFns.format("MMMM do");
+
+  const formattedBody = marked(article.body);
+
+  const articleActions = (
+    <>
+      <button
+        class="btn btn-sm btn-outline-secondary"
+        hx-post={url(["POST /profile/follow", { id: article.author.id }])}
+        hx-target="find .counter"
+        style={{
+          marginRight: "3px",
+        }}
+      >
+        <i
+          class="ion-plus-round"
+          style={{
+            marginRight: "3px",
+          }}
+        ></i>
+        Follow {article.author.username}(
+        <span class="counter">{article.author.followers.length}</span>)
+      </button>
+      <button
+        hx-post={url(["POST /favorite", { id: article.id }])}
+        hx-target="find .counter"
+        class="btn btn-sm btn-outline-primary"
+        style={{
+          marginRight: "3px",
+        }}
+      >
+        <i
+          class="ion-heart"
+          style={{
+            marginRight: "3px",
+          }}
+        ></i>
+        Favorite Post (<span class="counter">{article.favoritedBy.length}</span>
+        )
+      </button>
+      {currentUser?.id !== article.author.id ? undefined : (
+        <>
+          <ButtonThatIsActuallyALink
+            hx-get={url(["GET /article/editor", { id: article.id }])}
+            class="btn btn-sm btn-outline-secondary"
+            style={{
+              marginRight: "3px",
+            }}
+          >
+            <i
+              class="ion-edit"
+              style={{
+                marginRight: "3px",
+              }}
+            ></i>
+            Edit Article
+          </ButtonThatIsActuallyALink>
+          <button
+            hx-delete={url(["DELETE /article", { id: article.id }])}
+            class="btn btn-sm btn-outline-danger"
+          >
+            <i
+              class="ion-trash-a"
+              style={{
+                marginRight: "3px",
+              }}
+            ></i>
+            Delete Article
+          </button>
+        </>
+      )}
+    </>
+  );
+
+  return (
+    <div class="article-page">
+      <div class="banner">
+        <div class="container">
+          <h1>{article.title}</h1>
+
+          <div class="article-meta">
+            <Link url={["GET /profile", { id: article.author.id }]}>
+              <img src="http://i.imgur.com/Qr71crq.jpg" />
+            </Link>
+            <div class="info">
+              <Link
+                url={["GET /profile", { id: article.author.id }]}
+                class="author"
+              >
+                {article.author.username}
+              </Link>
+              <span class="date">
+                {formatDate(new Date(article.createdAt))}
+              </span>
+            </div>
+            {articleActions}
+          </div>
+        </div>
+      </div>
+
+      <div class="container page">
+        <div class="row article-content">
+          <div class="col-md-12">
+            <p>{article.description}</p>
+            <h2 id="introducing-ionic">{article.title}</h2>
+            <p>{fromHtml(formattedBody)}</p>
+            <ul class="tag-list">
+              {article.tags.map(({ name }) => (
+                <li class="tag-default tag-pill tag-outline">{name}</li>
+              ))}
+            </ul>
+          </div>
+        </div>
+
+        <hr />
+
+        <div class="article-actions">
+          <div class="article-meta">
+            <a href="profile.html">
+              <img src={article.author.avatar ?? undefined} />
+            </a>
+            <div class="info">
+              <Link
+                url={["GET /profile", { id: article.author.id }]}
+                class="author"
+              >
+                {article.author.username}
+              </Link>
+              <span class="date">
+                {formatDate(new Date(article.createdAt))}
+              </span>
+            </div>
+            {articleActions}
+          </div>
+        </div>
+
+        <div class="row">
+          <div class="col-xs-12 col-md-8 offset-md-2">
+            {currentUser == null ? (
+              <div
+                style={{
+                  margin: "1rem",
+                }}
+              >
+                <Link url={["GET /login"]}>Sign in</Link> or{" "}
+                <Link url={["GET /register"]}>sign up</Link> to add comments to
+                this article
+              </div>
+            ) : (
+              <form class="card comment-form">
+                <div class="card-block">
+                  <textarea
+                    class="form-control"
+                    placeholder="Write a comment..."
+                    rows={3}
+                  ></textarea>
+                </div>
+                <div class="card-footer">
+                  <img
+                    src={article.author.avatar ?? undefined}
+                    class="comment-author-img"
+                  />
+                  <button class="btn btn-sm btn-primary">Post Comment</button>
+                </div>
+              </form>
+            )}
+
+            {article.comments.map((comment) => (
+              <Comment comment={comment} currentUser={currentUser} />
+            ))}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function Profile({
+  profile,
+  currentUser,
+}: {
+  profile: Db.Profile;
+  currentUser: Db.User | undefined;
+}) {
+  return (
+    <div class="profile-page">
+      <div class="user-info">
+        <div class="container">
+          <div class="row">
+            <div class="col-xs-12 col-md-10 offset-md-1">
+              <img src={profile.avatar ?? undefined} class="user-img" />
+              <h4>{profile.username}</h4>
+              {profile.bio == null ? undefined : <p>{profile.bio}</p>}
+              <button
+                hx-post={url(["POST /profile/follow", { id: profile.id }])}
+                hx-target="find .counter"
+                class="btn btn-sm btn-outline-secondary action-btn"
+              >
+                <i class="ion-plus-round"></i>
+                Follow {profile.username} (
+                <span class="counter">{profile.followers.length}</span>)
+              </button>
+              {profile.id !== currentUser?.id ? undefined : (
+                <ButtonThatIsActuallyALink
+                  hx-get={url(["GET /profile/settings"])}
+                  class="btn btn-sm btn-outline-secondary action-btn"
+                >
+                  <i class="ion-gear-a"></i>
+                  Edit Profile Settings
+                </ButtonThatIsActuallyALink>
+              )}
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="container">
+        <div class="row">
+          <div class="col-xs-12 col-md-10 offset-md-1">
+            <div class="articles-toggle">
+              <ul class="nav nav-pills outline-active">
+                <li class="nav-item">
+                  <a
+                    hx-get={url([
+                      "GET /profile/articles/own",
+                      { id: profile.id, page: 0, size: 20 },
+                    ])}
+                    hx-target="next .feed-container"
+                    hx-trigger="click,load"
+                    _="on htmx:afterRequest remove .active from <.articles-toggle a.nav-link/> then add .active to me"
+                    class="nav-link active"
+                    href=""
+                  >
+                    My Articles
+                  </a>
+                </li>
+                <li class="nav-item">
+                  <a
+                    hx-get={url([
+                      "GET /profile/articles/favorited",
+                      { id: profile.id, page: 0, size: 20 },
+                    ])}
+                    hx-target="next .feed-container"
+                    hx-trigger="click"
+                    _="on htmx:afterRequest remove .active from <.articles-toggle a.nav-link/> then add .active to me"
+                    class="nav-link"
+                    href=""
+                  >
+                    Favorited Articles
+                  </a>
+                </li>
+              </ul>
+            </div>
+
+            <div class="feed-container"></div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function Home({
+  user,
+  popularTags,
+}: {
+  user: Db.User | undefined;
+  popularTags: Db.Tag[];
+}) {
+  return (
+    <div class="home-page">
+      <div class="banner">
+        <div class="container">
+          <h1 class="logo-font">conduit</h1>
+          <p>A place to share your knowledge.</p>
+        </div>
+      </div>
+
+      <div class="container page">
+        <div class="row tab-selection">
+          <div class="col-md-9">
+            <div class="feed-toggle">
+              <ul class="nav nav-pills outline-active">
+                <li class="nav-item">
+                  <a
+                    hx-get={url(["GET /feed/personal", { page: 0, size: 20 }])}
+                    hx-target="next .feed-container"
+                    hx-trigger="click"
+                    class="nav-link"
+                    href=""
+                    _="on htmx:afterRequest remove .active from <.feed-toggle .nav-link/> then add .active to me"
+                  >
+                    Your Feed
+                  </a>
+                </li>
+                <li class="nav-item">
+                  <a
+                    hx-get={url(["GET /feed/global", { page: 0, size: 20 }])}
+                    hx-target="next .feed-container"
+                    hx-trigger="click,load"
+                    class="nav-link active"
+                    href=""
+                    _="on htmx:afterRequest remove .active from <.feed-toggle .nav-link/> then add .active to me"
+                  >
+                    Global Feed
+                  </a>
+                </li>
+              </ul>
+            </div>
+
+            <div class="feed-container"></div>
+          </div>
+
+          <div
+            class="col-md-3"
+            _="
+            behavior TaggedFeed
+              on htmx:afterRequest 
+                set clone to (first <a.nav-link.active/> in closest <.container/>).cloneNode()
+                set clone @hx-get to my @hx-get
+                set clone @hx-trigger to 'click'
+                set clone.textContent to '#' + my.textContent
+                remove .active from <.feed-toggle .nav-link/>
+                remove <li.nav-item.tag-feed/> 
+                make an <li.nav-item.tag-feed/> put clone into it 
+                put it at the end of <ul.nav.nav-pills/> in closest <.container/>
+                htmx.process(clone)
+              end
+            end
+            "
+          >
+            <div class="sidebar">
+              <p>Popular Tags</p>
+
+              <div class="tag-list">
+                {popularTags.map((tag) => (
+                  <a
+                    hx-get={url([
+                      "GET /feed/global",
+                      { tag: tag.name, page: 0, size: 20 },
+                    ])}
+                    hx-target="previous .feed-container"
+                    hx-trigger="click"
+                    _="install TaggedFeed"
+                    class="tag-pill tag-default"
+                  >
+                    {tag.name}
+                  </a>
+                ))}
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function Login({
+  values,
+  errors,
+}: Form<{ username: string; password: string } | undefined>) {
+  return (
+    <div class="auth-page">
+      <div class="container page">
+        <div class="row">
+          <div class="col-md-6 offset-md-3 col-xs-12">
+            <h1 class="text-xs-center">Sign in</h1>
+            <p class="text-xs-center">
+              <Link url={["GET /register"]}>Need an account?</Link>
+            </p>
+
+            {errors == null ? undefined : <FormErrors errors={errors} />}
+
+            <form
+              hx-post={url(["POST /login"])}
+              hx-target="closest .auth-page"
+              hx-swap="outerHTML"
+            >
+              <fieldset class="form-group">
+                <input
+                  name="username"
+                  class="form-control form-control-lg"
+                  type="text"
+                  value={values?.username}
+                  placeholder="Username"
+                />
+              </fieldset>
+              <fieldset class="form-group">
+                <input
+                  name="password"
+                  class="form-control form-control-lg"
+                  type="password"
+                  value={values?.username}
+                  placeholder="Password"
+                />
+              </fieldset>
+              <button
+                type="submit"
+                class="btn btn-lg btn-primary pull-xs-right"
+              >
+                Sign in
+              </button>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function Register({
+  values,
+  errors,
+}: Form<{ username: string; email: string; password: string } | undefined>) {
+  return (
+    <div class="auth-page">
+      <div class="container page">
+        <div class="row">
+          <div class="col-md-6 offset-md-3 col-xs-12">
+            <h1 class="text-xs-center">Sign up</h1>
+            <p class="text-xs-center">
+              <Link url={["GET /login"]}>Have an account?</Link>
+            </p>
+
+            {errors == null ? undefined : <FormErrors errors={errors} />}
+
+            <form
+              hx-post={url(["POST /register"])}
+              hx-target="closest .auth-page"
+              hx-swap="outerHTML"
+            >
+              <fieldset class="form-group">
+                <input
+                  name="username"
+                  class="form-control form-control-lg"
+                  type="text"
+                  required
+                  value={values?.username}
+                  placeholder="Username"
+                />
+              </fieldset>
+              <fieldset class="form-group">
+                <input
+                  name="email"
+                  class="form-control form-control-lg"
+                  type="email"
+                  required
+                  value={values?.email}
+                  placeholder="Email"
+                />
+              </fieldset>
+              <fieldset class="form-group">
+                <input
+                  name="password"
+                  class="form-control form-control-lg"
+                  type="password"
+                  required
+                  value={values?.password}
+                  placeholder="Password"
+                />
+              </fieldset>
+              <button
+                type="submit"
+                class="btn btn-lg btn-primary pull-xs-right"
+              >
+                Sign up
+              </button>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function Settings({ values: user, errors }: Form<Db.User>) {
+  return (
+    <div class="settings-page">
+      <div class="container page">
+        <div class="row">
+          <div class="col-md-6 offset-md-3 col-xs-12">
+            <h1 class="text-xs-center">Your Settings</h1>
+
+            {errors == null ? undefined : <FormErrors errors={errors} />}
+
+            <form
+              hx-put={url(["PUT /profile/settings", { id: user.id }])}
+              hx-swap="outerHTML"
+              hx-target="closest .settings-page"
+            >
+              <fieldset>
+                <fieldset class="form-group">
+                  <input
+                    name="avatar"
+                    class="form-control"
+                    type="text"
+                    placeholder="URL of profile picture"
+                    value={user.avatar ?? undefined}
+                  />
+                </fieldset>
+                <fieldset class="form-group">
+                  <input
+                    name="username"
+                    class="form-control form-control-lg"
+                    type="text"
+                    required
+                    placeholder="Your Name"
+                    value={user.username}
+                  />
+                </fieldset>
+                <fieldset class="form-group">
+                  <textarea
+                    name="bio"
+                    class="form-control form-control-lg"
+                    rows={8}
+                    placeholder="Short bio about you"
+                  >
+                    {user.bio ?? ""}
+                  </textarea>
+                </fieldset>
+                <fieldset class="form-group">
+                  <input
+                    name="email"
+                    class="form-control form-control-lg"
+                    type="email"
+                    placeholder="Email"
+                    value={user.email}
+                  />
+                </fieldset>
+                <fieldset class="form-group">
+                  <input
+                    name="password"
+                    class="form-control form-control-lg"
+                    type="password"
+                    placeholder="New Password"
+                    value={user.password}
+                  />
+                </fieldset>
+                <button
+                  type="submit"
+                  class="btn btn-lg btn-primary pull-xs-right"
+                >
+                  Update Settings
+                </button>
+              </fieldset>
+            </form>
+            <hr />
+            <button
+              hx-delete={url(["DELETE /logout"])}
+              class="btn btn-outline-danger"
+            >
+              Or click here to logout.
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function Editor({
+  values: article,
+  errors,
+}: Form<Partial<Db.Article & { tags: Db.Tag[] }> | undefined>) {
+  function TagPill({ tag }: { tag: Pick<Db.Tag, "name"> }) {
+    return (
+      <span class="tag-default tag-pill">
+        <i
+          class="ion-close-round"
+          _={`on click send removed(tag:'${tag.name}') to closest <fieldset /> then remove closest .tag-pill`}
+        ></i>{" "}
+        <span class="name">{tag.name}</span>
+      </span>
+    );
+  }
+
+  return (
+    <div class="editor-page">
+      <div class="container page">
+        <div class="row">
+          <div class="col-md-10 offset-md-1 col-xs-12">
+            {errors == null ? undefined : <FormErrors errors={errors} />}
+
+            <form
+              hx-put={url(["PUT /article/editor"])}
+              hx-target="closest .editor-page"
+              hx-swap="outerHTML"
+            >
+              <fieldset>
+                <fieldset class="form-group">
+                  <input
+                    type="text"
+                    name="title"
+                    class="form-control form-control-lg"
+                    placeholder="Article Title"
+                    value={article?.title}
+                  />
+                </fieldset>
+                <fieldset class="form-group">
+                  <input
+                    type="text"
+                    name="description"
+                    class="form-control"
+                    placeholder="What's this article about?"
+                    value={article?.description}
+                  />
+                </fieldset>
+                <fieldset class="form-group">
+                  <textarea
+                    class="form-control"
+                    rows={8}
+                    placeholder="Write your article (in markdown)"
+                    name="body"
+                  >
+                    {article?.body}
+                  </textarea>
+                </fieldset>
+                <fieldset
+                  class="form-group"
+                  _="
+                  on load
+                    make a Set called :tags
+                    set :tagsInput to first <input[name='tags']/> in me
+                    set :tagList to first <.tag-list/> in me
+                    set :template to first <template/> in me
+                  end
+                  on added(tag)
+                    put tag into :tags
+                    set clonedContent to :template.content.cloneNode(true)
+                    set clone to first <.tag-pill/> in clonedContent
+                    set {textContent: tag} on (first <.name/> in clone)
+                    put clone at the end of :tagList then htmx.process(clone)
+                    make an Array from :tags called existing
+                    set :tagsInput@value to existing.join(',')
+                  end
+                  on removed(tag) 
+                    remove tag from :tags
+                    make an Array from tags called existing
+                    set :tagsInput@value to existing.join(',')
+                  end
+                  "
+                >
+                  <input
+                    type="text"
+                    class="form-control"
+                    placeholder="Enter tags"
+                    _="
+                    on keydown[key=='Enter'] 
+                    halt the event 
+                    repeat for tag in event.target.value.split(',')
+                      send added(tag:tag) to closest <fieldset/>
+                    end
+                    set my value to ''"
+                  />
+                  <input
+                    hidden
+                    name="tags"
+                    value={article?.tags?.map(({ name }) => name).join(",")}
+                  />
+                  <template>
+                    <TagPill tag={{ name: "" }} />
+                  </template>
+                  <div class="tag-list">
+                    {article?.tags?.map((tag) => <TagPill tag={tag} />)}
+                  </div>
+                </fieldset>
+                <button
+                  class="btn btn-lg pull-xs-right btn-primary"
+                  type="submit"
+                >
+                  Publish Article
+                </button>
+              </fieldset>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 94 - 0
realworld-htmlx/realworld-htmx/src/Routes.tsx

@@ -0,0 +1,94 @@
+/**
+ * only the "mountpoints" - i.e. which logic should be available under which url
+ */
+
+import { A } from "andale";
+import {
+  favoriteArticle,
+  followProfile,
+  getArticle,
+  getGlobalFeed,
+  getHome,
+  getPersonalFeed,
+  getProfile,
+  getRegister,
+  getLogin,
+  register,
+  login,
+  getSettings,
+  updateSettings,
+  getPublicAsset,
+  getOwnArticles,
+  getFavoritedArticles,
+  getEditor,
+  upsertArticle,
+  logout,
+  deleteArticle,
+} from "./Handlers";
+import { Db } from "./Db";
+
+export const create = (db: Db) =>
+  A.routes({
+    "": {
+      get: getHome(db),
+    },
+    feed: {
+      global: {
+        get: getGlobalFeed(db),
+      },
+      personal: {
+        get: getPersonalFeed(db),
+      },
+    },
+    login: {
+      get: getLogin(db),
+      post: login(db),
+    },
+    logout: {
+      delete: logout,
+    },
+    register: {
+      get: getRegister(db),
+      post: register(db),
+    },
+    article: {
+      "": {
+        get: getArticle(db),
+        delete: deleteArticle(db),
+      },
+      editor: {
+        get: getEditor(db),
+        put: upsertArticle(db),
+      },
+    },
+    profile: {
+      "": {
+        get: getProfile(db),
+      },
+      follow: {
+        post: followProfile(db),
+      },
+      settings: {
+        get: getSettings(db),
+        put: updateSettings(db),
+      },
+      articles: {
+        own: {
+          get: getOwnArticles(db),
+        },
+        favorited: {
+          get: getFavoritedArticles(db),
+        },
+      },
+    },
+    favorite: {
+      post: favoriteArticle(db),
+    },
+    public: {
+      [A.wildcard]: getPublicAsset,
+    },
+  });
+export type Routes = ReturnType<typeof create>;
+
+export type Url = A.Url<Routes>;
+export const url = A.url<Routes>;

+ 22 - 0
realworld-htmlx/realworld-htmx/src/Utils.ts

@@ -0,0 +1,22 @@
+import { either } from "fp-ts";
+import { SafeParseReturnType, ZodError, ZodFormattedError } from "zod";
+
+export const notNullish = <T>(value: T): value is NonNullable<T> =>
+  value != null;
+
+export const tap =
+  <T>(fn: (value: T) => void) =>
+  (value: T) => {
+    fn(value);
+    return value;
+  };
+
+export const eitherFromZodResult = <A, B>(result: SafeParseReturnType<A, B>) =>
+  result.success ? either.right(result.data) : either.left(result.error);
+
+export const formatZodError = (error: ZodError) =>
+  Object.values(error.format())
+    .map((x: string[] | undefined | ZodFormattedError<any>) =>
+      x == null ? [] : Array.isArray(x) ? x : x._errors,
+    )
+    .flat();

+ 15 - 0
realworld-htmlx/realworld-htmx/src/global.d.ts

@@ -0,0 +1,15 @@
+import * as HTMX from "htmx-tsx";
+
+declare global {
+  namespace JSX {
+    interface HypescriptAttributes {
+      _?: string;
+    }
+    interface IntrinsicAttributes
+      extends HTMXAttributes,
+        HypescriptAttributes {}
+
+    interface Element extends HypescriptAttributes {}
+  }
+}
+

+ 133 - 0
realworld-htmlx/realworld-htmx/src/index.ts

@@ -0,0 +1,133 @@
+/**
+ * program entry point
+ */
+
+import { A } from "andale";
+import * as Fs from "node:fs";
+import * as Routes from "./Routes";
+import * as Db from "./Db";
+import { ulid } from "ulid";
+import { faker } from "@faker-js/faker";
+import { sql } from "drizzle-orm";
+
+const dbHandle = "conduit.sqlite";
+
+const dbSeed = Fs.existsSync(dbHandle)
+  ? undefined
+  : ((): Db.Seed => {
+      const now = Date.now();
+
+      const users = Array.from(
+        { length: 100 },
+        (): Db.User => ({
+          id: ulid(),
+          username: faker.internet.userName(),
+          password: faker.internet.password(),
+          email: faker.internet.email(),
+          bio: faker.helpers.maybe(() => faker.lorem.sentence()) ?? null,
+          avatar: faker.internet.avatar(),
+        }),
+      );
+
+      const tags = Array.from(
+        { length: 50 },
+        (): Db.Tag => ({
+          name: faker.lorem.word(),
+        }),
+      );
+
+      const articles = faker.helpers
+        .arrayElements(users, { min: 100, max: Infinity })
+        .map((user) =>
+          faker.helpers.multiple(
+            (): Db.Article => {
+              const date = faker.date.past({ years: 5 });
+              const title = faker.lorem.words();
+              return {
+                id: ulid(),
+                slug: title.replaceAll(" ", "-"),
+                title,
+                description: faker.lorem.sentence(),
+                body: faker.lorem.paragraphs(),
+                authorId: user.id,
+                createdAt: date.toISOString(),
+                updatedAt:
+                  faker.helpers
+                    .maybe(() => faker.date.between({ from: date, to: now }))
+                    ?.toISOString() ?? null,
+              };
+            },
+            {
+              count: { min: 1, max: 10 },
+            },
+          ),
+        )
+        .flat();
+
+      const comments = articles
+        .map((article) =>
+          faker.helpers.arrayElements(users).map((user): Db.Comment => {
+            const date = faker.date.between({
+              from: article.createdAt,
+              to: Infinity,
+            });
+            return {
+              id: ulid(),
+              createdAt: date.toISOString(),
+              authorId: user.id,
+              articleId: article.id,
+              content: faker.lorem.sentence(),
+            };
+          }),
+        )
+        .flat();
+
+      const follows = users
+        .map((user) =>
+          faker.helpers
+            .arrayElements(users, { min: 3, max: 10 })
+            .filter((follows) => follows !== user)
+            .map(
+              (follows): Db.Follows => ({
+                followsId: follows.id,
+                userId: user.id,
+              }),
+            ),
+        )
+        .flat();
+
+      const favorites = users
+        .map((user) =>
+          faker.helpers.arrayElements(articles).map(
+            (article): Db.Favorites => ({
+              articleId: article.id,
+              userId: user.id,
+            }),
+          ),
+        )
+        .flat();
+
+      const tagged = articles
+        .map((article) =>
+          faker.helpers.arrayElements(tags, { min: 1, max: 4 }).map(
+            (tag): Db.Tagged => ({
+              articleId: article.id,
+              tag: tag.name,
+            }),
+          ),
+        )
+        .flat();
+
+      return { users, articles, comments, tags, follows, favorites, tagged };
+    })();
+
+const db = Db.create(dbHandle, dbSeed);
+
+const result = db.get<Db.User>(sql`SELECT * FROM users LIMIT 1`);
+console.log(`example user credentials: ${result.username}\t${result.password}`);
+
+const app = A.create(Routes.create(db));
+
+const server = app.listen({ port: 3000 });
+
+console.log(`Andale! I'm at ${server.hostname}:${server.port}`);

+ 24 - 0
realworld-htmlx/realworld-htmx/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "compilerOptions": {
+    "lib": ["ESNext"],
+    "module": "esnext",
+    "target": "esnext",
+    "moduleResolution": "bundler",
+    "moduleDetection": "force",
+    "allowImportingTsExtensions": true,
+    "noEmit": true,
+    "composite": true,
+    "strict": true,
+    "downlevelIteration": true,
+    "skipLibCheck": true,
+    "jsx": "react-jsx",
+    "jsxImportSource": "htmx-tsx",
+    "allowSyntheticDefaultImports": true,
+    "forceConsistentCasingInFileNames": true,
+    "allowJs": true,
+    "types": [
+      "htmx-tsx",
+      "bun-types" // add Bun global
+    ]
+  }
+}