other changes
vi did:web:vt3e.cat
Thu, 13 Aug 2026 00:15:48 +0100
14 files changed,
497 insertions(+),
28 deletions(-)
M
.gitignore
→
.gitignore
@@ -22,3 +22,4 @@ .DS_Store
# jetbrains setting folder .idea/ +.workspace/
A
src/components/DiscordMessages.astro
@@ -0,0 +1,68 @@
+--- +import type { DiscordMessage } from "@/types"; + +interface Props { + message: DiscordMessage; +} + +const { message } = Astro.props; +--- + +<div class="message-group"> + <div class="gutter"> + <img src={message.avatarUrl} alt="" aria-hidden="true" /> + </div> + <div class="message-content"> + <div class="message-author"> + <span class="author-name">{message.author}</span> + <span class="timestamp">{message.timestamp}</span> + </div> + <div class="message-text"> + {message.content.map((content) => <p>{content}</p>)} + </div> + </div> +</div> + +<style> + .message-group { + display: flex; + flex-direction: row; + gap: 0.5rem; + + padding: 0.5rem; + background-color: hsl(var(--surface0)); + border-radius: 1.5rem; + + .gutter { + width: 2rem; + img { + width: 100%; + height: auto; + border-radius: 50%; + } + } + + .message-content { + flex: 1; + + .message-author { + display: flex; + align-items: center; + gap: 0.5rem; + .author-name { + font-weight: bold; + } + .timestamp { + font-size: 0.75rem; + color: hsl(var(--subtext0)); + } + } + + .message-text { + font-size: 0.875rem; + line-height: 1.5; + color: hsl(var(--text)); + } + } + } +</style>
M
src/constants.ts
→
src/constants.ts
@@ -10,6 +10,21 @@ name: "TypeScript",
short: "TS", colour: "blue", }, + HTML: { + name: "HTML", + short: "HTML", + colour: "red", + }, + CSS: { + name: "CSS", + short: "CSS", + colour: "blue", + }, + Rust: { + name: "Rust", + short: "Rust", + colour: "orange", + }, Vue: { name: "Vue", short: "Vue",
A
src/content/projects/_purrkit.mdx
@@ -0,0 +1,241 @@
+--- +name: "purrkit" +description: "a meowing, fully-typesafe set of packages for interacting with the discord API" + +languages: ["TypeScript"] +stub: false + +links: + [ + { type: "source", label: "source", href: "https://tangled.org/vt3e.cat/purrkit.git" }, + { type: "website", label: "npm", href: "https://www.npmjs.com/package/@purrkit" }, + ] + +messages: + { + author: "april", + avatarUrl: "/avatar.webp", + timestamp: "02:15", + content: + [ + "im writing my own and its gonna be so clean and im gonna do type magic god i love doing type magic", + "its gonanbe called KITTEN and its gonna MEOW", + ], + } +--- + +import DiscordMessages from "../../components/DiscordMessages.astro"; + +<section> + <header> + <h2>what's a purrkit</h2> + <p>purrkit fixes this™</p> + </header> + <div class="content"> + i write a lot of discord stuff, and every time i started to write a new bot i'd end up + writing the exact same helpers, command loaders and interaction handlers ooover and ooooover + again. + + it got very tiring to do this, so i started looking around for some discord.js frameworks, + the main two i found were [sapphire](https://sapphirejs.dev/) and [commandkit](https://commandkit.dev/). + + i did start out writing sapphire, it had a fair amount of niceities, but + there's SO. MUCH. BOILERPLATE. why am i making an entire class just to + listen for new messages . hoe come commands have a nice pretty central + registry but for buttons, modals, and etc. you're on your own? did they + just give up? also maybe i was stupid but it seemed to just freaking + swallow errors. + + this is the point where i decided i would Write my Own framework . to quote + verbatim what i said: + + <DiscordMessages message={frontmatter.messages} /> + + at that point however a friend directed me to commandkit, i had a quick + look and i did like it, what's really interesting is that they use JSX + for components, i really liked that approach. but you were still on your + own for handling options and such . and there was no type magic . im a big + fan of type magic.. + + </div> + +</section> + +<section> + <header> + <h2>the type magic of slash options</h2> + <p>"but i beloved non-null asserting Everything"</p> + </header> + <div class="content"> + if you've written discord.js slash commands before then you know the pain of + options. vanilla discord.js just gives you `interaction.options.getString("name")`, + `getInteger(...)`, `getUser(...)`, and so on. + + the problem is this returns `(string | User | number | ...) | null`. doesn't + matter that you configured the option as required, typescript has NO idea, + discord.js ALSO has NO idea. so you're doing `?? ""` or slapping `!` on everything + which is just a bit nasty isn't it. THEN multiply that by 5-6 options per + command and it's, it's just pure misery. it's gross. + + purrkit fixes this™ by making you declare your options up front as a schema + instead of imperatively fishing for them: + + ```ts + const whois = kitten.command("whois", { + description: "get info about a user", + options: { + user: option.user.required("the user"), + ephemeral: option.boolean.optional("hide response"), + }, + async run(interaction, { user, ephemeral }) { + // `user` is strictly typed as User + // `ephemeral` is strictly typed as boolean | undefined + }, + }); + ``` + + ### how it work + + i wrote a mapped type, `InferOptions<O>`, that chews through your options object. + checks if `required: true`, and if so rips out the inner type (`User`, `string`, + whatever). if it's not required it unions with `undefined`. + + then at runtime kitten intercepts the command, walks the same schema, calls the + right discord.js getter (`getUser`, etc), and just. hands you a plain object that + matches the type EXACTLY. and it just works and it's beautiful as you can see above. + + </div> +</section> + +<section> + <header> + <h2>the stateless component problem</h2> + <p>"how should i store this button's data" the convenient uri-encoded custom id:</p> + </header> + <div class="content"> + discord components (so modals, select menus, modals and etc) are stateless + lil kittens. all discord gives you is a `customId` string with up to 100 chars, + which, to be fair, is probably how it should be. + + your options are pretty much: + * generate a unique id for each component and store the state in redis or whatever, + * or mash the state into a string like `ticket_close:ticketId`, untyped, and fragile, + and breaks if your data has an underscore in it. + + purrkit however treats your components like commands, you provide an optiosn schema, + and purrkit handles the encode/decode for you. + + ```ts + const ticketBtn = kitten.button("close-ticket", { + options: { ticketId: option.string() }, + async run(interaction, { ticketId }) { + await interaction.reply(`closing ticket ${ticketId}...`); + }, + }); + + const button = ticketBtn.button({ ticketId: "6767" }).setLabel("close"); + ``` + + now this won't necesarrily cut it for all use cases, but those use cases i + believe are fairly rare? i haven't encountered them, for example. + + i would like to implement custom \{de,\}serialisers for custom IDs in future. + + ### how it work + + call `.button({ ticketId: "6767" })` and purrkit walks your schema + keys, `encodeURIComponent`s each value, joins with `:`. so you get `"close-ticket:6767"`. + + purrkit also conveniently checks the lengths of the resulting custom IDs, if + they're larger than 100 chars, it\'ll throw a `CustomIdTooLong` error. + + when the click comes back in, kitten splits on the colon, reads the schema, + casts everything back to proper types (`"true"` -> boolean, `"67"` -> number) + and then hands it to your `run`. + + </div> +</section> + +<section> + <header> + <h2>mutating context without the `any`</h2> + <p>believe it or not . purrkit fixes this™ too</p> + </header> + <div class="content"> + instead of middleware fetching something and throwing it into the `interaction` + object or whatever, like SOME web frameworks, purrkit accumulates it into a fully + typed `context` object. + + the context accumulation here is basically stolen from koa (`ctx` threading through + middleware) crossed with trpc's procedure builder (chaining `.use()`, narrowing types + with each call). i wanted that shape for purrkit's middleware. + + ```ts + const authed = kitten.builder().use(async (interaction) => { + const dbUser = await db.users.find(interaction.user.id); + if (!dbUser) throw new HaltExecution({ content: "unauthorized" }); + return { dbUser }; + }); + + authed.command("balance", { + description: "check your balance", + async run(interaction, args, ctx) { + // ctx.dbUser is fully typed and guaranteed to exist here + await interaction.reply(`balance: $${ctx.dbUser.balance}`); + }, + }); + ``` + + ### how it work + + `CommandBuilder` is generic over its context, `CommandBuilder<Ctx>`. + every `.use(middleware)` call looks at what your middleware returns + (`NewCtx`) and hands you back a new builder typed as `CommandBuilder<Ctx & NewCtx>`. + intersection after intersection, it accumulates, down the whole chain, + like a snowball, or an onion, like an ogre. layers of types. + + so by the time `.command()` gets called, `ctx` in your `run` is the + intersection of everything every middleware in that chain returned. with + No Globals harmed in the making. + + but what if we need to stop! you can `throw new HaltExecution()`. kitten + catches it, optionally fires a reply, and aborts the chain. + </div> + +</section> + +<section> + <header> + <h2>a rest client that doesn't throw</h2> + <p class="subtitle">@purrkit/rest</p> + </header> + <div class="content"> + outside the router i still needed to just. talk to the discord api directly sometimes. and most fetch wrappers `throw` on 400/500 which is SO annoying because an http error code is not always a code exception + + like, if i ask for a user and they deleted their account, a 404 is a completely valid expected state, not a catastrophe. i don't want to wrap every call in a giant `try/catch` and have my error variable collapse into `unknown` mush + + so, `@purrkit/rest`. discriminated unions, my beloved: + +```ts +import { RESTClient } from "@purrkit/rest"; + +const client = RESTClient({ token: process.env.DISCORD_TOKEN }); + +const user = await client.users.me.get(); + +// typescript forces you to check `.ok` before accessing `.data` +if (user.ok) { + console.log(user.data.username); // UserPIIResponse +} else if (user.status === 429) { + console.log(user.data.retry_after); // RatelimitedResponse +} else { + console.error(user.data.message); // ErrorResponse +} +``` + + **how it works:** every method resolves to either `{ ok: true, status: 200, data: T }` or `{ ok: false, status: number, data: E }`. control flow analysis means `if (user.ok)` is a real type guard, inside it `data` is narrowed to the success shape, in the `else` it's narrowed to the error shape. no casting, no praying, typescript just KNOWS + + all the response types come straight from `@purrkit/types`, autogenerated off discord's own OpenAPI spec, so it's never out of sync with reality (unless discord lies to us, which, fair, has happened) + + </div> +</section>
A
src/content/projects/browser.cat.mdx
@@ -0,0 +1,17 @@
+--- +name: "browser.cat" +description: "a browser built with the chromium embedded framework made to not look awful" + +languages: ["Rust", "HTML", "CSS"] +stub: true + +links: + [ + { type: "website", label: "live", href: "https://vt3e.cat/" }, + { type: "source", label: "source", href: "https://tangled.org/vt3e.cat/catsite" }, + ] +--- + +<section> + <h2>meoww</h2> +</section>
D
src/content/projects/purrkit.mdx
@@ -1,17 +0,0 @@
---- -name: "purrkit" -description: "a meowing, fully-typesafe set of packages for interacting with the discord API" - -languages: ["TypeScript"] -stub: true - -links: - [ - { type: "source", label: "source", href: "https://tangled.org/vt3e.cat/purrkit.git" }, - { type: "website", label: "npm", href: "https://www.npmjs.com/package/@purrkit" }, - ] ---- - -<section> - <h2>meoww</h2> -</section>
M
src/layouts/Layout.astro
→
src/layouts/Layout.astro
@@ -59,19 +59,15 @@ </body>
</html> <style is:global> - body { - } - .viewport { display: flex; flex-direction: row; align-items: flex-start; gap: 0.5rem; - padding: 0.5rem; height: 100%; max-width: 1167px; - width: 100%; - margin: 1rem auto; + width: calc(100% - 1rem); + margin: 0.5rem auto; } .panel {@@ -101,7 +97,7 @@ .left {
flex: 1 1 auto; max-width: 400px; position: sticky; - top: 1rem; + top: 0.5rem; img { --radius: calc(var(--inner-radii) - 0.25rem);
M
src/pages/glorpify.astro
→
src/pages/glorpify.astro
@@ -6,7 +6,7 @@ <Layout>
<Fragment slot="left"> <header class="page-head"> <img - src="/kittens.jpg" + src="/kittens.webp" alt="two small orange & white kittens huddled together on a white surface. there is white text overlay over each of the kittens, the first reads 'you are made of stardust', the last reads 'and i am made of dirt'. there is a date stamp in the bottom right corner, it says '04.04.2008'." /> <div class="title">
M
src/pages/index.astro
→
src/pages/index.astro
@@ -5,6 +5,7 @@ import { ProjectCard } from "@/components";
const links = [ { label: "on bluesky", href: "https://bsky.app/profile/did:web:vt3e.cat" }, + { label: "on my git server", href: "https://git.vt3e.cat/" }, { label: "on tangled (git)", href: "https://tangled.org/did:web:vt3e.cat" }, { label: "on listenbrainz", href: "https://listenbrainz.org/user/aprvi" }, ];@@ -23,7 +24,7 @@ <Layout>
<Fragment slot="left"> <header class="page-head"> <img - src="/kittens.jpg" + src="/kittens.webp" alt="two small orange & white kittens huddled together on a white surface. there is white text overlay over each of the kittens, the first reads 'you are made of stardust', the last reads 'and i am made of dirt'. there is a date stamp in the bottom right corner, it says '04.04.2008'." /> <div class="title">
M
src/pages/projects/[slug].astro
→
src/pages/projects/[slug].astro
@@ -18,7 +18,7 @@ <Layout>
<Fragment slot="left"> <header class="page-head"> <img - src="/kittens.jpg" + src="/kittens.webp" alt="two small orange & white kittens huddled together on a white surface. there is white text overlay over each of the kittens, the first reads 'you are made of stardust', the last reads 'and i am made of dirt'. there is a date stamp in the bottom right corner, it says '04.04.2008'." /> <div class="title">@@ -36,3 +36,142 @@ <Content />
</article> </Fragment> </Layout> + +<style is:global> + .content { + display: flex; + flex-direction: column; + gap: 1rem; + color: hsl(var(--text)); + + p { + line-height: 1.6; + color: hsl(var(--subtext1)); + } + + strong { + font-weight: 700; + color: hsl(var(--text)); + } + + a:not(.link) { + color: hsl(var(--accent)); + text-decoration: underline; + text-decoration-color: hsla(var(--accent) / 0.4); + text-underline-offset: 3px; + font-weight: 600; + border-radius: 0.2rem; + + padding: 0; + background: transparent; + + &:hover, + &:focus-visible { + color: hsl(var(--blue)); + text-decoration-color: hsl(var(--blue)); + background: transparent; + } + } + + /* Markdown Blockquotes */ + blockquote { + position: relative; + + padding: 0.5rem 1rem; + background-color: hsl(var(--surface1) / 1); + color: hsl(var(--subtext1)); + font-style: italic; + overflow: hidden; + border-radius: 0.25rem; + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 0.5ch; + height: 100%; + background-color: hsl(var(--accent)); + } + } + + /* Lists */ + ul, + ol { + padding-left: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + + li::marker { + color: hsl(var(--accent)); + } + } + } + + /* --- Inline Code (`code`) --- */ + :not(pre) > code { + font-family: + "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, + Monaco, Consolas, monospace; + font-size: 0.85em; + padding: 0.15em 0.4em; + border-radius: 0.375rem; + color: hsl(var(--purple)); + border: 1px solid hsl(var(--surface2) / 0.4); + white-space: break-spaces; + word-break: break-word; + } + + /* --- Code Blocks (`pre` / Shiki output) --- */ + pre, + pre.astro-code { + position: relative; + margin: 0.5rem 0; + padding: 0.5rem; + border-radius: 0.75rem; + border: 1px solid hsl(var(--surface1)); + overflow-x: auto; + + font-family: + "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, + Monaco, Consolas, monospace; + font-size: 0.875rem; + line-height: 1.6; + tab-size: 2; + + scrollbar-width: thin; + scrollbar-color: hsla(var(--overlay1) / 0.6) transparent; + + &::-webkit-scrollbar { + height: 6px; + } + &::-webkit-scrollbar-thumb { + background: hsla(var(--overlay1) / 0.6); + border-radius: 3px; + } + + code { + font-family: inherit; + font-size: inherit; + background: transparent; + padding: 0; + border: none; + color: hsl(var(--text)); + } + } + + pre[data-language]::before { + content: attr(data-language); + position: absolute; + top: 0.5rem; + right: 0.75rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: hsl(var(--overlay1)); + user-select: none; + pointer-events: none; + } +</style>
M
src/types.ts
→
src/types.ts
@@ -20,9 +20,17 @@ name: z.string(),
description: z.string(), /** if true, link will not be rendered to its page */ stub: z.boolean(), + pinned: z.boolean().default(false).optional(), links: z.array(LinkSchema), languages: z.array(z.enum(LANGUAGE_KEYS)), }); +export type Project = z.infer<typeof ProjectSchema>; -export type Project = z.infer<typeof ProjectSchema>; +export const DiscordMessageSchema = z.object({ + author: z.string(), + avatarUrl: z.string(), + content: z.array(z.string()), + timestamp: z.string(), +}); +export type DiscordMessage = z.infer<typeof DiscordMessageSchema>;