/// /** * Query functions for EmDash content * * These wrap Astro's getLiveCollection/getLiveEntry with type filtering. * Use these instead of calling Astro's functions directly. * * Error handling follows Astro's pattern - returns { entries/entry, error } * so callers can gracefully handle errors (including 404s). * * Preview mode is handled implicitly via ALS request context — * no parameters needed. The middleware verifies the preview token * and sets the context; query functions read it automatically. * * The triple-slash directive above pulls in the ambient declaration for * `astro:content` (used by the dynamic imports below) so this source * file typechecks even when reached transitively by a sibling package * whose tsconfig doesn't list `astro/client` in `compilerOptions.types`. * * Note: the directive is stripped from the compiled output (`dist/*`) * by tsdown, so it does not propagate to downstream consumers of the * published package. Consumers are Astro sites and already provide their * own `astro/client` ambient surface anyway, so the runtime dynamic * import resolves there at typecheck time without our help. */ import { encodeCursor } from "./database/repositories/types.js"; import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js"; import { CURSOR_RAW_VALUES, FOLDED_BYLINES, FOLDED_TERMS, type WhereRange, type WhereValue, } from "./loader.js"; import { cachedQuery, contentNamespaces, invalidateSchemaObjectCache, } from "./object-cache/index.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.js"; import type { TaxonomyTerm } from "./taxonomies/types.js"; import { isMissingTableError } from "./utils/db-errors.js"; import { createEditable, createNoop, type EditProxy, type EditableOptions, } from "./visual-editing/editable.js"; /** * Collection type registry for type-safe queries. * * This interface is extended by the generated emdash-env.d.ts file * to provide type inference for collection names and their data shapes. * * @example * ```ts * // In emdash-env.d.ts (generated): * declare module "emdash" { * interface EmDashCollections { * posts: { title: string; content: PortableTextBlock[]; }; * pages: { title: string; body: PortableTextBlock[]; }; * } * } * * // Then in your code: * const { entries } = await getEmDashCollection("posts"); * // entries[0].data.title is typed as string * ``` */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface EmDashCollections {} /** * Helper type to infer the data type for a collection. * Returns the registered type if known, otherwise falls back to Record. */ export type InferCollectionData = T extends keyof EmDashCollections ? EmDashCollections[T] : Record; /** * Sort direction */ export type SortDirection = "asc" | "desc"; /** * Order by specification - field name to direction * @example { created_at: "desc" } - Sort by created_at descending * @example { title: "asc" } - Sort by title ascending * @example { published_at: "desc", title: "asc" } - Multi-field sort */ export type OrderBySpec = Record; export type { WhereRange, WhereValue }; /** * Fields shared by every collection query, independent of pagination mode. * * Cursor and offset pagination are mutually exclusive, so they live on the * `CursorCollectionFilter` / `OffsetCollectionFilter` variants rather than * here. Use the {@link CollectionFilter} union for any value that may be * either. */ export interface CollectionFilterBase { status?: "draft" | "published" | "archived"; limit?: number; /** * Filter by field values, taxonomy terms, byline credits, or ranges. * * Taxonomy names are detected automatically and filtered via JOIN. * The reserved `byline` key filters by byline credit (any credit, not * just the primary one) via the `_emdash_content_bylines` junction * table; its value is one or more byline translation groups. This * matches co-authored entries, which `primary_byline_id` alone misses. * Other keys are treated as column filters on the content table. * * @example { category: 'news' } - Filter by taxonomy term * @example { category: ['news', 'featured'] } - Filter by multiple terms (OR) * @example { byline: '01HXYZ...' } - Entries credited to a byline (any position) * @example { byline: ['01HXYZ...', '01HABC...'] } - Credited to any of these bylines (OR) * @example { series: 'main' } - Exact match on a content field * @example { published_at: { gte: '2024-01-01', lt: '2025-01-01' } } - Date range */ where?: Record; /** * Order results by field(s) * @default { created_at: "desc" } * @example { created_at: "desc" } - Sort by created_at descending (default) * @example { title: "asc" } - Sort by title ascending * @example { published_at: "desc", title: "asc" } - Multi-field sort */ orderBy?: OrderBySpec; /** * Filter by locale. When set, only returns entries in this locale. * Only relevant when i18n is configured. * @example "en" — English entries only * @example "fr" — French entries only */ locale?: string; } /** Keyset-paginated query filter. Cannot also carry an `offset`. */ export interface CursorCollectionFilter extends CollectionFilterBase { /** * Opaque cursor for keyset pagination. * Pass the `nextCursor` value from a previous result to fetch the next page. * @example * ```ts * const cursor = Astro.url.searchParams.get("cursor") ?? undefined; * const { entries, nextCursor } = await getEmDashCollection("posts", { * limit: 10, * cursor, * }); * ``` */ cursor?: string; offset?: never; } /** Offset-paginated query filter. Cannot also carry a `cursor`. */ export interface OffsetCollectionFilter extends CollectionFilterBase { /** * Skip this many entries before returning results (offset pagination). * * Use with `limit` to render numbered archive routes like `/page/2` * without walking cursors or over-fetching from the start: * * ```ts * const perPage = 20; * const { entries, hasMore } = await getEmDashCollection("posts", { * limit: perPage, * offset: (page - 1) * perPage, * orderBy: { published_at: "desc" }, * }); * ``` * * Only a positive integer applies. */ offset?: number; cursor?: never; } /** * Filter for `getEmDashCollection`. * * A union of the cursor and offset pagination variants: supplying both * `cursor` and `offset` is a compile-time error, since they are mutually * exclusive ways to express "the next page" (cursor wins at runtime). */ export type CollectionFilter = CursorCollectionFilter | OffsetCollectionFilter; export interface ContentEntry> { id: string; data: T; /** Visual editing annotations. Spread onto elements: {...entry.edit.title} */ edit: EditProxy; } /** Cache hint returned by the content loader for route caching */ export interface CacheHint { tags?: string[]; lastModified?: Date; } /** * Result from getEmDashCollection */ export interface CollectionResult { /** The entries (empty array if error or none found) */ entries: ContentEntry[]; /** Error if the query failed */ error?: Error; /** Cache hint for route caching (pass to Astro.cache.set()) */ cacheHint: CacheHint; /** * Opaque cursor for the next page. * Undefined when there are no more results. * Pass this as `cursor` in the next query to get the next page. */ nextCursor?: string; /** * Whether more entries exist beyond this page. Set whenever `limit` is * provided (cursor or offset pagination), so numbered archive routes can * render a "next page" link without computing a total count. */ hasMore?: boolean; } /** * Result from getEmDashEntry */ export interface EntryResult { /** The entry, or null if not found */ entry: ContentEntry | null; /** Error if the query failed (not set for "not found", only for actual errors) */ error?: Error; /** Whether we're in preview mode (valid token was provided) */ isPreview: boolean; /** Set when a fallback locale was used instead of the requested locale */ fallbackLocale?: string; /** Cache hint for route caching (pass to Astro.cache.set()) */ cacheHint: CacheHint; } const COLLECTION_NAME = "_emdash"; /** Symbol key for edit metadata on PT arrays — avoids collision with user data */ const EMDASH_EDIT = Symbol.for("__emdash"); /** Edit metadata attached to PT arrays in edit mode */ export interface EditFieldMeta { collection: string; id: string; field: string; } /** Type guard for EditFieldMeta */ function isEditFieldMeta(value: unknown): value is EditFieldMeta { if (typeof value !== "object" || value === null) return false; if (!("collection" in value) || !("id" in value) || !("field" in value)) return false; // After `in` checks, TS narrows to Record<"collection" | "id" | "field", unknown> const { collection, id, field } = value; return typeof collection === "string" && typeof id === "string" && typeof field === "string"; } /** * Read edit metadata from a value (returns undefined if not tagged). * Uses Object.getOwnPropertyDescriptor to access Symbol-keyed property * without an unsafe type assertion. */ export function getEditMeta(value: unknown): EditFieldMeta | undefined { if (value && typeof value === "object") { const desc = Object.getOwnPropertyDescriptor(value, EMDASH_EDIT); const meta: unknown = desc?.value; if (isEditFieldMeta(meta)) { return meta; } } return undefined; } /** * Tag PT-like arrays in entry data with edit metadata (non-enumerable). * A PT array is identified by: is an array, first element has _type property. */ function tagEditableFields(data: Record, collection: string, id: string): void { for (const [field, value] of Object.entries(data)) { if ( Array.isArray(value) && value.length > 0 && value[0] && typeof value[0] === "object" && "_type" in value[0] ) { Object.defineProperty(value, EMDASH_EDIT, { value: { collection, id, field } satisfies EditFieldMeta, enumerable: false, configurable: true, }); } } } /** Safely read a string field from a Record, with optional fallback */ function dataStr(data: Record, key: string, fallback = ""): string { const val = data[key]; return typeof val === "string" ? val : fallback; } /** Safely read a date-like field from a Record */ function dataDate(data: Record, key: string): Date | undefined { const val = data[key]; if (val instanceof Date) { return Number.isNaN(val.getTime()) ? undefined : val; } if (typeof val !== "string" && typeof val !== "number") return undefined; const date = new Date(val); return Number.isNaN(date.getTime()) ? undefined : date; } /** Type guard for Record */ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Extract data as Record from an Astro entry (which is any-typed) */ function entryData(entry: { data?: unknown }): Record { return isRecord(entry.data) ? entry.data : {}; } /** Extract the database ID from entry data (data.id is the ULID, entry.id is the slug) */ function entryDatabaseId(entry: { id: string; data?: unknown }): string { const d = entryData(entry); return dataStr(d, "id") || entry.id; } /** Extract edit options from entry data for the proxy */ function entryEditOptions(entry: { data?: unknown }): EditableOptions { const data = entryData(entry); const status = dataStr(data, "status", "draft"); const draftRevisionId = dataStr(data, "draftRevisionId") || undefined; const liveRevisionId = dataStr(data, "liveRevisionId") || undefined; const hasDraft = !!draftRevisionId && draftRevisionId !== liveRevisionId; return { status, hasDraft }; } /** * Get all entries of a content type * * Returns { entries, error } for graceful error handling. * * When emdash-env.d.ts is generated, the collection name will be * type-checked and the return type will be inferred automatically. * * @example * ```ts * import { getEmDashCollection } from "emdash"; * * const { entries: posts, error } = await getEmDashCollection("posts"); * if (error) { * console.error("Failed to load posts:", error); * return; * } * // posts[0].data.title is typed (if emdash-env.d.ts exists) * * // With filters * const { entries: drafts } = await getEmDashCollection("posts", { status: "draft" }); * ``` */ export async function getEmDashCollection>( type: T, filter?: CollectionFilter, ): Promise> { // Cache per (type, filter) within a single request. Edit mode and // preview are request-scoped and stable, so they don't need to be // part of the key. Widgets and layouts frequently request the same // collection shape as the page itself (e.g. a "recent posts" list // appears on the home page AND in the sidebar) — caching collapses // those duplicate queries, along with the bylines and taxonomy-term // hydration each call would otherwise re-do. // // Bucket small limits to a shared minimum so a page with several // "recent N posts" widgets at slightly different limits (e.g. a // post-detail page asking for 4 in the body and 5 in the sidebar) // shares one fetch + hydration round-trip rather than running two. // Cursor-paginated calls are exempt: their limit is part of the // pagination contract. const bucketed = bucketFilter(filter); const cached = await requestCached(collectionCacheKey(type, bucketed.fetchFilter), () => loadCollectionCached(type, bucketed.fetchFilter), ); return bucketed.requestedLimit === undefined ? cached : sliceCollectionResult(cached, bucketed.requestedLimit, filter?.orderBy); } /** Shape of a cached collection snapshot (entries reduced to JSON-safe form). */ interface CachedCollectionValue { entries: unknown[]; nextCursor?: string; hasMore?: boolean; cacheHint: CacheHint; } /** * Distributed (L2) read-through around {@link getEmDashCollectionUncached}. * * Caches a JSON-safe snapshot keyed by collection + filter + effective locale, * folding the shared `bylines`/`taxonomies` epochs into the key so renaming an * author or term invalidates affected lists. Errors are never cached. */ async function loadCollectionCached>( type: T, filter?: CollectionFilter, ): Promise> { const snapshot = await cachedQuery>({ namespace: contentNamespaces(type), key: `collection:${collectionCacheKey(type, filter)}|loc=${effectiveLocaleKey(filter)}`, load: async () => { const result = await getEmDashCollectionUncached(type, filter); if (result.error) { return { ok: false, error: result.error, cacheHint: result.cacheHint }; } return { ok: true, value: { entries: result.entries.map(entrySnapshot), nextCursor: result.nextCursor, hasMore: result.hasMore, cacheHint: result.cacheHint, }, }; }, cacheable: (snap) => snap.ok, }); if (!snapshot.ok) { return { entries: [], error: snapshot.error, cacheHint: snapshot.cacheHint }; } return { entries: snapshot.value.entries.map((entry) => reviveEntry(entry)), nextCursor: snapshot.value.nextCursor, hasMore: snapshot.value.hasMore, cacheHint: snapshot.value.cacheHint, }; } /** * Threshold for limit bucketing. Page templates routinely render small * "recent posts" widgets at limits 3-8; rounding those up to a single * shared bucket lets one fetch satisfy several widgets within a request. * Above this, the requested limit is honoured exactly — bucketing limit:50 * to limit:64 would waste hydration work for callers fetching real pages. */ const BUCKET_LIMIT_THRESHOLD = 10; interface BucketedFilter { /** Filter to pass to the loader (with limit possibly raised). */ fetchFilter: CollectionFilter | undefined; /** Original limit; defined only when bucketing was applied. */ requestedLimit: number | undefined; } /** @internal exported for unit tests; not part of the public API. */ export function bucketFilter(filter: CollectionFilter | undefined): BucketedFilter { const limit = filter?.limit; if ( limit === undefined || limit >= BUCKET_LIMIT_THRESHOLD || limit <= 0 || filter?.cursor !== undefined || // Offset paginates a deliberate page window; its limit is part of the // pagination contract, so don't round it up the way "recent N" widgets get. filter?.offset !== undefined ) { return { fetchFilter: filter, requestedLimit: undefined }; } return { fetchFilter: { ...filter, limit: BUCKET_LIMIT_THRESHOLD }, requestedLimit: limit, }; } /** * Slice a cached bucketed result down to the originally-requested limit * and recompute `nextCursor` from the row that would have been the * over-fetch detector for that limit. When truncation is needed, returns * a shallow-copied result with a new `entries` array; otherwise returns * the cached result unchanged (including error results and results * already within the requested limit). */ /** @internal exported for unit tests; not part of the public API. */ export function sliceCollectionResult( cached: CollectionResult, limit: number, orderBy: OrderBySpec | undefined, ): CollectionResult { if (cached.error) return cached; if (cached.entries.length <= limit) return cached; const sliced = cached.entries.slice(0, limit); // Mirror the loader's encoding: cursor points at the last returned row, // so "next page" picks up at the row immediately after it. See // buildCursorCondition in loader.ts — it filters strictly past this row. const lastEntry = sliced.at(-1); const nextCursor = lastEntry ? encodeEntryCursor(lastEntry, orderBy) : undefined; // Truncating to the requested limit means at least one more entry existed. return { ...cached, entries: sliced, nextCursor, hasMore: true }; } /** Map of database column names to camelCase keys present on entry.data. */ const ENTRY_DATA_KEY_MAP: Record = { created_at: "createdAt", updated_at: "updatedAt", published_at: "publishedAt", scheduled_at: "scheduledAt", author_id: "authorId", primary_byline_id: "primaryBylineId", }; // Mirror loader.ts FIELD_NAME_PATTERN. Kept in sync intentionally — diverging // would let the encoder accept a field name the loader's getPrimarySort then // rejected, producing a cursor that paginates against a different column. const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; /** * Encode a `nextCursor` from a content entry, mirroring the loader's * encoding scheme: `(orderValue, id)` where `orderValue` is the primary * sort field's stringified value. For date columns, reads the raw DB * string the loader stashed via CURSOR_RAW_VALUES — round-tripping the * parsed Date through `toISOString()` would lose precision for stored * values that aren't already ISO-with-milliseconds. */ function encodeEntryCursor( entry: ContentEntry, orderBy: OrderBySpec | undefined, ): string | undefined { const data = entryData(entry); const id = dataStr(data, "id"); if (!id) return undefined; // Match loader.ts getPrimarySort: take the first valid field, default to created_at. let dbField = "created_at"; if (orderBy) { for (const field of Object.keys(orderBy)) { if (FIELD_NAME_PATTERN.test(field)) { dbField = field; break; } } } // Date columns: prefer the raw stored string captured by the loader so // the cursor matches what a direct loader fetch would emit, regardless // of how the DB stored the timestamp. const rawDateValuesRaw = Reflect.get(data, CURSOR_RAW_VALUES); if (rawDateValuesRaw !== null && typeof rawDateValuesRaw === "object") { const raw = Reflect.get(rawDateValuesRaw, dbField); if (typeof raw === "string") return encodeCursor(raw, id); } const dataKey = ENTRY_DATA_KEY_MAP[dbField] ?? dbField; const value = data[dataKey]; let orderValue: string; if (value instanceof Date) { orderValue = value.toISOString(); } else if (typeof value === "string" || typeof value === "number") { orderValue = String(value); } else { // Match the loader's empty-string fallback for null/undefined order // values so cursor decoding stays valid even at the boundary. orderValue = ""; } return encodeCursor(orderValue, id); } /** * Build a canonical cache key for `getEmDashCollection`. * * `JSON.stringify` is insertion-order-sensitive, so two callers passing * semantically identical filters with different key orders would miss * the cache. We fix the top-level field order and sort `where` keys * (order there is irrelevant), while preserving `orderBy` key order * because that's the sort priority. */ function collectionCacheKey(type: string, filter?: CollectionFilter): string { if (!filter) return `collection:${type}:`; const parts = [ filter.status ?? "", filter.limit ?? "", filter.cursor ?? "", filter.offset ?? "", filter.where ? stableStringify(filter.where) : "", filter.orderBy ? JSON.stringify(filter.orderBy) : "", filter.locale ?? "", ]; return `collection:${type}:${parts.join("|")}`; } function stableStringify(value: Record): string { return JSON.stringify(stableOrder(value)); } function stableOrder(value: Record): Record { const keys = Object.keys(value).toSorted(); const ordered: Record = {}; for (const k of keys) { const v = value[k]; if (isRecord(v)) { ordered[k] = stableOrder(v); } else { ordered[k] = v; } } return ordered; } // ── Object-cache (L2) serialization for content reads ─────────────────────── // // Content entries can't be stored verbatim: each carries a non-serializable // `edit` proxy (a function) and a non-enumerable `CURSOR_RAW_VALUES` symbol on // `data` (raw date strings used to reproduce the loader's pagination cursor). // We reduce each entry to a JSON-safe snapshot before caching — copying the // cursor-raw values into an enumerable field and dropping `edit` — then rebuild // the symbol and re-attach a no-op `edit` on the way out. The object cache's // codec preserves `Date` instances, so timestamps survive the round-trip. // // L2 is only consulted for anonymous, non-preview, non-edit requests (see // `shouldBypass` in object-cache), where `edit` is always the no-op variant — // so dropping and recreating it is lossless. /** Enumerable field carrying the {@link CURSOR_RAW_VALUES} payload in snapshots. */ const CURSOR_RAW_FIELD = "__emdashCursorRaw"; /** Result wrapper distinguishing a cached error from a cacheable success. */ type ContentSnapshot = | { ok: true; value: S } | { ok: false; error?: Error; cacheHint: CacheHint }; function entrySnapshot(entry: ContentEntry): Record { const data = entryData(entry); const rawCursor = Reflect.get(data, CURSOR_RAW_VALUES); // Drop the `edit` function; copy enumerable data + the cursor-raw values. const { edit: _edit, ...rest } = entry as ContentEntry & { edit?: unknown }; return { ...rest, data: { ...data, [CURSOR_RAW_FIELD]: rawCursor ?? {} }, }; } function reviveEntry(raw: unknown): ContentEntry { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot shape produced by entrySnapshot const entry = raw as Record; // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot `data` is always a record const data: Record = { ...(entry.data as Record) }; // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot field written by entrySnapshot const rawCursor = (data[CURSOR_RAW_FIELD] as Record | undefined) ?? {}; delete data[CURSOR_RAW_FIELD]; Object.defineProperty(data, CURSOR_RAW_VALUES, { value: rawCursor, enumerable: false, configurable: false, writable: false, }); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- rebuilt to the ContentEntry shape with a no-op edit proxy return { ...entry, data, edit: createNoop() } as ContentEntry; } /** Resolve the effective locale used by content reads, for the L2 cache key. */ function effectiveLocaleKey(filter?: { locale?: string }): string { const ctx = getRequestContext(); const i18nConfig = getI18nConfig(); return ( filter?.locale ?? ctx?.locale ?? (isI18nEnabled() ? i18nConfig!.defaultLocale : undefined) ?? "" ); } async function getEmDashCollectionUncached>( type: T, filter?: CollectionFilter, ): Promise> { // Dynamic import to avoid build-time issues const { getLiveCollection } = await import("astro:content"); // Resolve locale: explicit filter > ALS context > defaultLocale (when i18n enabled) // Without this, queries return all locale rows, producing broken IDs const ctx = getRequestContext(); const i18nConfig = getI18nConfig(); const resolvedLocale = filter?.locale ?? ctx?.locale ?? (isI18nEnabled() ? i18nConfig!.defaultLocale : undefined); const requestedLimit = filter?.limit; // cursor and offset are mutually exclusive on the loader filter union, so // spread only the one in play (cursor wins) rather than emitting both keys. const pageParam = filter?.cursor !== undefined ? { cursor: filter.cursor } : filter?.offset !== undefined ? { offset: filter.offset } : {}; const result = await getLiveCollection(COLLECTION_NAME, { type, status: filter?.status, limit: requestedLimit && requestedLimit > 0 ? requestedLimit + 1 : filter?.limit, ...pageParam, where: filter?.where, orderBy: filter?.orderBy, locale: resolvedLocale, }); const { entries, error, cacheHint } = result; if (error) { return { entries: [], error, cacheHint: {} }; } const hasMore = requestedLimit != null && requestedLimit > 0 && entries.length > requestedLimit; const pageEntries = hasMore ? entries.slice(0, requestedLimit) : entries; const nextCursor = hasMore ? encodeEntryCursor(pageEntries.at(-1), filter?.orderBy) : undefined; // `hasMore` is only meaningful when a limit bounds the page; otherwise the // query returned everything and there is no "next page" to report. const hasMoreResult = requestedLimit != null && requestedLimit > 0 ? hasMore : undefined; const isEditMode = ctx?.editMode ?? false; const entriesWithEdit = pageEntries.map((entry: ContentEntry) => { const dbId = entryDatabaseId(entry); if (isEditMode) { tagEditableFields(entryData(entry), type, dbId); } return { ...entry, edit: isEditMode ? createEditable(type, dbId, entryEditOptions(entry)) : createNoop(), }; }); // Eagerly hydrate bylines and taxonomy terms for all entries in parallel. // Both are independent queries, so running them concurrently halves the // round-trip cost on remote databases (D1 replicas, etc.). await Promise.all([ hydrateEntryBylines(type, entriesWithEdit), // Hydrate terms in the same locale the content rows were resolved to, // otherwise localized entries get default-locale taxonomy terms (#1441). hydrateEntryTerms(type, entriesWithEdit, resolvedLocale), ]); return { entries: entriesWithEdit, nextCursor, hasMore: hasMoreResult, cacheHint: cacheHint ?? {}, }; } /** * Get a single entry by type and ID/slug * * Returns { entry, error, isPreview } for graceful error handling. * - entry is null if not found (not an error) * - error is set only for actual errors (db issues, etc.) * * Preview mode is detected automatically from request context (ALS). * When the URL has a valid `_preview` token, the middleware sets preview * context and this function serves draft revision data if available. * * @example * ```ts * import { getEmDashEntry } from "emdash"; * * // Simple usage — preview just works via middleware * const { entry: post, isPreview, error } = await getEmDashEntry("posts", "my-slug"); * if (!post) return Astro.redirect("/404"); * ``` */ export async function getEmDashEntry>( type: T, id: string, options?: { locale?: string }, ): Promise> { // Dynamic import to avoid build-time issues const { getLiveEntry } = await import("astro:content"); // Check ALS for preview and edit mode context const ctx = getRequestContext(); const preview = ctx?.preview; const isEditMode = ctx?.editMode ?? false; const isPreviewMode = !!preview && preview.collection === type; // Edit mode implies preview — editors should see draft content const serveDrafts = isPreviewMode || isEditMode; // Resolve locale: explicit option > ALS context > undefined (no filter) const requestedLocale = options?.locale ?? ctx?.locale; /** Wrap a raw Astro entry with edit proxy, tagging editable fields if needed */ function wrapEntry(raw: ContentEntry): ContentEntry { const dbId = entryDatabaseId(raw); if (isEditMode) { tagEditableFields(entryData(raw), type, dbId); } return { ...raw, edit: isEditMode ? createEditable(type, dbId, entryEditOptions(raw)) : createNoop(), }; } /** Check if an entry is publicly visible (published or scheduled past its time) */ function isVisible(entry: ContentEntry): boolean { const data = entryData(entry); const status = dataStr(data, "status"); const scheduledAt = dataDate(data, "scheduledAt"); const isPublished = status === "published"; const isScheduledAndReady = status === "scheduled" && scheduledAt !== undefined && scheduledAt.getTime() <= Date.now(); return isPublished || !!isScheduledAndReady; } /** True when an entry is scheduled to become visible at a future time. */ function isPendingScheduled(entry: ContentEntry): boolean { const data = entryData(entry); if (dataStr(data, "status") !== "scheduled") return false; const scheduledAt = dataDate(data, "scheduledAt"); return scheduledAt !== undefined && scheduledAt.getTime() > Date.now(); } // Build the fallback chain: [requestedLocale, fallback1, ..., defaultLocale] // When i18n is disabled or no locale requested, just use a single-element chain const localeChain = requestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale]; /** Return a successful EntryResult with bylines and taxonomy terms hydrated */ async function successResult( wrapped: ContentEntry, opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint }, ): Promise> { // Hydrate terms in the entry's resolved locale (fallback-aware) so a // localized entry never picks up default-locale taxonomy terms (#1441). // When i18n is disabled we leave the locale unset to preserve the // legacy "do not filter by locale" behaviour. const termLocale = isI18nEnabled() ? dataStr(entryData(wrapped), "locale") || undefined : undefined; await Promise.all([ hydrateEntryBylines(type, [wrapped]), hydrateEntryTerms(type, [wrapped], termLocale), ]); return { entry: wrapped, isPreview: opts.isPreview, fallbackLocale: opts.fallbackLocale, cacheHint: opts.cacheHint, }; } if (serveDrafts) { // Draft mode: try each locale in the fallback chain for (let i = 0; i < localeChain.length; i++) { const locale = localeChain[i]; const fallbackLocale = i > 0 ? locale : undefined; const { entry: baseEntry, error: baseError, cacheHint, } = await getLiveEntry(COLLECTION_NAME, { type, id, locale, }); if (baseError) { return { entry: null, error: baseError, isPreview: serveDrafts, cacheHint: {} }; } if (!baseEntry) continue; // Try next locale in chain // Preview tokens are item-scoped: verify the resolved entry matches. // Edit mode (authenticated editors) has collection-wide draft access. if (isPreviewMode && !isEditMode) { const dbId = entryDatabaseId(baseEntry); if (preview.id !== dbId && preview.id !== id) { // Token doesn't match — serve only if publicly visible, without draft access if (isVisible(baseEntry)) { return successResult(wrapEntry(baseEntry), { isPreview: false, fallbackLocale, cacheHint: cacheHint ?? {}, }); } // Not visible — try next locale in fallback chain continue; } } // Check if entry has a draft revision — if so, re-fetch with revision data const baseData = entryData(baseEntry); const draftRevisionId = dataStr(baseData, "draftRevisionId") || undefined; if (draftRevisionId) { const { entry: draftEntry, error: draftError } = await getLiveEntry(COLLECTION_NAME, { type, id, revisionId: draftRevisionId, locale, }); if (!draftError && draftEntry) { return successResult(wrapEntry(draftEntry), { isPreview: serveDrafts, fallbackLocale, cacheHint: cacheHint ?? {}, }); } } return successResult(wrapEntry(baseEntry), { isPreview: serveDrafts, fallbackLocale, cacheHint: cacheHint ?? {}, }); } // No entry found in any locale return { entry: null, isPreview: serveDrafts, cacheHint: {} }; } // Normal mode: try each locale in the fallback chain, only return published // content. The full resolution (fallback chain + visibility + byline/term // hydration) is wrapped in the distributed L2 cache, keyed by the requested // locale. Preview/edit requests took the `serveDrafts` branch above and // never reach here; the object cache additionally bypasses them. // A scheduled entry becomes visible on a future clock tick, not on a write, // so an L2 snapshot taken before its time would keep it hidden past go-live // (until the publish sweep bumps the epoch or the TTL lapses). Mark such a // resolution time-sensitive and skip caching it. let timeSensitive = false; const resolveNormal = async (): Promise> => { for (let i = 0; i < localeChain.length; i++) { const locale = localeChain[i]; const fallbackLocale = i > 0 ? locale : undefined; const { entry, error, cacheHint } = await getLiveEntry(COLLECTION_NAME, { type, id, locale }); if (error) { return { entry: null, error, isPreview: false, cacheHint: {} }; } if (entry && isVisible(entry)) { return successResult(wrapEntry(entry), { isPreview: false, fallbackLocale, cacheHint: cacheHint ?? {}, }); } if (entry && isPendingScheduled(entry)) { timeSensitive = true; } // Entry not found or not visible in this locale — try next } return { entry: null, isPreview: false, cacheHint: {} }; }; const snapshot = await cachedQuery>({ namespace: contentNamespaces(type), key: `entry:${id}|loc=${requestedLocale ?? ""}`, load: async () => { const result = await resolveNormal(); if (result.error) { return { ok: false, error: result.error, cacheHint: result.cacheHint }; } return { ok: true, value: { entry: result.entry ? entrySnapshot(result.entry) : null, isPreview: result.isPreview, fallbackLocale: result.fallbackLocale, cacheHint: result.cacheHint, }, }; }, cacheable: (snap) => snap.ok && !timeSensitive, }); if (!snapshot.ok) { return { entry: null, error: snapshot.error, isPreview: false, cacheHint: snapshot.cacheHint }; } return { entry: snapshot.value.entry ? reviveEntry(snapshot.value.entry) : null, isPreview: snapshot.value.isPreview, fallbackLocale: snapshot.value.fallbackLocale, cacheHint: snapshot.value.cacheHint, }; } /** Shape of a cached single-entry snapshot. */ interface CachedEntryValue { entry: Record | null; isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint; } /** * Eagerly hydrate byline data onto entry.data for one or more entries. * * Attaches `bylines` (array of ContentBylineCredit) and `byline` * (primary BylineSummary or null) to each entry's data object. * Uses batch queries to avoid N+1. * * Fails silently if the byline tables don't exist yet (pre-migration). */ async function hydrateEntryBylines(type: string, entries: ContentEntry[]): Promise { if (entries.length === 0) return; // Fast path: bylines were folded into the content query. Parse the JSON // (no extra round trip) for the common case — explicit credits, no byline // custom fields, no author fallback. The query path below handles the rest: // - author fallback (entry has authorId but no explicit credit), and // - custom byline fields (can't be expressed in the folded subquery). if (entries.every((e) => FOLDED_BYLINES in entryData(e))) { const parsed = entries.map((entry) => { const data = entryData(entry); const folded = Reflect.get(data, FOLDED_BYLINES); const rows = Array.isArray(folded) ? folded : []; const credits = rows .map((raw) => { const b = raw?.byline ?? {}; return { roleLabel: raw?.roleLabel ?? null, sortOrder: Number(raw?.sortOrder ?? 0), source: "explicit" as const, byline: { ...b, isGuest: Boolean(b.isGuest), customFields: {} }, }; }) .toSorted((a, b) => a.sortOrder - b.sortOrder); return { data, credits }; }); // Fall back to the full query path when the fold can't be trusted to be // complete: an entry with a byline reference (explicit primary, or an // author for the author-fallback) but no folded credits — e.g. a credit // in a different locale than the row, which the locale-correlated subquery // skips, or the author-fallback path which the fold doesn't express. let needsQueryPath = parsed.some( (p) => p.credits.length === 0 && (dataStr(p.data, "authorId") !== "" || dataStr(p.data, "primaryBylineId") !== ""), ); let hasCustomFields = false; if (!needsQueryPath) { try { const { getDb } = await import("./loader.js"); const db = await getDb(); const { getBylineFieldDefs } = await import("./bylines/field-defs-cache.js"); hasCustomFields = (await getBylineFieldDefs(db)).length > 0; } catch (error) { // A missing table is expected pre-migration and means there are no // custom fields — the fold's values are complete. Any other error // (lock-init failure, dialect error) means the probe can't be // trusted, so fall back to the query path rather than risk serving // folded bylines with empty customFields. if (!isMissingTableError(error)) needsQueryPath = true; } } if (!needsQueryPath && !hasCustomFields) { for (const p of parsed) { p.data.bylines = p.credits; p.data.byline = p.credits[0]?.byline ?? null; } return; } // Fall through to the full query path for fallback / custom-field cases. } try { const { getBylinesForEntries } = await import("./bylines/index.js"); const refs = entries .map((e) => { const data = entryData(e); const id = dataStr(data, "id"); if (!id) return null; return { id, authorId: dataStr(data, "authorId") || null, primaryBylineId: dataStr(data, "primaryBylineId") || null, locale: dataStr(data, "locale") || null, }; }) .filter( ( r, ): r is { id: string; authorId: string | null; primaryBylineId: string | null; locale: string | null; } => r !== null, ); if (refs.length === 0) return; const bylinesMap = await getBylinesForEntries(type, refs); for (const entry of entries) { const data = entryData(entry); const dbId = dataStr(data, "id"); if (!dbId) continue; const credits = bylinesMap.get(dbId) ?? []; data.bylines = credits; data.byline = credits[0]?.byline ?? null; } } catch (err) { // Only swallow "table not found" errors from pre-migration databases. // Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table // ... does not exist") via the shared helper. if (!isMissingTableError(err)) { const msg = err instanceof Error ? err.message : String(err); console.warn("[emdash] Failed to hydrate bylines:", msg); } } } /** * Eagerly hydrate taxonomy term data onto entry.data for one or more entries. * * Attaches `terms` (Record keyed by taxonomy name with an array of TaxonomyTerm * values) to each entry's data object. Uses a single batched JOIN query across * all taxonomies so the cost is O(1) regardless of the number of entries or * taxonomies on the site. * * This eliminates the common N+1 pattern where templates loop over list * results and call getEntryTerms() per entry. With hydration, the list page * stays at a single round-trip for term data. * * `locale` must be the locale the entries were resolved to. It is forwarded to * `getAllTermsForEntries` so terms are returned in the entry's locale rather * than falling back to the request-context / default locale (#1441). Pass * `undefined` to keep the legacy "do not filter by locale" behaviour. * * Fails silently if the taxonomy tables don't exist yet (pre-migration). */ async function hydrateEntryTerms( type: string, entries: ContentEntry[], locale?: string, ): Promise { if (entries.length === 0) return; // Fast path: terms were folded into the content query. Group the JSON and // skip the separate content_taxonomies query. if (entries.every((e) => FOLDED_TERMS in entryData(e))) { const perEntry: Array<{ entryId: string; byTaxonomy: Record }> = []; for (const entry of entries) { const data = entryData(entry); const folded = Reflect.get(data, FOLDED_TERMS); const rows = Array.isArray(folded) ? folded : []; const grouped: Record = {}; for (const r of rows) { const name = String(r?.name); (grouped[name] ??= []).push({ id: r?.id, name, slug: r?.slug, label: r?.label, parentId: r?.parent_id ?? undefined, children: [], locale: r?.locale, translationGroup: r?.translation_group, }); } // Match getAllTermsForEntries' ORDER BY label (dropped from the // aggregate since SQLite and Postgres order it differently). for (const [name, arr] of Object.entries(grouped)) { grouped[name] = arr.toSorted((a, b) => String(a.label).localeCompare(String(b.label))); } data.terms = grouped; const entryId = dataStr(data, "id"); if (entryId) perEntry.push({ entryId, byTaxonomy: grouped }); } // Prime the per-entry request cache (wildcard + present taxonomies) so // subsequent getEntryTerms(...) calls in this render hit the cache instead // of issuing an N+1 query. No DB lookup — purely from the folded data. const { primeFoldedEntryTerms } = await import("./taxonomies/index.js"); primeFoldedEntryTerms(type, perEntry, { locale }); return; } try { const { getAllTermsForEntries } = await import("./taxonomies/index.js"); const ids = entries.map((e) => dataStr(entryData(e), "id")).filter(Boolean); if (ids.length === 0) return; const termsMap = await getAllTermsForEntries(type, ids, { locale }); for (const entry of entries) { const data = entryData(entry); const dbId = dataStr(data, "id"); if (!dbId) continue; data.terms = termsMap.get(dbId) ?? {}; } } catch (err) { // Only swallow "table not found" errors from pre-migration databases. // Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table // ... does not exist") via the shared helper. if (!isMissingTableError(err)) { const msg = err instanceof Error ? err.message : String(err); console.warn("[emdash] Failed to hydrate terms:", msg); } } } /** * Translation summary for a single locale variant */ export interface TranslationSummary { /** Content item ID */ id: string; /** Locale code (e.g. "en", "fr") */ locale: string; /** URL slug */ slug: string | null; /** Current status */ status: string; } /** * Result from getTranslations */ export interface TranslationsResult { /** The translation group ID (shared across locales) */ translationGroup: string; /** All locale variants in this group */ translations: TranslationSummary[]; /** Error if the query failed */ error?: Error; } /** * Get all translations of a content item. * * Given a content entry, returns all locale variants that share the same * translation group. This is useful for building language switcher UI. * * @example * ```ts * import { getEmDashEntry, getTranslations } from "emdash"; * * const { entry: post } = await getEmDashEntry("posts", "hello-world", { locale: "en" }); * const { translations } = await getTranslations("posts", post.data.id); * // translations = [{ id: "...", locale: "en", slug: "hello-world", status: "published" }, ...] * ``` */ export async function getTranslations(type: string, id: string): Promise { try { const db = (await import("./loader.js")).getDb; const dbInstance = await db(); const { ContentRepository } = await import("./database/repositories/content.js"); const repo = new ContentRepository(dbInstance); // Find the item to get its translation group const item = await repo.findByIdOrSlug(type, id); if (!item) { return { translationGroup: "", translations: [], error: new Error(`Content item not found: ${id}`), }; } const group = item.translationGroup || item.id; const translations = await repo.findTranslations(type, group); return { translationGroup: group, translations: translations.map((t) => ({ id: t.id, locale: t.locale || "en", slug: t.slug, status: t.status, })), }; } catch (error) { return { translationGroup: "", translations: [], error: error instanceof Error ? error : new Error(String(error)), }; } } /** * Result from resolveEmDashPath */ export interface ResolvePathResult> { /** The matched entry */ entry: ContentEntry; /** The collection slug that matched */ collection: string; /** Extracted parameters from the URL pattern (e.g. { slug: "my-post" }) */ params: Record; } /** Matches `{paramName}` placeholders in URL patterns */ const URL_PARAM_PATTERN = /\{(\w+)\}/g; /** Convert a URL pattern like "/blog/{slug}" to a regex and param name list */ function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } { const paramNames: string[] = []; const regexStr = pattern.replace(URL_PARAM_PATTERN, (_match, name: string) => { paramNames.push(name); return "([^/]+)"; }); return { regex: new RegExp(`^${regexStr}$`), paramNames }; } /** Cached compiled URL patterns for resolveEmDashPath */ interface CachedPattern { slug: string; regex: RegExp; paramNames: string[]; } let cachedUrlPatterns: CachedPattern[] | null = null; /** * Invalidate the cached URL patterns used by resolveEmDashPath. * Call when collection URL patterns change (schema updates). * * Also busts the distributed schema cache (collection metadata such as * `commentsEnabled`, `supports`, fields read by `getCollectionInfo`), since * every schema-mutation path already routes through here. */ export function invalidateUrlPatternCache(): void { cachedUrlPatterns = null; invalidateSchemaObjectCache(); } /** * Resolve a URL path to a content entry by matching against collection URL patterns. * * Loads all collections with a `urlPattern` set, converts each pattern to a regex, * and tests the given path. On match, extracts the slug and fetches the entry. * * @example * ```ts * import { resolveEmDashPath } from "emdash"; * * // Given pages with urlPattern "/{slug}" and posts with "/blog/{slug}": * const result = await resolveEmDashPath("/blog/hello-world"); * if (result) { * console.log(result.collection); // "posts" * console.log(result.params.slug); // "hello-world" * console.log(result.entry.data); // post data * } * ``` */ export async function resolveEmDashPath>( path: string, ): Promise | null> { // Build and cache compiled patterns on first call if (!cachedUrlPatterns) { const { getDb } = await import("./loader.js"); const { SchemaRegistry } = await import("./schema/registry.js"); const db = await getDb(); const registry = new SchemaRegistry(db); const collections = await registry.listCollections(); cachedUrlPatterns = []; for (const collection of collections) { if (!collection.urlPattern) continue; const { regex, paramNames } = patternToRegex(collection.urlPattern); cachedUrlPatterns.push({ slug: collection.slug, regex, paramNames }); } } for (const pattern of cachedUrlPatterns) { const match = path.match(pattern.regex); if (!match) continue; // Extract params const params: Record = {}; for (let i = 0; i < pattern.paramNames.length; i++) { params[pattern.paramNames[i]] = match[i + 1]; } // Look up entry by slug (most common pattern) const slug = params.slug; if (!slug) continue; const { entry } = await getEmDashEntry(pattern.slug, slug); if (entry) { return { entry, collection: pattern.slug, params }; } } return null; }