Astro
-
Dependency Updates Needed a Coordinated Review
Six Dependabot pull requests in my wiki project were closed and replaced with two coordinated updates.
The packages still needed upgrading. The individual PRs just weren’t a useful way to review the related configuration and migration work.
After doing that twice, the project changed its Dependabot policy and added tests for the choices worth preserving.
The version change was only part of the work
The compiler upgrade needed a configuration change. The authentication update needed its runtime and CLI reviewed together. The framework upgrade included a rendering decision.
Astro 7 made that last one concrete. Its default
compressHTMLbehavior changed to JSX-style whitespace handling. The wiki chose to retain the previous behavior with:compressHTML: trueThat choice belongs in a framework upgrade review. A successful dependency resolution doesn’t establish that the resulting pages preserve the intended spacing.
Dependabot does more than edit a version string; it can update the lockfile and related dependency requirements. But a dependency PR doesn’t automatically settle application-specific migration choices.
Not every package in those six PRs necessarily needed to move with every other one. The benefit of the replacement updates was reviewing the connected changes together, not proving that individual dependency updates are always wrong.
The old policy excluded routine patch updates
The configuration had a wildcard rule ignoring patch-level version updates.
That reduced one kind of PR traffic, but it left the project reviewing minor and major updates without the same routine patch-update stream. Removing that blanket exclusion made sense.
Patch releases aren’t guaranteed safe, and security fixes aren’t confined to patch releases. The problem was the indiscriminate policy, not a rule that one version level deserves automatic approval.
The replacement created a version-update group covering the npm dependencies and a separate security-update group. Dependabot’s
applies-tosetting distinguishes those two types of group.Grouping can reduce the number of PRs and make related updates easier to review. It also creates a larger change to diagnose or revert if something breaks. For this project, the broad group was a starting policy, not evidence that every future upgrade should land as one bundle.
A difficult major upgrade can still deserve a separate review.
Test the policy, then test the application
The new policy tests check that the version group includes major, minor, and patch updates, and that the blanket patch-ignore rule doesn’t return.
Those assertions don’t prove the Dependabot configuration is optimal. They preserve a deliberate decision and make a later reversal visible.
The suite also checks that the Astro configuration retains
compressHTML: true. That protects the explicit setting, but checking for text in a config file isn’t a rendering test. It cannot prove the setting is active or that a particular page has the right whitespace.For that, a representative production build and rendered-page check provide different evidence. I want the small policy assertion without pretending it replaces that check.
The next grouped PR showed that Dependabot was producing the intended review shape. Whether its updates were ready to merge remained a separate question.
The useful outcome wasn’t fewer tabs by itself. It was having the dependency changes and their application consequences in a review where they could be considered together.
Sources
- Dependabot options reference — grouping, update types, and separate version/security policies.
- Astro 7 upgrade guide — whitespace behavior and preserving the previous setting with
compressHTML: true.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
A Dependency Bump Changed the Shape of My Vectors
The commit is titled
feat(deps): consolidate dependency updates. Sounds like a Tuesday. Bump some carets, watch CI go green, move on.What it actually did was cut every embedding in the database in half and rename the table they live in.
The one line that did it
- "@logan/libsql-search": "jsr:^0.1.3", + "@logan/libsql-search": "jsr:^0.7.1",That’s a jump across 14 published versions. I checked the JSR registry rather than trusting my memory of it: between 0.1.3 and 0.7.1 there are 0.1.4, 0.1.5, 0.1.6, 0.2.0 through 0.2.4, 0.3.0, 0.4.0, 0.5.0, 0.6.0, and 0.7.0. The package is at 0.11.1 now, so I was already well behind when I started.
Everything downstream of that line is the interesting part, and none of it appears in the commit message.
The embedding runtime got swapped underneath me
The library generates embeddings locally, no API key required. In 0.1.x it did that with
@xenova/transformers. In 0.7.x it doesn’t.I didn’t take the changelog’s word for this. I checked what’s actually installed:
$ grep -c "xenova" pnpm-lock.yaml 0 $ grep -n "onnxruntime" pnpm-lock.yaml | head -3 2455: [email protected]: 2461: [email protected]:Zero references to the old runtime.
[email protected]in its place, and nothing undernode_modules/@xenovaon disk. The inference engine changed.There’s a second place that tell shows up, and it’s one I’d never thought to read as a signal before. pnpm keeps an allowlist of packages permitted to run install scripts, because native modules need to compile:
-onlyBuiltDependencies: - - sharp - - "@xenova/transformers" +allowBuilds: + esbuild: true + onnxruntime-node: true + protobufjs: true + sharp: trueTwo things happened in that hunk. pnpm 10 to 11 renamed the field, which is why it’s a rewrite rather than an edit. And the membership changed:
@xenova/transformersout,onnxruntime-nodeandprotobufjsin. Your native build allowlist is a map of every package that compiles C++ on your machine. When entries appear and disappear there, something real moved.768 became 384
This is the part with actual consequences.
- embedding F32_BLOB(768),The new local model emits 384-dimension vectors instead of 768. Those are not interchangeable. A 384-float vector cannot be compared against a 768-float vector, so every embedding computed before this commit became unusable the moment it landed.
Which is why the table name is now pinned to the dimension:
export const SEARCH_TABLE_NAME = 'articles_local_384'; export const LOCAL_EMBEDDING_DIMENSIONS = 384;That two-line file is the best decision in the whole commit. The old code had
768written intoscripts/init-db.tsas raw DDL, and separately into the indexer, and separately into the search query. Three places, no shared constant, and nothing that would fail loudly if one drifted. Now the dimension lives in one module and the table it belongs to carries the number in its name, so a mismatched index can’t quietly overwrite a good one. It creates a new table instead.The hand-written schema went away with it.
scripts/init-db.tslost 37 lines and gained 13: aCREATE TABLE, alibsql_vector_idxindex, two more indexes, and their log lines all collapsed into one call.await createTable(client, SEARCH_TABLE_NAME, LOCAL_EMBEDDING_DIMENSIONS);I’m of two minds about that. Less schema I maintain is good. But the vector index definition is now something I have to go read the library source to see, and if it changes in 0.12 I won’t find out from my own repo.
Also smuggled in
Since I was already reading the diff instead of the title:
- TypeScript 6 to 7. A major version, in a commit that says “dependency updates.”
tsc --versionconfirms 7.0.2 is what’s installed. baseUrldeleted from tsconfig.json. One line, no comment. Thepathsmapping for@/*still works because modern resolution doesn’t need it, butignoreDeprecations: "6.0"is still sitting in there pointing at the previous major.- pnpm 10.20.0 to 11.23.0, and Node’s floor moved from 22.12.0 to 22.13.0.
The one that actually left
I removed
winstonin the same window, and the skeptical move is to assume it’s still there. Optional and transitive dependencies survive manifest deletions constantly, so “removed from package.json” and “gone” are different claims.This time they matched. Zero lockfile references, nothing on disk, no imports anywhere in source. The reason is that
logan-loggerwent 1.1.16 to 2.5.1, and version 2.5.1’s ownpackage.jsondeclares no dependencies at all. Winston was its transport layer, and dropping it took the whole subtree out.So the removal was clean. I’m mentioning it because I expected it not to be, and reporting only the findings that confirm your suspicions is how you end up with a blog full of nonsense.
The caret did its job
Worth being fair about what went wrong here, because it isn’t semver.
^0.1.3does not resolve to 0.7.1. For 0.x releases the caret pins to the minor, so^0.1.3stays inside0.1.x. Getting to 0.7.1 required editing that string by hand. No install silently pulled a breaking change in, and no lockfile refresh could have.The upgrade was deliberate. The migration note even made it into the repo’s
CLAUDE.md, saying to rundb:initbeforeindexwhen coming from the legacy 768-dimension table.What failed is the commit message.
feat(deps): consolidate dependency updatesis a true statement that hides a data migration, a runtime swap, and a compiler major. Six months from now, bisecting a search quality regression, that title is going to send me straight past the commit that caused it.Things I’m doing differently:
git diff <base>..HEAD -- pnpm-lock.yamlbefore believing any manifest. Additions, removals, and version changes are three separate stories.- Read the native build allowlist as a diff.
allowBuildsin pnpm 11,onlyBuiltDependenciesin 10. Changes there mean a compiled dependency moved. - Put the dimension in the table name.
articles_local_384cannot be corrupted by a 768-dimension writer.articlescan. - Don’t bundle a compiler major with anything. TypeScript 6 to 7 deserved its own commit and its own revert point.
- If a dep bump invalidates stored data, the commit message says so.
feat(deps)!:with aBREAKING CHANGEfooter costs nothing and is the only thing future-me will actually see.
One thing I have not done and won’t pretend otherwise: I haven’t measured whether 384-dimension search results are worse than the 768-dimension ones were. Half the floats for a corpus this small is likely a fine trade, and it’s faster, but “likely fine” is a guess and I should go get a number.
Sources
- @logan/libsql-search on JSR — version history, if you want to count the gap yourself
- ONNX Runtime for Node.js — the inference engine that replaced the old transformers package
- pnpm settings reference —
allowBuilds, and what it replaced in pnpm 10 - Turso: AI and embeddings —
F32_BLOBandlibsql_vector_idx, the storage side of all this - semantic-docs — the repo, and PR #88 is the commit in question
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- TypeScript 6 to 7. A major version, in a commit that says “dependency updates.”
-
My Rate Limiter Documented Its Own Bypass
I went looking through the rate limiter on a docs search API this weekend and found a comment I’d written explaining exactly how to defeat the code directly underneath it.
Not a TODO. Not a “we should probably fix this someday.” An accurate, well-written security note describing the bypass, sitting three lines above the bypass.
Here’s what it said:
// X-Forwarded-For format: "client, proxy1, proxy2, ..." // The leftmost IP is the original client IP as reported to the first proxy. // We take the leftmost IP because it represents the originating client. // Security note: The leftmost IP can be spoofed if upstream proxies don't // strip or validate existing X-Forwarded-For headers from incoming requests. // For higher security, prefer x-real-ip from trusted proxies. proxyIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null;Read that in order. Sentence three says we take the leftmost IP. Sentence four says the leftmost IP can be spoofed. Then the code takes the leftmost IP.
Why leftmost is the wrong end
X-Forwarded-Foris an append-only chain. Each proxy that handles a request tacks the address it saw onto the right. So the header grows left to right, oldest first.That means the leftmost value is whatever the original client claimed, and every value to the right of it was written by a piece of infrastructure. If a request arrives at your app with
X-Forwarded-For: 203.0.113.9, nothing in that string is evidence of anything. I can put whatever I want there with curl.The rightmost entries are the trustworthy ones, because your own proxies wrote them. Which is why the correct read is to count backwards a known number of hops, and take the address just before your trusted infrastructure starts.
The old code did the opposite. Worse, the call site made the header trusted by default:
// Rate limiting: 20 requests per minute per IP // Use 'x-forwarded-for' for common proxy setups. const rateLimitResult = checkRateLimit(request, { maxRequests: 20, windowSeconds: 60, trustedProxyHeader: 'x-forwarded-for', });There’s no environment check there. No “only when deployed behind nginx.” Every deployment, including a plain
node ./dist/server/entry.mjson a box with no proxy in front of it, trusted a header that any client can set.The limiter allows 20 requests per minute per client identity. If the client picks its own identity, the limit is 20 requests per minute per made-up IP address, and there are about four billion of those. The rate limiter wasn’t weak. It was decorative.
The fix
Three changes, all in one commit.
The header is ignored unless you explicitly opt in with
RATE_LIMIT_TRUSTED_PROXY_HEADER. Default identity now comes fromclientAddress, which Astro gets from the actual socket and a client can’t forge. And when you do opt in,RATE_LIMIT_TRUSTED_PROXY_HOPStells the resolver how many entries on the right belong to your infrastructure:const chain = value.split(',').map((address) => address.trim()); if (chain.length === 0 || chain.some((address) => isIP(address) === 0)) { return null; } const clientIndex = Math.max(0, chain.length - trustedProxyHops - 1); return chain[clientIndex] ?? null;Note the validation on the whole chain, not just the value being extracted. If any entry isn’t a real IP, the header gets thrown out entirely. That’s using
isIPfromnode:netinstead of the pair of hand-rolled regexes that used to live there, one of which was a 400-character IPv6 pattern with a comment calling itself “simplified.”I also bounded the store. It’s an in-memory
Map, and buckets are keyed on client identity, so unbounded distinct keys is a memory leak with a friendly interface. It now caps atRATE_LIMIT_MAX_ENTRIES(default 10,000) and evicts the entry with the earliest reset time when full.One deliberate ugly bit survived. When there’s no direct address and no trusted header, everything buckets together under the literal string
unknown. That’s a shared bucket, and yes, one noisy client can exhaust it for everyone in that state. I kept it because the alternative is worse: a unique key per unidentifiable request means the limiter silently turns itself off for exactly the traffic you can’t identify. A limiter that’s too aggressive is a bug report. A limiter that’s off is a bill.The indexer had the same disease
Same weekend, different file. The content indexer would print this and exit 0:
Successfully indexed 47/52 documents Failed to index 5 documentsExit zero. CI green. Deploy proceeds. Five documents missing from the search index, and every automated signal in the pipeline says the build succeeded.
The failure count was right there in the output. Nobody reads the output of a step that passed.
The fix pulled the logic into
runContentIndexing(), which returns a0 | 1the CLI assigns toprocess.exitCode, and the new test file asserts the nonzero path on both partial failure and a thrown error. That last case mattered more than the partial one, since a rejected promise mid-index was also landing as a clean exit.What actually connects these
Both bugs are the same shape, and it isn’t “I forgot to handle an edge case.” Both were mechanisms that reported success while doing nothing.
The rate limiter returned well-formed
X-RateLimit-Remainingheaders the whole time. The indexer printed a tidy progress log. If you were watching either one from the outside, they looked healthy. That’s the failure mode I now go looking for first, because it’s invisible by construction, and no amount of staring at green dashboards will surface it.The thing that finally caught both was writing tests that assert on the failure path. Test count went from 153 to 178 across these fixes. Most of those 25 new tests do something unglamorous: they check that the thing fails when it’s supposed to.
A few specifics worth stealing:
- Trust
X-Forwarded-Forfrom the right, never the left, and only with an explicit hop count you configured for that deployment. - Validate the entire chain, not just the value you pull out of it. Use
isIPfromnode:netand delete your IP regex. - Bound any in-memory store keyed on request-derived data. If an attacker picks the key, the attacker picks your memory ceiling.
- Make CLI scripts set
process.exitCodeon partial failure. Printing the failure count isn’t reporting it. - Write the test that asserts the failure path. If your suite only covers the happy path, a no-op implementation passes every test you have.
Go read the comments in your own auth and rate limiting code. Not the code, the comments. Past you may have already written the vulnerability report.
Sources
- MDN: X-Forwarded-For — the append-order behavior and its spoofing caveat
- RFC 7239 — the standardized
Forwardedheader that replaced the de factoX-Forwarded-*set - Astro API reference —
clientAddress, the socket-derived address used as the default identity - OWASP API4:2023 Unrestricted Resource Consumption — the category both of these fall under
- semantic-docs — the repo, if you want the full diffs in PR #93 and PR #92
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- Trust
-
I Removed Three Dependencies. One of Them Actually Left.
In forty-six minutes one morning I opened three pull requests, and every one of them deleted a dependency.
The commits are timestamped, so I can be precise about it:
- 07:33 replace Sharp transforms with
Bun.Image - 07:55 enforce signed upload content type (the AWS SDK removal that didn’t happen)
- 08:19 adopt
Bun.YAMLfor sync
Two merged with the dependency gone from
package.json. One I abandoned on purpose. That’s a decent morning, and it’s how I described it to myself at the time.Then I went back and looked at the lockfile.
Only one of those packages actually left.
Sharp Is Still Sitting in node_modules Right Now
The image one looked like the clean win. My media pipeline generates thumbnail, medium, and large variants, plus WebP versions, and it used Sharp to do it. Bun 1.4 ships a native
Bun.Image, so I rewrotesrc/lib/media.tsonto it and pulled"sharp": "^0.35.3"out of my dependencies.The benchmark was encouraging. Same workload, 2400×1600 inputs, separate processes,
/usr/bin/time -lon macOS:Input Sharp peak RSS Bun.Image peak RSS Elapsed JPEG 218 MB 75 MB 224ms → 207ms PNG 185 MB 80 MB 389ms → 549ms WebP 177 MB 77 MB 238ms → 180ms Memory is not close. Roughly a third of the footprint across all three formats. Speed is a wash, and PNG got noticeably worse, about 40% slower. The encoded output isn’t byte-identical either: JPEG came out 9% larger, PNG 2% smaller, WebP 3% larger. Fine for my purposes, but it’s a re-encode, not a port.
One caveat I want to be honest about, because it undercuts the comparison: Sharp wouldn’t load under Bun 1.4.0 on this machine at all. It failed on a missing
libvips-cpp.8.18.3.dylib. Undernode -e 'import("sharp")'it imported fine. So that table is Sharp-under-Node against Bun.Image-under-Bun, which means it’s measuring two runtimes, not two image libraries.Anyway. I removed Sharp, the tests passed, I merged it.
Sharp is still installed. Here it is:
$ grep -c '"@img/sharp-\|"sharp"' bun.lock 27 $ ls -d node_modules/sharp node_modules/sharpAstro 7 declares Sharp as an optional dependency for its own image integration. My lockfile says so in as many words:
"[email protected]" ... "optionalDependencies": { "sharp": "0.35.2" }So when I deleted my direct dependency, the resolver didn’t drop Sharp. It just stopped hearing my opinion about which version to use, and fell back to Astro’s. Sharp went from 0.35.3 to 0.35.2. My dependency removal was, in lockfile terms, a downgrade.
The One That Actually Left
The YAML change is the boring one and it’s the only real removal.
My blog sync script parsed frontmatter with the
yamlpackage. Bun 1.4 hasBun.YAMLwithparseandstringify, which is the entire surface I was using:const data = Bun.YAML.parse(frontmatter);That’s it.
"yaml": "^2.9.0"came out ofpackage.json, one entry came out ofbun.lock, and nothing else in the tree wanted it. Gone.No benchmark for this one. I didn’t run one, because a dependency that does one thing I can do with a builtin doesn’t need a performance argument to justify deleting it.
The Third One I Talked Myself Out Of
The AWS SDK is the one I wrote about separately. Short version: Bun’s native
S3Clientpresigns URLs about 32x faster and would take 25 packages out of the lockfile, and I kept the AWS SDK anyway, because Bun 1.4 can’t sendCache-Controlon writes and can’t forceContent-Typeinto a presigned URL’s signed headers. Faster, smaller, and not behaviorally equivalent.That one at least failed honestly. I evaluated it, wrote down why not, and the packages are still there because I decided they should be.
Three Words That Mean Three Different Things
Here’s what I actually learned, and it’s a vocabulary problem more than a technical one. “I removed a dependency” turns out to describe at least three unrelated states:
- It’s out of my
package.json. Sharp, yaml. This is the one people mean when they say it, and it’s the weakest of the three. - It’s out of the lockfile and off the disk. Only yaml. Sharp is still there, 27 entries, physically present in
node_modules. - It’s out of
trustedDependencies. This is the one I undersold at the time.esbuildis now the only package in my project allowed to run install lifecycle scripts. Sharp used to be in that list and isn’t anymore.
Number three is the one that actually changed my exposure. Sharp is still on disk, but nothing in my code calls it, and it no longer gets to execute arbitrary code at install time. Number two never happened. Number one is mostly bookkeeping.
I would have told you I removed two dependencies that morning. The lockfile says I removed one, downgraded another, and revoked install-script trust from a package that’s still sitting right there. All three of those are fine outcomes. They’re just not the same outcome, and I had been filing them under one word.
Check your lockfile after you delete something. Transitive optional dependencies do not care what your
package.jsonsays.Sources
- Bun.Image runtime documentation — the image API that replaced Sharp
- Bun.YAML runtime documentation —
parseandstringify, nothing else - Bun 1.4 release announcement — released 2026-08-20
- Astro images guide — Astro’s built-in image service, which is why Sharp stays
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- 07:33 replace Sharp transforms with
-
SvelteKit vs Astro: How I Choose in 2026
If you’ve followed my blog over the past few months, you know I’ve spent a lot of time digging into Svelte 5. Runes completely changed the way I think about reactivity, and SvelteKit is still one of the most elegant frameworks I’ve used for building genuinely interactive web apps.
So lately I’ve been thinking about: when does it make sense to use Astro instead of SvelteKit?
The honest answer is that they’re not really competing, or at least they didn’t used to be. Both frameworks are excellent, and historically they solved two pretty different problems. That line has gotten blurrier lately, but the old split is still the best starting point I’ve got. After building so many things with Astro’s recent updates, here’s how I think about picking one in 2026.
SvelteKit Is for Apps
When you’re building a dashboard, a complex SaaS tool, or anything where state needs to persist across a lot of dynamic route changes, SvelteKit is hard to beat.
SvelteKit assumes your app is going to do a lot of client-side work, and it leans into that. Form actions, nested layouts, complex data loading, it makes all of it straightforward. Combine the routing with Svelte 5’s runes and you get a developer experience that feels like writing plain JavaScript, except the reactivity just happens.
My rule of thumb: if the user is going to be clicking around, dragging things, and holding a long-lived session, I reach for SvelteKit.
Astro Is for Content
Astro comes at the web from the opposite direction. The core philosophy is “zero JS by default,” and it means it.
If you’re building a blog, a marketing site, documentation, or a storefront where the whole point is getting content in front of the reader as fast as possible, Astro wins. Its Islands Architecture ships raw, fast HTML and only hydrates the specific interactive pieces that actually need JavaScript. The result is a site that’s quick out of the box without you having to fight for it.
I should be fair, though: Astro hasn’t stayed in its lane. Recent releases have pushed it toward more dynamic, app-like territory with Server Islands, type-safe Actions, and a sessions API. The “content only” box doesn’t hold the way it did a year ago, and you can build genuinely interactive things in Astro now if you want to.
So I’d put it this way instead: as a rule of thumb, if it feels more like a website than a piece of software, that’s still where Astro shines.
You Don’t Actually Have to Choose
Here’s the the fun part, Astro has first-class support for Svelte.
You don’t give up Svelte to use Astro. Say I’m building a content-heavy site but I need one genuinely complex interactive piece, like a calendar component. I can write that calendar in Svelte, drop it into an Astro page, and tell Astro to hydrate only that component with a
client:loaddirective.You get the performance of a static or server-rendered Astro page, plus the developer experience of writing Svelte for the bits that need to be interactive. That’s a really good deal.
So Which One?
There’s real overlap now, so treat this as a default rather than a law. But it mostly still comes down to one question: are you building software or a website?
- Reach for SvelteKit when you’re building an application. If it feels like software, this is your tool.
- Reach for Astro when you’re building a content site. If SEO, initial load speed, and reading experience are the priorities, let Astro do the heavy lifting and sprinkle in Svelte components where you need them.
Pick the framework that matches the kind of thing you’re building, lean on the overlap when it helps, and don’t agonize over it. Either way you’re starting from a good place.
What are you reaching for first when you spin up a new project these days?
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
Sources
- Islands Architecture — Astro Docs — the “zero JS by default” philosophy and hydration control via client directives like
client:load. - Erika & Matthew Phillips, “Astro 4.12: Server Islands” (Astro Blog, July 18, 2024) — introduction of Server Islands to combine static caching and dynamic server-rendered blocks.
- Actions — Astro Docs — official documentation for Astro’s type-safe Actions API.
- Sessions — Astro Docs — official documentation for Astro’s sessions API.
- Introducing runes — Svelte Blog — Svelte 5 runes announcement introducing compiler-directive reactivity.
Astro javascript svelte Web development Sveltekit Frameworks
-
Semantic Docs Spring Update: Astro 6, Auto-Releases, npm
The last two months on Semantic Docs have mostly been maintenance work, but a few things I wanted to talk about. I pushed through a major framework upgrade, swapped out a vendored library for a real published package, and finally automated the release pipeline. Five tagged releases later, here’s where we are.
The Headlines
- Upgraded to Astro 6
- Switched from a vendored logger to the published
logan-loggernpm package - Shipped an auto-release workflow driven by Conventional Commits
- Three rounds of dependency updates plus a security-focused sweep
- Five tagged releases,
v1.3.3throughv1.5.0
Astro 6
The Astro 6 upgrade was easy. Semantic Docs runs a hybrid setup, static article pages plus a server-rendered search endpoint, and that part barely needed any attention. Most of the work was in the dependency layout, not the application code.
One note if you’re forking or syncing this theme: if you’re upgrading from
v1.3.5or earlier (anything pre-Astro-6, which landed inv1.4.0), delete yournode_modulesand your lockfile and do a clean install. Skip that step and you’ll get weird errors that look like your code is broken when it’s really just leftover state.A Real npm Package Instead of a Vendored Logger
For a while, the project was using a logger I wrote to experiment with publishing to both npm and JSR. It was a useful exercise. I wanted to see what a clean foundational package looked like across both registries, and I think it turned out well.
But for this repo, I wanted consistency over experimentation. So I swapped the vendored copy for the published
logan-loggernpm package. Behavior is the same, the surface area is the same, it’s just back on the npm registry.Automated Releases
I’ve liked using Conventional Commits to drive automated releases. When a PR merges to main, the workflow figures out the next version from the commit messages, tags it, and publishes a GitHub release with a generated changelog.
The commit type determines the version bump.
feat:bumps the minor,fix:bumps the patch, breaking changes bump the major. The changelog falls out of the same metadata. More automation here the better.If you’ve been on the fence about Conventional Commits, this is the use case that sold me.
What’s Next: Embedding Quality
The reference implementation uses TEI for search embeddings, and that’s been fine. But “fine” is not the same as “good,” and I want to actually compare quality across providers before I commit to anything long term.
Two I want to test:
- Jina (now owned by Elastic)
- Mistral, which has been putting out genuinely strong embedding models
The goal is to run the same corpus through each, evaluate the search results, and figure out which one earns a highlight. Whatever I learn from that work will get folded back into the open source Semantic Docs repo so anyone running their own instance can make an informed choice instead of just trusting my defaults.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
Routing in SvelteKit vs Next.js vs Astro
Continuing my series on Svelte, I want to dig into how SvelteKit handles routing and how it compares with Next.js and Astro. These are the frameworks I am most familiar with, so apologies if you wanted a different framework.
Let’s start with everybody’s favorite topic: routing. Okay, maybe I’m the only one excited about this, but stick with me.
Astro: The Traditional Approach
Astro’s routing feels the most traditional of the three. You have a
src/pages/directory, and the file name is the route. Sosrc/pages/dashboard.astromaps to/dashboard. If you’ve worked with PHP or other languages with file-based routing, this will feel immediately familiar.Inside the
.astrofile, you separate your backend logic at the top of the file with---fences. That code runs entirely on the server, and everything below is your HTML template. Clean and straightforward.Next.js: Folder-Based with the App Router
Next.js (the current versions, at least) uses the App Router. The structure is folder-based:
app/dashboard/page.tsxmaps to/dashboard. The key difference from Astro is that the folder name determines the route, but the file must always be calledpage.tsx.By default, everything in the
appdirectory is a server component, meaning it runs on the server. If you want client-side interactivity, you explicitly add"use client"at the top of the file. You can also set"use server"if you want to be explicit, but it’s the default. I think Next.js does a really good job of making it clear what runs where.SvelteKit: Convention Over Configuration
SvelteKit also uses folder-based routing, similar to Next.js. The structure is
src/routes/dashboard/, but instead ofpage.tsx, SvelteKit uses a reserved+prefix for its special files:+page.svelte— Renders on the server, then hydrates on the client+page.server.ts— Runs only on the server (data loading, form actions)+server.ts— A raw API endpoint with no UI
There’s no
"use client"or"use server"directive. The file naming convention itself tells SvelteKit what should run where. If you fetch a database record in+page.server.ts, that data is returned as an object, fully typed, and available in your+page.sveltevia the$propsrune. It just works.The Case for Standard File Names
I can see if some people get annoyed that every route has files named
+page.svelteand+page.server.ts. The files will all look the same in the IDE, but there’s a real advantage here: you can group all related components in the same route folder.For example, if you’re building a dashboard, you can keep your
DashboardChart.svelte,DashboardFilters.svelte, and other components right alongside your+page.svelteand+page.server.ts. You always know which file is the route entry point, which handles server logic, and which are supporting components. It encourages logical grouping instead of scattering related files across the project.Quick Comparison
Feature Astro Next.js SvelteKit Route structure File name = route Folder + page.tsxFolder + +page.svelteServer/client split ---fences"use client"directiveFile naming convention API routes src/pages/api/app/api/route.ts+server.tsDefault rendering Server Server Server + hydration All three frameworks use file-based routing, but they each have a slightly different philosophy about how to organize and separate concerns. Astro keeps it simple with traditional file mapping. Next.js gives you explicit directives. SvelteKit leans on naming conventions to keep things clean.
I think I can get used to the
+prefix convention in SvelteKit. The type safety between your server file and your page component is nice.Next up in the series, I’ll dig into how each framework handles data loading and forms.
-
Introducing EmDash — the spiritual successor to WordPress that solves plugin security
Today we are launching the beta of EmDash, a full-stack serverless JavaScript CMS built on Astro 6.0. It combines the features of a traditional CMS with modern security, running plugins in sandboxed Worker isolates.
-
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress - emdash-cms/emdash
-
It’s always some weird networking thing. Switched an internal Astro site from PNPM to Bun last night, didn’t test it before bed. Woke up to port 4321 not binding; dev server wouldn’t start. Turns out it randomly decided to only bind on IPv6 overnight. Had to explicitly tell it to bind on IPv4. Why is it always networking?