first commit
This commit is contained in:
237
packages/core/src/bylines/index.ts
Normal file
237
packages/core/src/bylines/index.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Runtime API for bylines
|
||||
*
|
||||
* Provides functions to query byline profiles and byline credits
|
||||
* associated with content entries. Follows the same pattern as
|
||||
* the taxonomies runtime API.
|
||||
*/
|
||||
|
||||
import { sql } from "kysely";
|
||||
|
||||
import { BylineRepository } from "../database/repositories/byline.js";
|
||||
import type { BylineSummary, ContentBylineCredit } from "../database/repositories/types.js";
|
||||
import { validateIdentifier } from "../database/validate.js";
|
||||
import { getDb } from "../loader.js";
|
||||
|
||||
/**
|
||||
* Get a byline by ID.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { getByline } from "emdash";
|
||||
*
|
||||
* const byline = await getByline("01HXYZ...");
|
||||
* if (byline) {
|
||||
* console.log(byline.displayName);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getByline(id: string): Promise<BylineSummary | null> {
|
||||
const db = await getDb();
|
||||
const repo = new BylineRepository(db);
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a byline by slug.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { getBylineBySlug } from "emdash";
|
||||
*
|
||||
* const byline = await getBylineBySlug("jane-doe");
|
||||
* if (byline) {
|
||||
* console.log(byline.displayName); // "Jane Doe"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getBylineBySlug(slug: string): Promise<BylineSummary | null> {
|
||||
const db = await getDb();
|
||||
const repo = new BylineRepository(db);
|
||||
return repo.findBySlug(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get byline credits for a single content entry.
|
||||
*
|
||||
* Returns explicit byline credits from the junction table. If none exist
|
||||
* but the entry has an `authorId`, falls back to the user-linked byline
|
||||
* (marked as source: "inferred").
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { getEntryBylines } from "emdash";
|
||||
*
|
||||
* const bylines = await getEntryBylines("posts", post.data.id);
|
||||
* for (const credit of bylines) {
|
||||
* console.log(credit.byline.displayName, credit.roleLabel);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getEntryBylines(
|
||||
collection: string,
|
||||
entryId: string,
|
||||
): Promise<ContentBylineCredit[]> {
|
||||
validateIdentifier(collection, "collection");
|
||||
const db = await getDb();
|
||||
const repo = new BylineRepository(db);
|
||||
|
||||
const explicit = await repo.getContentBylines(collection, entryId);
|
||||
if (explicit.length > 0) {
|
||||
return explicit.map((c) => ({ ...c, source: "explicit" as const }));
|
||||
}
|
||||
|
||||
// Fallback: look up user-linked byline from author_id
|
||||
const authorId = await getAuthorId(db, collection, entryId);
|
||||
if (authorId) {
|
||||
const fallback = await repo.findByUserId(authorId);
|
||||
if (fallback) {
|
||||
return [{ byline: fallback, sortOrder: 0, roleLabel: null, source: "inferred" }];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch byline credits for multiple content entries in a single query.
|
||||
*
|
||||
* This is more efficient than calling getEntryBylines for each entry
|
||||
* when you need bylines for a list of entries (e.g., a blog index page).
|
||||
*
|
||||
* @param collection - The collection slug (e.g., "posts")
|
||||
* @param entryIds - Array of entry IDs
|
||||
* @returns Map from entry ID to array of byline credits
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { getBylinesForEntries, getEmDashCollection } from "emdash";
|
||||
*
|
||||
* const { entries } = await getEmDashCollection("posts");
|
||||
* const ids = entries.map(e => e.data.id);
|
||||
* const bylinesMap = await getBylinesForEntries("posts", ids);
|
||||
*
|
||||
* for (const entry of entries) {
|
||||
* const bylines = bylinesMap.get(entry.data.id) ?? [];
|
||||
* // render bylines
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getBylinesForEntries(
|
||||
collection: string,
|
||||
entryIds: string[],
|
||||
): Promise<Map<string, ContentBylineCredit[]>> {
|
||||
validateIdentifier(collection, "collection");
|
||||
const result = new Map<string, ContentBylineCredit[]>();
|
||||
|
||||
// Initialize all entry IDs with empty arrays
|
||||
for (const id of entryIds) {
|
||||
result.set(id, []);
|
||||
}
|
||||
|
||||
if (entryIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const repo = new BylineRepository(db);
|
||||
|
||||
// 1. Batch fetch all explicit byline credits
|
||||
const bylinesMap = await repo.getContentBylinesMany(collection, entryIds);
|
||||
|
||||
// 2. Collect entry IDs that need fallback lookup
|
||||
const fallbackEntryIds: string[] = [];
|
||||
const needsFallback: Map<string, string> = new Map(); // entryId -> authorId
|
||||
|
||||
for (const id of entryIds) {
|
||||
if (!bylinesMap.has(id)) {
|
||||
// Need to check author_id for this entry — but we only have the IDs,
|
||||
// so batch-fetch them from the content table
|
||||
fallbackEntryIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch author_ids for entries that need fallback
|
||||
if (fallbackEntryIds.length > 0) {
|
||||
const authorMap = await getAuthorIds(db, collection, fallbackEntryIds);
|
||||
for (const [entryId, authorId] of authorMap) {
|
||||
needsFallback.set(entryId, authorId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Batch fetch user-linked bylines for fallback
|
||||
const uniqueAuthorIds = [...new Set(needsFallback.values())];
|
||||
const authorBylineMap = await repo.findByUserIds(uniqueAuthorIds);
|
||||
|
||||
// 4. Assign results
|
||||
for (const id of entryIds) {
|
||||
const explicit = bylinesMap.get(id);
|
||||
if (explicit && explicit.length > 0) {
|
||||
result.set(
|
||||
id,
|
||||
explicit.map((c) => ({ ...c, source: "explicit" as const })),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const authorId = needsFallback.get(id);
|
||||
if (authorId) {
|
||||
const fallback = authorBylineMap.get(authorId);
|
||||
if (fallback) {
|
||||
result.set(id, [{ byline: fallback, sortOrder: 0, roleLabel: null, source: "inferred" }]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Already initialized with empty array
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the author_id for a single content entry.
|
||||
* Uses raw SQL since we need dynamic table names.
|
||||
*/
|
||||
async function getAuthorId(
|
||||
db: Awaited<ReturnType<typeof getDb>>,
|
||||
collection: string,
|
||||
entryId: string,
|
||||
): Promise<string | null> {
|
||||
const tableName = `ec_${collection}`;
|
||||
validateIdentifier(tableName, "content table");
|
||||
|
||||
const result = await sql<{ author_id: string | null }>`
|
||||
SELECT author_id FROM ${sql.ref(tableName)}
|
||||
WHERE id = ${entryId}
|
||||
LIMIT 1
|
||||
`.execute(db);
|
||||
|
||||
return result.rows[0]?.author_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch author_ids for multiple content entries.
|
||||
* Returns Map<entryId, authorId> (only entries with non-null author_id).
|
||||
*/
|
||||
async function getAuthorIds(
|
||||
db: Awaited<ReturnType<typeof getDb>>,
|
||||
collection: string,
|
||||
entryIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const tableName = `ec_${collection}`;
|
||||
validateIdentifier(tableName, "content table");
|
||||
|
||||
const result = await sql<{ id: string; author_id: string | null }>`
|
||||
SELECT id, author_id FROM ${sql.ref(tableName)}
|
||||
WHERE id IN (${sql.join(entryIds.map((id) => sql`${id}`))})
|
||||
`.execute(db);
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const row of result.rows) {
|
||||
if (row.author_id) {
|
||||
map.set(row.id, row.author_id);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
Reference in New Issue
Block a user