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,309 @@
/**
* Runtime API for taxonomies
*
* Provides functions to query taxonomy definitions and terms.
*/
import { getDb } from "../loader.js";
import type { TaxonomyDef, TaxonomyTerm, TaxonomyTermRow } from "./types.js";
/**
* Get all taxonomy definitions
*/
export async function getTaxonomyDefs(): Promise<TaxonomyDef[]> {
const db = await getDb();
const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
return rows.map((row) => ({
id: row.id,
name: row.name,
label: row.label,
labelSingular: row.label_singular ?? undefined,
hierarchical: row.hierarchical === 1,
collections: row.collections ? JSON.parse(row.collections) : [],
}));
}
/**
* Get a single taxonomy definition by name
*/
export async function getTaxonomyDef(name: string): Promise<TaxonomyDef | null> {
const db = await getDb();
const row = await db
.selectFrom("_emdash_taxonomy_defs")
.selectAll()
.where("name", "=", name)
.executeTakeFirst();
if (!row) return null;
return {
id: row.id,
name: row.name,
label: row.label,
labelSingular: row.label_singular ?? undefined,
hierarchical: row.hierarchical === 1,
collections: row.collections ? JSON.parse(row.collections) : [],
};
}
/**
* Get all terms for a taxonomy (as tree for hierarchical, flat for tags)
*/
export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
const db = await getDb();
// Get taxonomy definition to check if hierarchical
const def = await getTaxonomyDef(taxonomyName);
if (!def) return [];
// Get all terms for this taxonomy
const rows = await db
.selectFrom("taxonomies")
.selectAll()
.where("name", "=", taxonomyName)
.orderBy("label", "asc")
.execute();
// Count entries for each term
const countsResult = await db
.selectFrom("content_taxonomies")
.select(["taxonomy_id"])
.select((eb) => eb.fn.count<number>("entry_id").as("count"))
.groupBy("taxonomy_id")
.execute();
const counts = new Map<string, number>();
for (const row of countsResult) {
counts.set(row.taxonomy_id, row.count);
}
const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({
id: row.id,
name: row.name,
slug: row.slug,
label: row.label,
parent_id: row.parent_id,
data: row.data,
}));
// If hierarchical, build tree. Otherwise return flat
if (def.hierarchical) {
return buildTree(flatTerms, counts);
}
return flatTerms.map((term) => ({
id: term.id,
name: term.name,
slug: term.slug,
label: term.label,
children: [],
count: counts.get(term.id) ?? 0,
}));
}
/**
* Get a single term by taxonomy and slug
*/
export async function getTerm(taxonomyName: string, slug: string): Promise<TaxonomyTerm | null> {
const db = await getDb();
const row = await db
.selectFrom("taxonomies")
.selectAll()
.where("name", "=", taxonomyName)
.where("slug", "=", slug)
.executeTakeFirst();
if (!row) return null;
// Get entry count
const countResult = await db
.selectFrom("content_taxonomies")
.select((eb) => eb.fn.count<number>("entry_id").as("count"))
.where("taxonomy_id", "=", row.id)
.executeTakeFirst();
const count = countResult?.count ?? 0;
// Get children if hierarchical
const childRows = await db
.selectFrom("taxonomies")
.selectAll()
.where("parent_id", "=", row.id)
.orderBy("label", "asc")
.execute();
const children = childRows.map((child) => ({
id: child.id,
name: child.name,
slug: child.slug,
label: child.label,
parentId: child.parent_id ?? undefined,
children: [],
}));
return {
id: row.id,
name: row.name,
slug: row.slug,
label: row.label,
parentId: row.parent_id ?? undefined,
description: row.data ? JSON.parse(row.data).description : undefined,
children,
count,
};
}
/**
* Get terms assigned to an entry
*/
export async function getEntryTerms(
collection: string,
entryId: string,
taxonomyName?: string,
): Promise<TaxonomyTerm[]> {
const db = await getDb();
let query = db
.selectFrom("content_taxonomies")
.innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
.selectAll("taxonomies")
.where("content_taxonomies.collection", "=", collection)
.where("content_taxonomies.entry_id", "=", entryId);
if (taxonomyName) {
query = query.where("taxonomies.name", "=", taxonomyName);
}
const rows = await query.execute();
return rows.map((row) => ({
id: row.id,
name: row.name,
slug: row.slug,
label: row.label,
parentId: row.parent_id ?? undefined,
children: [],
}));
}
/**
* Get terms for multiple entries in a single query (batched API)
*
* This is more efficient than calling getEntryTerms for each entry
* when you need terms for a list of entries.
*
* @param collection - The collection type (e.g., "posts")
* @param entryIds - Array of entry IDs
* @param taxonomyName - The taxonomy name (e.g., "categories")
* @returns Map from entry ID to array of terms
*/
export async function getTermsForEntries(
collection: string,
entryIds: string[],
taxonomyName: string,
): Promise<Map<string, TaxonomyTerm[]>> {
const result = new Map<string, TaxonomyTerm[]>();
// 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 rows = await db
.selectFrom("content_taxonomies")
.innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
.select([
"content_taxonomies.entry_id",
"taxonomies.id",
"taxonomies.name",
"taxonomies.slug",
"taxonomies.label",
"taxonomies.parent_id",
])
.where("content_taxonomies.collection", "=", collection)
.where("content_taxonomies.entry_id", "in", entryIds)
.where("taxonomies.name", "=", taxonomyName)
.execute();
for (const row of rows) {
const entryId = row.entry_id;
const term: TaxonomyTerm = {
id: row.id,
name: row.name,
slug: row.slug,
label: row.label,
parentId: row.parent_id ?? undefined,
children: [],
};
const terms = result.get(entryId);
if (terms) {
terms.push(term);
}
}
return result;
}
/**
* Get entries by term (wraps getEmDashCollection)
*/
export async function getEntriesByTerm(
collection: string,
taxonomyName: string,
termSlug: string,
): Promise<Array<{ id: string; data: Record<string, unknown> }>> {
const { getEmDashCollection } = await import("../query.js");
// Build options as the expected type — getEmDashCollection accepts
// a generic options object with `where` for filtering by taxonomy
const options: Record<string, unknown> = {
where: { [taxonomyName]: termSlug },
};
const { entries } = await getEmDashCollection(collection, options);
return entries;
}
/**
* Build tree structure from flat terms
*/
function buildTree(flatTerms: TaxonomyTermRow[], counts: Map<string, number>): TaxonomyTerm[] {
const map = new Map<string, TaxonomyTerm>();
const roots: TaxonomyTerm[] = [];
// First pass: create nodes
for (const term of flatTerms) {
map.set(term.id, {
id: term.id,
name: term.name,
slug: term.slug,
label: term.label,
parentId: term.parent_id ?? undefined,
description: term.data ? JSON.parse(term.data).description : undefined,
children: [],
count: counts.get(term.id) ?? 0,
});
}
// Second pass: build tree
for (const term of map.values()) {
if (term.parentId && map.has(term.parentId)) {
map.get(term.parentId)!.children.push(term);
} else {
roots.push(term);
}
}
return roots;
}

View File

@@ -0,0 +1,61 @@
/**
* Taxonomy types for EmDash CMS
*/
/**
* Taxonomy definition - describes a taxonomy like "category" or "tag"
*/
export interface TaxonomyDef {
id: string;
name: string; // 'category', 'tag', 'genre'
label: string; // 'Categories', 'Tags'
labelSingular?: string; // 'Category', 'Tag'
hierarchical: boolean;
collections: string[]; // ['posts', 'pages']
}
/**
* Taxonomy term - a specific term within a taxonomy (e.g., "News" in "category")
*/
export interface TaxonomyTerm {
id: string;
name: string; // Taxonomy name ('category')
slug: string; // Term slug ('news')
label: string; // Display label ('News')
parentId?: string;
description?: string;
children: TaxonomyTerm[]; // For tree structure
count?: number; // Entry count
}
/**
* Flat version for DB row
*/
export interface TaxonomyTermRow {
id: string;
name: string;
slug: string;
label: string;
parent_id: string | null;
data: string | null; // JSON
}
/**
* Input for creating a term
*/
export interface CreateTermInput {
slug: string;
label: string;
parentId?: string;
description?: string;
}
/**
* Input for updating a term
*/
export interface UpdateTermInput {
slug?: string;
label?: string;
parentId?: string | null;
description?: string;
}