Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Content Engine

## Running a Single Cypress Spec (Recipe Editor)

```
cd websites/recipe-website/editor
pnpm exec start-server-and-test dev:test http://localhost:3000 "cypress run --spec cypress/e2e/<spec-file>.cy.ts"
```

Test runs attempt to use the same server port, so multiple runs must be executed sequentially.
48 changes: 38 additions & 10 deletions packages/content-engine/content/createContent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Key } from "lmdb";
import { exists } from "fs-extra";
import { getContentDirectory } from "../fs/getContentDirectory";
import { commitContentChanges } from "../git/commit";
import { getContentDatabase, writeToIndex } from "./database";
import {
getContentItemDirectory,
getUploadInfo,
processUploadChanges,
writeContentToFilesystem,
Expand All @@ -13,6 +15,13 @@ import type {
FileUploadData,
} from "./types";

export class SlugConflictError extends Error {
constructor(public readonly slug: string) {
super(`Content with slug "${slug}" already exists`);
this.name = "SlugConflictError";
}
}

/**
* Default upload processor for creating content.
* Processes each upload field by writing new files.
Expand All @@ -22,16 +31,19 @@ export async function defaultCreateUploadsProcessor(
slug: string,
uploads: Record<string, FileUploadData | undefined>,
contentDirectory: string,
): Promise<void> {
): Promise<string[]> {
const paths: string[] = [];
for (const [, uploadData] of Object.entries(uploads)) {
await processUploadChanges(
const uploadPaths = await processUploadChanges(
config,
slug,
uploadData,
undefined, // No existing file for create
contentDirectory,
);
paths.push(...uploadPaths);
}
return paths;
}

/**
Expand All @@ -57,11 +69,9 @@ export async function defaultCreateUploadsProcessor(
* });
* ```
*/
export async function createContent<
TData,
TIndexValue,
TKey extends Key,
>(options: CreateContentOptions<TData, TIndexValue, TKey>): Promise<void> {
export async function createContent<TData, TIndexValue, TKey extends Key>(
options: CreateContentOptions<TData, TIndexValue, TKey>,
): Promise<void> {
const {
config,
slug,
Expand All @@ -71,31 +81,49 @@ export async function createContent<
commitMessage,
uploads,
processUploads = defaultCreateUploadsProcessor,
action,
} = options;

const contentDirectory = providedContentDirectory || getContentDirectory();
const touchedPaths: string[] = [];

// 0. Check for slug conflict
if (action !== "overwrite") {
const itemDir = getContentItemDirectory(
config as ContentTypeConfig,
slug,
contentDirectory,
);
if (await exists(itemDir)) {
throw new SlugConflictError(slug);
}
}

// 1. Process uploads
if (uploads) {
const resolvedUploads: Record<string, FileUploadData | undefined> = {};
for (const [fieldName, spec] of Object.entries(uploads)) {
resolvedUploads[fieldName] = await getUploadInfo(spec);
}
await processUploads(
const uploadPaths = await processUploads(
config as ContentTypeConfig,
slug,
resolvedUploads,
contentDirectory,
);
if (uploadPaths) {
touchedPaths.push(...uploadPaths);
}
}

// 2. Write to filesystem
await writeContentToFilesystem(
const dataFilePath = await writeContentToFilesystem(
config as ContentTypeConfig<TData>,
slug,
data,
contentDirectory,
);
touchedPaths.push(dataFilePath);

// 3. Write to index
const db = getContentDatabase<TIndexValue, TKey>(
Expand All @@ -112,7 +140,7 @@ export async function createContent<

// 4. Commit to git
const message = commitMessage || `Add new ${config.contentType}: ${slug}`;
await commitContentChanges(message, author);
await commitContentChanges(message, author, touchedPaths);
}

export default createContent;
4 changes: 2 additions & 2 deletions packages/content-engine/content/deleteContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function deleteContent<
const contentDirectory = providedContentDirectory || getContentDirectory();

// 1. Delete from filesystem (including uploads)
await deleteContentFromFilesystem(
const deletedPaths = await deleteContentFromFilesystem(
config as ContentTypeConfig,
slug,
contentDirectory,
Expand All @@ -60,7 +60,7 @@ export async function deleteContent<

// 3. Commit to git
const message = commitMessage || `Delete ${config.contentType}: ${slug}`;
await commitContentChanges(message, author);
await commitContentChanges(message, author, deletedPaths);
}

export default deleteContent;
35 changes: 30 additions & 5 deletions packages/content-engine/content/filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createWriteStream } from "fs";
import { ensureDir, exists, outputJSON, readJson, rename, rm } from "fs-extra";
import { join, parse, resolve } from "path";
import { join, parse, relative, resolve } from "path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { ReadableStream } from "node:stream/web";
Expand Down Expand Up @@ -93,7 +93,8 @@ export async function writeContentToFilesystem<TData>(
slug: string,
data: TData,
contentDirectory?: string,
): Promise<void> {
): Promise<string> {
const baseDir = contentDirectory || getContentDirectory();
const itemDirectory = getContentItemDirectory(
config as ContentTypeConfig,
slug,
Expand All @@ -103,6 +104,7 @@ export async function writeContentToFilesystem<TData>(
await outputJSON(join(itemDirectory, config.dataFilename), data, {
spaces: 2,
});
return relative(baseDir, join(itemDirectory, config.dataFilename));
}

/**
Expand All @@ -128,17 +130,24 @@ export async function deleteContentFromFilesystem(
config: ContentTypeConfig,
slug: string,
contentDirectory?: string,
): Promise<void> {
): Promise<string[]> {
const baseDir = contentDirectory || getContentDirectory();
const paths: string[] = [];

const itemDirectory = getContentItemDirectory(config, slug, contentDirectory);
if (await exists(itemDirectory)) {
paths.push(relative(baseDir, itemDirectory));
await rm(itemDirectory, { recursive: true });
}

// Also remove uploads directory if it exists
const uploadsBase = getUploadsBaseDirectory(config, slug, contentDirectory);
if (await exists(uploadsBase)) {
paths.push(relative(baseDir, uploadsBase));
await rm(uploadsBase, { recursive: true, force: true });
}

return paths;
}

/**
Expand All @@ -149,21 +158,30 @@ export async function renameContentDirectory(
oldSlug: string,
newSlug: string,
contentDirectory?: string,
): Promise<void> {
): Promise<string[]> {
const baseDir = contentDirectory || getContentDirectory();
const paths: string[] = [];

const oldDir = getContentItemDirectory(config, oldSlug, contentDirectory);
const newDir = getContentItemDirectory(config, newSlug, contentDirectory);

if (await exists(oldDir)) {
paths.push(relative(baseDir, oldDir));
paths.push(relative(baseDir, newDir));
await rename(oldDir, newDir);
}

// Also rename uploads directory if it exists
const oldUploadsDir = getUploadsDirectory(config, oldSlug, contentDirectory);
const newUploadsDir = getUploadsDirectory(config, newSlug, contentDirectory);
if (await exists(oldUploadsDir)) {
paths.push(relative(baseDir, oldUploadsDir));
paths.push(relative(baseDir, newUploadsDir));
await ensureDir(resolve(newUploadsDir, ".."));
await rename(oldUploadsDir, newUploadsDir);
}

return paths;
}

/**
Expand Down Expand Up @@ -267,17 +285,24 @@ export async function processUploadChanges(
uploadData: FileUploadData | undefined,
existingFile: string | undefined,
contentDirectory?: string,
): Promise<void> {
): Promise<string[]> {
const baseDir = contentDirectory || getContentDirectory();
const paths: string[] = [];

// Check if file should be deleted
if (
existingFile !== undefined &&
(uploadData === undefined || uploadData.file || uploadData.fileImportUrl)
) {
paths.push(relative(baseDir, getUploadFilePath(config, slug, existingFile, contentDirectory)));
await removeUploadFile(config, slug, existingFile, contentDirectory);
}

// Write new file if provided
if (uploadData) {
paths.push(relative(baseDir, getUploadFilePath(config, slug, uploadData.fileName, contentDirectory)));
await writeUploadFile(config, slug, uploadData, contentDirectory);
}

return paths;
}
9 changes: 6 additions & 3 deletions packages/content-engine/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,16 @@ export interface CreateContentOptions<
/**
* Optional custom upload processor. If provided, this will be called
* instead of the default upload processing. Receives the resolved
* upload data for each field.
* upload data for each field. Returns paths touched (relative to contentDirectory).
*/
processUploads?: (
config: ContentTypeConfig,
slug: string,
uploads: Record<string, FileUploadData | undefined>,
contentDirectory: string,
) => Promise<void>;
) => Promise<string[] | void>;

action?: "overwrite";
}

/**
Expand Down Expand Up @@ -167,6 +169,7 @@ export interface UpdateContentOptions<
* Optional custom upload processor. If provided, this will be called
* instead of the default upload processing. Receives the resolved
* upload data for each field, plus additional context for updates.
* Returns paths touched (relative to contentDirectory).
*/
processUploads?: (
config: ContentTypeConfig,
Expand All @@ -175,7 +178,7 @@ export interface UpdateContentOptions<
contentDirectory: string,
currentSlug: string,
uploadSpecs: Record<string, UploadSpec>,
) => Promise<void>;
) => Promise<string[] | void>;
}

/**
Expand Down
26 changes: 19 additions & 7 deletions packages/content-engine/content/updateContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@ export async function defaultUpdateUploadsProcessor(
contentDirectory: string,
currentSlug: string,
uploadSpecs: Record<string, UploadSpec>,
): Promise<void> {
): Promise<string[]> {
const paths: string[] = [];
// Process uploads at current slug location before rename
for (const [fieldName, uploadData] of Object.entries(uploads)) {
const existingFile = uploadSpecs[fieldName]?.existingFile;
await processUploadChanges(
const uploadPaths = await processUploadChanges(
config,
currentSlug,
uploadData,
existingFile,
contentDirectory,
);
paths.push(...uploadPaths);
}
return paths;
}

/**
Expand Down Expand Up @@ -86,6 +89,7 @@ export async function updateContent<

const contentDirectory = providedContentDirectory || getContentDirectory();
const willRename = currentSlug !== slug;
const touchedPaths: string[] = [];

// 1. Process uploads at current slug location (before rename)
if (uploads) {
Expand All @@ -95,33 +99,38 @@ export async function updateContent<
}

const uploadProcessor = processUploads || defaultUpdateUploadsProcessor;
await uploadProcessor(
const uploadPaths = await uploadProcessor(
config as ContentTypeConfig,
slug,
resolvedUploads,
contentDirectory,
currentSlug,
uploads,
);
if (uploadPaths) {
touchedPaths.push(...uploadPaths);
}
}

// 2. Rename directories if slug changed
if (willRename) {
await renameContentDirectory(
const renamePaths = await renameContentDirectory(
config as ContentTypeConfig,
currentSlug,
slug,
contentDirectory,
);
touchedPaths.push(...renamePaths);
}

// 3. Write to filesystem
await writeContentToFilesystem(
const dataFilePath = await writeContentToFilesystem(
config as ContentTypeConfig<TData>,
slug,
data,
contentDirectory,
);
touchedPaths.push(dataFilePath);

// 4. Update index
const db = getContentDatabase<TIndexValue, TKey>(
Expand All @@ -147,17 +156,20 @@ export async function updateContent<

// 5. Update references in content that references this type
if (willRename && config.referencedBy && config.referencedBy.length > 0) {
await updateReferences({
const refResults = await updateReferences({
oldSlug: currentSlug,
newSlug: slug,
referenceSpecs: config.referencedBy,
contentDirectory,
});
for (const refResult of refResults) {
touchedPaths.push(...refResult.updatedPaths);
}
}

// 6. Commit to git
const message = commitMessage || `Update ${config.contentType}: ${slug}`;
await commitContentChanges(message, author);
await commitContentChanges(message, author, touchedPaths);
}

export default updateContent;
Loading
Loading