@rimelight/cms 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +16 -14
- package/src/admin/api/media-file.ts +39 -0
- package/src/admin/api/media.ts +199 -0
- package/src/admin/layouts/CMSDashboardLayout.astro +178 -0
- package/src/admin/layouts/DefaultParentLayout.astro +50 -0
- package/src/admin/pages/assets.astro +424 -0
- package/src/admin/pages/byline-schema.astro +62 -0
- package/src/admin/pages/bylines.astro +16 -0
- package/src/admin/pages/categories.astro +21 -0
- package/src/admin/pages/content-types.astro +55 -0
- package/src/admin/pages/dashboard.astro +61 -0
- package/src/admin/pages/pages-edit.astro +595 -0
- package/src/admin/pages/pages-index.astro +146 -0
- package/src/admin/pages/pages-new.astro +130 -0
- package/src/admin/pages/pages-preview.astro +111 -0
- package/src/admin/pages/pages-review.astro +236 -0
- package/src/admin/pages/settings.astro +203 -0
- package/src/admin/pages/tags.astro +21 -0
- package/src/admin/pages/templates-edit.astro +234 -0
- package/src/admin/pages/templates-index.astro +95 -0
- package/src/admin/pages/templates-new.astro +193 -0
- package/src/admin/pages/users.astro +74 -0
- package/src/admin/pages/versions.astro +74 -0
- package/src/astro/BlockRenderer.astro +44 -102
- package/src/astro/PageRenderer.astro +9 -2
- package/src/astro/blocks/CalloutBlock.astro +7 -7
- package/src/astro/blocks/CardBlock.astro +5 -5
- package/src/astro/blocks/CodeBlock.astro +2 -2
- package/src/astro/blocks/FileTreeBlock.astro +7 -7
- package/src/astro/blocks/ImageBlock.astro +2 -2
- package/src/astro/blocks/OrderedListBlock.astro +2 -2
- package/src/astro/blocks/ParagraphBlock.astro +3 -3
- package/src/astro/blocks/SectionBlock.astro +6 -6
- package/src/astro/blocks/StepItemBlock.astro +3 -3
- package/src/astro/blocks/StepsBlock.astro +1 -1
- package/src/astro/blocks/TableBlock.astro +6 -6
- package/src/astro/blocks/TabsBlock.astro +9 -9
- package/src/astro/blocks/UnorderedListBlock.astro +2 -2
- package/src/astro/blocks/index.ts +35 -0
- package/src/client/components/RimelightBlock.tsx +19 -19
- package/src/client/components/RimelightBlockPicker.tsx +9 -13
- package/src/client/components/RimelightBylinesManager.tsx +438 -0
- package/src/client/components/RimelightEditor.tsx +3 -3
- package/src/client/components/RimelightPreview.tsx +37 -45
- package/src/client/components/RimelightPropertiesPanel.tsx +368 -26
- package/src/client/components/RimelightTaxonomyManager.tsx +369 -0
- package/src/client/components/RimelightTaxonomyMasterDetail.tsx +412 -0
- package/src/client/components/RimelightTemplateEditor.tsx +6 -8
- package/src/client/components/editors/CalloutEditor.tsx +2 -4
- package/src/client/components/editors/CardsEditor.tsx +2 -4
- package/src/client/components/editors/CodeEditor.tsx +1 -3
- package/src/client/components/editors/FileTreeEditor.tsx +2 -4
- package/src/client/components/editors/ImageEditor.tsx +3 -3
- package/src/client/components/editors/ParagraphEditor.tsx +2 -2
- package/src/client/components/editors/SceneEditor.tsx +6 -12
- package/src/client/components/editors/ScriptEditor.tsx +8 -16
- package/src/client/components/editors/SectionEditor.tsx +4 -4
- package/src/client/components/editors/StepItemEditor.tsx +2 -2
- package/src/client/components/editors/StepsEditor.tsx +2 -4
- package/src/client/components/editors/TabItemEditor.tsx +3 -3
- package/src/client/components/editors/TableEditor.tsx +5 -5
- package/src/client/components/editors/TabsEditor.tsx +2 -4
- package/src/client/components/index.ts +3 -0
- package/src/client/loader.ts +3 -0
- package/src/core/diff.ts +145 -0
- package/src/core/excerpt.ts +159 -0
- package/src/core/preview.ts +87 -0
- package/src/core/registry.ts +0 -86
- package/src/core/types/blocks.ts +6 -1
- package/src/core/types/bylines.ts +14 -0
- package/src/core/types/media.ts +13 -0
- package/src/core/types/taxonomies.ts +20 -0
- package/src/cron/scheduled.ts +57 -0
- package/src/docs/agent.ts +116 -0
- package/src/docs/config.ts +25 -0
- package/src/docs/index.ts +5 -0
- package/src/docs/routing.ts +104 -0
- package/src/docs/sidebar.ts +251 -0
- package/src/docs/toc.ts +47 -0
- package/src/env.d.ts +13 -0
- package/src/index.ts +40 -8
- package/src/integration.ts +196 -0
- package/src/loader/live.ts +254 -0
- package/src/markdown/index.ts +0 -1
- package/src/mcp/index.ts +228 -0
- package/src/schema/bylines.ts +23 -0
- package/src/schema/index.ts +3 -4
- package/src/schema/page_versions.ts +2 -0
- package/src/schema/pages.ts +2 -0
- package/src/schema/site_settings.ts +41 -0
- package/src/schema/taxonomies.ts +49 -0
- package/src/services/bylines.ts +74 -0
- package/src/services/search-indexer.ts +61 -0
- package/src/services/site-settings.ts +108 -0
- package/src/services/taxonomies.ts +276 -0
- package/src/storage/index.ts +253 -0
- package/src/astro/blocks/ApiDocumentationBlock.astro +0 -350
- package/src/astro/blocks/DialogueBlock.astro +0 -23
- package/src/astro/blocks/LivePreviewBlock.astro +0 -27
- package/src/astro/blocks/SceneBlock.astro +0 -65
- package/src/astro/blocks/ScriptBlock.astro +0 -59
- package/src/core/template-sync.ts +0 -75
- package/src/markdown/parser.ts +0 -347
- package/src/migration-tools.ts +0 -334
- package/src/migration.ts +0 -155
- package/src/schema/component_dependencies.ts +0 -21
- package/src/schema/component_metadata.ts +0 -31
- package/src/schema/component_metadata_cache.ts +0 -15
- package/src/schema/component_versions.ts +0 -19
- package/src/search/sync.ts +0 -32
- package/src/services/component-metadata.ts +0 -134
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { eq, and, isNull, or } from "drizzle-orm"
|
|
2
|
+
import { bylines, type Byline, type NewByline } from "../schema/bylines.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns all active (non-deleted) bylines.
|
|
6
|
+
*/
|
|
7
|
+
export async function getBylines(db: any): Promise<Byline[]> {
|
|
8
|
+
if (!db) return []
|
|
9
|
+
return db.select().from(bylines).where(isNull(bylines.deletedAt)).orderBy(bylines.name)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Retrieves a single byline by ID or slug.
|
|
14
|
+
*/
|
|
15
|
+
export async function getByline(db: any, idOrSlug: string): Promise<Byline | null> {
|
|
16
|
+
if (!db || !idOrSlug) return null
|
|
17
|
+
const rows = await db
|
|
18
|
+
.select()
|
|
19
|
+
.from(bylines)
|
|
20
|
+
.where(
|
|
21
|
+
and(
|
|
22
|
+
isNull(bylines.deletedAt),
|
|
23
|
+
or(eq(bylines.id, idOrSlug as any), eq(bylines.slug, idOrSlug))
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
.limit(1)
|
|
27
|
+
|
|
28
|
+
return rows[0] || null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new byline.
|
|
33
|
+
*/
|
|
34
|
+
export async function createByline(db: any, data: NewByline): Promise<Byline> {
|
|
35
|
+
const [created] = await db
|
|
36
|
+
.insert(bylines)
|
|
37
|
+
.values({
|
|
38
|
+
...data,
|
|
39
|
+
updatedAt: new Date()
|
|
40
|
+
})
|
|
41
|
+
.returning()
|
|
42
|
+
return created
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Updates an existing byline.
|
|
47
|
+
*/
|
|
48
|
+
export async function updateByline(
|
|
49
|
+
db: any,
|
|
50
|
+
id: string,
|
|
51
|
+
data: Partial<NewByline>
|
|
52
|
+
): Promise<Byline | null> {
|
|
53
|
+
const [updated] = await db
|
|
54
|
+
.update(bylines)
|
|
55
|
+
.set({
|
|
56
|
+
...data,
|
|
57
|
+
updatedAt: new Date()
|
|
58
|
+
})
|
|
59
|
+
.where(and(eq(bylines.id, id as any), isNull(bylines.deletedAt)))
|
|
60
|
+
.returning()
|
|
61
|
+
return updated || null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Soft deletes a byline.
|
|
66
|
+
*/
|
|
67
|
+
export async function deleteByline(db: any, id: string): Promise<boolean> {
|
|
68
|
+
const [deleted] = await db
|
|
69
|
+
.update(bylines)
|
|
70
|
+
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
|
71
|
+
.where(and(eq(bylines.id, id as any), isNull(bylines.deletedAt)))
|
|
72
|
+
.returning()
|
|
73
|
+
return !!deleted
|
|
74
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { extractTextFromBlocks } from "../core/excerpt"
|
|
2
|
+
import { contentSearchIndex } from "../schema/search"
|
|
3
|
+
import { eq, and, sql } from "drizzle-orm"
|
|
4
|
+
|
|
5
|
+
export async function indexPageForSearch(
|
|
6
|
+
db: any,
|
|
7
|
+
page: {
|
|
8
|
+
id: string
|
|
9
|
+
title: any
|
|
10
|
+
content: any
|
|
11
|
+
},
|
|
12
|
+
locales: string[] = ["en", "pt"]
|
|
13
|
+
) {
|
|
14
|
+
if (!db) return
|
|
15
|
+
|
|
16
|
+
const parseJson = (val: any) => (typeof val === "string" ? JSON.parse(val) : val)
|
|
17
|
+
const titleObj = parseJson(page.title) || {}
|
|
18
|
+
const contentObj = parseJson(page.content) || {}
|
|
19
|
+
const blocks = contentObj.blocks || []
|
|
20
|
+
|
|
21
|
+
for (const locale of locales) {
|
|
22
|
+
const titleText =
|
|
23
|
+
typeof titleObj === "object" && titleObj !== null
|
|
24
|
+
? titleObj[locale] || titleObj.en || Object.values(titleObj)[0] || ""
|
|
25
|
+
: String(titleObj || "")
|
|
26
|
+
|
|
27
|
+
const contentText = extractTextFromBlocks(blocks, locale)
|
|
28
|
+
|
|
29
|
+
if (!titleText && !contentText) continue
|
|
30
|
+
|
|
31
|
+
// Upsert search index record
|
|
32
|
+
try {
|
|
33
|
+
const existing = await db
|
|
34
|
+
.select()
|
|
35
|
+
.from(contentSearchIndex)
|
|
36
|
+
.where(and(eq(contentSearchIndex.pageId, page.id), eq(contentSearchIndex.locale, locale)))
|
|
37
|
+
.limit(1)
|
|
38
|
+
|
|
39
|
+
if (existing.length > 0) {
|
|
40
|
+
await db
|
|
41
|
+
.update(contentSearchIndex)
|
|
42
|
+
.set({
|
|
43
|
+
titleText,
|
|
44
|
+
contentText,
|
|
45
|
+
searchVector: sql`to_tsvector('english', ${titleText} || ' ' || ${contentText})`
|
|
46
|
+
})
|
|
47
|
+
.where(eq(contentSearchIndex.id, existing[0].id))
|
|
48
|
+
} else {
|
|
49
|
+
await db.insert(contentSearchIndex).values({
|
|
50
|
+
pageId: page.id,
|
|
51
|
+
locale,
|
|
52
|
+
titleText,
|
|
53
|
+
contentText,
|
|
54
|
+
searchVector: sql`to_tsvector('english', ${titleText} || ' ' || ${contentText})`
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(`[Search Indexer] Failed to index page ${page.id} for locale ${locale}:`, err)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm"
|
|
2
|
+
import {
|
|
3
|
+
siteSettings,
|
|
4
|
+
type InsertSiteSettings,
|
|
5
|
+
type SiteSettings
|
|
6
|
+
} from "../schema/site_settings.ts"
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_SITE_SETTINGS: InsertSiteSettings = {
|
|
9
|
+
id: "default",
|
|
10
|
+
name: "Rimelight",
|
|
11
|
+
description: "Built with Rimelight CMS",
|
|
12
|
+
url: "https://rimelight.com",
|
|
13
|
+
ogImage: "https://cdn.rimelight.com/Images/og-placeholder.webp",
|
|
14
|
+
author: "Rimelight",
|
|
15
|
+
email: "",
|
|
16
|
+
branding: {
|
|
17
|
+
logo: {
|
|
18
|
+
alt: "Rimelight"
|
|
19
|
+
},
|
|
20
|
+
favicon: {
|
|
21
|
+
svg: "/favicon.svg"
|
|
22
|
+
},
|
|
23
|
+
colors: {
|
|
24
|
+
themeColor: "#ffffff",
|
|
25
|
+
backgroundColor: "#ffffff"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
seo: {
|
|
29
|
+
titleTemplate: "%s | Rimelight",
|
|
30
|
+
ogImageFallback: "https://cdn.rimelight.com/Images/og-placeholder.webp",
|
|
31
|
+
maxDescriptionLength: 160
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Retrieves the dynamic site settings from the database. If no settings are found, seeds the
|
|
37
|
+
* default record and returns it.
|
|
38
|
+
*/
|
|
39
|
+
export async function getSiteSettings(
|
|
40
|
+
db: any,
|
|
41
|
+
fallbackDefaults?: Partial<InsertSiteSettings>
|
|
42
|
+
): Promise<SiteSettings> {
|
|
43
|
+
if (!db) {
|
|
44
|
+
// eslint-disable-next-line typescript/no-unsafe-type-assertion
|
|
45
|
+
return {
|
|
46
|
+
...DEFAULT_SITE_SETTINGS,
|
|
47
|
+
...fallbackDefaults,
|
|
48
|
+
createdAt: new Date(),
|
|
49
|
+
updatedAt: new Date()
|
|
50
|
+
} as SiteSettings
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const records = await db
|
|
55
|
+
.select()
|
|
56
|
+
.from(siteSettings)
|
|
57
|
+
.where(eq(siteSettings.id, "default"))
|
|
58
|
+
.limit(1)
|
|
59
|
+
|
|
60
|
+
if (records.length > 0) {
|
|
61
|
+
return records[0]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const initial: InsertSiteSettings = {
|
|
65
|
+
...DEFAULT_SITE_SETTINGS,
|
|
66
|
+
...fallbackDefaults,
|
|
67
|
+
id: "default"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const inserted = await db.insert(siteSettings).values(initial).returning()
|
|
71
|
+
return inserted[0]
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.warn("Failed to fetch site settings from DB, using fallback:", error)
|
|
74
|
+
// eslint-disable-next-line typescript/no-unsafe-type-assertion
|
|
75
|
+
return {
|
|
76
|
+
...DEFAULT_SITE_SETTINGS,
|
|
77
|
+
...fallbackDefaults,
|
|
78
|
+
createdAt: new Date(),
|
|
79
|
+
updatedAt: new Date()
|
|
80
|
+
} as SiteSettings
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Updates dynamic site settings in the database.
|
|
86
|
+
*/
|
|
87
|
+
export async function updateSiteSettings(
|
|
88
|
+
db: any,
|
|
89
|
+
updates: Partial<InsertSiteSettings>,
|
|
90
|
+
id: string = "default"
|
|
91
|
+
): Promise<SiteSettings> {
|
|
92
|
+
const current = await getSiteSettings(db)
|
|
93
|
+
|
|
94
|
+
const updatedValues = {
|
|
95
|
+
...updates,
|
|
96
|
+
branding: updates.branding ? { ...current.branding, ...updates.branding } : current.branding,
|
|
97
|
+
seo: updates.seo ? { ...current.seo, ...updates.seo } : current.seo,
|
|
98
|
+
updatedAt: new Date()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await db
|
|
102
|
+
.update(siteSettings)
|
|
103
|
+
.set(updatedValues)
|
|
104
|
+
.where(eq(siteSettings.id, id))
|
|
105
|
+
.returning()
|
|
106
|
+
|
|
107
|
+
return result[0]
|
|
108
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { taxonomyTerms, pageTaxonomyTerms } from "../schema/taxonomies"
|
|
2
|
+
import { pages } from "../schema/pages"
|
|
3
|
+
import type { TaxonomyTerm, TaxonomyQueryOptions } from "../core/types/taxonomies"
|
|
4
|
+
import { eq, and, isNull, asc, sql } from "drizzle-orm"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Retrieves all terms for a given taxonomy. If hierarchical, returns a tree structure with
|
|
8
|
+
* `children: TaxonomyTerm[]`.
|
|
9
|
+
*/
|
|
10
|
+
export async function getTaxonomyTerms(
|
|
11
|
+
db: any,
|
|
12
|
+
taxonomy: string,
|
|
13
|
+
options: TaxonomyQueryOptions = {}
|
|
14
|
+
): Promise<TaxonomyTerm[]> {
|
|
15
|
+
if (!db) return []
|
|
16
|
+
|
|
17
|
+
const includeCounts = options.includeCounts ?? true
|
|
18
|
+
|
|
19
|
+
const rawTerms = await db
|
|
20
|
+
.select()
|
|
21
|
+
.from(taxonomyTerms)
|
|
22
|
+
.where(eq(taxonomyTerms.taxonomy, taxonomy))
|
|
23
|
+
.orderBy(asc(taxonomyTerms.displayOrder), asc(taxonomyTerms.createdAt))
|
|
24
|
+
|
|
25
|
+
if (!rawTerms || rawTerms.length === 0) return []
|
|
26
|
+
|
|
27
|
+
// Count active published posts per term if requested
|
|
28
|
+
const countsMap = new Map<string, number>()
|
|
29
|
+
if (includeCounts) {
|
|
30
|
+
try {
|
|
31
|
+
const counts = await db
|
|
32
|
+
.select({
|
|
33
|
+
termId: pageTaxonomyTerms.termId,
|
|
34
|
+
count: sql<number>`count(${pageTaxonomyTerms.pageId})::int`
|
|
35
|
+
})
|
|
36
|
+
.from(pageTaxonomyTerms)
|
|
37
|
+
.innerJoin(pages, eq(pages.id, pageTaxonomyTerms.pageId))
|
|
38
|
+
.where(isNull(pages.deletedAt))
|
|
39
|
+
.groupBy(pageTaxonomyTerms.termId)
|
|
40
|
+
|
|
41
|
+
for (const row of counts) {
|
|
42
|
+
countsMap.set(row.termId, Number(row.count))
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Fallback if joined counts query fails
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const termsWithCounts: TaxonomyTerm[] = rawTerms.map((t: any) => ({
|
|
50
|
+
id: t.id,
|
|
51
|
+
taxonomy: t.taxonomy,
|
|
52
|
+
slug: t.slug,
|
|
53
|
+
label: t.label,
|
|
54
|
+
description: t.description,
|
|
55
|
+
parentId: t.parentId,
|
|
56
|
+
displayOrder: t.displayOrder,
|
|
57
|
+
count: countsMap.get(t.id) ?? 0,
|
|
58
|
+
children: [],
|
|
59
|
+
createdAt: t.createdAt,
|
|
60
|
+
updatedAt: t.updatedAt
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
// Determine if this taxonomy is hierarchical (any term has parentId)
|
|
64
|
+
const isHierarchical = termsWithCounts.some((t) => !!t.parentId)
|
|
65
|
+
if (!isHierarchical) {
|
|
66
|
+
return termsWithCounts
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Build tree
|
|
70
|
+
const idMap = new Map<string, TaxonomyTerm>()
|
|
71
|
+
const rootTerms: TaxonomyTerm[] = []
|
|
72
|
+
|
|
73
|
+
termsWithCounts.forEach((term) => {
|
|
74
|
+
idMap.set(term.id, term)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
termsWithCounts.forEach((term) => {
|
|
78
|
+
if (term.parentId && idMap.has(term.parentId)) {
|
|
79
|
+
const parent = idMap.get(term.parentId)!
|
|
80
|
+
parent.children = parent.children || []
|
|
81
|
+
parent.children.push(term)
|
|
82
|
+
} else {
|
|
83
|
+
rootTerms.push(term)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return rootTerms
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Retrieves a single term by taxonomy and slug.
|
|
92
|
+
*/
|
|
93
|
+
export async function getTerm(
|
|
94
|
+
db: any,
|
|
95
|
+
taxonomy: string,
|
|
96
|
+
slug: string
|
|
97
|
+
): Promise<TaxonomyTerm | null> {
|
|
98
|
+
if (!db) return null
|
|
99
|
+
|
|
100
|
+
const results = await db
|
|
101
|
+
.select()
|
|
102
|
+
.from(taxonomyTerms)
|
|
103
|
+
.where(and(eq(taxonomyTerms.taxonomy, taxonomy), eq(taxonomyTerms.slug, slug)))
|
|
104
|
+
.limit(1)
|
|
105
|
+
|
|
106
|
+
if (!results[0]) return null
|
|
107
|
+
|
|
108
|
+
// Get post count
|
|
109
|
+
let count = 0
|
|
110
|
+
try {
|
|
111
|
+
const countResult = await db
|
|
112
|
+
.select({ count: sql<number>`count(${pageTaxonomyTerms.pageId})::int` })
|
|
113
|
+
.from(pageTaxonomyTerms)
|
|
114
|
+
.innerJoin(pages, eq(pages.id, pageTaxonomyTerms.pageId))
|
|
115
|
+
.where(and(eq(pageTaxonomyTerms.termId, results[0].id), isNull(pages.deletedAt)))
|
|
116
|
+
count = Number(countResult[0]?.count ?? 0)
|
|
117
|
+
} catch {
|
|
118
|
+
count = 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...results[0],
|
|
123
|
+
count,
|
|
124
|
+
children: []
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Retrieves all terms assigned to a specific page entry.
|
|
130
|
+
*/
|
|
131
|
+
export async function getEntryTerms(
|
|
132
|
+
db: any,
|
|
133
|
+
pageId: string,
|
|
134
|
+
taxonomy?: string
|
|
135
|
+
): Promise<TaxonomyTerm[]> {
|
|
136
|
+
if (!db || !pageId) return []
|
|
137
|
+
|
|
138
|
+
const conditions = [eq(pageTaxonomyTerms.pageId, pageId)]
|
|
139
|
+
if (taxonomy) {
|
|
140
|
+
conditions.push(eq(taxonomyTerms.taxonomy, taxonomy))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const results = await db
|
|
144
|
+
.select({
|
|
145
|
+
id: taxonomyTerms.id,
|
|
146
|
+
taxonomy: taxonomyTerms.taxonomy,
|
|
147
|
+
slug: taxonomyTerms.slug,
|
|
148
|
+
label: taxonomyTerms.label,
|
|
149
|
+
description: taxonomyTerms.description,
|
|
150
|
+
parentId: taxonomyTerms.parentId,
|
|
151
|
+
displayOrder: taxonomyTerms.displayOrder
|
|
152
|
+
})
|
|
153
|
+
.from(pageTaxonomyTerms)
|
|
154
|
+
.innerJoin(taxonomyTerms, eq(taxonomyTerms.id, pageTaxonomyTerms.termId))
|
|
155
|
+
.where(and(...conditions))
|
|
156
|
+
.orderBy(asc(taxonomyTerms.displayOrder))
|
|
157
|
+
|
|
158
|
+
return results
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Retrieves pages tagged with a given taxonomy term.
|
|
163
|
+
*/
|
|
164
|
+
export async function getEntriesByTerm(
|
|
165
|
+
db: any,
|
|
166
|
+
taxonomy: string,
|
|
167
|
+
slug: string,
|
|
168
|
+
options: { limit?: number; offset?: number; type?: string } = {}
|
|
169
|
+
) {
|
|
170
|
+
if (!db) return { entries: [], total: 0 }
|
|
171
|
+
|
|
172
|
+
const term = await getTerm(db, taxonomy, slug)
|
|
173
|
+
if (!term) return { entries: [], total: 0 }
|
|
174
|
+
|
|
175
|
+
const conditions: any[] = [eq(pageTaxonomyTerms.termId, term.id), isNull(pages.deletedAt)]
|
|
176
|
+
|
|
177
|
+
if (options.type) {
|
|
178
|
+
conditions.push(eq(pages.type, options.type))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let query = db
|
|
182
|
+
.select({ page: pages })
|
|
183
|
+
.from(pageTaxonomyTerms)
|
|
184
|
+
.innerJoin(pages, eq(pages.id, pageTaxonomyTerms.pageId))
|
|
185
|
+
.where(and(...conditions))
|
|
186
|
+
.orderBy(asc(pages.createdAt))
|
|
187
|
+
|
|
188
|
+
if (options.limit) query = query.limit(options.limit)
|
|
189
|
+
if (options.offset) query = query.offset(options.offset)
|
|
190
|
+
|
|
191
|
+
const rows = await query
|
|
192
|
+
const entries = rows.map((r: any) => r.page)
|
|
193
|
+
|
|
194
|
+
return { entries, total: term.count ?? entries.length, term }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Sets/syncs the assigned terms for a page within a specific taxonomy.
|
|
199
|
+
*/
|
|
200
|
+
export async function assignEntryTerms(
|
|
201
|
+
db: any,
|
|
202
|
+
pageId: string,
|
|
203
|
+
taxonomy: string,
|
|
204
|
+
termIds: string[]
|
|
205
|
+
) {
|
|
206
|
+
if (!db || !pageId) return
|
|
207
|
+
|
|
208
|
+
// Find all current terms in this taxonomy assigned to the page
|
|
209
|
+
const currentAssigned = await getEntryTerms(db, pageId, taxonomy)
|
|
210
|
+
const currentTermIds = new Set(currentAssigned.map((t) => t.id))
|
|
211
|
+
const newTermIds = new Set(termIds)
|
|
212
|
+
|
|
213
|
+
// Remove unassigned
|
|
214
|
+
for (const term of currentAssigned) {
|
|
215
|
+
if (!newTermIds.has(term.id)) {
|
|
216
|
+
await db
|
|
217
|
+
.delete(pageTaxonomyTerms)
|
|
218
|
+
.where(and(eq(pageTaxonomyTerms.pageId, pageId), eq(pageTaxonomyTerms.termId, term.id)))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Insert newly assigned
|
|
223
|
+
for (const termId of termIds) {
|
|
224
|
+
if (!currentTermIds.has(termId)) {
|
|
225
|
+
await db.insert(pageTaxonomyTerms).values({ pageId, termId }).onConflictDoNothing()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Creates a new taxonomy term.
|
|
232
|
+
*/
|
|
233
|
+
export async function createTerm(
|
|
234
|
+
db: any,
|
|
235
|
+
data: {
|
|
236
|
+
taxonomy: string
|
|
237
|
+
slug: string
|
|
238
|
+
label: any
|
|
239
|
+
description?: any
|
|
240
|
+
parentId?: string | null
|
|
241
|
+
displayOrder?: number
|
|
242
|
+
}
|
|
243
|
+
) {
|
|
244
|
+
const result = await db.insert(taxonomyTerms).values(data).returning()
|
|
245
|
+
return result[0]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Updates an existing taxonomy term.
|
|
250
|
+
*/
|
|
251
|
+
export async function updateTerm(
|
|
252
|
+
db: any,
|
|
253
|
+
id: string,
|
|
254
|
+
updates: Partial<{
|
|
255
|
+
slug: string
|
|
256
|
+
label: any
|
|
257
|
+
description: any
|
|
258
|
+
parentId: string | null
|
|
259
|
+
displayOrder: number
|
|
260
|
+
}>
|
|
261
|
+
) {
|
|
262
|
+
const result = await db
|
|
263
|
+
.update(taxonomyTerms)
|
|
264
|
+
.set({ ...updates, updatedAt: new Date() })
|
|
265
|
+
.where(eq(taxonomyTerms.id, id))
|
|
266
|
+
.returning()
|
|
267
|
+
return result[0]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Deletes a taxonomy term.
|
|
272
|
+
*/
|
|
273
|
+
export async function deleteTerm(db: any, id: string) {
|
|
274
|
+
await db.delete(taxonomyTerms).where(eq(taxonomyTerms.id, id))
|
|
275
|
+
return { success: true }
|
|
276
|
+
}
|