diff --git a/packages/content-engine/content/createContent.ts b/packages/content-engine/content/createContent.ts index 51756b49..328c23b2 100644 --- a/packages/content-engine/content/createContent.ts +++ b/packages/content-engine/content/createContent.ts @@ -58,7 +58,7 @@ export async function defaultCreateUploadsProcessor( * ``` */ export async function createContent< - TData extends Record, + TData, TIndexValue, TKey extends Key, >(options: CreateContentOptions): Promise { @@ -91,7 +91,7 @@ export async function createContent< // 2. Write to filesystem await writeContentToFilesystem( - config as ContentTypeConfig, + config as ContentTypeConfig, slug, data, contentDirectory, diff --git a/packages/content-engine/content/deleteContent.ts b/packages/content-engine/content/deleteContent.ts index 29f85c61..1eae8b39 100644 --- a/packages/content-engine/content/deleteContent.ts +++ b/packages/content-engine/content/deleteContent.ts @@ -25,7 +25,7 @@ import type { ContentTypeConfig, DeleteContentOptions } from "./types"; * ``` */ export async function deleteContent< - TData extends Record, + TData, TIndexValue, TKey extends Key, >(options: DeleteContentOptions): Promise { diff --git a/packages/content-engine/content/filesystem.ts b/packages/content-engine/content/filesystem.ts index 2c2b8aea..30c7da27 100644 --- a/packages/content-engine/content/filesystem.ts +++ b/packages/content-engine/content/filesystem.ts @@ -89,7 +89,7 @@ export function getUploadFilePath( * Write content data to the filesystem */ export async function writeContentToFilesystem< - TData extends Record, + TData, >( config: ContentTypeConfig, slug: string, @@ -109,7 +109,7 @@ export async function writeContentToFilesystem< * Read content data from the filesystem */ export async function readContentFromFilesystem< - TData extends Record, + TData, >( config: ContentTypeConfig, slug: string, diff --git a/packages/content-engine/content/readContentFile.ts b/packages/content-engine/content/readContentFile.ts index dfb8c15d..bca3daa9 100644 --- a/packages/content-engine/content/readContentFile.ts +++ b/packages/content-engine/content/readContentFile.ts @@ -1,4 +1,4 @@ -import type { Key } from "lmdb"; +import { Key } from "lmdb"; import { getContentDirectory } from "../fs/getContentDirectory"; import { readContentFromFilesystem } from "./filesystem"; import type { ContentTypeConfig, ReadContentFileOptions } from "./types"; @@ -15,16 +15,16 @@ import type { ContentTypeConfig, ReadContentFileOptions } from "./types"; * ``` */ export async function readContentFile< - TData extends Record, - TIndexValue, - TKey extends Key, + TData = Record, + TIndexValue = TData, + TKey extends Key = Key, >(options: ReadContentFileOptions): Promise { const { config, slug, contentDirectory: providedContentDirectory } = options; const contentDirectory = providedContentDirectory || getContentDirectory(); return readContentFromFilesystem( - config as ContentTypeConfig, + config as ContentTypeConfig, slug, contentDirectory, ); diff --git a/packages/content-engine/content/readContentIndex.ts b/packages/content-engine/content/readContentIndex.ts index 4372c22a..30e00fbf 100644 --- a/packages/content-engine/content/readContentIndex.ts +++ b/packages/content-engine/content/readContentIndex.ts @@ -20,12 +20,8 @@ import type { * }); * ``` */ -export async function readContentIndex< - TData extends Record, - TIndexValue, - TKey extends Key, ->( - options: ReadContentIndexOptions, +export async function readContentIndex( + options: ReadContentIndexOptions, ): Promise> { const { config, @@ -47,7 +43,8 @@ export async function readContentIndex< offset, reverse, }); - const entries = await entriesIterator.asArray; + const entriesPromise = entriesIterator.asArray; + const entries = await entriesPromise; const total = getIndexCount(db); const more = (offset || 0) + (limit || 0) < total; diff --git a/packages/content-engine/content/rebuildIndex.ts b/packages/content-engine/content/rebuildIndex.ts index 37edc767..8529e2ec 100644 --- a/packages/content-engine/content/rebuildIndex.ts +++ b/packages/content-engine/content/rebuildIndex.ts @@ -19,7 +19,7 @@ import type { ContentTypeConfig, RebuildIndexOptions } from "./types"; * ``` */ export async function rebuildIndex< - TData extends Record, + TData, TIndexValue, TKey extends Key, >(options: RebuildIndexOptions): Promise { @@ -45,7 +45,7 @@ export async function rebuildIndex< for (const slug of slugDirectories) { try { const data = await readContentFromFilesystem( - config as ContentTypeConfig, + config as ContentTypeConfig, slug, contentDirectory, ); diff --git a/packages/content-engine/content/types.ts b/packages/content-engine/content/types.ts index 7652c8e9..d9469e6a 100644 --- a/packages/content-engine/content/types.ts +++ b/packages/content-engine/content/types.ts @@ -5,7 +5,7 @@ import type { Key, RootDatabase } from "lmdb"; * TKey is flexible to support different index key structures (string, number, array, etc.) */ export interface ContentTypeConfig< - TData extends Record = Record, + TData = Record, TIndexValue = unknown, TKey extends Key = Key, > { @@ -52,7 +52,7 @@ export interface UploadSpec { * Options for creating content */ export interface CreateContentOptions< - TData extends Record = Record, + TData = Record, TIndexValue = unknown, TKey extends Key = Key, > { @@ -94,7 +94,7 @@ export interface CreateContentOptions< * Options for updating content */ export interface UpdateContentOptions< - TData extends Record = Record, + TData = Record, TIndexValue = unknown, TKey extends Key = Key, > { @@ -144,7 +144,7 @@ export interface UpdateContentOptions< * Options for deleting content */ export interface DeleteContentOptions< - TData extends Record = Record, + TData = Record, TIndexValue = unknown, TKey extends Key = Key, > { @@ -171,12 +171,12 @@ export interface DeleteContentOptions< * Options for reading content from the index */ export interface ReadContentIndexOptions< - TData extends Record = Record, TIndexValue = unknown, TKey extends Key = Key, > { /** The content type configuration */ - config: ContentTypeConfig; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: ContentTypeConfig; /** Maximum number of entries to return */ limit?: number; @@ -210,7 +210,7 @@ export interface ReadContentIndexResult< * Options for reading a single content file */ export interface ReadContentFileOptions< - TData extends Record = Record, + TData = Record, TIndexValue = unknown, TKey extends Key = Key, > { @@ -240,7 +240,7 @@ export interface FileUploadData { * Options for processing uploads */ export interface ProcessUploadsOptions< - TData extends Record = Record, + TData = Record, TIndexValue = unknown, TKey extends Key = Key, > { @@ -277,7 +277,7 @@ export type ContentDatabase< * Options for rebuilding the index */ export interface RebuildIndexOptions< - TData extends Record = Record, + TData = Record, TIndexValue = unknown, TKey extends Key = Key, > { diff --git a/packages/content-engine/content/updateContent.ts b/packages/content-engine/content/updateContent.ts index ef46aca8..ba956efb 100644 --- a/packages/content-engine/content/updateContent.ts +++ b/packages/content-engine/content/updateContent.ts @@ -66,7 +66,7 @@ export async function defaultUpdateUploadsProcessor( * ``` */ export async function updateContent< - TData extends Record, + TData, TIndexValue, TKey extends Key, >(options: UpdateContentOptions): Promise { @@ -116,7 +116,7 @@ export async function updateContent< // 3. Write to filesystem await writeContentToFilesystem( - config as ContentTypeConfig, + config as ContentTypeConfig, slug, data, contentDirectory, diff --git a/packages/content-engine/demo/.gitignore b/packages/content-engine/demo/.gitignore new file mode 100644 index 00000000..72b03d3f --- /dev/null +++ b/packages/content-engine/demo/.gitignore @@ -0,0 +1,32 @@ +# dependencies +node_modules + +# next.js +.next/ +out/ + +# production +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files +.env*.local + +# typescript +*.tsbuildinfo +next-env.d.ts + +# test content directory +/test-content + +# cypress +cypress/videos +cypress/screenshots diff --git a/packages/content-engine/demo/app/layout.tsx b/packages/content-engine/demo/app/layout.tsx new file mode 100644 index 00000000..95be0801 --- /dev/null +++ b/packages/content-engine/demo/app/layout.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Content Engine Demo", + description: "Demo application for content-engine package", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
+

+ + Content Engine Demo + +

+
+
{children}
+ + + ); +} diff --git a/packages/content-engine/demo/app/notes/[slug]/delete/page.tsx b/packages/content-engine/demo/app/notes/[slug]/delete/page.tsx new file mode 100644 index 00000000..7c5eaa80 --- /dev/null +++ b/packages/content-engine/demo/app/notes/[slug]/delete/page.tsx @@ -0,0 +1,109 @@ +import { redirect, notFound } from "next/navigation"; +import Link from "next/link"; +import { readContentFile } from "content-engine/content/readContentFile"; +import { deleteContent } from "content-engine/content/deleteContent"; +import { getContentDirectory } from "content-engine/fs/getContentDirectory"; +import { + noteConfig, + NoteIndexValue, + type Note, + type NoteIndexKey, +} from "@/lib/notes"; + +export const dynamic = "force-dynamic"; + +interface Props { + params: Promise<{ slug: string }>; +} + +async function performDelete(formData: FormData) { + "use server"; + + const slug = formData.get("slug") as string; + const date = parseInt(formData.get("date") as string, 10); + const contentDirectory = getContentDirectory(); + const indexKey: NoteIndexKey = [date, slug]; + + await deleteContent({ + config: noteConfig, + slug, + indexKey, + contentDirectory, + commitMessage: `Delete note: ${slug}`, + }); + + redirect("/"); +} + +export default async function DeleteNotePage({ params }: Props) { + const { slug } = await params; + const contentDirectory = getContentDirectory(); + + let note: Note; + try { + note = await readContentFile({ + config: noteConfig, + slug, + contentDirectory, + }); + } catch { + notFound(); + } + + return ( +
+

Delete Note

+
+

+ Are you sure you want to delete this note? +

+

+ {note.title} +

+
+ +
+ + + +
+ + + Cancel + +
+
+
+ ); +} diff --git a/packages/content-engine/demo/app/notes/[slug]/edit/page.tsx b/packages/content-engine/demo/app/notes/[slug]/edit/page.tsx new file mode 100644 index 00000000..1f33446c --- /dev/null +++ b/packages/content-engine/demo/app/notes/[slug]/edit/page.tsx @@ -0,0 +1,87 @@ +import { redirect, notFound } from "next/navigation"; +import { readContentFile } from "content-engine/content/readContentFile"; +import { updateContent } from "content-engine/content/updateContent"; +import { getContentDirectory } from "content-engine/fs/getContentDirectory"; +import { + noteConfig, + noteFormSchema, + formDataToNote, + generateSlug, + type Note, + type NoteIndexKey, + NoteIndexValue, +} from "@/lib/notes"; +import { NoteForm } from "../../form"; + +export const dynamic = "force-dynamic"; + +interface Props { + params: Promise<{ slug: string }>; +} + +async function updateNote(formData: FormData) { + "use server"; + + const currentSlug = formData.get("currentSlug") as string; + const currentDate = parseInt(formData.get("currentDate") as string, 10); + + const rawData = { + title: formData.get("title") as string, + content: formData.get("content") as string, + slug: formData.get("slug") as string, + tags: formData.get("tags") as string, + date: formData.get("date") as string, + }; + + const parsed = noteFormSchema.safeParse(rawData); + if (!parsed.success) { + throw new Error("Invalid form data: " + parsed.error.message); + } + + const newSlug = parsed.data.slug || generateSlug(parsed.data.title); + const note = formDataToNote(parsed.data, currentDate); + const contentDirectory = getContentDirectory(); + const currentIndexKey: NoteIndexKey = [currentDate, currentSlug]; + + await updateContent({ + config: noteConfig, + slug: newSlug, + currentSlug, + currentIndexKey, + data: note, + contentDirectory, + commitMessage: `Update note: ${note.title}`, + }); + + redirect(`/notes/${newSlug}`); +} + +export default async function EditNotePage({ params }: Props) { + const { slug } = await params; + const contentDirectory = getContentDirectory(); + + let note: Note; + try { + note = await readContentFile({ + config: noteConfig, + slug, + contentDirectory, + }); + } catch { + notFound(); + } + + return ( +
+

Edit Note

+
+ + +
+ ); +} diff --git a/packages/content-engine/demo/app/notes/[slug]/page.tsx b/packages/content-engine/demo/app/notes/[slug]/page.tsx new file mode 100644 index 00000000..8aba0f60 --- /dev/null +++ b/packages/content-engine/demo/app/notes/[slug]/page.tsx @@ -0,0 +1,116 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { readContentFile } from "content-engine/content/readContentFile"; +import { getContentDirectory } from "content-engine/fs/getContentDirectory"; +import { + noteConfig, + NoteIndexKey, + NoteIndexValue, + type Note, +} from "@/lib/notes"; + +export const dynamic = "force-dynamic"; + +interface Props { + params: Promise<{ slug: string }>; +} + +export default async function ViewNotePage({ params }: Props) { + const { slug } = await params; + const contentDirectory = getContentDirectory(); + + let note: Note; + try { + note = await readContentFile({ + config: noteConfig, + slug, + contentDirectory, + }); + } catch { + notFound(); + } + + return ( +
+
+
+

{note.title}

+

+ {new Date(note.date).toLocaleString()} +

+
+
+ + Edit + + + Delete + +
+
+ + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + +
+ {note.content || No content} +
+ +
+ + ← Back to all notes + +
+
+ ); +} diff --git a/packages/content-engine/demo/app/notes/form.tsx b/packages/content-engine/demo/app/notes/form.tsx new file mode 100644 index 00000000..980c2303 --- /dev/null +++ b/packages/content-engine/demo/app/notes/form.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import type { Note } from "@/lib/notes"; + +interface NoteFormProps { + note?: Note; + slug?: string; + submitLabel: string; + cancelHref: string; +} + +function getTimezone() { + return typeof window !== "undefined" + ? Intl.DateTimeFormat().resolvedOptions().timeZone + : undefined; +} + +function subscribe() { + return () => {}; +} + +export function NoteForm({ note, slug, submitLabel, cancelHref }: NoteFormProps) { + const currentTimezone = useSyncExternalStore(subscribe, getTimezone, () => undefined); + + const dateValue = note?.date; + const dateObject = dateValue ? new Date(dateValue) : undefined; + // Format as YYYY-MM-DDTHH:mm for datetime-local input compatibility + const defaultDateValue = dateObject?.toISOString().slice(0, 16); + + return ( +
+ {slug && ( + <> + + + + )} + +
+ + +
+ +
+ + +
+ +
+ +