Files
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

956 lines
31 KiB
TypeScript

/**
* 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<string, unknown>.
*/
export type InferCollectionData<T extends string> = T extends keyof EmDashCollections
? EmDashCollections[T]
: Record<string, unknown>;
/**
* 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<string, SortDirection>;
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<string, string | string[]>;
/**
* 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<T = Record<string, unknown>> {
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<T> {
/** The entries (empty array if error or none found) */
entries: ContentEntry<T>[];
/** 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<T> {
/** The entry, or null if not found */
entry: ContentEntry<T> | 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<string, unknown>, 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<string, unknown>, key: string, fallback = ""): string {
const val = data[key];
return typeof val === "string" ? val : fallback;
}
/** Type guard for Record<string, unknown> */
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> {
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<T extends string, D = InferCollectionData<T>>(
type: T,
filter?: CollectionFilter,
): Promise<CollectionResult<D>> {
// 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<T, D>(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<D>(
cached: CollectionResult<D>,
limit: number,
orderBy: OrderBySpec | undefined,
): CollectionResult<D> {
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<string, string> = {
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<D>(
entry: ContentEntry<D>,
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, unknown>): string {
const keys = Object.keys(value).toSorted();
const ordered: Record<string, unknown> = {};
for (const k of keys) ordered[k] = value[k];
return JSON.stringify(ordered);
}
async function getEmDashCollectionUncached<T extends string, D = InferCollectionData<T>>(
type: T,
filter?: CollectionFilter,
): Promise<CollectionResult<D>> {
// 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<D>) => {
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<T extends string, D = InferCollectionData<T>>(
type: T,
id: string,
options?: { locale?: string },
): Promise<EntryResult<D>> {
// 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<D>): ContentEntry<D> {
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<D>): 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<D>,
opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },
): Promise<EntryResult<D>> {
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<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {
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<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {
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<TranslationsResult> {
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<T = Record<string, unknown>> {
/** The matched entry */
entry: ContentEntry<T>;
/** The collection slug that matched */
collection: string;
/** Extracted parameters from the URL pattern (e.g. { slug: "my-post" }) */
params: Record<string, string>;
}
/** 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<T = Record<string, unknown>>(
path: string,
): Promise<ResolvePathResult<T> | 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<string, string> = {};
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<string, T>(pattern.slug, slug);
if (entry) {
return { entry, collection: pattern.slug, params };
}
}
return null;
}