Skip to content

Internals

yCENzh edited this page Sep 19, 2026 · 1 revision

How it works

For people extending the theme, contributing upstream, or debugging something the other pages did not cover.

Two modes, one integration

The integration runs in two situations and behaves differently in each:

Installed as a package Theme repository
isThemeRepo false true
Config comes from shirones/config/ src/config/
Pages come from injectRoute src/pages/
Font subsets go to .shirones/fonts/ src/assets/fonts/.subset/
Content directory resolved from paths src/content/

It is one boolean, not several. The obvious design — separate flags for "is this a plugin install" and "am I inside the repository" — turns out to have three reachable states, and the third one is nonsense: package-mode paths, no init warning, and no injected routes, which yields an empty site with no error. pnpm link and workspace installs landed exactly there. One boolean cannot be inconsistent with itself.

The overlay

The theme's components import each other with relative paths, and reference config through @/config/.... When a user overrides one file, every other file's import of it has to be redirected. Rewriting the theme's source at publish time would mean the package no longer matches the repository, so resolution happens at build time instead.

A Vite plugin with a resolveId hook handles it:

  1. A module resolves normally against the project root.
  2. A module that misses, whose path starts with the theme's package location, is retried against the user's src/.
  3. A hit becomes the override.
  4. Relative imports get a package fallback: a copied component can reference theme siblings it has not mirrored.

Step 2 only fires for paths under the theme's src/. Routing every specifier through a project-relative probe looks equivalent and is not — Vite interprets an unresolved bare specifier as a root-relative URL, so import "svelte" silently becomes /svelte and the build fails with something unrelated to what you changed.

The override table itself is built by walking the package's component, layout, config and data trees and probing the project for each path. It rebuilds on file changes under those directories, so adding an override in dev does not need a restart.

index.ts barrels are excluded at every depth. The theme relies on their named exports, and an override that changes a barrel's export list breaks every importer.

Why there is an esbuild in the config path

Astro config runs before Vite exists. The theme's config modules are TypeScript, and astro.config.mjs has to read them — for the site URL, the base path, the trailing-slash policy, the font options. There is no bundler available yet.

So the integration bundles each config module with esbuild, writes the result to .shirones/loaded/<name>.js, and imports that. The bundling:

  • inlines your imports of other config modules, so a siteConfig that reads from a shared module works
  • externalises the config modules themselves, which is what makes shadowing work — your file is imported, not the theme's
  • externalises astro/zod, so schema identity survives and instanceof checks hold
  • keeps Node built-ins external; anything else resolves to the package

Bundles are cached by a hash of their entry file and every non-external dependency. The theme's dependency graph is walked once and memoised, so a warm start costs about a millisecond.

Failures name the module:

[shirones] Failed to bundle "config:siteConfig" from /you/my-blog/shirones/config/siteConfig.ts:
  …

without the cause chain, an esbuild error surfaces as an opaque one-line throw from somewhere inside Astro's config loader.

Collections

The theme declares its collections in collections.manifest.json:

[
  { "key": "posts",   "pattern": "**/*.{md,mdx}", "schema": "postSchema" },
  { "key": "moments", "pattern": "**/*.md",       "schema": "momentSchema" },
  { "key": "spec",    "pattern": "**/*.{md,mdx}", "schema": "specSchema" },
  { "key": "series",  "pattern": "**/*.md",       "schema": "seriesSchema" }
]

init turns that into src/content.config.ts with one defineCollection per entry. It used to be a one-line helper — defineCollections() — and that had to go: Astro's typegen cannot introspect a schema hidden behind a function call, so it needs defineCollection with the schema visible inline.

The schemas themselves are exported from shirones/collections, which is why the generated file imports from the package but still spells out the structure.

Routes and the trailing slash

Pages are registered with injectRoute at astro:config:setup, because a package-mode project has no src/pages/ for Astro to discover.

trailingSlash is forced to "always" for a reason that is easy to reintroduce as a bug. Astro's resolveConfig — which runs after the integration's hook — rewrites image.endpoint.route with appendForwardSlash when the policy is always. Set route: "/_image" and leave the policy alone and you get a /_image/ endpoint whose dev-router pattern does not match the URLs the image service generates. Every transformed image 404s in dev while the production build works, because the build resolves image URLs differently.

The integration therefore sets both values together:

trailingSlash: "always",
image: { endpoint: { route: "/_image" } },

so the pair stays consistent regardless of what the user writes in astro.config.mjs.

CommonJS globals in prerender

Some of the theme's dependencies are CommonJS, and Astro inlines their modules into the prerender bundle. Those modules reference __filename, __dirname, require, exports and module, none of which exist in the ESM output. astro build fails during prerendering with __filename is not defined.

A Vite plugin injects a shim into every inlined module that references one of those globals:

import { createRequire as __shironesCreateRequire } from "node:module";
import { fileURLToPath as __shironesFileURLToPath } from "node:url";
import { dirname as __shironesDirname } from "node:path";
const require = __shironesCreateRequire(import.meta.url);
const __filename = __shironesFileURLToPath(import.meta.url);
const __dirname = __shironesDirname(__filename);

Modules that never touch those globals are left alone. It is scoped to the SSR/prerender bundle and skipped when Rollup already provides __filename as an output option.

Font subsetting timing

Subsetting runs in astro:config:setup, not in buildStart. Font URLs end up in transformedHtml and are not visible until after build starts, so the subsets have to exist before that.

The charset scan runs before the cache check, because remote playlist text is part of the charset and therefore part of the cache key. That is why allowRemoteText costs a network request on every build even when nothing else changed.

What ships in the package

files in the published package.json is ["src", "public", "manifest.json", ...]. Notably absent: scripts/.

The theme repository has Node scripts under scripts/ for icon generation, font subsetting and content sync. None of them ship, because in package mode the integration does all of that work itself. A user who tried to run them would fail at the first import anyway — the package has no TypeScript toolchain configured for its own source.

src/integration/ does ship, compiled into dist/index.js. It is not a second copy; it is the integration.

The publishing pipeline

The package is built from the theme repository by a separate pipeline that clones upstream at a pinned ref, copies src/ and public/, generates manifest.json from the real trees, derives peerDependencies from upstream's manifest, and validates the result by installing the tarball into a scratch project and building it.

Peer ranges are derived rather than hardcoded because a literal lags behind every upstream bump, and that drift is how users ended up with a sharp minor Astro's image service could not load.

Reading the source

Start at src/integration/index.ts for the hook ordering, then paths.ts for mode detection, overlay.ts for resolution, load-config.ts for the bundling, and fonts.ts for subsetting. Each has a header comment explaining the non-obvious part.

Next

Clone this wiki locally