blume@1.4.0
Minor Changes
-
6afe539: Add declared facets:
content.types.<type>.facetsnames custom frontmatter keys whose values become filterable metadata. Faceted values ride along on search documents (blume-search.jsonand the MCP snapshot), and the MCPsearch_docsandlist_pagestools accept afiltersobject matching against them ({"domain": "architecture", "status": "enforced"}, every entry must match) — so a knowledge base holding RFCs, runbooks, or policies can drive progressive-disclosure agent workflows straight off its static content. Results carry their facet values,list_pagesshows each page’s, and the shared Orama index gains afacetTermsenum-array field so one static schema serves every project’s facet keys. Each facet name must be a declared custom key (per-type orfrontmatter.extend, validated at config load), and string, number, and boolean values facet — numbers and booleans stringified. -
7e9f7dd: Add
blume translate: agent-driven i18n translation with a committed freshness ledger and a CI drift gate.blume translate --claude(or--codex) finds every default-locale page that is missing or outdated in each configured locale and translates it headlessly with your local agent CLI — Blume builds the prompts, disables the agent’s tools, validates each reply’s structure (frontmatter reconstructed from the source, code-fence counts preserved), and writes the files itself. A committedblume.translations.jsonledger records the source hash behind every translation, so reruns are incremental, and hand-authored translations are adopted rather than overwritten (only--forceretranslates them). Under thedirparser, folder-navmeta.tstitles are translated too, copying every other key verbatim so per-locale sidebars keep their ordering.blume translate --checkis the read-only CI gate: it exits non-zero when any translation is missing or stale, with--jsonemitting the shared diagnostics report shape. -
6afe539: Let MCP clients filter by content type.
search_docsandlist_pagesaccept an optionalcontentTypesarray that narrows results to pages of the given frontmattertypes (["rfc"],["blog", "changelog"]), so an agent working against a site that mixes docs with RFCs, runbooks, or policies can scope retrieval to the kind of page it needs. Search hits now name their content type alongside the title, route, and excerpt, search documents carry the resolved type end to end (blume-search.jsonincluded), and the shared Orama index gains acontentTypeenum field filtered with an exactwherematch — the same mechanism the locale filter uses. -
6afe539: Add per-type frontmatter schemas via
content.types.<type>.frontmatter. Wherefrontmatter.extenddeclares custom keys site-wide, a per-type declaration scopes them to pages whose frontmattertypematches — so a project can require an RFC’sstatusor a runbook’sservicewithout loosening every other page. Keys follow the same rules asextend: any Standard Schema library validates them (Zod at whatever version the project installs, Valibot, ArkType), every declared key is checked on every page of the type so required schemas enforce type-wide, and validated values land on the page record’scustomfield. A declaration forcontent.defaultTypeapplies to pages that set notype, a key declared only for another type stays unknown elsewhere (typo-catching is unchanged), and a key can’t be declared both site-wide and per-type.
Patch Changes
-
f88d256: Honor
Accept: text/markdownon Cloudflare server builds. Deployed Workers sites ignored the header — the.mdmirrors were reachable only at their explicit URLs — because the ASSETS binding serves the prerendered content pages before the Worker script runs, and even a request that does reach the Worker is answered by@astrojs/cloudflare’s handler straight from that binding, ahead of the only place Astro middleware runs. No middleware could ever see a content-page request, so the negotiation now lives in the deploy bundle itself: afterastro build, Blume writes a small wrapper Worker in front of the adapter’s entry and scopesassets.run_worker_firstindist/server/wrangler.jsonto the content routes (grouped into a handful of route globs, merged with any user-configured rules, and staying within Wrangler’s rule limits).When the client prefers Markdown, the wrapper serves the page’s prerendered
.mdmirror from the assets binding withVary: Accept; every other request is delegated to the Astro Worker untouched, so only content routes pay the extra Worker hop. Cloudflare does not apply_headersto worker-first routes, so the wrapper re-stamps what the static layer would otherwise add on the routes it takes over — the homepage agent-discoveryLinkheader, the homepagex-markdown-tokensestimate, and the Markdowncharset=utf-8— while negative rules keep the raw.md/.mdxURLs on the static fast path with their_headerstreatment intact. The agent readability manifest now advertisescontentNegotiationon Cloudflare server builds too. -
bd939ba: Keep configured
redirectsoff the Cloudflare worker-first routes, so they ship the status you configured instead of a permanent 301. On a server build Blume routesredirectsthrough Astro’s own config, and@astrojs/cloudflareturns those intodist/client/_redirectsentries carrying the exact status — a file only Cloudflare’s static layer reads. Butassets.run_worker_firstis derived from the first path segment of the content routes, so a single page at/docs/referenceemits/docs/*, which swallows every redirect under/docs—/docs/api,/docs/api/run-query, and so on. Inside a worker-first route the static layer never runs,_redirectsis never consulted, and Astro’s SSR redirect handler answers instead. That handler honors the configured status only when the destination resolves to a discrete route, which under Blume it never does (every page is served from[...slug]), socomputeRedirectStatusdefaults a GET to 301. A configured 302 was therefore served as a permanent redirect that browsers cache more or less forever — the opposite of what a temporary redirect is for, and unfixable from the config.The generated Worker wiring now emits a negative
run_worker_firstrule for every configured redirect a positive rule would otherwise claim, so the static layer keeps serving it. The exemptions are derived from the merged rule set, so redirects claimed by your ownrun_worker_firstrules are exempted too (a configuredtrueis rewritten to its array spelling/*so the negatives can ride along), and they compare full served URLs, so subpath deploys (deployment.base) guard their content routes correctly. A redirect that no rule would have claimed gets no rule, and a path that is also a content route is never exempted — the page owns it, and taking it off the Worker would silently disable its Markdown negotiation; a docs-only site redirecting/is exempted like any other path. A retired section collapses to a{path}+{path}/*pair, which is two rules however many URLs it held, but only when no content route lives underneath, and only the request spellings a positive actually claims are emitted, conserving Wrangler’s cap of 100 rules. A rule over Wrangler’s 100-character limit collapses to its nearest safe ancestor glob instead of costing the whole site its negotiation. The exemptions ride along into the coarse fallback, where/*claims everything and they matter more rather than less; if even that cannot fit the limits the negotiation is skipped entirely, since a redirect cached as permanent is a worse outcome than raw Markdown staying at its explicit.mdURL. -
2e231bc: Serve configured
redirectsfrom the generated Cloudflare Worker itself instead of carving them out ofassets.run_worker_first. The wrapper Worker now bakes in a redirect table and answers any redirect a worker-first rule claims with its exact configured status, before delegating to the Astro Worker; redirects outside every worker-first rule still never invoke the Worker and are served by the static layer from_redirects, as before.Unlike the negative-rule exemptions this replaces, a baked-in table costs nothing against Wrangler’s caps of 100 rules and 100 characters — so a large or deeply nested redirect set can no longer push the rule set into the coarse fallback or cost the site its
Accept: text/markdownnegotiation, and redirects claimed by your ownrun_worker_firstrules (including a baretrue) are answered correctly without rewriting your configuration. A redirect at a content route’s own path is still never honored — the page owns it — and non-ASCII destinations are percent-encoded into theLocationheader. -
50a9ea7: Keep
blume devfrom tearing down React islands built from project components. The generated runtime is the Vite root, so user pages, islands, and alias-reachable components were invisible to the dep optimizer’s startup scan — and the Babel-injectedreact/compiler-runtimeimport can never be scanned — so their dependencies were only discovered mid-session. That re-optimization served a second React copy to islands hydrating at that moment, crashing them with “Invalid hook call”. The generated config now points the optimizer’s startup scan at user pages, theislands/directory, and tsconfig-alias directories, and force-includes the compiler runtime, so every dependency hydration can reach is part of the first optimization run. -
7e9f7dd: Backfill every built-in UI language pack with the chrome strings added since launch — the Export menu, “Copy code”, “Copy Codex command”, the Ask AI panel, navigation and theme-toggle labels, the 404 page, and the newer search dialog strings — so localized sites no longer show English for those surfaces. The “Open in v0/ChatGPT/Claude/…” provider labels, previously hardcoded, now localize through a new
actions.openIntemplate ("Open in {name}"). -
b62278b: Emit
_headerson a Cloudflare server build, so the agent-discovery surface it already generates is actually advertised.buildHomeLinkHeader()had three consumers and every one excluded this deployment: the_headerswriter returned early unlessoutput === "static", the Vercel routing-config injection only runs for that adapter, and the middleware that callsres.setHeader("Link", …)is mounted onastro:server:setup, so it is dev-only. A Cloudflare server build therefore served no homepageLinkheader at all, and noContent-Typeon the extensionless well-known files — an API catalog went out with no media type rather thanapplication/linkset+json.The gate is now
readsHeaderFiles(), which is true for any static build and additionally for a Cloudflare server build: the Worker servesdist/clientthrough its ASSETS binding, and Workers static assets honor_headersfrom that directory exactly as Pages does. Node server builds stay excluded, because the standalone server’s static handler ignores the file and writing it there would be inert. Vercel server builds stay excluded because their headers arrive through the routing config, which this would duplicate.Two related corrections fell out of testing it against a real Cloudflare server build, and both apply to static builds as well:
- The user opt-out is now checked at
public/_headersrather than indist.@astrojs/cloudflarewrites its own_headersduring the build (an immutableCache-Controlfor/_astro/*), so testingdistread an adapter-generated file as a user opt-out and skipped silently — the fix above would not have fired without this. - When a
_headersalready exists in the output, its rules are preserved and the generated ones are appended, so the adapter’s caching rule and Blume’s discovery rules coexist. On a static build this changes behavior for a_headersthat reacheddistsome way other thanpublic/(an integration writing it directly, say): that file previously suppressed generation entirely and is now appended to. Shippingpublic/_headersremains the opt-out.
The charset rules in this file remain redundant on a server build, where the runtime endpoint sets
Content-Typeon the Response itself; they are harmless, because a static-asset rule only applies to a file served from that directory. TheLinkand well-known media-type rules are the part that was missing, and the previous comment’s reasoning — that server adapters set Content-Type themselves — was true of the charset rules only and had been applied to the whole file. - The user opt-out is now checked at
-
f9f045c: Fix
blume checkfailing on the generated MCP endpoint whenai.mcpis enabled. JSON imports widen literal types, somcp-data.jsoncould never satisfyMcpData’s discriminated navigation nodes; the generated endpoint now asserts the snapshot back toMcpDataat the JSON boundary. -
7e9f7dd: The header’s search field and language switcher now collapse to compact icon buttons below the
lgbreakpoint (previouslysm), and the logo mark no longer shrinks when the header runs out of room — fixing the mid-width squeeze where the inline tab bar pressed the two controls into each other. -
eb8e1dc: Restore the content inset on
Tabpanels that mix a code fence with prose. The panel dropped its padding via:has(>pre), a rule meant for code-only panels — a fence owns its own frame, so insetting it again double-pads it. But the bare selector also matched a panel holding a fence and prose, stripping the padding from the prose as well; combined with[&>:last-child]:mb-0!a trailing paragraph ended up flush against the panel border with no space on any side, and lost its left inset so text started hard against the frame. The rule is now[&:has(>pre):not(:has(>:not(pre,template)))], sop-0applies only when the panel holds nothing but fences and mixed panels keep the inset like every other code-block host.templateis excluded because aTabwith aniconrenders a<template data-blume-tab-icon>that stays a permanent child — the tabs script clones its content into the trigger rather than moving the node. That rules out the two naive “is the fence alone?” tests, each for a different reason::only-childsees two children on every icon tab and so silently reintroduces the double inset, while:only-of-typeignores thetemplate(it only counts siblingpreelements) and therefore matches a single fence but stops matching a code-only panel holding two or more of them. -
aa33ae7: Write a Cloudflare server build’s artifacts where the Worker can actually serve them.
@astrojs/cloudflaredeclarespreserveBuildClientDir: true, so it keeps Astro’sdist/client+dist/serversplit and points the ASSETS binding in thedist/server/wrangler.jsonit generates at../client— the deployed Worker servesdist/clientand nothing above it. Blume still treateddist/as the served root, sorobots.txt,sitemap.xml,llms.txt,llms-full.txt,agent-readability.json,.well-known/api-catalog, and the Pagefind index all landed one directory too high and 404’d in production, andblume auditcrawleddist/and readclient/as a route segment, reporting ~118 phantom broken links.deployStaticDirand its isolated-build mirrorisolatedStaticDirnow resolve todist/clientfor a Cloudflare server build, matching the existing Node standalone handling. Cloudflare static builds are unaffected — they have no client/server split, so theoutDirroot is still what ships. -
8d4fd17: Allow
search.popularicons to use image paths/URLs and inline SVG, matching nav icons. Markup is resolved on the server before the Cmd+K island runs. -
7e9f7dd: Header tab labels (and tab dropdown item labels) accept a per-locale map alongside the plain-string form:
label: { en: "Docs", fr: "Documentation" }. Each locale’s navigation resolves its own entry, falling back to the default locale’s and then the map’s first entry, so an i18n site can translate its header without forking the config. -
7e9f7dd: Locales accept an optional
style— freeform guidance like “Brazilian Portuguese, informal você” that rides along in everyblume translateprompt, pinning register and dialect from a locale’s first translation and winning over an existing translation’s style on reruns. -
7e9f7dd:
blume translatenow shows the agent the existing translation when retranslating a stale page and instructs it to match its register, dialect, and terminology, so a small source edit yields a minimal retranslation instead of a from-scratch rewrite that can flip style.