From ae49edb30734b8de085b89635ead9e48608e3fb2 Mon Sep 17 00:00:00 2001 From: Gregory Linscheid Date: Sun, 7 Jun 2026 10:56:13 -0700 Subject: [PATCH 1/2] skeleton of fourth post --- src/content/blog/building-vilos92-com.md | 160 +++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/content/blog/building-vilos92-com.md diff --git a/src/content/blog/building-vilos92-com.md b/src/content/blog/building-vilos92-com.md new file mode 100644 index 0000000..7807095 --- /dev/null +++ b/src/content/blog/building-vilos92-com.md @@ -0,0 +1,160 @@ +--- +title: 'Building vilos92.com' +description: 'A tiny project hub on Preact, Vite+, and Cloudflare Workers — static public repo list, fuzzy search, short URLs' +pubDate: 'June 7 2026' +--- + + + + + + + +## What it is + +[vilos92.com](https://vilos92.com) is a project hub: one search box on `/`, and short paths like `vilos92.com/gdex` that redirect to the matching GitHub repo. Miss a slug and you land back on the hub with the query pre-filled so you can pick from fuzzy matches. + +The whole app is deliberately small — a prerendered Preact page, a Hono worker for redirects and resolve, and a checked-in JSON file for the repo catalog. Source: [Vilos92/vilos92.com](https://github.com/Vilos92/vilos92.com). + +## Where the repo list comes from + +The site does not call the GitHub API on every page load. Repo metadata lives in `src/projects.json`, regenerated locally with `bun run sync:projects`: + +```bash +gh repo list Vilos92 --limit 1000 --json name,isPrivate,isFork --jq ' + [.[] | select(.isFork == false)] + | map({ + slug: (.name | ascii_downcase), + name: .name, + githubUrl: "https://github.com/Vilos92/\(.name)", + private: .isPrivate + }) + | sort_by(.slug) +' +``` + +The shell script wraps that (`scripts/sync-projects-json.sh`): requires `gh` + `jq`, skips the write when nothing changed, and exits with code 10 when `gh` is missing or unauthenticated so agents can fall back to other tooling. + +At build time, Zod validates the JSON and the app splits public from private: + +```typescript +const projectSchema = z.object({ + slug: z.string(), + name: z.string(), + githubUrl: z.url(), + private: z.boolean() +}); + +export const projects = projectsSchema.parse(projectsJson); +export const publicProjects = projects.filter(project => !project.private); +``` + +**Public repos** feed the client-side search combobox — names and slugs ship in the static bundle, so typing never hits the network. **Private repos** stay out of that list; the worker still knows about them for slug resolution when you explicitly submit a query (see below). Re-run `sync:projects` when you add or rename repos, commit the diff, deploy. + + + + + + + +## Stack + +| Layer | Choice | +| ---------- | ----------------------------------------------------------------------- | +| UI | Preact 10 + Vanilla Extract (`*.css.ts`) | +| Toolchain | [Vite+](https://viteplus.dev/guide/) (`vp dev`, `vp check`, `vp build`) | +| Worker | [Hono](https://hono.dev/) on Cloudflare Workers | +| Deploy | `wrangler deploy` — `src/worker.ts` is the entry | +| Search | [fuzzysort](https://github.com/farzher/fuzzysort) over public slugs | +| Validation | Zod for `projects.json`; Vitest + fallow in CI | + +Wrangler config is minimal — worker name, compatibility date, main module: + +```jsonc +{ + "name": "vilos92-com", + "compatibility_date": "2025-04-17", + "main": "src/worker.ts" +} +``` + +The Vite config uses `@cloudflare/vite-plugin`, `@preact/preset-vite` with **prerender enabled**, and Vanilla Extract. One HTML shell, one client entry: + +```html +
+ +``` + +Build-time prerender renders `HubApp` to HTML; the client hydrates on load: + +```typescript +export async function prerender() { + return {html: renderToString()}; +} + +// client +hydrate(, root); +``` + +## Snappy by design + +Despite the feature set (combobox, keyboard nav, URL sync, slug redirects), the shipped assets stay tiny. A recent production build: + +| Asset | Size | +| ----------------------- | ------- | +| `hub-app-*.js` | ~103 KB | +| `hub-app-*.css` | ~5 KB | +| `rolldown-runtime-*.js` | ~0.5 KB | + +Why it stays fast: + +1. **Prerendered shell** — first paint is HTML, not a blank `#root`. +2. **No search API** — `publicProjects` is in the bundle; `fuzzysort` runs locally as you type: + +```typescript +export function searchPublicProjects(projects: readonly Project[], query: string, limit = 8) { + return searchPublicProjectsScored(projects, query, limit).map(result => result.obj); +} +``` + +3. **Worker only on submit** — choosing or submitting a slug calls `/api/resolve?q=…`; the worker returns `{ok, slug, name, url}` or `{ok: false}`. Success opens GitHub in a new tab. +4. **302 redirects for bookmarkable paths** — `GET /:slug` never serves a page; it redirects to GitHub or back to `/?q=slug`: + +```typescript +export function resolveSlugPath(pathname: string): RedirectResult { + const slug = pathname.replace(/\/+$/, '').slice(1); + const exact = exactProjectBySlug(projects, slug); + if (exact) { + return {kind: 'redirect', location: exact.githubUrl}; + } + const fuzzy = fuzzyFindPublicProject(projects, slug.toLowerCase()); + if (fuzzy) { + return {kind: 'redirect', location: fuzzy.githubUrl}; + } + return {kind: 'redirect', location: hubSearchUrl(slug)}; +} +``` + +Fuzzy matching uses a score threshold and a gap between first- and second-place matches so ambiguous slugs (e.g. two repos that both match `ck`) fall through to hub search instead of a wrong redirect. + + + + + + + +## Quality gate + +Same playbook as [this site's rebuild](/blog/the-new-new-greglinscheid-com/): `vp check`, Vitest, fallow audit in CI. Hub search and routing logic are heavily unit-tested (`routing.test.ts`, `hub-search*.test.ts`, `slug-fuzzy.test.ts`) because the redirect and combobox behavior is easy to regress. + +## Links + +- Live: [vilos92.com](https://vilos92.com) +- Repo: [github.com/Vilos92/vilos92.com](https://github.com/Vilos92/vilos92.com) +- Sync script: `bun run sync:projects` + + + + + + From d7c03b0f3d241b974d8cddc451a0b1e84fad22f4 Mon Sep 17 00:00:00 2001 From: Gregory Linscheid Date: Sun, 7 Jun 2026 11:17:06 -0700 Subject: [PATCH 2/2] post --- public/blog/building-vilos92-com.jpg | Bin 0 -> 45489 bytes src/content/blog/building-vilos92-com.md | 61 +++++++++-------------- 2 files changed, 23 insertions(+), 38 deletions(-) create mode 100644 public/blog/building-vilos92-com.jpg diff --git a/public/blog/building-vilos92-com.jpg b/public/blog/building-vilos92-com.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0637d7a3b42f336ca807a063e95c111a361d414f GIT binary patch literal 45489 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 F{{U)!p1lA7 literal 0 HcmV?d00001 diff --git a/src/content/blog/building-vilos92-com.md b/src/content/blog/building-vilos92-com.md index 7807095..e3ffe90 100644 --- a/src/content/blog/building-vilos92-com.md +++ b/src/content/blog/building-vilos92-com.md @@ -1,20 +1,21 @@ --- title: 'Building vilos92.com' -description: 'A tiny project hub on Preact, Vite+, and Cloudflare Workers — static public repo list, fuzzy search, short URLs' -pubDate: 'June 7 2026' +description: 'A tiny project hub on Preact, Vite+, and Cloudflare Workers: static public repo list, fuzzy search, short URLs' +pubDate: 'June 8 2026' +heroImage: '/blog/building-vilos92-com.jpg' --- - +## Seriously, why did you build this? - +As I mentioned in [my last blog post](/blog/the-new-new-greglinscheid-com/), I wanted a reason to write a blog post about something other than blog posts, so here we... ah I did it again. - +Anyways, the real motivation: I hate going to GitHub and then finding my projects in their clunky, slow UI. It works, and for many years it wasn't something I considered worth fixing. -## What it is +But now with LLMs, prototyping and building simple projects is quite cheap. -[vilos92.com](https://vilos92.com) is a project hub: one search box on `/`, and short paths like `vilos92.com/gdex` that redirect to the matching GitHub repo. Miss a slug and you land back on the hub with the query pre-filled so you can pick from fuzzy matches. +So, [vilos92.com](https://vilos92.com) is a project hub: one search box on `/`, and short paths like `vilos92.com/gdex` that redirect to the matching GitHub repo. Miss a slug and you land back on the hub with the query pre-filled so you can pick from fuzzy matches. -The whole app is deliberately small — a prerendered Preact page, a Hono worker for redirects and resolve, and a checked-in JSON file for the repo catalog. Source: [Vilos92/vilos92.com](https://github.com/Vilos92/vilos92.com). +The whole app is deliberately small. It's a prerendered Preact page, a Hono worker for redirects and resolve, and a checked-in JSON file for the repo catalog. Source: [Vilos92/vilos92.com](https://github.com/Vilos92/vilos92.com). ## Where the repo list comes from @@ -49,13 +50,7 @@ export const projects = projectsSchema.parse(projectsJson); export const publicProjects = projects.filter(project => !project.private); ``` -**Public repos** feed the client-side search combobox — names and slugs ship in the static bundle, so typing never hits the network. **Private repos** stay out of that list; the worker still knows about them for slug resolution when you explicitly submit a query (see below). Re-run `sync:projects` when you add or rename repos, commit the diff, deploy. - - - - - - +**Public repos** feed the client-side search combobox. Names and slugs ship in the static bundle, so typing never hits the network. **Private repos** stay out of that list. The worker still knows about them for slug resolution when you explicitly submit a query (see below). Re-run `sync:projects` when you add or rename repos, commit the diff, deploy. ## Stack @@ -64,11 +59,11 @@ export const publicProjects = projects.filter(project => !project.private); | UI | Preact 10 + Vanilla Extract (`*.css.ts`) | | Toolchain | [Vite+](https://viteplus.dev/guide/) (`vp dev`, `vp check`, `vp build`) | | Worker | [Hono](https://hono.dev/) on Cloudflare Workers | -| Deploy | `wrangler deploy` — `src/worker.ts` is the entry | +| Deploy | `wrangler deploy`, entry `src/worker.ts` | | Search | [fuzzysort](https://github.com/farzher/fuzzysort) over public slugs | -| Validation | Zod for `projects.json`; Vitest + fallow in CI | +| Validation | Zod for `projects.json`, Vitest + fallow in CI | -Wrangler config is minimal — worker name, compatibility date, main module: +Wrangler config is minimal: worker name, compatibility date, main module: ```jsonc { @@ -85,7 +80,7 @@ The Vite config uses `@cloudflare/vite-plugin`, `@preact/preset-vite` with **pre ``` -Build-time prerender renders `HubApp` to HTML; the client hydrates on load: +Build-time prerender renders `HubApp` to HTML so that the client can hydrate on load: ```typescript export async function prerender() { @@ -108,8 +103,8 @@ Despite the feature set (combobox, keyboard nav, URL sync, slug redirects), the Why it stays fast: -1. **Prerendered shell** — first paint is HTML, not a blank `#root`. -2. **No search API** — `publicProjects` is in the bundle; `fuzzysort` runs locally as you type: +1. **Prerendered shell**: first paint is HTML, not a blank `#root`. +2. **No search API**: `publicProjects` is in the bundle. `fuzzysort` runs locally as you type: ```typescript export function searchPublicProjects(projects: readonly Project[], query: string, limit = 8) { @@ -117,8 +112,8 @@ export function searchPublicProjects(projects: readonly Project[], query: string } ``` -3. **Worker only on submit** — choosing or submitting a slug calls `/api/resolve?q=…`; the worker returns `{ok, slug, name, url}` or `{ok: false}`. Success opens GitHub in a new tab. -4. **302 redirects for bookmarkable paths** — `GET /:slug` never serves a page; it redirects to GitHub or back to `/?q=slug`: +3. **Worker only on submit**: choosing or submitting a slug calls `/api/resolve?q=…`. The worker returns `{ok, slug, name, url}` or `{ok: false}`. Success opens GitHub in a new tab. +4. **302 redirects for bookmarkable paths**: `GET /:slug` never serves a page. It redirects to GitHub or back to `/?q=slug`: ```typescript export function resolveSlugPath(pathname: string): RedirectResult { @@ -137,24 +132,14 @@ export function resolveSlugPath(pathname: string): RedirectResult { Fuzzy matching uses a score threshold and a gap between first- and second-place matches so ambiguous slugs (e.g. two repos that both match `ck`) fall through to hub search instead of a wrong redirect. - - - - - +All in all, this setup does what I need: fast personal links to my repos, fuzzy enough to forgive typos, and shareable short URLs that redirect to GitHub. ## Quality gate -Same playbook as [this site's rebuild](/blog/the-new-new-greglinscheid-com/): `vp check`, Vitest, fallow audit in CI. Hub search and routing logic are heavily unit-tested (`routing.test.ts`, `hub-search*.test.ts`, `slug-fuzzy.test.ts`) because the redirect and combobox behavior is easy to regress. - -## Links - -- Live: [vilos92.com](https://vilos92.com) -- Repo: [github.com/Vilos92/vilos92.com](https://github.com/Vilos92/vilos92.com) -- Sync script: `bun run sync:projects` +This project follows the same playbook as [this site's rebuild](/blog/the-new-new-greglinscheid-com/): `vp check`, Vitest, fallow audit in CI. Hub search and routing logic are heavily unit-tested (`routing.test.ts`, `hub-search*.test.ts`, `slug-fuzzy.test.ts`) because the redirect and combobox behavior is easy to regress. - +Live at [vilos92.com](https://vilos92.com), source at [github.com/Vilos92/vilos92.com](https://github.com/Vilos92/vilos92.com). Repo list sync: `bun run sync:projects`. - +## What's next - +I'd like to post about [gdex](https://github.com/Vilos92/gdex) sometime, but we'll see if this homepage burst can keep pace.