Skip to content

Commit ed452ba

Browse files
codercatdevclaude
andcommitted
feat: Astro Sanity data layer and consolidated typegen
Adds apps/site/src/lib/sanity/* and converts all 26 GROQ queries from `groq` tagged templates to `defineQuery`, so generated types flow to call sites and the `as` casts the Next app needed are gone. Two assumptions from the migration plan turned out to be wrong, and were verified empirically before committing to a layout: - TypeGen DOES resolve const interpolation inside defineQuery, including across module boundaries. The shared projection partials (baseFieldsNoContent, contentFields, ...) are therefore kept in queries/shared.ts rather than being inlined into every query. - @astrojs/cloudflare v14 reduced `Runtime` to `{ cfContext }` and marks `locals.runtime` a deprecated getter. Bindings and vars are read via `import { env } from "cloudflare:workers"` instead — which is also what the pre-Next Astro app did. Three bugs in the harvested fetch layer (dev:apps/web/src/utils/sanity.ts) are fixed rather than carried over: - `useCdn: false` on the base client. Under Next this was masked by ISR; under pure SSR it would put every anonymous page view on the uncached live API. Published reads now use the CDN, which Sanity purges on publish. - stega configured on the base client and suppressed per-request. Inverted: off by default, on only in previewClient, so RSS/sitemap/OG/JSON-LD cannot leak zero-width characters into hrefs and XML. - read token sourced from `import.meta.env`, which Vite inlines into the server bundle — that would bake the token into the deployed Worker script. Now read per-request from the runtime env, and preview fails closed when absent. Config is resolved per-request because wrangler `vars` reach the Worker through the runtime env, not `import.meta.env`. apiVersion is pinned to 2025-09-30 to match apps/sanity/sanity.config.ts; the old Astro app used 2026-03-17, a silent divergence between what authors preview in Vision and what the site renders. Middleware builds the request context onto `locals.sanity` and sets `no-store, private` + `noindex` on preview responses, so a page containing unpublished drafts cannot be written to the edge cache. Queries that took a $type parameter (docCount, rssQuery) are split per type — a parameterised _type widens the generated result to a union of every document type. urlForImage is consolidated to one implementation keeping the `.auto("format")` behaviour; resolveHref now covers the whole public route map instead of only "post". Typegen moves entirely into apps/sanity: apps/web/sanity.cli.ts is deleted along with its typegen/predev/prebuild scripts and its `sanity` devDependency, and the two stale schema copies (apps/web/sanity/extract.json, apps/web/schema.json) are removed. CI regenerates types before typechecking and fails if the committed extract.json has drifted. Verified end to end: `wrangler dev` serves the home page with real Sanity content from the dev dataset, typechecking clean with no casts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
1 parent 1b01b48 commit ed452ba

28 files changed

Lines changed: 880 additions & 24766 deletions

‎.github/workflows/ci.yml‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ jobs:
2727
- name: Install dependencies
2828
run: pnpm install --frozen-lockfile
2929

30+
# Generates apps/sanity/extract.json and the site's types.gen.ts, both of
31+
# which are derived artifacts and therefore gitignored. Runs before
32+
# typecheck because the site imports the generated types.
33+
- name: Typegen
34+
run: pnpm typegen
35+
env:
36+
SANITY_STUDIO_PROJECT_ID: hfh83o0w
37+
SANITY_STUDIO_DATASET: production
38+
3039
- name: Lint (site)
3140
run: pnpm --filter @codingcatdev/site lint
3241

@@ -45,17 +54,13 @@ jobs:
4554
SANITY_STUDIO_PROJECT_ID: hfh83o0w
4655
SANITY_STUDIO_DATASET: production
4756

48-
# extract.json is the single source of truth for typegen. If it drifts
49-
# from the schema, generated types silently go stale — which is how three
50-
# divergent copies accumulated before.
57+
# extract.json is committed and is the single source of truth for typegen.
58+
# The Typegen step above regenerates it; if that changed the file, the
59+
# commit is stale. This is what stopped three divergent copies before.
5160
- name: Verify extract.json is current
5261
run: |
53-
pnpm --filter @codingcatdev/sanity typegen:extract
5462
if ! git diff --quiet -- apps/sanity/extract.json; then
5563
echo "::error::apps/sanity/extract.json is stale. Run 'pnpm typegen' and commit the result."
5664
git diff --stat -- apps/sanity/extract.json
5765
exit 1
5866
fi
59-
env:
60-
SANITY_STUDIO_PROJECT_ID: hfh83o0w
61-
SANITY_STUDIO_DATASET: production

‎apps/sanity/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"build": "sanity build",
88
"deploy": "sanity deploy",
99
"typegen:extract": "sanity schema extract --path=./extract.json --workspace production --force",
10-
"typegen": "pnpm run typegen:extract"
10+
"typegen": "pnpm run typegen:extract && sanity typegen generate"
1111
},
1212
"dependencies": {
1313
"@portabletext/block-tools": "^5.2.0",

‎apps/sanity/sanity.cli.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,12 @@ export default defineCliConfig({
77
},
88
// Set via SANITY_STUDIO_HOSTNAME in CI: e.g. "codingcat.dev" (prod), "codingcat-dev" (dev)
99
studioHost: process.env.SANITY_STUDIO_HOSTNAME,
10+
typegen: {
11+
// Queries must live in .ts files — TypeGen cannot parse .astro frontmatter,
12+
// so defineQuery calls inside .astro are silently skipped.
13+
path: "../site/src/lib/sanity/queries/**/*.ts",
14+
schema: "./extract.json",
15+
generates: "../site/src/lib/sanity/types.gen.ts",
16+
overloadClientMethods: true,
17+
},
1018
});

‎apps/site/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@astrojs/check": "^0.9.10",
3434
"@biomejs/biome": "2.2.4",
3535
"@tailwindcss/typography": "^0.5.19",
36+
"@types/node": "^24.12.4",
3637
"typescript": "^5.9",
3738
"wrangler": "^4.123.0"
3839
}

‎apps/site/src/env.d.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/// <reference path="../.astro/types.d.ts" />
2+
3+
declare namespace App {
4+
interface Locals {
5+
sanity: import("./lib/sanity/context").SanityRequestContext;
6+
}
7+
}

‎apps/site/src/lib/sanity/client.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {
2+
type ClientPerspective,
3+
createClient,
4+
type SanityClient,
5+
} from "@sanity/client";
6+
import type { SanityConfig } from "./config";
7+
8+
/**
9+
* Clients are memoized per (config, mode) for the life of the isolate. Workers
10+
* reuses isolates across requests, so this avoids rebuilding a client per hit
11+
* while still letting config come from the request.
12+
*/
13+
const cache = new Map<string, SanityClient>();
14+
15+
function memo(key: string, make: () => SanityClient): SanityClient {
16+
let client = cache.get(key);
17+
if (!client) {
18+
client = make();
19+
cache.set(key, client);
20+
}
21+
return client;
22+
}
23+
24+
/**
25+
* Anonymous, published-only reads.
26+
*
27+
* `useCdn: true` is the single highest-leverage setting here. There is no
28+
* Next-style cache layer under SSR on Workers, so every request would otherwise
29+
* hit the uncached live API. apicdn is purged by Sanity on publish, so content
30+
* is still fresh within seconds.
31+
*
32+
* stega is OFF here and enabled only in previewClient. Encoded zero-width
33+
* characters are harmless in text nodes but corrupt hrefs, <meta> content,
34+
* JSON-LD and XML feeds — defaulting it off fails safe.
35+
*/
36+
export function publishedClient(config: SanityConfig): SanityClient {
37+
const { projectId, dataset, apiVersion } = config;
38+
return memo(`pub:${projectId}:${dataset}:${apiVersion}`, () =>
39+
createClient({
40+
projectId,
41+
dataset,
42+
apiVersion,
43+
useCdn: true,
44+
perspective: "published",
45+
stega: false,
46+
}),
47+
);
48+
}
49+
50+
/** Authenticated draft/preview reads, with stega and source maps enabled. */
51+
export function previewClient(
52+
config: SanityConfig,
53+
token: string,
54+
perspective: ClientPerspective,
55+
): SanityClient {
56+
const { projectId, dataset, apiVersion, studioUrl } = config;
57+
const key = `prev:${projectId}:${dataset}:${apiVersion}:${String(perspective)}`;
58+
return memo(key, () =>
59+
createClient({
60+
projectId,
61+
dataset,
62+
apiVersion,
63+
useCdn: false,
64+
perspective,
65+
token,
66+
resultSourceMap: "withKeyArraySelector",
67+
stega: { enabled: true, studioUrl },
68+
}),
69+
);
70+
}

‎apps/site/src/lib/sanity/config.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Sanity connection config.
3+
*
4+
* Resolved per-request rather than at module scope: on Workers the values in
5+
* `wrangler.jsonc` `vars` arrive through `locals.runtime.env`, and are NOT
6+
* visible to `import.meta.env` (which Vite bakes in at build time). The
7+
* `import.meta.env` fallback exists so `astro dev` works without a Worker.
8+
*/
9+
10+
export interface SanityConfig {
11+
projectId: string;
12+
dataset: string;
13+
apiVersion: string;
14+
/**
15+
* Absolute Studio URL used for stega edit-intent links.
16+
*
17+
* MUST include the workspace basePath. sanity.config.ts defines workspaces
18+
* with basePath "/production" and "/dev"; without it every click-to-edit
19+
* link lands on the workspace picker instead of the document.
20+
*/
21+
studioUrl: string;
22+
}
23+
24+
type EnvLike = Record<string, string | undefined>;
25+
26+
/** apiVersion is pinned to match apps/sanity/sanity.config.ts exactly. */
27+
const DEFAULTS = {
28+
projectId: "hfh83o0w",
29+
dataset: "production",
30+
apiVersion: "2025-09-30",
31+
studioUrl: "https://codingcat.dev.sanity.studio/production",
32+
} as const;
33+
34+
function read(env: EnvLike | undefined, key: string): string | undefined {
35+
const fromRuntime = env?.[key];
36+
if (fromRuntime) {
37+
return fromRuntime;
38+
}
39+
const fromBuild = (import.meta.env as EnvLike)[key];
40+
return fromBuild || undefined;
41+
}
42+
43+
export function resolveSanityConfig(env?: EnvLike): SanityConfig {
44+
return {
45+
projectId: read(env, "PUBLIC_SANITY_PROJECT_ID") ?? DEFAULTS.projectId,
46+
dataset: read(env, "PUBLIC_SANITY_DATASET") ?? DEFAULTS.dataset,
47+
apiVersion: read(env, "PUBLIC_SANITY_API_VERSION") ?? DEFAULTS.apiVersion,
48+
studioUrl: read(env, "PUBLIC_SANITY_STUDIO_URL") ?? DEFAULTS.studioUrl,
49+
};
50+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { ClientReturn, QueryParams } from "@sanity/client";
2+
import type { SanityImageSource } from "@sanity/image-url";
3+
import { resolveSanityConfig, type SanityConfig } from "./config";
4+
import { resolveOpenGraphImage, urlForImage } from "./image";
5+
import { fetchPublished, loadQuery } from "./load-query";
6+
import {
7+
PERSPECTIVE_PARAM,
8+
PREVIEW_COOKIE,
9+
type PreviewContext,
10+
resolvePreview,
11+
} from "./preview";
12+
13+
/**
14+
* Per-request Sanity access, built once in middleware and exposed on
15+
* `Astro.locals.sanity` so pages don't have to thread config/preview around.
16+
*/
17+
export interface SanityRequestContext {
18+
config: SanityConfig;
19+
preview: PreviewContext;
20+
/** Draft-aware read. Uses stega + drafts when preview is active. */
21+
loadQuery<const Q extends string>(
22+
query: Q,
23+
params?: QueryParams,
24+
): Promise<ClientReturn<Q>>;
25+
/** Published-only, stega-free read. Use for RSS, sitemap, OG, JSON-LD. */
26+
fetchPublished<const Q extends string>(
27+
query: Q,
28+
params?: QueryParams,
29+
): Promise<ClientReturn<Q>>;
30+
urlForImage(
31+
source: SanityImageSource | undefined,
32+
): ReturnType<typeof urlForImage>;
33+
resolveOpenGraphImage(
34+
image: (SanityImageSource & { alt?: string }) | undefined,
35+
width?: number,
36+
height?: number,
37+
): ReturnType<typeof resolveOpenGraphImage>;
38+
}
39+
40+
export function createSanityContext({
41+
env,
42+
url,
43+
hasPreviewCookie,
44+
}: {
45+
env: Record<string, string | undefined> | undefined;
46+
url: URL;
47+
hasPreviewCookie: boolean;
48+
}): SanityRequestContext {
49+
const config = resolveSanityConfig(env);
50+
const preview = resolvePreview({
51+
env,
52+
hasPreviewCookie,
53+
perspectiveParam: url.searchParams.get(PERSPECTIVE_PARAM),
54+
});
55+
56+
return {
57+
config,
58+
preview,
59+
loadQuery: (query, params) => loadQuery(config, preview, query, params),
60+
fetchPublished: (query, params) => fetchPublished(config, query, params),
61+
urlForImage: (source) => urlForImage(config, source),
62+
resolveOpenGraphImage: (image, width, height) =>
63+
resolveOpenGraphImage(config, image, width, height),
64+
};
65+
}
66+
67+
export { PREVIEW_COOKIE, PERSPECTIVE_PARAM };

‎apps/site/src/lib/sanity/image.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { stegaClean } from "@sanity/client/stega";
2+
import {
3+
createImageUrlBuilder,
4+
type SanityImageSource,
5+
} from "@sanity/image-url";
6+
import type { SanityConfig } from "./config";
7+
8+
type Builder = ReturnType<typeof createImageUrlBuilder>;
9+
10+
const builders = new Map<string, Builder>();
11+
12+
function builderFor({ projectId, dataset }: SanityConfig): Builder {
13+
const key = `${projectId}:${dataset}`;
14+
let b = builders.get(key);
15+
if (!b) {
16+
b = createImageUrlBuilder({ projectId, dataset });
17+
builders.set(key, b);
18+
}
19+
return b;
20+
}
21+
22+
/**
23+
* Consolidated from the Next app, which had two divergent copies: a bare one in
24+
* sanity/lib/image.ts and one in sanity/lib/utils.ts that added
25+
* `.auto("format").fit("max")`. The latter behaviour wins — auto=format is what
26+
* serves WebP/AVIF.
27+
*/
28+
export function urlForImage(
29+
config: SanityConfig,
30+
source: SanityImageSource | undefined,
31+
) {
32+
if (!source || !(source as { asset?: unknown })?.asset) {
33+
return undefined;
34+
}
35+
return builderFor(config).image(source).auto("format").fit("max");
36+
}
37+
38+
export function resolveOpenGraphImage(
39+
config: SanityConfig,
40+
image: (SanityImageSource & { alt?: string }) | undefined,
41+
width = 1920,
42+
height = 1080,
43+
) {
44+
const url = urlForImage(config, image)?.width(width).height(height).url();
45+
if (!url) {
46+
return undefined;
47+
}
48+
return {
49+
// stegaClean: this lands in a <meta content> attribute, where stega's
50+
// zero-width characters would corrupt the URL.
51+
url: stegaClean(url),
52+
alt: stegaClean(image?.alt) || "CodingCat.dev Image",
53+
width,
54+
height,
55+
};
56+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { ClientReturn, QueryParams } from "@sanity/client";
2+
import { previewClient, publishedClient } from "./client";
3+
import type { SanityConfig } from "./config";
4+
import type { PreviewContext } from "./preview";
5+
6+
/**
7+
* The `const QueryString extends string` generic preserves the query's literal
8+
* type through the wrapper, which is what lets `ClientReturn<Q>` pick the right
9+
* result type out of the `declare module "@sanity/client"` query map emitted by
10+
* `sanity typegen`. That is what removes the `as` casts the Next app needed.
11+
*/
12+
export async function loadQuery<const QueryString extends string>(
13+
config: SanityConfig,
14+
preview: PreviewContext,
15+
query: QueryString,
16+
params: QueryParams = {},
17+
): Promise<ClientReturn<QueryString>> {
18+
const client = preview.enabled
19+
? previewClient(config, preview.token, preview.perspective)
20+
: publishedClient(config);
21+
return client.fetch<ClientReturn<QueryString>>(query, params);
22+
}
23+
24+
/**
25+
* Published-only, stega-free reads for RSS, sitemap, OG images and JSON-LD.
26+
*
27+
* Deliberately a separate function rather than a flag on loadQuery, so these
28+
* paths can never be handed a preview context by accident.
29+
*/
30+
export async function fetchPublished<const QueryString extends string>(
31+
config: SanityConfig,
32+
query: QueryString,
33+
params: QueryParams = {},
34+
): Promise<ClientReturn<QueryString>> {
35+
return publishedClient(config).fetch<ClientReturn<QueryString>>(
36+
query,
37+
params,
38+
);
39+
}

0 commit comments

Comments
 (0)