first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View 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;
}