/** * 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. */ import { encodeCursor } from "./database/repositories/types.js"; import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js"; import { CURSOR_RAW_VALUES } from "./loader.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.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 interface CollectionFilter { status?: "draft" | "published" | "archived"; limit?: number; /** * 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; /** * Filter by field values or taxonomy terms * @example { category: 'news' } - Filter by taxonomy term * @example { category: ['news', 'featured'] } - Filter by multiple terms (OR) */ 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; } 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; } /** * 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; } /** 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), () => getEmDashCollectionUncached(type, bucketed.fetchFilter), ); return bucketed.requestedLimit === undefined ? cached : sliceCollectionResult(cached, bucketed.requestedLimit, filter?.orderBy); } /** * 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 ) { 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; return { ...cached, entries: sliced, nextCursor }; } /** 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.where ? stableStringify(filter.where) : "", filter.orderBy ? JSON.stringify(filter.orderBy) : "", filter.locale ?? "", ]; return `collection:${type}:${parts.join("|")}`; } function stableStringify(value: Record): string { const keys = Object.keys(value).toSorted(); const ordered: Record = {}; for (const k of keys) ordered[k] = value[k]; return JSON.stringify(ordered); } 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 result = await getLiveCollection(COLLECTION_NAME, { type, status: filter?.status, limit: filter?.limit, cursor: filter?.cursor, where: filter?.where, orderBy: filter?.orderBy, locale: resolvedLocale, }); const { entries, error, cacheHint } = result; // nextCursor is returned by the emdash loader but not part of Astro's base // LiveLoader return type. Extract it safely via property descriptor to avoid // an unsafe type assertion on the `any`-typed result object. const rawCursor = Object.getOwnPropertyDescriptor(result, "nextCursor")?.value; const nextCursor: string | undefined = typeof rawCursor === "string" ? rawCursor : undefined; if (error) { return { entries: [], error, cacheHint: {} }; } const isEditMode = ctx?.editMode ?? false; const entriesWithEdit = entries.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), hydrateEntryTerms(type, entriesWithEdit), ]); return { entries: entriesWithEdit, nextCursor, 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 = dataStr(data, "scheduledAt") || undefined; const isPublished = status === "published"; const isScheduledAndReady = status === "scheduled" && scheduledAt && new Date(scheduledAt) <= new Date(); return isPublished || !!isScheduledAndReady; } // 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> { await Promise.all([hydrateEntryBylines(type, [wrapped]), hydrateEntryTerms(type, [wrapped])]); 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 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 ?? {}, }); } // Entry not found or not visible in this locale — try next } return { entry: null, isPreview: false, 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; try { const { getBylinesForEntries } = await import("./bylines/index.js"); const refs = entries .map((e) => { const data = entryData(e); const id = dataStr(data, "id"); return id ? { id, authorId: dataStr(data, "authorId") || null } : null; }) .filter((r): r is { id: string; authorId: 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. * * Fails silently if the taxonomy tables don't exist yet (pre-migration). */ async function hydrateEntryTerms(type: string, entries: ContentEntry[]): Promise { if (entries.length === 0) 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); 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). */ export function invalidateUrlPatternCache(): void { cachedUrlPatterns = null; } /** * 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; }