diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts
index 53ea66444..eb74c6f82 100644
--- a/packages/vitnode/src/api/lib/module.ts
+++ b/packages/vitnode/src/api/lib/module.ts
@@ -1,5 +1,6 @@
import { OpenAPIHono } from "@hono/zod-openapi";
+import type { AnyContentContainerDefinition } from "@/content/containers/types";
import type { AnyContentModel } from "@/content/server/model";
import type { AnyContentTypeDefinition } from "@/content/types";
@@ -21,6 +22,7 @@ export interface BaseBuildModuleReturn<
M extends string = string,
Routes extends Route[] = Route
[],
> {
+ contentContainers?: AnyContentContainerDefinition[];
contentModels?: AnyContentModel[];
contentTypes?: AnyContentTypeDefinition[];
@@ -57,6 +59,7 @@ export function buildModule<
pluginId,
name,
modules,
+ contentContainers,
contentModels,
contentTypes,
notificationTypes,
@@ -67,6 +70,7 @@ export function buildModule<
searchIndexers,
webSockets = [],
}: {
+ contentContainers?: AnyContentContainerDefinition[];
contentModels?: AnyContentModel[];
contentTypes?: AnyContentTypeDefinition[];
cronJobs?: BuildCronReturn[];
@@ -84,8 +88,8 @@ export function buildModule<
const hono = new OpenAPIHono();
if (routes) {
- routes.forEach(({ handler, route }) => {
- hono.openapi(route, handler);
+ routes.forEach(({ handler, hook, route }) => {
+ hono.openapi(route, handler, hook as never);
});
}
@@ -101,6 +105,7 @@ export function buildModule<
hono,
name,
modules,
+ contentContainers,
contentModels,
contentTypes,
cronJobs,
diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts
index db839f7a0..8bd15bf6b 100644
--- a/packages/vitnode/src/api/lib/plugin.ts
+++ b/packages/vitnode/src/api/lib/plugin.ts
@@ -1,6 +1,7 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import type { BlockPluginSource } from "@/blocks/types";
+import type { AnyContentContainerDefinition } from "@/content/containers/types";
import type { AnyEditablePageDefinition } from "@/content/editor/types";
import type { RegisteredContentType } from "@/content/registry";
import type { AnyContentModel } from "@/content/server/model";
@@ -10,6 +11,11 @@ import type { LocaleMessagesMap } from "@/lib/i18n/types";
import type { NavigationPresetDeclaration } from "@/lib/navigation";
import { BlockError } from "@/blocks/errors";
+import {
+ assertContentContainerNamespaces,
+ contentContainerPermissionEntries,
+ validateContentContainers,
+} from "@/content/containers/registry";
import { contentPublicUrls } from "@/content/public-urls";
import {
validateContentTypes,
@@ -49,6 +55,7 @@ export interface BuildPluginApiReturn<
> {
aiActions?: AnyAiActionDefinition[];
blocks?: BlockPluginSource;
+ contentContainers?: AnyContentContainerDefinition[];
contentModels?: AnyContentModel[];
contentTypes?: AnyContentTypeDefinition[];
cronJobs?: Omit[];
@@ -123,6 +130,7 @@ export function buildApiPlugin<
collectAiActions([{ aiActions, pluginId }]);
const hono = new OpenAPIHono();
+ const contentContainers: AnyContentContainerDefinition[] = [];
const contentModels: AnyContentModel[] = [];
const contentTypes: AnyContentTypeDefinition[] = [];
const publicContentTypes: AnyContentTypeDefinition[] = [];
@@ -140,6 +148,9 @@ export function buildApiPlugin<
hono.route(`/${handler.name}`, handler.hono);
+ contentContainers.push(
+ ...collectModuleTree(handler, m => m.contentContainers),
+ );
contentModels.push(...collectContentModels(handler));
contentTypes.push(...collectContentTypes(handler));
publicContentTypes.push(...collectPublicContentTypes(handler));
@@ -169,6 +180,18 @@ export function buildApiPlugin<
contentTypes.map(definition => ({ definition, pluginId })),
);
+ const registeredContainers = validateContentContainers(
+ contentContainers.map(definition => ({ definition, pluginId })),
+ ).map(entry => entry.definition);
+ assertContentContainerNamespaces({
+ containers: registeredContainers,
+ contentTypes: [
+ ...registered.map(entry => entry.definition),
+ ...publicContentTypes,
+ ],
+ pluginId,
+ });
+
validateSearchIndexers(indexers.map(indexer => ({ ...indexer, pluginId })));
validateNotificationTypes(
@@ -202,6 +225,7 @@ export function buildApiPlugin<
navigation,
hono,
openApiTags: [...new Set(openApiTags)],
+ contentContainers: registeredContainers,
contentModels,
contentTypes: registered.map(entry => entry.definition),
publicContentTypes: [...publishing.values()],
@@ -214,13 +238,31 @@ export function buildApiPlugin<
// Every content type contributes can_view/can_create/can_edit/can_delete
// unless the plugin declared that module itself.
permissionStaff: withModerationPermissions(
- withContentPermissions(permissionStaff, registered, publicContentTypes),
+ withContainerPermissions(
+ withContentPermissions(permissionStaff, registered, publicContentTypes),
+ registeredContainers,
+ ),
reportTargets,
),
moderation: { reportTargets, scopes: moderation?.scopes ?? [] },
};
}
+function withContainerPermissions(
+ permissionStaff: PermissionStaffConfig | undefined,
+ containers: readonly AnyContentContainerDefinition[],
+): PermissionStaffConfig | undefined {
+ if (containers.length === 0) return permissionStaff;
+
+ const admin = { ...permissionStaff?.admin };
+ for (const definition of containers) {
+ admin[definition.admin.permissionModule] ??=
+ contentContainerPermissionEntries();
+ }
+
+ return { ...permissionStaff, admin };
+}
+
/**
* Walks the whole module tree. Content types are collected recursively - unlike
* `events`, `cronJobs` and friends, which only come from top-level modules - so
diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts
index c2b16a03a..7e32640b5 100644
--- a/packages/vitnode/src/api/lib/route.ts
+++ b/packages/vitnode/src/api/lib/route.ts
@@ -1,5 +1,6 @@
import type { RouteConfig, RouteHandler } from "@hono/zod-openapi";
-import type { MiddlewareHandler } from "hono";
+import type { Context, MiddlewareHandler } from "hono";
+import type { ZodError } from "zod";
import { createRoute as createRouteHono } from "@hono/zod-openapi";
@@ -10,6 +11,11 @@ import { pluginMiddleware } from "../middlewares/plugin.middleware";
import { assertStaffPermission } from "./check-staff-permission";
import { pluginTag } from "./openapi-tags";
+export type RouteValidationHook = (
+ result: { error: ZodError; success: false } | { success: true },
+ c: Context,
+) => Promise | Response | undefined;
+
export interface AdminStaffPermission {
module: string;
permission: string;
@@ -61,11 +67,13 @@ export const buildRoute = <
>({
route,
handler,
+ hook,
pluginId,
adminStaffPermission,
}: {
adminStaffPermission?: AdminStaffPermission;
handler: RouteHandler;
+ hook?: RouteValidationHook;
pluginId: Plugin;
route: R;
}): Route => {
@@ -108,6 +116,7 @@ export const buildRoute = <
middleware,
}),
handler: handler as Route["handler"],
+ ...(hook ? { hook } : {}),
pluginId,
};
};
@@ -117,6 +126,7 @@ export interface Route<
R extends RouteConfig = RouteConfig,
> {
handler: (...args: unknown[]) => Promise | Response;
+ hook?: RouteValidationHook;
pluginId: Plugin;
route: R;
}
diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index 13e1ce059..22a2f10b6 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -29,6 +29,11 @@ import {
import { SessionModel } from "@/api/models/session";
import { SessionAdminModel } from "@/api/models/session-admin";
import { StorageModel } from "@/api/models/storage";
+import {
+ assertContentContainerReferences,
+ type RegisteredContentContainer,
+ validateContentContainers,
+} from "@/content/containers/registry";
import { validateContentTypes } from "@/content/registry";
import { ensureContentLocalizationLanguages } from "@/content/server/language-resolver";
import { warnAboutContentPreviewConfig } from "@/content/server/preview-config";
@@ -133,6 +138,7 @@ export interface EnvVariablesVitNode {
};
captcha?: Pick["captcha"];
+ contentContainers: RegisteredContentContainer[];
contentModels: RegisteredContentModel[];
contentPreviewSecret?: string;
@@ -330,6 +336,22 @@ export const globalMiddleware = ({
),
);
+ const contentContainersMetadata = validateContentContainers(
+ plugins.flatMap(plugin =>
+ (plugin.contentContainers ?? []).map(definition => ({
+ definition,
+ pluginId: plugin.pluginId,
+ })),
+ ),
+ );
+ assertContentContainerReferences(
+ [
+ ...contentTypesMetadata.map(entry => entry.definition),
+ ...plugins.flatMap(plugin => plugin.publicContentTypes ?? []),
+ ],
+ contentContainersMetadata,
+ );
+
// Once, here, because "does anything have preview enabled" is only answerable
// after every plugin's content types are in. A warning, never a boot failure:
// preview is one content type's opt-in feature, not a prerequisite for the
@@ -509,6 +531,7 @@ export const globalMiddleware = ({
navigation: navigationMetadata,
notifications: notificationsMetadata,
notificationWorkers,
+ contentContainers: contentContainersMetadata,
contentModels: contentModelsMetadata,
contentRevalidateOrigins: content?.revalidateOrigins,
contentTypes: contentTypesMetadata,
diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts
index 4a78722a4..6c440826a 100644
--- a/packages/vitnode/src/api/models/events.ts
+++ b/packages/vitnode/src/api/models/events.ts
@@ -7,6 +7,13 @@ import type { SsoProfileField } from "@/lib/sso-profile";
import type { EventListenerConfig } from "../lib/events";
+export interface ContentContainerEventPayload {
+ containerId: number;
+ definitionId: string;
+ parentId: null | number;
+ pluginId: string;
+}
+
export interface VitNodeEvents {
"ai.run.completed": {
actionKey: string;
@@ -21,6 +28,15 @@ export interface VitNodeEvents {
runId: number;
userId: null | number;
};
+ "content.container.created": ContentContainerEventPayload;
+ "content.container.deleted": ContentContainerEventPayload;
+ "content.container.moved": ContentContainerEventPayload & {
+ fromParentId: null | number;
+ position: number;
+ };
+ "content.container.updated": ContentContainerEventPayload & {
+ changedFields: string[];
+ };
"core.page-layout.updated": {
changedZones: string[];
pageId: string;
diff --git a/packages/vitnode/src/blocks/capabilities.ts b/packages/vitnode/src/blocks/capabilities.ts
index 69e205783..3d538cbb9 100644
--- a/packages/vitnode/src/blocks/capabilities.ts
+++ b/packages/vitnode/src/blocks/capabilities.ts
@@ -32,6 +32,8 @@ const REFERENCE_LIFECYCLE =
const DEFERRED: Partial> = {
blocks:
"nested blocks are a later stage: a block that contains a zone needs the zone's own allowlist, depth limits and cycle detection before it can be stored safely",
+ container:
+ "a container is where a whole record is filed - one forum board, one blog section. A block is part of a record, not a record of its own, so it has nowhere to be filed",
file: `${REFERENCE_LIFECYCLE}. Supporting it means extracting the references out of a block's data on write and pinning them exactly as a \`file\` column is pinned, with \`ON DELETE RESTRICT\`, so the Files screen answers 409 instead of orphaning a page`,
relation: `${REFERENCE_LIFECYCLE}. It also needs somewhere to honour \`onDelete\`, which for a column is the foreign key itself`,
repeatable:
diff --git a/packages/vitnode/src/content/admin/containers.ts b/packages/vitnode/src/content/admin/containers.ts
new file mode 100644
index 000000000..b2f8dc0d4
--- /dev/null
+++ b/packages/vitnode/src/content/admin/containers.ts
@@ -0,0 +1,82 @@
+import type { AnyContentContainerDefinition } from "../containers/types";
+import type { ContentLabelTranslator } from "./labels";
+
+import { contentEntityKey, humanizeFieldName } from "./labels";
+
+type ContentContainerLabelSubject = Pick;
+
+export const contentContainerAdminHref = (
+ definition: Pick,
+): string => `/admin/containers/${definition.admin.path}`;
+
+export const contentContainerI18nKeys = (
+ definition: ContentContainerLabelSubject,
+ pluginId: string,
+) => {
+ const base = `${pluginId}.containers.${contentEntityKey(definition.id)}`;
+
+ return {
+ base,
+ desc: `${base}.desc`,
+ enumValue: (field: string, value: string) =>
+ `${base}.enums.${field}.${value}`,
+ field: (field: string) => `${base}.fields.${field}`,
+ label: `${base}.label`,
+ title: `${base}.title`,
+ };
+};
+
+export const contentContainerNamespace = (
+ definition: ContentContainerLabelSubject,
+ pluginId: string,
+): string => contentContainerI18nKeys(definition, pluginId).base;
+
+export const contentContainerNouns = (
+ definition: ContentContainerLabelSubject,
+ pluginId: string,
+ t: ContentLabelTranslator,
+): { plural: string; singular: string; title: string } => {
+ const keys = contentContainerI18nKeys(definition, pluginId);
+ const fallback = humanizeFieldName(contentEntityKey(definition.id));
+ const noun = (count: number): string =>
+ t.has(keys.label) ? t(keys.label, { count }) : fallback;
+ const plural = noun(2);
+
+ return {
+ plural,
+ singular: noun(1),
+ title: t.has(keys.title) ? t(keys.title) : plural,
+ };
+};
+
+export interface ContentContainerLabels {
+ desc: string | undefined;
+ labelEnum: (field: string, value: string) => string;
+ labelField: (name: string) => string;
+ plural: string;
+ singular: string;
+ title: string;
+}
+
+export const contentContainerLabelsFrom = (
+ definition: ContentContainerLabelSubject,
+ pluginId: string,
+ t: ContentLabelTranslator,
+): ContentContainerLabels => {
+ const keys = contentContainerI18nKeys(definition, pluginId);
+
+ return {
+ desc: t.has(keys.desc) ? t(keys.desc) : undefined,
+ labelEnum: (field, value) => {
+ const key = keys.enumValue(field, value);
+
+ return t.has(key) ? t(key) : humanizeFieldName(value);
+ },
+ labelField: name => {
+ const key = keys.field(name);
+
+ return t.has(key) ? t(key) : humanizeFieldName(name);
+ },
+ ...contentContainerNouns(definition, pluginId, t),
+ };
+};
diff --git a/packages/vitnode/src/content/admin/registry.ts b/packages/vitnode/src/content/admin/registry.ts
index 7e8e789db..5c171e066 100644
--- a/packages/vitnode/src/content/admin/registry.ts
+++ b/packages/vitnode/src/content/admin/registry.ts
@@ -1,10 +1,13 @@
import type {
+ ContentContainerFrontendRegistration,
ContentFrontendPluginSource,
ContentTypeFrontendRegistration,
} from "../../lib/plugin";
+import type { AnyContentContainerDefinition } from "../containers/types";
import type { AnyContentTypeDefinition } from "../types";
import type { ContentTypeLookup } from "./route";
+import { validateContentContainers } from "../containers/registry";
import { validateContentTypes } from "../registry";
/** A content type's frontend registration, plus the plugin that registered it. */
@@ -14,6 +17,22 @@ export interface RegisteredFrontendContentType {
registration: ContentTypeFrontendRegistration;
}
+export interface RegisteredFrontendContentContainer {
+ definition: AnyContentContainerDefinition;
+ pluginId: string;
+ registration: ContentContainerFrontendRegistration;
+}
+
+export interface ContentContainerFrontendRegistry {
+ all: () => readonly RegisteredFrontendContentContainer[];
+ byAdminPath: (
+ adminPath: string,
+ ) => RegisteredFrontendContentContainer | undefined;
+ byId: (
+ definitionId: string,
+ ) => RegisteredFrontendContentContainer | undefined;
+}
+
export interface ContentFrontendRegistry {
/** Every registration, sorted by content type id. */
all: () => readonly RegisteredFrontendContentType[];
@@ -21,10 +40,39 @@ export interface ContentFrontendRegistry {
byAdminPath: (adminPath: string) => RegisteredFrontendContentType | undefined;
/** By `definition.id` - `blog.post`. */
byId: (contentTypeId: string) => RegisteredFrontendContentType | undefined;
+ containers: ContentContainerFrontendRegistry;
lookup: ContentTypeLookup;
}
+const buildContainerRegistry = (
+ plugins: readonly ContentFrontendPluginSource[],
+): ContentContainerFrontendRegistry => {
+ const entries = plugins.flatMap(plugin =>
+ (plugin.containers ?? []).map(registration => ({
+ definition: registration.definition,
+ pluginId: plugin.pluginId,
+ registration,
+ })),
+ );
+
+ const firstRegistrations = new Set(validateContentContainers(entries));
+ const all = entries
+ .filter(entry => firstRegistrations.has(entry))
+ .sort((a, b) => a.definition.id.localeCompare(b.definition.id));
+
+ const byId = new Map(all.map(entry => [entry.definition.id, entry]));
+ const byAdminPath = new Map(
+ all.map(entry => [entry.definition.admin.path, entry]),
+ );
+
+ return {
+ all: () => all,
+ byAdminPath: adminPath => byAdminPath.get(adminPath),
+ byId: definitionId => byId.get(definitionId),
+ };
+};
+
export const buildContentFrontendRegistry = (
plugins: readonly ContentFrontendPluginSource[],
): ContentFrontendRegistry => {
@@ -53,6 +101,7 @@ export const buildContentFrontendRegistry = (
all: () => all,
byAdminPath: adminPath => byAdminPath.get(adminPath),
byId: contentTypeId => byId.get(contentTypeId),
+ containers: buildContainerRegistry(plugins),
lookup: adminPath => byAdminPath.get(adminPath)?.definition,
};
};
diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts
index 7898957b5..9afe73843 100644
--- a/packages/vitnode/src/content/admin/spec.ts
+++ b/packages/vitnode/src/content/admin/spec.ts
@@ -39,6 +39,7 @@ export interface ContentFormFieldSpec {
allowedExtensions?: string[];
/** `file` fields only: the media types the field accepts, lowercased. */
allowedMimeTypes?: string[];
+ containerDefinitionId?: string;
defaultValue?: boolean | null | number | string;
description?: string;
display?: "radio" | "select";
@@ -101,6 +102,7 @@ export interface ContentFormSpec {
}
export interface ContentColumnSpec {
+ containerDefinitionId?: string;
kind: "publication" | "system" | ContentFieldKind;
label: string;
@@ -156,6 +158,8 @@ export const projectFormField = (
switch (fieldValue.kind) {
case "boolean":
return { ...base, defaultValue: fieldValue.defaultValue };
+ case "container":
+ return { ...base, containerDefinitionId: fieldValue.definition.id };
case "enum":
return {
...base,
@@ -359,6 +363,9 @@ export const buildContentColumnSpec = ({
...(fieldValue && isContentReferenceCollection(fieldValue)
? { multiple: true }
: {}),
+ ...(fieldValue?.kind === "container"
+ ? { containerDefinitionId: fieldValue.definition.id }
+ : {}),
...(name === definition.admin.titleField ? { title: true } : {}),
...(name === definition.admin.titleField &&
definition.admin.list.thumbnailField !== null
@@ -466,6 +473,8 @@ const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => {
return z.array(zodContentNode);
case "boolean":
return z.boolean();
+ case "container":
+ return z.number().int().positive();
case "dateTime":
// ISO strings all the way through the form - `z.toJSONSchema`, which
// AutoForm runs on every schema, throws on `z.date()`.
diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts
index d09989df3..afdfef604 100644
--- a/packages/vitnode/src/content/cache.ts
+++ b/packages/vitnode/src/content/cache.ts
@@ -57,6 +57,9 @@ export const contentDeliverySitemapTag = (
locale?: string,
): string => tag(contentTypeId, "sitemap", ...localeParts(locale));
+export const contentContainerTag = (definitionId: string): string =>
+ tag(definitionId, "containers");
+
export type ContentInvalidationMode = "immediate" | "stale-while-revalidate";
export interface ContentLocaleInvalidation {
diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts
index 58e7baedd..f334665d3 100644
--- a/packages/vitnode/src/content/const.ts
+++ b/packages/vitnode/src/content/const.ts
@@ -44,6 +44,7 @@ export const CONTENT_PUBLICATION_STATUS_LENGTH = 32;
export const CONTENT_FILTERABLE_FIELD_KINDS = [
"boolean",
+ "container",
"enum",
"number",
"relation",
@@ -211,6 +212,7 @@ export const CONTENT_PUBLIC_RESERVED_PATHS = ["admin"] as const;
export const CONTENT_PUBLIC_EXPOSABLE_KINDS = [
"blocks",
"boolean",
+ "container",
"dateTime",
"enum",
// A file is exposable, and what crosses is the normalised descriptor rather
@@ -238,6 +240,7 @@ export const CONTENT_PUBLIC_ALWAYS_ORDERABLE = "publishedAt";
export const CONTENT_PUBLIC_WRITABLE_KINDS = [
"boolean",
+ "container",
"dateTime",
"enum",
"number",
diff --git a/packages/vitnode/src/content/containers/association.ts b/packages/vitnode/src/content/containers/association.ts
new file mode 100644
index 000000000..a46156e1a
--- /dev/null
+++ b/packages/vitnode/src/content/containers/association.ts
@@ -0,0 +1,49 @@
+import type { AnyContentTypeDefinition, ContentContainerField } from "../types";
+
+import { ContentInputError } from "../errors";
+
+export interface ContentContainerFieldEntry {
+ field: ContentContainerField;
+ name: string;
+}
+
+export const contentContainerFieldOf = (
+ definition: Pick,
+): ContentContainerFieldEntry | null => {
+ for (const [name, field] of Object.entries(definition.fields)) {
+ if (field.kind === "container") return { field, name };
+ }
+
+ return null;
+};
+
+export type ContentContainerAssignmentCode =
+ | "CONTAINER_DEFINITION_MISMATCH"
+ | "CONTAINER_NOT_FOUND";
+
+export class ContentContainerAssignmentError extends ContentInputError {
+ constructor({
+ code,
+ contentTypeId,
+ field,
+ }: {
+ code: ContentContainerAssignmentCode;
+ contentTypeId: string;
+ field: string;
+ }) {
+ const message =
+ code === "CONTAINER_NOT_FOUND"
+ ? "The selected container does not exist."
+ : "The selected container belongs to a different collection.";
+ super(message, { contentTypeId });
+
+ this.name = "ContentContainerAssignmentError";
+ this.code = code;
+ this.detail = message;
+ this.field = field;
+ }
+
+ readonly code: ContentContainerAssignmentCode;
+ readonly detail: string;
+ readonly field: string;
+}
diff --git a/packages/vitnode/src/content/containers/const.ts b/packages/vitnode/src/content/containers/const.ts
new file mode 100644
index 000000000..1a532c9e6
--- /dev/null
+++ b/packages/vitnode/src/content/containers/const.ts
@@ -0,0 +1,57 @@
+export const CONTENT_CONTAINER_FIELD_KINDS = [
+ "boolean",
+ "enum",
+ "number",
+ "text",
+ "textarea",
+] as const;
+
+export const CONTENT_CONTAINER_COLUMNS = [
+ "id",
+ "parentId",
+ "name",
+ "slug",
+ "position",
+ "createdAt",
+ "updatedAt",
+] as const;
+
+export const CONTENT_CONTAINER_RESERVED_FIELDS = [
+ ...CONTENT_CONTAINER_COLUMNS,
+ "children",
+ "definitionId",
+ "depth",
+ "metadata",
+ "pluginId",
+] as const;
+
+export const CONTENT_CONTAINER_ORDERING_MODES = [
+ "manual",
+ "alphabetical",
+] as const;
+
+export const CONTENT_CONTAINER_DEFAULT_MAX_DEPTH = 5;
+
+export const CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH = 10;
+
+export const CONTENT_CONTAINER_NAME_MAX_LENGTH = 255;
+
+export const CONTENT_CONTAINER_SLUG_MAX_LENGTH = 160;
+
+export const CONTENT_CONTAINER_MAX_PER_DEFINITION = 5000;
+
+export const CONTENT_CONTAINER_ERROR_CODES = [
+ "cycle",
+ "definitionMismatch",
+ "hasChildren",
+ "hasContent",
+ "invalidInput",
+ "invalidMetadata",
+ "invalidPosition",
+ "limitReached",
+ "maxDepth",
+ "notFound",
+ "parentNotFound",
+ "slugEmpty",
+ "slugTaken",
+] as const;
diff --git a/packages/vitnode/src/content/containers/define.test-d.ts b/packages/vitnode/src/content/containers/define.test-d.ts
new file mode 100644
index 000000000..ec6c41aff
--- /dev/null
+++ b/packages/vitnode/src/content/containers/define.test-d.ts
@@ -0,0 +1,93 @@
+import { describe, expectTypeOf, it } from "vitest";
+
+import type { ContentCreateInput } from "../types";
+import type {
+ ContentContainerPublicRecord,
+ ContentContainerRecord,
+} from "./types";
+
+import { defineContentType } from "../define";
+import { field } from "../fields";
+import { defineContentContainer } from "./define";
+
+const boards = defineContentContainer({
+ id: "test.boards",
+ fields: {
+ color: field.text({ maxLength: 7, required: true }),
+ locked: field.boolean({ defaultValue: false }),
+ kind: field.enum({ values: ["forum", "blog"], nullable: true }),
+ secret: field.number({ integer: true, nullable: true }),
+ },
+ publicApi: { path: "boards", fields: ["color", "kind"] },
+});
+
+const sections = defineContentContainer({ id: "test.sections" });
+
+const thread = (required: TRequired) =>
+ defineContentType({
+ id: "test.thread",
+ tableName: "test_threads",
+ fields: {
+ title: field.text({ required: true }),
+ board: field.container({ definition: boards, required }),
+ },
+ });
+
+describe("content container types", () => {
+ it("infers the metadata from the fields, every value nullable", () => {
+ expectTypeOf<
+ ContentContainerRecord["metadata"]
+ >().toEqualTypeOf<
+ Readonly<{
+ color: null | string;
+ kind: "blog" | "forum" | null;
+ locked: boolean | null;
+ secret: null | number;
+ }>
+ >();
+ });
+
+ it("limits the public metadata to publicApi.fields", () => {
+ expectTypeOf<
+ ContentContainerPublicRecord["metadata"]
+ >().toEqualTypeOf<
+ Readonly<{ color: null | string; kind: "blog" | "forum" | null }>
+ >();
+ });
+
+ it("has no metadata for a definition without fields", () => {
+ expectTypeOf<
+ ContentContainerRecord["metadata"]
+ >().toEqualTypeOf>();
+ expectTypeOf<
+ ContentContainerPublicRecord["metadata"]
+ >().toEqualTypeOf>();
+ });
+
+ it("keeps the public API flag and path literal", () => {
+ expectTypeOf(boards.publicApi.enabled).toEqualTypeOf();
+ expectTypeOf(boards.publicApi.path).toEqualTypeOf<"boards">();
+ expectTypeOf(sections.publicApi.enabled).toEqualTypeOf();
+ });
+
+ it("rejects a public field the definition does not declare", () => {
+ defineContentContainer({
+ id: "test.boards",
+ fields: { color: field.text({ nullable: true }) },
+ // @ts-expect-error - "nope" is not a field
+ publicApi: { path: "boards", fields: ["nope"] },
+ });
+ });
+
+ it("makes a required container a required number on create", () => {
+ expectTypeOf<
+ ContentCreateInput>>
+ >().toEqualTypeOf<{ board: number; title: string }>();
+ });
+
+ it("makes an optional container an optional number or null on create", () => {
+ expectTypeOf<
+ ContentCreateInput>>
+ >().toEqualTypeOf<{ board?: null | number; title: string }>();
+ });
+});
diff --git a/packages/vitnode/src/content/containers/define.test.ts b/packages/vitnode/src/content/containers/define.test.ts
new file mode 100644
index 000000000..0b933f788
--- /dev/null
+++ b/packages/vitnode/src/content/containers/define.test.ts
@@ -0,0 +1,365 @@
+import { describe, expect, it } from "vitest";
+
+import { testCategoryContentType } from "@/tests/content-fixtures";
+
+import { ContentEngineError } from "../errors";
+import { field } from "../fields";
+import { defineContentContainer } from "./define";
+
+const define = (overrides: Record = {}) =>
+ defineContentContainer({ id: "test.boards", ...overrides });
+
+const boards = defineContentContainer({
+ id: "test.boards",
+ fields: {
+ description: field.textarea({ maxLength: 20, nullable: true }),
+ color: field.text({ maxLength: 7, required: true }),
+ locked: field.boolean({ defaultValue: false }),
+ secret: field.text({ nullable: true }),
+ },
+ publicApi: { path: "boards", fields: ["description", "color"] },
+});
+
+describe("defineContentContainer", () => {
+ describe("a valid definition", () => {
+ it("resolves the defaults", () => {
+ const definition = defineContentContainer({ id: "example.kb.sections" });
+
+ expect(definition).toMatchObject({
+ admin: {
+ navigation: { enabled: true },
+ path: "example/kb/sections",
+ permissionModule: "example_kb_sections",
+ },
+ fields: {},
+ hierarchy: { enabled: true, maxDepth: 5 },
+ id: "example.kb.sections",
+ kind: "contentContainer",
+ ordering: { mode: "manual" },
+ publicApi: { enabled: false, fields: [], path: "" },
+ });
+ });
+
+ it("snake_cases a dashed id into the permission module", () => {
+ expect(define({ id: "test.help-desk" }).admin.permissionModule).toBe(
+ "test_help_desk",
+ );
+ });
+
+ it("keeps explicit options", () => {
+ const definition = define({
+ admin: {
+ navigation: false,
+ path: "forum/boards",
+ permissionModule: "boards",
+ },
+ hierarchy: { maxDepth: 3 },
+ ordering: { mode: "alphabetical" },
+ publicApi: { path: "boards" },
+ });
+
+ expect(definition).toMatchObject({
+ admin: {
+ navigation: { enabled: false },
+ path: "forum/boards",
+ permissionModule: "boards",
+ },
+ hierarchy: { enabled: true, maxDepth: 3 },
+ ordering: { mode: "alphabetical" },
+ publicApi: { enabled: true, fields: [], path: "boards" },
+ });
+ });
+
+ it("turns a disabled hierarchy into one level", () => {
+ expect(define({ hierarchy: { enabled: false } }).hierarchy).toEqual({
+ enabled: false,
+ maxDepth: 1,
+ });
+ });
+
+ it("treats publicApi: false as disabled", () => {
+ expect(define({ publicApi: false }).publicApi.enabled).toBe(false);
+ });
+
+ it("accepts every scalar field kind", () => {
+ expect(() =>
+ define({
+ fields: {
+ count: field.number({ integer: true, nullable: true }),
+ description: field.textarea({ nullable: true }),
+ kind: field.enum({ values: ["a", "b"], defaultValue: "a" }),
+ locked: field.boolean({ defaultValue: false }),
+ title: field.text({ required: true }),
+ },
+ }),
+ ).not.toThrow();
+ });
+ });
+
+ describe("errors", () => {
+ it.each([
+ ["an id without a dot", { id: "boards" }, /must look like/],
+ ["an uppercase id", { id: "Test.Boards" }, /must look like/],
+ ["an id with a trailing dot", { id: "test." }, /must look like/],
+ ["a non-string id", { id: 1 }, /must look like/],
+ ["fields that are not an object", { fields: [] }, /fields is an object/],
+ [
+ "a field name that is not camelCase",
+ { fields: { Color: field.text() } },
+ /must be camelCase/,
+ ],
+ ...[
+ "name",
+ "slug",
+ "parentId",
+ "position",
+ "metadata",
+ "depth",
+ "children",
+ ].map(name => [
+ `the reserved field ${name}`,
+ { fields: { [name]: field.text() } },
+ new RegExp(`Field "${name}" is reserved`),
+ ]),
+ [
+ "a value that is not a field descriptor",
+ { fields: { color: "red" } },
+ /"color" is not a field descriptor/,
+ ],
+ ...(
+ [
+ ["richText", field.richText()],
+ [
+ "relation",
+ field.relation({ target: () => testCategoryContentType }),
+ ],
+ ["user", field.user()],
+ [
+ "file",
+ field.file({
+ allowedExtensions: [".png"],
+ allowedMimeTypes: ["image/png"],
+ maxBytes: 1024,
+ }),
+ ],
+ ["dateTime", field.dateTime({ nullable: true })],
+ ] as [string, unknown][]
+ ).map(([kind, descriptor]) => [
+ `a ${kind} field`,
+ { fields: { extra: descriptor } },
+ new RegExp(`"extra" is a "${kind}" field`),
+ ]),
+ [
+ "a localized field",
+ { fields: { color: field.text({ localized: true }) } },
+ /`localized: true`/,
+ ],
+ [
+ "a unique field",
+ { fields: { color: field.text({ unique: true }) } },
+ /`unique: true`/,
+ ],
+ [
+ "an AI-assisted field",
+ {
+ fields: {
+ color: field.text({
+ ai: { action: "x:y", mode: "suggestion", sourceFields: [] },
+ }),
+ },
+ },
+ /AI assist is for content types only/,
+ ],
+ [
+ "minLength greater than maxLength",
+ { fields: { color: field.text({ maxLength: 5, minLength: 10 }) } },
+ /minLength 10 greater than maxLength 5/,
+ ],
+ ...[1, 0, 11, 1.5].map(maxDepth => [
+ `hierarchy.maxDepth ${maxDepth}`,
+ { hierarchy: { maxDepth } },
+ /maxDepth must be a whole number from 2 to 10/,
+ ]),
+ [
+ "maxDepth on a disabled hierarchy",
+ { hierarchy: { enabled: false, maxDepth: 3 } },
+ /maxDepth is set while hierarchy is disabled/,
+ ],
+ [
+ "an unknown hierarchy key",
+ { hierarchy: { depth: 3 } },
+ /hierarchy.depth is not an option/,
+ ],
+ [
+ "a non-boolean hierarchy.enabled",
+ { hierarchy: { enabled: "yes" } },
+ /hierarchy.enabled is `true` or `false`/,
+ ],
+ [
+ "an unknown ordering mode",
+ { ordering: { mode: "newest" } },
+ /ordering.mode must be one of: manual, alphabetical/,
+ ],
+ [
+ "an unknown ordering key",
+ { ordering: { direction: "asc" } },
+ /ordering.direction is not an option/,
+ ],
+ [
+ "a publicApi path with uppercase letters",
+ { publicApi: { path: "Boards" } },
+ /publicApi.path "Boards" must be one lowercase URL segment/,
+ ],
+ [
+ "a publicApi path with a slash",
+ { publicApi: { path: "forum/boards" } },
+ /must be one lowercase URL segment/,
+ ],
+ [
+ "the reserved publicApi path admin",
+ { publicApi: { path: "admin" } },
+ /publicApi.path "admin" is reserved/,
+ ],
+ [
+ "an unknown publicApi key",
+ { publicApi: { path: "boards", filterableFields: [] } },
+ /publicApi.filterableFields is not an option/,
+ ],
+ [
+ "an unknown publicApi field",
+ { publicApi: { path: "boards", fields: ["nope"] } },
+ /publicApi.fields references unknown field "nope"/,
+ ],
+ [
+ "a publicApi field listed twice",
+ {
+ fields: { color: field.text({ nullable: true }) },
+ publicApi: { path: "boards", fields: ["color", "color"] },
+ },
+ /lists "color" twice/,
+ ],
+ [
+ "a core column in publicApi.fields",
+ { publicApi: { path: "boards", fields: ["slug"] } },
+ /lists "slug", which every public container already returns/,
+ ],
+ ...["/forum", "forum/", "forum//boards", "Forum", "forum_boards"].map(
+ path => [
+ `the admin path ${path}`,
+ { admin: { path } },
+ /admin.path .* is not a URL path/,
+ ],
+ ),
+ ...["Boards", "1boards", "forum-boards"].map(permissionModule => [
+ `the permission module ${permissionModule}`,
+ { admin: { permissionModule } },
+ /admin.permissionModule .* must be snake_case/,
+ ]),
+ [
+ "an unknown admin key",
+ { admin: { title: "Boards" } },
+ /admin.title is not an option/,
+ ],
+ ] as [string, Record, RegExp][])(
+ "rejects %s",
+ (_label, overrides, message) => {
+ expect(() => define(overrides)).toThrow(ContentEngineError);
+ expect(() => define(overrides)).toThrow(message);
+ },
+ );
+ });
+
+ describe("schemas", () => {
+ const { schemas } = boards;
+
+ it.each([
+ ["a name only", { name: "General" }],
+ [
+ "every option",
+ {
+ metadata: { color: "#fff" },
+ name: "General",
+ parentId: 3,
+ position: 0,
+ slug: "general",
+ },
+ ],
+ ["a root parent", { name: "General", parentId: null }],
+ ])("create accepts %s", (_label, body) => {
+ expect(schemas.create.safeParse(body).success).toBe(true);
+ });
+
+ it.each([
+ ["no name", {}],
+ ["a blank name", { name: " " }],
+ ["a name past 255 characters", { name: "a".repeat(256) }],
+ ["a slug past 160 characters", { name: "A", slug: "a".repeat(161) }],
+ ["a zero parent id", { name: "A", parentId: 0 }],
+ ["a negative position", { name: "A", position: -1 }],
+ ["a fractional position", { name: "A", position: 1.5 }],
+ ["an unknown key", { depth: 2, name: "A" }],
+ ["metadata that misses a required field", { metadata: {}, name: "A" }],
+ ])("create rejects %s", (_label, body) => {
+ expect(schemas.create.safeParse(body).success).toBe(false);
+ });
+
+ it.each([
+ ["a name", { name: "Renamed" }],
+ ["a partial metadata patch", { metadata: { locked: true } }],
+ ])("update accepts %s", (_label, body) => {
+ expect(schemas.update.safeParse(body).success).toBe(true);
+ });
+
+ it.each([
+ ["an empty body", {}],
+ ["a parent id, which is a move", { parentId: 1 }],
+ ["a position, which is a move", { position: 1 }],
+ ["an unknown metadata key", { metadata: { nope: 1 } }],
+ ])("update rejects %s", (_label, body) => {
+ expect(schemas.update.safeParse(body).success).toBe(false);
+ });
+
+ it.each([
+ ["a root", { parentId: null, position: 0 }, true],
+ ["a parent", { parentId: 4, position: 2 }, true],
+ ["a missing parent id", { position: 0 }, false],
+ ["a missing position", { parentId: null }, false],
+ ["a negative position", { parentId: null, position: -1 }, false],
+ ["an extra key", { name: "A", parentId: null, position: 0 }, false],
+ ])("move with %s", (_label, body, success) => {
+ expect(schemas.move.safeParse(body).success).toBe(success);
+ });
+
+ it.each([
+ ["every field", { color: "#fff", description: "Hi", locked: true }, true],
+ ["only the required field", { color: "#fff" }, true],
+ ["a null nullable field", { color: "#fff", description: null }, true],
+ ["the required field missing", { description: "Hi" }, false],
+ ["the required field null", { color: null }, false],
+ ["a value past maxLength", { color: "#ffffff00" }, false],
+ ["a wrong type", { color: "#fff", locked: "yes" }, false],
+ ["an unknown field", { color: "#fff", extra: 1 }, false],
+ ])("metadata with %s", (_label, metadata, success) => {
+ expect(schemas.metadata.safeParse(metadata).success).toBe(success);
+ });
+
+ it("strips non-public metadata from a public record", () => {
+ const parsed = schemas.publicRecord.parse({
+ depth: 1,
+ id: 1,
+ metadata: {
+ color: "#fff",
+ description: null,
+ locked: true,
+ secret: "s3cret",
+ },
+ name: "General",
+ parentId: null,
+ position: 0,
+ slug: "general",
+ });
+
+ expect(parsed.metadata).toEqual({ color: "#fff", description: null });
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/containers/define.ts b/packages/vitnode/src/content/containers/define.ts
new file mode 100644
index 000000000..755f5d1b5
--- /dev/null
+++ b/packages/vitnode/src/content/containers/define.ts
@@ -0,0 +1,389 @@
+import { z } from "zod";
+
+import type { ContentFieldDescriptor, ContentFieldMap } from "../types";
+import type {
+ ContentContainerAdminInput,
+ ContentContainerDefinition,
+ ContentContainerFieldMap,
+ ContentContainerHierarchyInput,
+ ContentContainerOrderingInput,
+ ContentContainerPublicApiInput,
+ ContentContainerSchemas,
+} from "./types";
+
+import {
+ CONTENT_ADMIN_PATH_SEGMENT_PATTERN,
+ CONTENT_FIELD_NAME_PATTERN,
+ CONTENT_ID_PATTERN,
+ CONTENT_PUBLIC_PATH_MAX_LENGTH,
+ CONTENT_PUBLIC_PATH_PATTERN,
+ CONTENT_PUBLIC_RESERVED_PATHS,
+ CONTENT_TABLE_NAME_PATTERN,
+} from "../const";
+import { ContentEngineError } from "../errors";
+import { scalarFieldConstraintIssue } from "../field-constraints";
+import { contentTypeToPath } from "../registry";
+import {
+ contentFieldPatchObject,
+ contentFieldSelectSchema,
+ contentFieldValuesObject,
+} from "../schemas";
+import {
+ CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH,
+ CONTENT_CONTAINER_DEFAULT_MAX_DEPTH,
+ CONTENT_CONTAINER_FIELD_KINDS,
+ CONTENT_CONTAINER_NAME_MAX_LENGTH,
+ CONTENT_CONTAINER_ORDERING_MODES,
+ CONTENT_CONTAINER_RESERVED_FIELDS,
+ CONTENT_CONTAINER_SLUG_MAX_LENGTH,
+} from "./const";
+
+const fieldKinds: ReadonlySet = new Set(CONTENT_CONTAINER_FIELD_KINDS);
+const reservedFields: readonly string[] = CONTENT_CONTAINER_RESERVED_FIELDS;
+const orderingModes: readonly string[] = CONTENT_CONTAINER_ORDERING_MODES;
+const reservedPublicPaths: readonly string[] = CONTENT_PUBLIC_RESERVED_PATHS;
+
+const fail = (id: string, message: string): never => {
+ throw new ContentEngineError(message, { contentTypeId: id });
+};
+
+const assertKnownKeys = (
+ id: string,
+ label: string,
+ value: unknown,
+ known: readonly string[],
+): void => {
+ if (value === undefined) return;
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ fail(id, `${label} is an object.`);
+ }
+
+ const unknown = Object.keys(value as object).find(
+ key => !known.includes(key),
+ );
+ if (unknown !== undefined) {
+ fail(
+ id,
+ `${label}.${unknown} is not an option. ${label} accepts: ${known.join(", ")}.`,
+ );
+ }
+};
+
+const assertContainerField = (
+ id: string,
+ name: string,
+ fieldValue: ContentFieldDescriptor,
+): void => {
+ if (!CONTENT_FIELD_NAME_PATTERN.test(name)) {
+ fail(
+ id,
+ `Field "${name}" must be camelCase and start with a lowercase letter.`,
+ );
+ }
+
+ if (reservedFields.includes(name)) {
+ fail(
+ id,
+ `Field "${name}" is reserved. Every container already has ${reservedFields.join(", ")} - declare only what your containers add on top.`,
+ );
+ }
+
+ if (
+ !fieldKinds.has((fieldValue as undefined | { kind?: string })?.kind ?? "")
+ ) {
+ fail(
+ id,
+ `Field "${name}" is ${fieldValue?.kind ? `a "${fieldValue.kind}" field` : "not a field descriptor"}. A container field is one value stored beside the container - use field.${CONTENT_CONTAINER_FIELD_KINDS.join("(), field.")}().`,
+ );
+ }
+
+ if (fieldValue.localized === true) {
+ fail(
+ id,
+ `Field "${name}" is \`localized: true\`. Container fields have one value in every language.`,
+ );
+ }
+
+ if ((fieldValue as { unique?: boolean }).unique === true) {
+ fail(
+ id,
+ `Field "${name}" is \`unique: true\`. Container fields live in one JSON document per container, which no unique index covers - only the slug is unique.`,
+ );
+ }
+
+ if ((fieldValue as { ai?: unknown }).ai !== undefined) {
+ fail(
+ id,
+ `Field "${name}" has \`ai\`. AI assist is for content types only.`,
+ );
+ }
+
+ const issue = scalarFieldConstraintIssue(name, fieldValue);
+ if (issue) fail(id, issue);
+};
+
+const resolveHierarchy = (
+ id: string,
+ hierarchy: ContentContainerHierarchyInput | undefined,
+): { enabled: boolean; maxDepth: number } => {
+ assertKnownKeys(id, "hierarchy", hierarchy, ["enabled", "maxDepth"]);
+
+ const enabled = hierarchy?.enabled ?? true;
+ if (typeof enabled !== "boolean")
+ fail(id, "hierarchy.enabled is `true` or `false`.");
+
+ if (!enabled) {
+ if (hierarchy?.maxDepth !== undefined && hierarchy.maxDepth !== 1) {
+ fail(
+ id,
+ "hierarchy.maxDepth is set while hierarchy is disabled. A flat list is one level deep - drop maxDepth, or enable the hierarchy.",
+ );
+ }
+
+ return { enabled: false, maxDepth: 1 };
+ }
+
+ const maxDepth = hierarchy?.maxDepth ?? CONTENT_CONTAINER_DEFAULT_MAX_DEPTH;
+ if (
+ !Number.isInteger(maxDepth) ||
+ maxDepth < 2 ||
+ maxDepth > CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH
+ ) {
+ fail(
+ id,
+ `hierarchy.maxDepth must be a whole number from 2 to ${CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH}. For a single level, set \`hierarchy: { enabled: false }\`.`,
+ );
+ }
+
+ return { enabled: true, maxDepth };
+};
+
+const resolveOrdering = (
+ id: string,
+ ordering: ContentContainerOrderingInput | undefined,
+): { mode: (typeof CONTENT_CONTAINER_ORDERING_MODES)[number] } => {
+ assertKnownKeys(id, "ordering", ordering, ["mode"]);
+ const mode = ordering?.mode ?? "manual";
+ if (!orderingModes.includes(mode)) {
+ fail(id, `ordering.mode must be one of: ${orderingModes.join(", ")}.`);
+ }
+
+ return { mode };
+};
+
+const resolvePublicApi = (
+ id: string,
+ fields: ContentContainerFieldMap,
+ publicApi: ContentContainerPublicApiInput | false | undefined,
+): { enabled: boolean; fields: string[]; path: string } => {
+ if (publicApi === undefined || publicApi === false) {
+ return { enabled: false, fields: [], path: "" };
+ }
+
+ assertKnownKeys(id, "publicApi", publicApi, ["fields", "path"]);
+ const { path } = publicApi;
+
+ if (typeof path !== "string" || !CONTENT_PUBLIC_PATH_PATTERN.test(path)) {
+ fail(
+ id,
+ `publicApi.path ${JSON.stringify(path)} must be one lowercase URL segment: a letter, then letters, digits or dashes.`,
+ );
+ }
+
+ if (path.length > CONTENT_PUBLIC_PATH_MAX_LENGTH) {
+ fail(
+ id,
+ `publicApi.path "${path}" is longer than ${CONTENT_PUBLIC_PATH_MAX_LENGTH} characters.`,
+ );
+ }
+
+ if (reservedPublicPaths.includes(path)) {
+ fail(id, `publicApi.path "${path}" is reserved.`);
+ }
+
+ const exposed = [...(publicApi.fields ?? [])].map(String);
+ for (const [index, name] of exposed.entries()) {
+ if (exposed.indexOf(name) !== index) {
+ fail(id, `publicApi.fields lists "${name}" twice.`);
+ }
+
+ if (fields[name] === undefined) {
+ fail(
+ id,
+ reservedFields.includes(name)
+ ? `publicApi.fields lists "${name}", which every public container already returns. List only the definition's own fields.`
+ : `publicApi.fields references unknown field "${name}".`,
+ );
+ }
+ }
+
+ return { enabled: true, fields: exposed, path };
+};
+
+const resolveAdmin = (
+ id: string,
+ admin: ContentContainerAdminInput | undefined,
+): ContentContainerDefinition["admin"] => {
+ assertKnownKeys(id, "admin", admin, [
+ "navigation",
+ "path",
+ "permissionModule",
+ ]);
+
+ const path = admin?.path ?? contentTypeToPath(id);
+ const invalid = path
+ .split("/")
+ .find(segment => !CONTENT_ADMIN_PATH_SEGMENT_PATTERN.test(segment));
+ if (invalid !== undefined) {
+ fail(
+ id,
+ `admin.path "${path}" is not a URL path. Every segment starts with a lowercase letter and holds only lowercase letters, digits and dashes.`,
+ );
+ }
+
+ const permissionModule =
+ admin?.permissionModule ??
+ id.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
+ if (!CONTENT_TABLE_NAME_PATTERN.test(permissionModule)) {
+ fail(
+ id,
+ `admin.permissionModule "${permissionModule}" must be snake_case and start with a letter.`,
+ );
+ }
+
+ return {
+ navigation: { enabled: admin?.navigation ?? true },
+ path,
+ permissionModule,
+ };
+};
+
+const buildSchemas = (
+ fields: ContentContainerFieldMap,
+ publicFields: readonly string[],
+): ContentContainerSchemas => {
+ const fieldMap = fields as ContentFieldMap;
+ const metadata = contentFieldValuesObject(fieldMap);
+ const metadataPatch = contentFieldPatchObject(fieldMap);
+ const name = z.string().trim().min(1).max(CONTENT_CONTAINER_NAME_MAX_LENGTH);
+ const slug = z.string().trim().min(1).max(CONTENT_CONTAINER_SLUG_MAX_LENGTH);
+ const id = z.number().int().positive();
+ const position = z.number().int().min(0);
+
+ const selectShape = (names: readonly string[]) =>
+ z.object(
+ Object.fromEntries(
+ names.map(fieldName => [
+ fieldName,
+ contentFieldSelectSchema(fieldMap[fieldName]).nullable(),
+ ]),
+ ),
+ );
+
+ const columns = {
+ depth: z.number().int().positive(),
+ id,
+ name: z.string(),
+ parentId: id.nullable(),
+ position,
+ slug: z.string(),
+ };
+
+ return {
+ create: z.strictObject({
+ metadata: metadata.optional(),
+ name,
+ parentId: id.nullable().optional(),
+ position: position.optional(),
+ slug: slug.optional(),
+ }),
+ metadata,
+ metadataPatch,
+ move: z.strictObject({ parentId: id.nullable(), position }),
+ publicRecord: z.object({
+ ...columns,
+ metadata: selectShape(publicFields),
+ }),
+ record: z.object({
+ ...columns,
+ createdAt: z.date(),
+ metadata: selectShape(Object.keys(fields)),
+ updatedAt: z.date(),
+ }),
+ update: z
+ .strictObject({
+ metadata: metadataPatch.optional(),
+ name: name.optional(),
+ slug: slug.optional(),
+ })
+ .refine(value => Object.keys(value).length > 0, {
+ message: "Provide at least one value to update.",
+ }),
+ };
+};
+
+export const defineContentContainer = <
+ const TId extends string,
+ const TFields extends ContentContainerFieldMap = Record,
+ const TPublicField extends keyof TFields & string = never,
+ const TPublicPath extends string = string,
+ TPublicApi extends
+ | ContentContainerPublicApiInput
+ | false
+ | undefined = undefined,
+>({
+ admin,
+ fields = {} as TFields,
+ hierarchy,
+ id,
+ ordering,
+ publicApi,
+}: {
+ admin?: ContentContainerAdminInput;
+ fields?: TFields;
+ hierarchy?: ContentContainerHierarchyInput;
+ id: TId;
+ ordering?: ContentContainerOrderingInput;
+ publicApi?: (
+ | ContentContainerPublicApiInput
+ | false
+ ) &
+ TPublicApi;
+}): ContentContainerDefinition<
+ TId,
+ TFields,
+ TPublicField,
+ TPublicApi extends ContentContainerPublicApiInput ? true : false,
+ TPublicApi extends ContentContainerPublicApiInput
+ ? TPath
+ : string
+> => {
+ if (typeof id !== "string" || !CONTENT_ID_PATTERN.test(id)) {
+ throw new ContentEngineError(
+ `Container definition id ${JSON.stringify(id)} must look like "plugin.collection" (lowercase, dot separated).`,
+ );
+ }
+
+ if (typeof fields !== "object" || fields === null || Array.isArray(fields)) {
+ fail(id, "fields is an object of `field.*()` descriptors.");
+ }
+
+ for (const [name, fieldValue] of Object.entries(fields)) {
+ assertContainerField(id, name, fieldValue);
+ }
+
+ const resolvedPublicApi = resolvePublicApi(id, fields, publicApi);
+
+ return {
+ admin: resolveAdmin(id, admin),
+ fields,
+ hierarchy: resolveHierarchy(id, hierarchy),
+ id,
+ kind: "contentContainer",
+ ordering: resolveOrdering(id, ordering),
+ publicApi: resolvedPublicApi as never,
+ schemas: buildSchemas(fields, resolvedPublicApi.fields),
+ };
+};
+
+export { isContentContainerDefinition } from "./guard";
diff --git a/packages/vitnode/src/content/containers/errors.ts b/packages/vitnode/src/content/containers/errors.ts
new file mode 100644
index 000000000..e13d15cce
--- /dev/null
+++ b/packages/vitnode/src/content/containers/errors.ts
@@ -0,0 +1,62 @@
+import type { ContentContainerErrorCode } from "./types";
+
+import { ContentEngineError } from "../errors";
+
+const MESSAGES: Record = {
+ cycle: "A container cannot be moved inside itself or one of its descendants.",
+ definitionMismatch:
+ "The container belongs to a different container collection.",
+ hasChildren:
+ "The container still has child containers. Move or delete them first.",
+ hasContent:
+ "The container still holds content. Move or delete that content first.",
+ invalidInput: "The request does not describe a valid container.",
+ invalidMetadata: "The container's values do not match its definition.",
+ invalidPosition: "The position is outside the list of siblings.",
+ limitReached: "This collection already holds as many containers as it can.",
+ maxDepth: "The container would be nested deeper than this collection allows.",
+ notFound: "The container does not exist.",
+ parentNotFound: "The parent container does not exist.",
+ slugEmpty: "The slug has no letters or digits left once it is normalised.",
+ slugTaken: "Another container in this collection already uses that slug.",
+};
+
+const STATUS: Record = {
+ cycle: 400,
+ definitionMismatch: 400,
+ hasChildren: 409,
+ hasContent: 409,
+ invalidInput: 400,
+ invalidMetadata: 400,
+ invalidPosition: 400,
+ limitReached: 409,
+ maxDepth: 400,
+ notFound: 404,
+ parentNotFound: 400,
+ slugEmpty: 400,
+ slugTaken: 409,
+};
+
+export class ContentContainerError extends ContentEngineError {
+ constructor({
+ code,
+ definitionId,
+ message,
+ }: {
+ code: ContentContainerErrorCode;
+ definitionId: string;
+ message?: string;
+ }) {
+ const detail = message ?? MESSAGES[code];
+ super(detail, { contentTypeId: definitionId });
+
+ this.name = "ContentContainerError";
+ this.code = code;
+ this.detail = detail;
+ this.status = STATUS[code];
+ }
+
+ readonly code: ContentContainerErrorCode;
+ readonly detail: string;
+ readonly status: 400 | 404 | 409;
+}
diff --git a/packages/vitnode/src/content/containers/guard.ts b/packages/vitnode/src/content/containers/guard.ts
new file mode 100644
index 000000000..cb1ec610b
--- /dev/null
+++ b/packages/vitnode/src/content/containers/guard.ts
@@ -0,0 +1,8 @@
+import type { ContentContainerDefinition } from "./types";
+
+export const isContentContainerDefinition = (
+ value: unknown,
+): value is ContentContainerDefinition =>
+ typeof value === "object" &&
+ value !== null &&
+ (value as { kind?: unknown }).kind === "contentContainer";
diff --git a/packages/vitnode/src/content/containers/registry.test.ts b/packages/vitnode/src/content/containers/registry.test.ts
new file mode 100644
index 000000000..42771ded2
--- /dev/null
+++ b/packages/vitnode/src/content/containers/registry.test.ts
@@ -0,0 +1,193 @@
+import { describe, expect, it } from "vitest";
+
+import { testCategoryContentType } from "@/tests/content-fixtures";
+
+import type { AnyContentContainerDefinition } from "./types";
+
+import { defineContentType } from "../define";
+import { ContentEngineError } from "../errors";
+import { field } from "../fields";
+import { defineContentContainer } from "./define";
+import {
+ assertContentContainerNamespaces,
+ assertContentContainerReferences,
+ validateContentContainers,
+} from "./registry";
+
+const boards = defineContentContainer({
+ id: "test.boards",
+ publicApi: { path: "boards" },
+}) as AnyContentContainerDefinition;
+const sections = defineContentContainer({
+ id: "test.sections",
+}) as AnyContentContainerDefinition;
+
+const threadType = (container?: AnyContentContainerDefinition) =>
+ defineContentType({
+ id: "test.thread",
+ tableName: "test_threads",
+ publication: true,
+ fields: {
+ title: field.text({ required: true }),
+ slug: field.slug({ source: "title" }),
+ ...(container
+ ? { board: field.container({ definition: container }) }
+ : {}),
+ },
+ publicApi: { path: "threads", fields: ["title", "slug"] },
+ });
+
+describe("validateContentContainers", () => {
+ it("returns every definition once, sorted by id", () => {
+ expect(
+ validateContentContainers([
+ { definition: sections, pluginId: "test" },
+ { definition: boards, pluginId: "test" },
+ { definition: boards, pluginId: "test" },
+ ]).map(({ definition }) => definition.id),
+ ).toEqual(["test.boards", "test.sections"]);
+ });
+
+ it.each([
+ [
+ "one id from two plugins",
+ [
+ { definition: boards, pluginId: "test" },
+ { definition: boards, pluginId: "other" },
+ ],
+ /Duplicate container definition id, registered by both test -> test.boards and other -> test.boards/,
+ ],
+ [
+ "two objects with one id",
+ [
+ { definition: boards, pluginId: "test" },
+ {
+ definition: defineContentContainer({
+ admin: { path: "test/other" },
+ id: "test.boards",
+ }),
+ pluginId: "test",
+ },
+ ],
+ /Duplicate container definition id/,
+ ],
+ [
+ "one admin path claimed twice",
+ [
+ { definition: boards, pluginId: "test" },
+ {
+ definition: defineContentContainer({
+ admin: { path: "test/boards" },
+ id: "other.boards",
+ }),
+ pluginId: "other",
+ },
+ ],
+ /AdminCP path "test\/boards" is claimed by both/,
+ ],
+ [
+ "an object not built with defineContentContainer",
+ [{ definition: { id: "test.boards" }, pluginId: "test" }],
+ /test registers a container that was not built with `defineContentContainer`/,
+ ],
+ [
+ "a content type",
+ [{ definition: testCategoryContentType, pluginId: "test" }],
+ /not built with `defineContentContainer`/,
+ ],
+ ])("rejects %s", (_label, entries, message) => {
+ const run = () => validateContentContainers(entries as never);
+
+ expect(run).toThrow(ContentEngineError);
+ expect(run).toThrow(message);
+ });
+});
+
+describe("assertContentContainerNamespaces", () => {
+ const assert =
+ (
+ containers: AnyContentContainerDefinition[],
+ contentTypes = [threadType()],
+ ) =>
+ () =>
+ assertContentContainerNamespaces({
+ containers,
+ contentTypes,
+ pluginId: "test",
+ });
+
+ it("accepts containers that share nothing with the content types", () => {
+ expect(assert([boards, sections])).not.toThrow();
+ });
+
+ it.each([
+ [
+ "the permission module of a content type",
+ defineContentContainer({
+ admin: { permissionModule: "thread" },
+ id: "test.forums",
+ }),
+ /Permission module "thread" is used by both test -> test.thread and test -> test.forums/,
+ ],
+ [
+ "the permission module of another container",
+ defineContentContainer({
+ admin: { path: "test/forums", permissionModule: "test_boards" },
+ id: "test.forums",
+ }),
+ /Permission module "test_boards" is used by both test -> test.boards and test -> test.forums/,
+ ],
+ [
+ "the public path of a content type",
+ defineContentContainer({
+ id: "test.forums",
+ publicApi: { path: "threads" },
+ }),
+ /Public path "threads" is claimed by both test -> test.thread and test -> test.forums/,
+ ],
+ [
+ "the public path of a content type with the same id",
+ defineContentContainer({
+ id: "test.thread",
+ publicApi: { path: "threads" },
+ }),
+ /Public path "threads" is claimed by both test -> test.thread and test -> test.thread/,
+ ],
+ [
+ "the public path of another container",
+ defineContentContainer({
+ id: "test.forums",
+ publicApi: { path: "boards" },
+ }),
+ /Public path "boards" is claimed by both test -> test.boards and test -> test.forums/,
+ ],
+ ])("rejects a container that takes %s", (_label, container, message) => {
+ const run = assert([boards, container]);
+
+ expect(run).toThrow(ContentEngineError);
+ expect(run).toThrow(message);
+ });
+});
+
+describe("assertContentContainerReferences", () => {
+ const registered = [{ definition: boards, pluginId: "test" }];
+
+ it.each([
+ ["a content type without a container field", threadType()],
+ ["a container field naming a registered collection", threadType(boards)],
+ ])("accepts %s", (_label, contentType) => {
+ expect(() =>
+ assertContentContainerReferences([contentType], registered),
+ ).not.toThrow();
+ });
+
+ it("rejects a container field naming a collection no plugin registers", () => {
+ const run = () =>
+ assertContentContainerReferences([threadType(sections)], registered);
+
+ expect(run).toThrow(ContentEngineError);
+ expect(run).toThrow(
+ /Field "board" uses the container collection "test.sections", which no plugin registers/,
+ );
+ });
+});
diff --git a/packages/vitnode/src/content/containers/registry.ts b/packages/vitnode/src/content/containers/registry.ts
new file mode 100644
index 000000000..50f5b33ff
--- /dev/null
+++ b/packages/vitnode/src/content/containers/registry.ts
@@ -0,0 +1,145 @@
+import type { PermissionStaffEntryInput } from "../../api/lib/permission-staff";
+import type { AnyContentTypeDefinition } from "../types";
+import type { AnyContentContainerDefinition } from "./types";
+
+import { CONTENT_PERMISSIONS } from "../const";
+import { ContentEngineError } from "../errors";
+import { contentContainerFieldOf } from "./association";
+import { isContentContainerDefinition } from "./guard";
+
+export interface RegisteredContentContainer {
+ definition: AnyContentContainerDefinition;
+ pluginId: string;
+}
+
+const describe = (entry: RegisteredContentContainer): string =>
+ `${entry.pluginId} -> ${entry.definition.id}`;
+
+export const validateContentContainers = (
+ entries: readonly RegisteredContentContainer[],
+): RegisteredContentContainer[] => {
+ const byId = new Map();
+ const byAdminPath = new Map();
+
+ for (const entry of entries) {
+ const { definition } = entry;
+ if (!isContentContainerDefinition(definition)) {
+ throw new ContentEngineError(
+ `${entry.pluginId} registers a container that was not built with \`defineContentContainer\`.`,
+ );
+ }
+
+ const duplicate = byId.get(definition.id);
+ if (duplicate) {
+ if (
+ duplicate.definition === definition &&
+ duplicate.pluginId === entry.pluginId
+ ) {
+ continue;
+ }
+
+ throw new ContentEngineError(
+ `Duplicate container definition id, registered by both ${describe(duplicate)} and ${describe(entry)}.`,
+ { contentTypeId: definition.id },
+ );
+ }
+ byId.set(definition.id, entry);
+
+ const duplicatePath = byAdminPath.get(definition.admin.path);
+ if (duplicatePath) {
+ throw new ContentEngineError(
+ `AdminCP path "${definition.admin.path}" is claimed by both ${describe(duplicatePath)} and ${describe(entry)}. Give one of them a different \`admin.path\`.`,
+ { contentTypeId: definition.id },
+ );
+ }
+ byAdminPath.set(definition.admin.path, entry);
+ }
+
+ return [...byId.values()].sort((a, b) =>
+ a.definition.id.localeCompare(b.definition.id),
+ );
+};
+
+export const assertContentContainerNamespaces = ({
+ containers,
+ contentTypes,
+ pluginId,
+}: {
+ containers: readonly AnyContentContainerDefinition[];
+ contentTypes: readonly AnyContentTypeDefinition[];
+ pluginId: string;
+}): void => {
+ const modules = new Map();
+ const paths = new Map();
+ const describeOwner = (owner: string): string =>
+ `${pluginId} -> ${owner.slice(owner.indexOf(":") + 1)}`;
+
+ for (const definition of contentTypes) {
+ modules.set(definition.permissionModule, `type:${definition.id}`);
+ if (definition.publicApi.enabled) {
+ paths.set(definition.publicApi.path, `type:${definition.id}`);
+ }
+ }
+
+ for (const definition of containers) {
+ const self = `container:${definition.id}`;
+ const module = definition.admin.permissionModule;
+ const moduleOwner = modules.get(module);
+ if (moduleOwner !== undefined && moduleOwner !== self) {
+ throw new ContentEngineError(
+ `Permission module "${module}" is used by both ${describeOwner(moduleOwner)} and ${pluginId} -> ${definition.id}. Set \`admin.permissionModule\` on one of them.`,
+ { contentTypeId: definition.id },
+ );
+ }
+ modules.set(module, self);
+
+ if (!definition.publicApi.enabled) continue;
+ const path = definition.publicApi.path;
+ const pathOwner = paths.get(path);
+ if (pathOwner !== undefined && pathOwner !== self) {
+ throw new ContentEngineError(
+ `Public path "${path}" is claimed by both ${describeOwner(pathOwner)} and ${pluginId} -> ${definition.id}. Give one of them a different \`publicApi.path\`.`,
+ { contentTypeId: definition.id },
+ );
+ }
+ paths.set(path, self);
+ }
+};
+
+export const assertContentContainerReferences = (
+ contentTypes: readonly AnyContentTypeDefinition[],
+ containers: readonly RegisteredContentContainer[],
+): void => {
+ const registered = new Map(
+ containers.map(entry => [entry.definition.id, entry.definition]),
+ );
+
+ for (const definition of contentTypes) {
+ const container = contentContainerFieldOf(definition);
+ if (!container) continue;
+
+ if (!registered.has(container.field.definition.id)) {
+ throw new ContentEngineError(
+ `Field "${container.name}" uses the container collection "${container.field.definition.id}", which no plugin registers. Pass it to \`buildContentAdminModule({ containers: [...] })\`.`,
+ { contentTypeId: definition.id },
+ );
+ }
+ }
+};
+
+export const contentContainerPermissionEntries =
+ (): PermissionStaffEntryInput[] => [
+ CONTENT_PERMISSIONS.view,
+ {
+ dependsOn: [CONTENT_PERMISSIONS.view],
+ permission: CONTENT_PERMISSIONS.create,
+ },
+ {
+ dependsOn: [CONTENT_PERMISSIONS.view],
+ permission: CONTENT_PERMISSIONS.edit,
+ },
+ {
+ dependsOn: [CONTENT_PERMISSIONS.view],
+ permission: CONTENT_PERMISSIONS.delete,
+ },
+ ];
diff --git a/packages/vitnode/src/content/containers/server/admin-routes.test.ts b/packages/vitnode/src/content/containers/server/admin-routes.test.ts
new file mode 100644
index 000000000..9c6ba8d1c
--- /dev/null
+++ b/packages/vitnode/src/content/containers/server/admin-routes.test.ts
@@ -0,0 +1,292 @@
+import type { Context, MiddlewareHandler } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+import { describe, expect, it } from "vitest";
+
+import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
+
+import { core_content_containers } from "@/database/content-containers";
+import { createTestCache } from "@/tests/cache";
+import { testBoardContainers } from "@/tests/content-fixtures";
+import { createMemoryDb } from "@/tests/memory-db";
+import {
+ grantStaffPermissions,
+ ROOT_STAFF_PERMISSIONS,
+} from "@/tests/staff-permissions";
+
+import type { ContentContainerUsageCheck } from "./service";
+
+import { CONTENT_PERMISSIONS } from "../../const";
+import { buildContentContainerAdminRoutes } from "./admin-routes";
+
+const PLUGIN_ID = "@vitnode/example";
+const OTHER_PLUGIN_ID = "@vitnode/other";
+const MODULE = testBoardContainers.admin.permissionModule;
+const ALL_PERMISSIONS = Object.values(CONTENT_PERMISSIONS);
+const CREATED = new Date("2026-09-01T10:00:00.000Z");
+
+const BOARD = { CHILD: 2, ELSEWHERE: 9, FULL: 3, MISSING: 99, ROOT: 1 };
+
+const adminUser = { email: "test@test.com", id: 1, name: "Test", roleId: 1 };
+
+const row = (
+ id: number,
+ slug: string,
+ parentId: null | number,
+ position: number,
+ definitionId: string = testBoardContainers.id,
+) => ({
+ createdAt: CREATED,
+ definitionId,
+ id,
+ metadata: {},
+ name: slug,
+ parentId,
+ pluginId: PLUGIN_ID,
+ position,
+ slug,
+ updatedAt: CREATED,
+});
+
+const holdsContent: ContentContainerUsageCheck = async (_tx, ids) =>
+ await Promise.resolve(ids.includes(BOARD.FULL));
+
+const routes = buildContentContainerAdminRoutes(testBoardContainers, {
+ pluginId: PLUGIN_ID,
+ usage: holdsContent,
+});
+
+const harness = async (
+ permissions:
+ | PermissionsStaffArgs[]
+ | typeof ROOT_STAFF_PERMISSIONS = ROOT_STAFF_PERMISSIONS,
+) => {
+ const memory = createMemoryDb([
+ [
+ core_content_containers,
+ [
+ row(BOARD.ROOT, "root", null, 0),
+ row(BOARD.CHILD, "child", BOARD.ROOT, 0),
+ row(BOARD.FULL, "full", null, 1),
+ row(BOARD.ELSEWHERE, "elsewhere", null, 0, "test.sections"),
+ ],
+ ],
+ ]);
+ const cache = createTestCache();
+ await grantStaffPermissions(cache, { permissions, userId: adminUser.id });
+
+ const app = new OpenAPIHono();
+ const context: MiddlewareHandler = async (c, next) => {
+ c.set("admin", { user: adminUser } as unknown as Context["var"]["admin"]);
+ c.set("cache", cache);
+ c.set("db", memory.db as unknown as Context["var"]["db"]);
+ c.set("events", {
+ emit: async () => await Promise.resolve({ failures: [] }),
+ } as unknown as Context["var"]["events"]);
+ await next();
+ };
+ app.use("*", context);
+ app.onError((error, c) =>
+ error instanceof HTTPException
+ ? error.getResponse()
+ : c.json({ error: String(error) }, 500),
+ );
+ for (const { handler, hook, route } of routes) {
+ app.openapi(route, handler, hook as never);
+ }
+
+ const send = async (method: string, path: string, body?: unknown) =>
+ await app.request(path, {
+ method,
+ ...(body === undefined
+ ? {}
+ : {
+ body: JSON.stringify(body),
+ headers: { "content-type": "application/json" },
+ }),
+ });
+
+ return { rows: () => memory.rows(core_content_containers), send };
+};
+
+describe("status mapping", () => {
+ it("lists one collection in tree order", async () => {
+ const { send } = await harness();
+
+ const response = await send("GET", "/");
+
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as { items: { id: number }[] };
+ expect(body.items.map(item => item.id)).toEqual([
+ BOARD.ROOT,
+ BOARD.CHILD,
+ BOARD.FULL,
+ ]);
+ });
+
+ it.each([
+ ["POST", "/", { name: "New board" }, 201],
+ ["PATCH", `/${BOARD.CHILD}`, { name: "Renamed" }, 200],
+ ["POST", `/${BOARD.CHILD}/move`, { parentId: null, position: 0 }, 200],
+ ["DELETE", `/${BOARD.CHILD}`, undefined, 204],
+ ])("answers %s %s with %i", async (method, path, body, status) => {
+ const { send } = await harness();
+
+ expect((await send(method, path, body)).status).toBe(status);
+ });
+
+ it("returns the created container", async () => {
+ const { rows, send } = await harness();
+
+ const response = await send("POST", "/", {
+ metadata: { color: "#000000" },
+ name: "New board",
+ parentId: BOARD.ROOT,
+ });
+
+ const created = (await response.json()) as { id: number };
+ expect(created).toMatchObject({
+ depth: 2,
+ metadata: { color: "#000000", locked: false },
+ parentId: BOARD.ROOT,
+ position: 1,
+ slug: "new-board",
+ });
+ expect(rows().some(candidate => candidate.id === created.id)).toBe(true);
+ });
+
+ it.each([
+ ["POST", "/", { name: "Taken", slug: "root" }, 409, "slugTaken"],
+ [
+ "POST",
+ "/",
+ { metadata: { color: "#1234567" }, name: "Loud" },
+ 400,
+ "invalidMetadata",
+ ],
+ ["POST", "/", { name: "" }, 400, "invalidInput"],
+ [
+ "PATCH",
+ `/${BOARD.CHILD}`,
+ { metadata: { unknown: true } },
+ 400,
+ "invalidMetadata",
+ ],
+ [
+ "POST",
+ `/${BOARD.CHILD}/move`,
+ { parentId: null, position: -1 },
+ 400,
+ "invalidInput",
+ ],
+ [
+ "POST",
+ "/",
+ { name: "Lost", parentId: BOARD.ELSEWHERE },
+ 400,
+ "parentNotFound",
+ ],
+ ["PATCH", `/${BOARD.MISSING}`, { name: "Gone" }, 404, "notFound"],
+ ["PATCH", `/${BOARD.ELSEWHERE}`, { name: "Not mine" }, 404, "notFound"],
+ ["PATCH", `/${BOARD.CHILD}`, { slug: "root" }, 409, "slugTaken"],
+ [
+ "POST",
+ `/${BOARD.ROOT}/move`,
+ { parentId: BOARD.CHILD, position: 0 },
+ 400,
+ "cycle",
+ ],
+ [
+ "POST",
+ `/${BOARD.MISSING}/move`,
+ { parentId: null, position: 0 },
+ 404,
+ "notFound",
+ ],
+ ["DELETE", `/${BOARD.ROOT}`, undefined, 409, "hasChildren"],
+ ["DELETE", `/${BOARD.FULL}`, undefined, 409, "hasContent"],
+ ["DELETE", `/${BOARD.MISSING}`, undefined, 404, "notFound"],
+ ])(
+ "refuses %s %s with %i and a code",
+ async (method, path, body, status, code) => {
+ const { send } = await harness();
+
+ const response = await send(method, path, body);
+
+ expect(response.status).toBe(status);
+ await expect(response.json()).resolves.toEqual({
+ code,
+ message: expect.any(String),
+ });
+ },
+ );
+});
+
+describe("permission matrix", () => {
+ const DOCUMENTED: Record = {
+ "DELETE /{id}": CONTENT_PERMISSIONS.delete,
+ "GET /": CONTENT_PERMISSIONS.view,
+ "PATCH /{id}": CONTENT_PERMISSIONS.edit,
+ "POST /": CONTENT_PERMISSIONS.create,
+ "POST /{id}/move": CONTENT_PERMISSIONS.edit,
+ };
+
+ const REQUESTS: Record = {
+ "DELETE /{id}": ["DELETE", `/${BOARD.CHILD}`, undefined],
+ "GET /": ["GET", "/", undefined],
+ "PATCH /{id}": ["PATCH", `/${BOARD.CHILD}`, { name: "Renamed" }],
+ "POST /": ["POST", "/", { name: "New board" }],
+ "POST /{id}/move": [
+ "POST",
+ `/${BOARD.CHILD}/move`,
+ { parentId: null, position: 0 },
+ ],
+ };
+
+ const grantOf = (
+ permissions: readonly string[],
+ plugin = PLUGIN_ID,
+ ): PermissionsStaffArgs[] =>
+ permissions.map(permission => ({ module: MODULE, permission, plugin }));
+
+ const statusWith = async (label: string, grant: PermissionsStaffArgs[]) => {
+ const { send } = await harness(grant);
+ const [method, path, body] = REQUESTS[label];
+
+ return (await send(method, path, body)).status;
+ };
+
+ it("documents a permission for exactly the routes the builder produced", () => {
+ expect(
+ routes
+ .map(({ route }) => `${route.method.toUpperCase()} ${route.path}`)
+ .sort(),
+ ).toEqual(Object.keys(DOCUMENTED).sort());
+ });
+
+ it.each(Object.entries(DOCUMENTED))(
+ "%s is refused without %s and allowed with only it",
+ async (label, permission) => {
+ await expect(statusWith(label, [])).resolves.toBe(403);
+ await expect(
+ statusWith(
+ label,
+ grantOf(ALL_PERMISSIONS.filter(other => other !== permission)),
+ ),
+ ).resolves.toBe(403);
+ await expect(
+ statusWith(label, grantOf([permission])),
+ ).resolves.toBeLessThan(300);
+ },
+ );
+
+ it.each(Object.entries(DOCUMENTED))(
+ "%s is refused when %s was granted for another plugin",
+ async (label, permission) => {
+ await expect(
+ statusWith(label, grantOf([permission], OTHER_PLUGIN_ID)),
+ ).resolves.toBe(403);
+ },
+ );
+});
diff --git a/packages/vitnode/src/content/containers/server/admin-routes.ts b/packages/vitnode/src/content/containers/server/admin-routes.ts
new file mode 100644
index 000000000..57744dee9
--- /dev/null
+++ b/packages/vitnode/src/content/containers/server/admin-routes.ts
@@ -0,0 +1,233 @@
+import type { Context } from "hono";
+
+import { z } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+
+import type { RouteValidationHook } from "../../../api/lib/route";
+import type {
+ AnyContentContainerDefinition,
+ ContentContainerCreateInput,
+ ContentContainerMoveInput,
+ ContentContainerRecord,
+ ContentContainerUpdateInput,
+} from "../types";
+import type { ContentContainerUsageCheck } from "./service";
+
+import { buildRoute } from "../../../api/lib/route";
+import { CONTENT_PERMISSIONS } from "../../const";
+import { CONTENT_CONTAINER_ERROR_CODES } from "../const";
+import { ContentContainerError } from "../errors";
+import {
+ contentContainerInputRefusal,
+ createContentContainerService,
+} from "./service";
+
+export const zodContentContainerError = z.object({
+ code: z.enum(CONTENT_CONTAINER_ERROR_CODES),
+ message: z.string(),
+});
+
+const containerError = (error: unknown): never => {
+ if (error instanceof ContentContainerError) {
+ throw new HTTPException(error.status, {
+ res: Response.json(
+ { code: error.code, message: error.detail },
+ { status: error.status },
+ ),
+ });
+ }
+
+ throw error;
+};
+
+const validationHook: RouteValidationHook = (result, c) => {
+ if (result.success) return undefined;
+
+ return c.json(contentContainerInputRefusal(result.error), 400);
+};
+
+const idParams = z.object({
+ id: z.coerce.number().int().positive(),
+});
+
+const errorResponse = (description: string) => ({
+ content: { "application/json": { schema: zodContentContainerError } },
+ description,
+});
+
+export const buildContentContainerAdminRoutes = <
+ TDefinition extends AnyContentContainerDefinition,
+ P extends string,
+>(
+ definition: TDefinition,
+ { pluginId, usage }: { pluginId: P; usage?: ContentContainerUsageCheck },
+) => {
+ const module = definition.admin.permissionModule;
+ const schemas = definition.schemas as unknown as {
+ create: z.ZodType>;
+ move: z.ZodType;
+ record: z.ZodType>;
+ update: z.ZodType>;
+ };
+ const service = (c: Context) =>
+ createContentContainerService(c, { definition, pluginId, usage });
+
+ const list = buildRoute({
+ pluginId,
+ adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view },
+ route: {
+ method: "get",
+ path: "/",
+ description: `List every container of ${definition.id}, in tree order (Admin only)`,
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({ items: z.array(schemas.record) }),
+ },
+ },
+ description: "Every container, each parent followed by its subtree",
+ },
+ 403: { description: "Access Denied" },
+ },
+ },
+ handler: async c => c.json({ items: await service(c).list() }, 200),
+ });
+
+ const create = buildRoute({
+ pluginId,
+ hook: validationHook,
+ adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.create },
+ route: {
+ method: "post",
+ path: "/",
+ description: `Create a container in ${definition.id} (Admin only)`,
+ request: {
+ body: {
+ required: true,
+ content: { "application/json": { schema: schemas.create } },
+ },
+ },
+ responses: {
+ 201: {
+ content: { "application/json": { schema: schemas.record } },
+ description: "Container created",
+ },
+ 400: errorResponse("The tree rules or the definition refuse it"),
+ 403: { description: "Access Denied" },
+ 409: errorResponse("The slug is taken, or the collection is full"),
+ },
+ },
+ handler: async c => {
+ try {
+ return c.json(await service(c).create(c.req.valid("json")), 201);
+ } catch (error) {
+ return containerError(error);
+ }
+ },
+ });
+
+ const update = buildRoute({
+ pluginId,
+ hook: validationHook,
+ adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit },
+ route: {
+ method: "patch",
+ path: "/{id}",
+ description: `Update a container's name, slug or values in ${definition.id} (Admin only)`,
+ request: {
+ params: idParams,
+ body: {
+ required: true,
+ content: { "application/json": { schema: schemas.update } },
+ },
+ },
+ responses: {
+ 200: {
+ content: { "application/json": { schema: schemas.record } },
+ description: "Container updated",
+ },
+ 400: errorResponse("The values do not match the definition"),
+ 403: { description: "Access Denied" },
+ 404: errorResponse("No such container in this collection"),
+ 409: errorResponse("The slug is taken"),
+ },
+ },
+ handler: async c => {
+ try {
+ const { id } = c.req.valid("param");
+
+ return c.json(await service(c).update(id, c.req.valid("json")), 200);
+ } catch (error) {
+ return containerError(error);
+ }
+ },
+ });
+
+ const move = buildRoute({
+ pluginId,
+ hook: validationHook,
+ adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit },
+ route: {
+ method: "post",
+ path: "/{id}/move",
+ description: `Move a container to another parent, another position among its siblings, or both, in ${definition.id} (Admin only)`,
+ request: {
+ params: idParams,
+ body: {
+ required: true,
+ content: { "application/json": { schema: schemas.move } },
+ },
+ },
+ responses: {
+ 200: {
+ content: { "application/json": { schema: schemas.record } },
+ description: "Container moved",
+ },
+ 400: errorResponse(
+ "A cycle, a missing parent, a position out of range or too deep",
+ ),
+ 403: { description: "Access Denied" },
+ 404: errorResponse("No such container in this collection"),
+ },
+ },
+ handler: async c => {
+ try {
+ const { id } = c.req.valid("param");
+
+ return c.json(await service(c).move(id, c.req.valid("json")), 200);
+ } catch (error) {
+ return containerError(error);
+ }
+ },
+ });
+
+ const remove = buildRoute({
+ pluginId,
+ hook: validationHook,
+ adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete },
+ route: {
+ method: "delete",
+ path: "/{id}",
+ description: `Delete an empty container from ${definition.id} (Admin only)`,
+ request: { params: idParams },
+ responses: {
+ 204: { description: "Container deleted" },
+ 403: { description: "Access Denied" },
+ 404: errorResponse("No such container in this collection"),
+ 409: errorResponse("The container still has children or content"),
+ },
+ },
+ handler: async c => {
+ try {
+ await service(c).delete(c.req.valid("param").id);
+
+ return c.body(null, 204);
+ } catch (error) {
+ return containerError(error);
+ }
+ },
+ });
+
+ return [list, create, update, move, remove];
+};
diff --git a/packages/vitnode/src/content/containers/server/assignment.test.ts b/packages/vitnode/src/content/containers/server/assignment.test.ts
new file mode 100644
index 000000000..6a565ce55
--- /dev/null
+++ b/packages/vitnode/src/content/containers/server/assignment.test.ts
@@ -0,0 +1,246 @@
+import type { Context, MiddlewareHandler } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+import { describe, expect, it } from "vitest";
+
+import { core_content_containers } from "@/database/content-containers";
+import { createTestCache } from "@/tests/cache";
+import {
+ testCategoryContentType,
+ testThreadContentType,
+} from "@/tests/content-fixtures";
+import { createMemoryDb } from "@/tests/memory-db";
+import {
+ grantStaffPermissions,
+ ROOT_STAFF_PERMISSIONS,
+} from "@/tests/staff-permissions";
+
+import type { ContentDatabase } from "../../server/service";
+
+import { createContentModel } from "../../server/model";
+import { buildContentRoutes } from "../../server/routes";
+import { ContentContainerAssignmentError } from "../association";
+import { resolveContentContainerAssignment } from "./assignment";
+
+const PLUGIN_ID = "@vitnode/example";
+const CREATED = new Date("2026-09-01T10:00:00.000Z");
+const BOARD = { ELSEWHERE: 9, GENERAL: 1, HELP: 2, MISSING: 99 } as const;
+const THREAD = 7;
+
+const threads = createContentModel(testThreadContentType);
+
+const container = (
+ id: number,
+ slug: string,
+ definitionId: string,
+ parentId: null | number = null,
+) => ({
+ createdAt: CREATED,
+ definitionId,
+ id,
+ metadata: {},
+ name: slug,
+ parentId,
+ pluginId: PLUGIN_ID,
+ position: 0,
+ slug,
+ updatedAt: CREATED,
+});
+
+const seed = () =>
+ createMemoryDb([
+ [
+ core_content_containers,
+ [
+ container(BOARD.GENERAL, "general", "test.boards"),
+ container(BOARD.HELP, "help", "test.boards", BOARD.GENERAL),
+ container(BOARD.ELSEWHERE, "elsewhere", "test.sections"),
+ ],
+ ],
+ [
+ threads.table,
+ [
+ {
+ author: 1,
+ board: BOARD.GENERAL,
+ createdAt: CREATED,
+ id: THREAD,
+ publishedAt: null,
+ slug: "hello",
+ status: "draft",
+ title: "Hello",
+ updatedAt: CREATED,
+ },
+ ],
+ ],
+ ]);
+
+const contextOf = (memory: ReturnType) =>
+ ({
+ get: (key: string) =>
+ key === "db"
+ ? memory.db
+ : key === "events"
+ ? { emit: async () => await Promise.resolve({ failures: [] }) }
+ : undefined,
+ }) as unknown as Context;
+
+const failureOf = async (run: () => Promise) =>
+ await run().then(
+ () => null,
+ (error: unknown) => error,
+ );
+
+describe("resolveContentContainerAssignment", () => {
+ const resolve = async (values: Record) =>
+ await resolveContentContainerAssignment(
+ seed().db as unknown as ContentDatabase,
+ testThreadContentType,
+ values,
+ );
+
+ it("returns the container the write names, read back from the database", async () => {
+ await expect(resolve({ board: BOARD.HELP })).resolves.toEqual({
+ definitionId: "test.boards",
+ id: BOARD.HELP,
+ parentId: BOARD.GENERAL,
+ pluginId: PLUGIN_ID,
+ slug: "help",
+ });
+ });
+
+ it.each([
+ ["a write that does not touch the container", {}, undefined],
+ ["a write that clears it", { board: null }, null],
+ ])("passes %s through", async (_label, values, expected) => {
+ await expect(resolve(values)).resolves.toBe(expected);
+ });
+
+ it("ignores a content type without a container field", async () => {
+ await expect(
+ resolveContentContainerAssignment(
+ seed().db as unknown as ContentDatabase,
+ testCategoryContentType,
+ { board: BOARD.MISSING },
+ ),
+ ).resolves.toBeUndefined();
+ });
+
+ it.each([
+ ["a container that does not exist", BOARD.MISSING, "CONTAINER_NOT_FOUND"],
+ ["a zero id", 0, "CONTAINER_NOT_FOUND"],
+ ["an id that is not a number", String(BOARD.HELP), "CONTAINER_NOT_FOUND"],
+ [
+ "another collection's container",
+ BOARD.ELSEWHERE,
+ "CONTAINER_DEFINITION_MISMATCH",
+ ],
+ ])("refuses %s and names the field", async (_label, board, code) => {
+ const error = await failureOf(async () => await resolve({ board }));
+
+ expect(error).toBeInstanceOf(ContentContainerAssignmentError);
+ expect(error).toMatchObject({ code, field: "board" });
+ });
+});
+
+describe("the content service checks the container on every write", () => {
+ it("creates a record inside a container of its collection", async () => {
+ const memory = seed();
+
+ const created = await threads
+ .service(contextOf(memory))
+ .create({ author: 1, board: BOARD.HELP, title: "Second thread" });
+
+ expect(
+ memory.rows(threads.table).find(row => row.id === created.id),
+ ).toMatchObject({ board: BOARD.HELP });
+ });
+
+ it("moves a record into another container of its collection", async () => {
+ const memory = seed();
+
+ await threads
+ .service(contextOf(memory))
+ .update(THREAD, { board: BOARD.HELP });
+
+ expect(
+ memory.rows(threads.table).find(row => row.id === THREAD),
+ ).toMatchObject({ board: BOARD.HELP });
+ });
+
+ it.each([
+ ["create", BOARD.MISSING, "CONTAINER_NOT_FOUND"],
+ ["create", BOARD.ELSEWHERE, "CONTAINER_DEFINITION_MISMATCH"],
+ ["update", BOARD.MISSING, "CONTAINER_NOT_FOUND"],
+ ["update", BOARD.ELSEWHERE, "CONTAINER_DEFINITION_MISMATCH"],
+ ])(
+ "refuses to %s a record in container %i and writes nothing",
+ async (operation, board, code) => {
+ const memory = seed();
+ const service = threads.service(contextOf(memory));
+ const before = memory.rows(threads.table);
+
+ const error = await failureOf(async () =>
+ operation === "create"
+ ? await service.create({ author: 1, board, title: "Lost thread" })
+ : await service.update(THREAD, { board }),
+ );
+
+ expect(error).toMatchObject({ code, field: "board" });
+ expect(memory.rows(threads.table)).toEqual(before);
+ },
+ );
+});
+
+describe("the generated admin routes", () => {
+ it("answer a container of another collection with 400 naming the field", async () => {
+ const memory = seed();
+ const cache = createTestCache();
+ await grantStaffPermissions(cache, {
+ permissions: ROOT_STAFF_PERMISSIONS,
+ userId: 1,
+ });
+
+ const app = new OpenAPIHono();
+ const context: MiddlewareHandler = async (c, next) => {
+ c.set("admin", {
+ user: { id: 1, roleId: 1 },
+ } as unknown as Context["var"]["admin"]);
+ c.set("cache", cache);
+ c.set("db", memory.db as unknown as Context["var"]["db"]);
+ c.set("events", {
+ emit: async () => await Promise.resolve({ failures: [] }),
+ } as unknown as Context["var"]["events"]);
+ await next();
+ };
+ app.use("*", context);
+ app.onError((error, c) =>
+ error instanceof HTTPException
+ ? error.getResponse()
+ : c.json({ error: String(error) }, 500),
+ );
+ for (const { handler, route } of buildContentRoutes(threads, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ const response = await app.request("/", {
+ body: JSON.stringify({
+ author: 1,
+ board: BOARD.ELSEWHERE,
+ title: "Lost thread",
+ }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toEqual({
+ code: "CONTAINER_DEFINITION_MISMATCH",
+ field: "board",
+ message: expect.any(String),
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/containers/server/assignment.ts b/packages/vitnode/src/content/containers/server/assignment.ts
new file mode 100644
index 000000000..8b362e7b6
--- /dev/null
+++ b/packages/vitnode/src/content/containers/server/assignment.ts
@@ -0,0 +1,64 @@
+import { eq } from "drizzle-orm";
+
+import type { ContentDatabase } from "../../server/service";
+import type { AnyContentTypeDefinition } from "../../types";
+
+import { core_content_containers } from "../../../database/content-containers";
+import {
+ ContentContainerAssignmentError,
+ contentContainerFieldOf,
+} from "../association";
+
+export interface ResolvedContentContainerContext {
+ definitionId: string;
+ id: number;
+ parentId: null | number;
+ pluginId: string;
+ slug: string;
+}
+
+export const resolveContentContainerAssignment = async (
+ tx: ContentDatabase,
+ definition: AnyContentTypeDefinition,
+ values: Record,
+): Promise => {
+ const container = contentContainerFieldOf(definition);
+ if (!container) return undefined;
+
+ const value = values[container.name];
+ if (value === undefined) return undefined;
+ if (value === null) return null;
+
+ const refuse = (
+ code: ContentContainerAssignmentError["code"],
+ ): ContentContainerAssignmentError =>
+ new ContentContainerAssignmentError({
+ code,
+ contentTypeId: definition.id,
+ field: container.name,
+ });
+
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
+ throw refuse("CONTAINER_NOT_FOUND");
+ }
+
+ const [row] = await tx
+ .select({
+ definitionId: core_content_containers.definitionId,
+ id: core_content_containers.id,
+ parentId: core_content_containers.parentId,
+ pluginId: core_content_containers.pluginId,
+ slug: core_content_containers.slug,
+ })
+ .from(core_content_containers)
+ .where(eq(core_content_containers.id, value))
+ .limit(1)
+ .for("share");
+
+ if (!row) throw refuse("CONTAINER_NOT_FOUND");
+ if (row.definitionId !== container.field.definition.id) {
+ throw refuse("CONTAINER_DEFINITION_MISMATCH");
+ }
+
+ return row;
+};
diff --git a/packages/vitnode/src/content/containers/server/public-routes.test.ts b/packages/vitnode/src/content/containers/server/public-routes.test.ts
new file mode 100644
index 000000000..c0a95b43c
--- /dev/null
+++ b/packages/vitnode/src/content/containers/server/public-routes.test.ts
@@ -0,0 +1,187 @@
+import type { Context } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+import { describe, expect, it } from "vitest";
+
+import { core_content_containers } from "@/database/content-containers";
+import { testBoardContainers } from "@/tests/content-fixtures";
+import { createMemoryDb } from "@/tests/memory-db";
+
+import { buildContentContainerPublicRoutes } from "./public-routes";
+
+const PLUGIN_ID = "@vitnode/example";
+const CREATED = new Date("2026-09-01T10:00:00.000Z");
+
+const BOARD = {
+ ELSEWHERE: 9,
+ GENERAL: 1,
+ HELP: 4,
+ MISSING: 99,
+ NEWS: 3,
+ OFF_TOPIC: 2,
+ RELEASES: 5,
+} as const;
+
+const PRIVATE = { locked: true, notes: "Staff only" };
+
+const row = (
+ id: number,
+ slug: string,
+ parentId: null | number,
+ position: number,
+ definitionId: string = testBoardContainers.id,
+) => ({
+ createdAt: CREATED,
+ definitionId,
+ id,
+ metadata: { color: "#ffffff", description: `About ${slug}`, ...PRIVATE },
+ name: slug,
+ parentId,
+ pluginId: PLUGIN_ID,
+ position,
+ slug,
+ updatedAt: CREATED,
+});
+
+const SEED = [
+ row(BOARD.OFF_TOPIC, "off-topic", null, 1),
+ row(BOARD.GENERAL, "general", null, 0),
+ row(BOARD.HELP, "help", BOARD.GENERAL, 1),
+ row(BOARD.NEWS, "news", BOARD.GENERAL, 0),
+ row(BOARD.RELEASES, "releases", BOARD.NEWS, 0),
+ row(BOARD.ELSEWHERE, "elsewhere", null, 0, "test.sections"),
+];
+
+interface PublicContainer {
+ depth: number;
+ id: number;
+ metadata: Record;
+}
+
+const harness = (seed: readonly object[] = SEED) => {
+ const memory = createMemoryDb([[core_content_containers, seed]]);
+ const app = new OpenAPIHono();
+ app.use("*", async (c, next) => {
+ c.set("db", memory.db as unknown as Context["var"]["db"]);
+ await next();
+ });
+ app.onError((error, c) =>
+ error instanceof HTTPException
+ ? error.getResponse()
+ : c.json({ error: String(error) }, 500),
+ );
+ for (const { handler, route } of buildContentContainerPublicRoutes(
+ testBoardContainers,
+ { pluginId: PLUGIN_ID },
+ )) {
+ app.openapi(route, handler);
+ }
+
+ return app;
+};
+
+const ids = (items: readonly PublicContainer[]) => items.map(item => item.id);
+
+const list = async (query = "") => {
+ const response = await harness().request(`/${query}`);
+ const body = (await response.json()) as { items: PublicContainer[] };
+
+ return { body, status: response.status };
+};
+
+describe("GET /", () => {
+ it("lists the collection in tree order, with depths", async () => {
+ const { body, status } = await list();
+
+ expect(status).toBe(200);
+ expect(body.items.map(item => [item.id, item.depth])).toEqual([
+ [BOARD.GENERAL, 1],
+ [BOARD.NEWS, 2],
+ [BOARD.RELEASES, 3],
+ [BOARD.HELP, 2],
+ [BOARD.OFF_TOPIC, 1],
+ ]);
+ });
+
+ it.each([
+ ["root", [BOARD.GENERAL, BOARD.OFF_TOPIC]],
+ [String(BOARD.GENERAL), [BOARD.NEWS, BOARD.HELP]],
+ [String(BOARD.RELEASES), []],
+ ])(
+ "narrows the list to one level with parent=%s",
+ async (parent, expected) => {
+ const { body, status } = await list(`?parent=${parent}`);
+
+ expect(status).toBe(200);
+ expect(ids(body.items)).toEqual(expected);
+ },
+ );
+
+ it.each([BOARD.MISSING, BOARD.ELSEWHERE])(
+ "is a 404 for parent=%i, which is not in the collection",
+ async parent => {
+ expect((await harness().request(`/?parent=${parent}`)).status).toBe(404);
+ },
+ );
+
+ it("answers an empty collection with no items", async () => {
+ const response = await harness([]).request("/");
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({ items: [] });
+ });
+});
+
+describe("GET /{idOrSlug}", () => {
+ it.each([
+ ["id", String(BOARD.NEWS)],
+ ["slug", "news"],
+ ])(
+ "returns a container by %s, with its ancestors and children",
+ async (_label, segment) => {
+ const response = await harness().request(`/${segment}`);
+
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ ancestors: PublicContainer[];
+ children: PublicContainer[];
+ container: PublicContainer;
+ };
+ expect(body.container).toEqual({
+ depth: 2,
+ id: BOARD.NEWS,
+ metadata: { color: "#ffffff", description: "About news" },
+ name: "news",
+ parentId: BOARD.GENERAL,
+ position: 0,
+ slug: "news",
+ });
+ expect(ids(body.children)).toEqual([BOARD.RELEASES]);
+ expect(ids(body.ancestors)).toEqual([BOARD.GENERAL]);
+ },
+ );
+
+ it.each([
+ String(BOARD.MISSING),
+ "nope",
+ String(BOARD.ELSEWHERE),
+ "elsewhere",
+ ])("is a 404 for %s", async segment => {
+ expect((await harness().request(`/${segment}`)).status).toBe(404);
+ });
+});
+
+describe("private metadata", () => {
+ it.each(["/", "/?parent=root", `/${BOARD.NEWS}`, "/news"])(
+ "never leaves GET %s",
+ async path => {
+ const text = await (await harness().request(path)).text();
+
+ expect(text).not.toContain("locked");
+ expect(text).not.toContain("notes");
+ expect(text).not.toContain("Staff only");
+ expect(text).not.toContain("createdAt");
+ },
+ );
+});
diff --git a/packages/vitnode/src/content/containers/server/public-routes.ts b/packages/vitnode/src/content/containers/server/public-routes.ts
new file mode 100644
index 000000000..6ed538bbe
--- /dev/null
+++ b/packages/vitnode/src/content/containers/server/public-routes.ts
@@ -0,0 +1,134 @@
+import { z } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+
+import type {
+ AnyContentContainerDefinition,
+ ContentContainerPublicRecord,
+} from "../types";
+
+import { setPageCacheTags } from "../../../api/lib/page-cache";
+import { buildRoute } from "../../../api/lib/route";
+import { contentContainerTag } from "../../cache";
+import { CONTENT_CONTAINER_SLUG_MAX_LENGTH } from "../const";
+import { contentContainerAncestors } from "../tree";
+import { createContentContainerService } from "./service";
+
+const NUMERIC_ID = /^[1-9][0-9]{0,15}$/;
+
+export const buildContentContainerPublicRoutes = <
+ TDefinition extends AnyContentContainerDefinition,
+ P extends string,
+>(
+ definition: TDefinition,
+ { pluginId }: { pluginId: P },
+) => {
+ const record = definition.schemas.publicRecord as unknown as z.ZodType<
+ ContentContainerPublicRecord
+ >;
+ const service = (c: Parameters[0]) =>
+ createContentContainerService(c, { definition, pluginId });
+
+ const list = buildRoute({
+ pluginId,
+ route: {
+ method: "get",
+ path: "/",
+ description: `List the containers of ${definition.id}, in tree order. \`parent=root\` or \`parent={id}\` narrows the list to one level.`,
+ request: {
+ query: z.object({
+ parent: z
+ .union([z.literal("root"), z.coerce.number().int().positive()])
+ .optional(),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({ items: z.array(record) }),
+ },
+ },
+ description:
+ "Containers, each parent followed by its subtree - nest them with `contentContainerTree`",
+ },
+ 404: { description: "The parent does not exist" },
+ },
+ },
+ handler: async c => {
+ const { parent } = c.req.valid("query");
+ const containers = service(c);
+ const all = await containers.list();
+ let items = all;
+
+ if (parent === "root") {
+ items = all.filter(item => item.parentId === null);
+ } else if (parent !== undefined) {
+ if (!all.some(item => item.id === parent)) {
+ throw new HTTPException(404, { message: "Container not found." });
+ }
+ items = all.filter(item => item.parentId === parent);
+ }
+
+ setPageCacheTags(c, [contentContainerTag(definition.id)]);
+
+ return c.json({ items: items.map(containers.toPublic) }, 200);
+ },
+ });
+
+ const detail = buildRoute({
+ pluginId,
+ route: {
+ method: "get",
+ path: "/{idOrSlug}",
+ description: `One container of ${definition.id} by id or slug, with its ancestors (root first) and its direct children`,
+ request: {
+ params: z.object({
+ idOrSlug: z.string().min(1).max(CONTENT_CONTAINER_SLUG_MAX_LENGTH),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ ancestors: z.array(record),
+ children: z.array(record),
+ container: record,
+ }),
+ },
+ },
+ description: "The container, its breadcrumb and its children",
+ },
+ 404: { description: "Container not found" },
+ },
+ },
+ handler: async c => {
+ const { idOrSlug } = c.req.valid("param");
+ const containers = service(c);
+ const all = await containers.list();
+ const container = NUMERIC_ID.test(idOrSlug)
+ ? all.find(item => item.id === Number(idOrSlug))
+ : all.find(item => item.slug === idOrSlug);
+ if (!container) {
+ throw new HTTPException(404, { message: "Container not found." });
+ }
+
+ const ancestors = contentContainerAncestors(all, container.id);
+
+ setPageCacheTags(c, [contentContainerTag(definition.id)]);
+
+ return c.json(
+ {
+ ancestors: ancestors.map(containers.toPublic),
+ children: all
+ .filter(item => item.parentId === container.id)
+ .map(containers.toPublic),
+ container: containers.toPublic(container),
+ },
+ 200,
+ );
+ },
+ });
+
+ return [list, detail];
+};
diff --git a/packages/vitnode/src/content/containers/server/service.test.ts b/packages/vitnode/src/content/containers/server/service.test.ts
new file mode 100644
index 000000000..b8e1a1a34
--- /dev/null
+++ b/packages/vitnode/src/content/containers/server/service.test.ts
@@ -0,0 +1,662 @@
+import type { Context } from "hono";
+
+import { describe, expect, it } from "vitest";
+
+import { core_content_containers } from "@/database/content-containers";
+import {
+ testBoardContainers,
+ testThreadContentType,
+} from "@/tests/content-fixtures";
+import { createMemoryDb } from "@/tests/memory-db";
+
+import type {
+ ContentContainerService,
+ ContentContainerUsageCheck,
+} from "./service";
+
+import { createContentModel } from "../../server/model";
+import { ContentContainerError } from "../errors";
+import { createContentContainerService } from "./service";
+
+const PLUGIN_ID = "@vitnode/example";
+const CREATED = new Date("2026-09-01T10:00:00.000Z");
+
+const BOARD = {
+ BUGS: 6,
+ ELSEWHERE: 9,
+ GENERAL: 1,
+ HELP: 4,
+ MISSING: 99,
+ NEWS: 3,
+ OFF_TOPIC: 2,
+ RELEASES: 5,
+} as const;
+
+const row = (
+ id: number,
+ name: string,
+ parentId: null | number,
+ position: number,
+ overrides: Record = {},
+) => ({
+ createdAt: CREATED,
+ definitionId: testBoardContainers.id,
+ id,
+ metadata: {},
+ name,
+ parentId,
+ pluginId: PLUGIN_ID,
+ position,
+ slug: name.toLowerCase().replace(/ /g, "-"),
+ updatedAt: CREATED,
+ ...overrides,
+});
+
+const seed = () => [
+ row(BOARD.GENERAL, "General", null, 0, {
+ metadata: {
+ color: "#ffffff",
+ description: "Say hi",
+ locked: true,
+ notes: "Staff only",
+ },
+ }),
+ row(BOARD.OFF_TOPIC, "Off topic", null, 1),
+ row(BOARD.NEWS, "News", BOARD.GENERAL, 0),
+ row(BOARD.HELP, "Help", BOARD.GENERAL, 1),
+ row(BOARD.RELEASES, "Releases", BOARD.NEWS, 0),
+ row(BOARD.BUGS, "Bugs", BOARD.GENERAL, 2),
+ row(BOARD.ELSEWHERE, "Elsewhere", null, 0, {
+ definitionId: "test.sections",
+ }),
+];
+
+const holdsContent: ContentContainerUsageCheck = async (_tx, ids) =>
+ await Promise.resolve(ids.includes(BOARD.OFF_TOPIC));
+
+const harness = ({
+ core,
+ usage = holdsContent,
+}: {
+ core?: unknown;
+ usage?: ContentContainerUsageCheck | null;
+} = {}) => {
+ const memory = createMemoryDb([[core_content_containers, seed()]]);
+ const emitted: { name: string; payload: unknown }[] = [];
+ const variables: Record = {
+ core,
+ db: memory.db,
+ events: {
+ emit: async (name: string, payload: unknown) => {
+ emitted.push({ name, payload });
+
+ return await Promise.resolve({ failures: [] });
+ },
+ },
+ };
+ const c = { get: (key: string) => variables[key] } as unknown as Context;
+ const service = createContentContainerService(c, {
+ definition: testBoardContainers,
+ pluginId: PLUGIN_ID,
+ usage: usage ?? undefined,
+ });
+ const rows = () => memory.rows(core_content_containers);
+ const placement = (id: number) => {
+ const found = rows().find(candidate => candidate.id === id);
+
+ return found && { parentId: found.parentId, position: found.position };
+ };
+
+ return { emitted, memory, placement, rows, service };
+};
+
+const event = (name: string, payload: Record) => ({
+ name,
+ payload: {
+ definitionId: testBoardContainers.id,
+ pluginId: PLUGIN_ID,
+ ...payload,
+ },
+});
+
+describe("create", () => {
+ it("adds a root last, with a slug derived from the name", async () => {
+ const { emitted, rows, service } = harness();
+
+ const created = await service.create({ name: "Hello World!" });
+
+ expect(created).toMatchObject({
+ depth: 1,
+ name: "Hello World!",
+ parentId: null,
+ position: 2,
+ slug: "hello-world",
+ metadata: { color: null, description: null, locked: false, notes: null },
+ });
+ expect(rows().find(candidate => candidate.id === created.id)).toMatchObject(
+ {
+ definitionId: testBoardContainers.id,
+ pluginId: PLUGIN_ID,
+ },
+ );
+ expect(emitted).toEqual([
+ event("content.container.created", {
+ containerId: created.id,
+ parentId: null,
+ }),
+ ]);
+ });
+
+ it("adds a child last among its siblings", async () => {
+ const { service } = harness();
+
+ await expect(
+ service.create({ name: "Ideas", parentId: BOARD.GENERAL }),
+ ).resolves.toMatchObject({
+ depth: 2,
+ parentId: BOARD.GENERAL,
+ position: 3,
+ });
+ });
+
+ it("inserts at a position and shifts the siblings after it", async () => {
+ const { placement, service } = harness();
+
+ const created = await service.create({ name: "Welcome", position: 0 });
+
+ expect(created.position).toBe(0);
+ expect(
+ [BOARD.GENERAL, BOARD.OFF_TOPIC, BOARD.ELSEWHERE].map(placement),
+ ).toEqual([
+ { parentId: null, position: 1 },
+ { parentId: null, position: 2 },
+ { parentId: null, position: 0 },
+ ]);
+ });
+
+ it.each([
+ [
+ "suffixes a derived slug the collection already uses",
+ { name: "General" },
+ "general-2",
+ ],
+ [
+ "ignores a slug another collection uses",
+ { name: "Elsewhere" },
+ "elsewhere",
+ ],
+ ["falls back when the name has no letters", { name: "!!!" }, "container"],
+ [
+ "normalises an explicit slug",
+ { name: "Anything", slug: "Café Corner" },
+ "cafe-corner",
+ ],
+ ])("%s", async (_label, input, slug) => {
+ const { service } = harness();
+
+ await expect(service.create(input)).resolves.toMatchObject({ slug });
+ });
+
+ it("keeps suffixing until the slug is free", async () => {
+ const { service } = harness();
+
+ await service.create({ name: "General" });
+
+ await expect(service.create({ name: "General" })).resolves.toMatchObject({
+ slug: "general-3",
+ });
+ });
+
+ it("serializes concurrent creates, so each one plans against the other", async () => {
+ const { service } = harness();
+
+ const created = await Promise.all([
+ service.create({ name: "Twin" }),
+ service.create({ name: "Twin" }),
+ ]);
+
+ expect(created.map(({ position, slug }) => ({ position, slug }))).toEqual([
+ { position: 2, slug: "twin" },
+ { position: 3, slug: "twin-2" },
+ ]);
+ });
+});
+
+describe("update", () => {
+ it("merges a metadata patch into the stored values and announces the change", async () => {
+ const { emitted, rows, service } = harness();
+
+ const updated = await service.update(BOARD.GENERAL, {
+ metadata: { color: "#000000" },
+ name: "Lobby",
+ });
+
+ expect(updated).toMatchObject({
+ metadata: {
+ color: "#000000",
+ description: "Say hi",
+ locked: true,
+ notes: "Staff only",
+ },
+ name: "Lobby",
+ slug: "general",
+ });
+ expect(
+ rows().find(candidate => candidate.id === BOARD.GENERAL),
+ ).toMatchObject({
+ metadata: updated.metadata,
+ name: "Lobby",
+ });
+ expect(emitted).toEqual([
+ event("content.container.updated", {
+ changedFields: ["name", "color"],
+ containerId: BOARD.GENERAL,
+ parentId: null,
+ }),
+ ]);
+ });
+
+ it("changes the slug only when one is sent", async () => {
+ const { service } = harness();
+
+ await expect(
+ service.update(BOARD.HELP, { slug: "Support Desk" }),
+ ).resolves.toMatchObject({ name: "Help", slug: "support-desk" });
+ });
+
+ it("keeps its own slug without calling it taken", async () => {
+ const { emitted, service } = harness();
+
+ await expect(
+ service.update(BOARD.HELP, { slug: "help" }),
+ ).resolves.toMatchObject({ slug: "help" });
+ expect(emitted).toEqual([]);
+ });
+
+ it("writes and announces nothing when nothing changed", async () => {
+ const { emitted, rows, service } = harness();
+ const before = rows();
+
+ await service.update(BOARD.GENERAL, {
+ metadata: { color: "#ffffff" },
+ name: "General",
+ });
+
+ expect(rows()).toEqual(before);
+ expect(emitted).toEqual([]);
+ });
+});
+
+describe("move", () => {
+ it("moves a subtree to another parent and closes the gap it left", async () => {
+ const { emitted, service } = harness();
+
+ const moved = await service.move(BOARD.NEWS, {
+ parentId: BOARD.OFF_TOPIC,
+ position: 0,
+ });
+
+ expect(moved).toMatchObject({
+ depth: 2,
+ parentId: BOARD.OFF_TOPIC,
+ position: 0,
+ });
+ expect(
+ (await service.list()).map(({ depth, id, position }) => [
+ id,
+ depth,
+ position,
+ ]),
+ ).toEqual([
+ [BOARD.GENERAL, 1, 0],
+ [BOARD.HELP, 2, 0],
+ [BOARD.BUGS, 2, 1],
+ [BOARD.OFF_TOPIC, 1, 1],
+ [BOARD.NEWS, 2, 0],
+ [BOARD.RELEASES, 3, 0],
+ ]);
+ expect(emitted).toEqual([
+ event("content.container.moved", {
+ containerId: BOARD.NEWS,
+ fromParentId: BOARD.GENERAL,
+ parentId: BOARD.OFF_TOPIC,
+ position: 0,
+ }),
+ ]);
+ });
+
+ it("reorders among siblings", async () => {
+ const { placement, service } = harness();
+
+ await service.move(BOARD.BUGS, { parentId: BOARD.GENERAL, position: 0 });
+
+ expect([BOARD.BUGS, BOARD.NEWS, BOARD.HELP].map(placement)).toEqual([
+ { parentId: BOARD.GENERAL, position: 0 },
+ { parentId: BOARD.GENERAL, position: 1 },
+ { parentId: BOARD.GENERAL, position: 2 },
+ ]);
+ });
+
+ it("moves to the root", async () => {
+ const { service } = harness();
+
+ await expect(
+ service.move(BOARD.HELP, { parentId: null, position: 0 }),
+ ).resolves.toMatchObject({ depth: 1, parentId: null, position: 0 });
+ });
+
+ it("lets a leaf go as deep as the collection allows", async () => {
+ const { service } = harness();
+
+ await expect(
+ service.move(BOARD.HELP, { parentId: BOARD.NEWS, position: 1 }),
+ ).resolves.toMatchObject({ depth: 3, parentId: BOARD.NEWS });
+ });
+
+ it("announces nothing when a container is dropped where it was", async () => {
+ const { emitted, rows, service } = harness();
+ const before = rows();
+
+ await expect(
+ service.move(BOARD.HELP, { parentId: BOARD.GENERAL, position: 1 }),
+ ).resolves.toMatchObject({ id: BOARD.HELP, position: 1 });
+ expect(rows()).toEqual(before);
+ expect(emitted).toEqual([]);
+ });
+});
+
+describe("delete", () => {
+ it("removes a leaf, renumbers its siblings and announces it", async () => {
+ const { emitted, placement, rows, service } = harness();
+
+ await service.delete(BOARD.HELP);
+
+ expect(rows().some(candidate => candidate.id === BOARD.HELP)).toBe(false);
+ expect([BOARD.NEWS, BOARD.BUGS].map(placement)).toEqual([
+ { parentId: BOARD.GENERAL, position: 0 },
+ { parentId: BOARD.GENERAL, position: 1 },
+ ]);
+ expect(emitted).toEqual([
+ event("content.container.deleted", {
+ containerId: BOARD.HELP,
+ parentId: BOARD.GENERAL,
+ }),
+ ]);
+ });
+});
+
+describe("refused writes leave the tree exactly as it was", () => {
+ type Write = (
+ service: ContentContainerService,
+ ) => Promise;
+
+ it.each<[string, Write, string]>([
+ [
+ "create with a taken slug",
+ async s => await s.create({ name: "Lobby", slug: "general" }),
+ "slugTaken",
+ ],
+ [
+ "create with a slug that normalises to nothing",
+ async s => await s.create({ name: "Lobby", slug: "!!!" }),
+ "slugEmpty",
+ ],
+ [
+ "create with values the definition refuses",
+ async s =>
+ await s.create({ metadata: { color: "#1234567" }, name: "Lobby" }),
+ "invalidMetadata",
+ ],
+ [
+ "create with a value the definition does not declare",
+ async s =>
+ await s.create({ metadata: { nope: 1 } as never, name: "Lobby" }),
+ "invalidMetadata",
+ ],
+ [
+ "create with a blank name",
+ async s => await s.create({ name: " " }),
+ "invalidInput",
+ ],
+ [
+ "create under another collection's container",
+ async s => await s.create({ name: "Lobby", parentId: BOARD.ELSEWHERE }),
+ "parentNotFound",
+ ],
+ [
+ "create under a missing parent",
+ async s => await s.create({ name: "Lobby", parentId: BOARD.MISSING }),
+ "parentNotFound",
+ ],
+ [
+ "create below the deepest level",
+ async s => await s.create({ name: "Lobby", parentId: BOARD.RELEASES }),
+ "maxDepth",
+ ],
+ [
+ "create past the end of the siblings",
+ async s => await s.create({ name: "Lobby", position: 3 }),
+ "invalidPosition",
+ ],
+ [
+ "update to a taken slug",
+ async s => await s.update(BOARD.HELP, { slug: "general" }),
+ "slugTaken",
+ ],
+ [
+ "update with values the definition refuses",
+ async s =>
+ await s.update(BOARD.HELP, { metadata: { color: "#1234567" } }),
+ "invalidMetadata",
+ ],
+ [
+ "update with nothing in it",
+ async s => await s.update(BOARD.HELP, {}),
+ "invalidInput",
+ ],
+ [
+ "update another collection's container",
+ async s => await s.update(BOARD.ELSEWHERE, { name: "Mine" }),
+ "notFound",
+ ],
+ [
+ "move into itself",
+ async s =>
+ await s.move(BOARD.GENERAL, { parentId: BOARD.GENERAL, position: 0 }),
+ "cycle",
+ ],
+ [
+ "move into a descendant",
+ async s =>
+ await s.move(BOARD.GENERAL, { parentId: BOARD.RELEASES, position: 0 }),
+ "cycle",
+ ],
+ [
+ "move a subtree past the deepest level",
+ async s =>
+ await s.move(BOARD.NEWS, { parentId: BOARD.HELP, position: 0 }),
+ "maxDepth",
+ ],
+ [
+ "move under another collection's container",
+ async s =>
+ await s.move(BOARD.OFF_TOPIC, {
+ parentId: BOARD.ELSEWHERE,
+ position: 0,
+ }),
+ "parentNotFound",
+ ],
+ [
+ "move another collection's container",
+ async s => await s.move(BOARD.ELSEWHERE, { parentId: null, position: 0 }),
+ "notFound",
+ ],
+ [
+ "move past the end of the siblings",
+ async s =>
+ await s.move(BOARD.HELP, { parentId: BOARD.OFF_TOPIC, position: 1 }),
+ "invalidPosition",
+ ],
+ [
+ "delete a container with children",
+ async s => await s.delete(BOARD.GENERAL),
+ "hasChildren",
+ ],
+ [
+ "delete a container that holds content",
+ async s => await s.delete(BOARD.OFF_TOPIC),
+ "hasContent",
+ ],
+ [
+ "delete another collection's container",
+ async s => await s.delete(BOARD.ELSEWHERE),
+ "notFound",
+ ],
+ ])("refuses to %s", async (_label, write, code) => {
+ const { emitted, rows, service } = harness();
+ const before = rows();
+
+ const error = await write(service).then(
+ () => null,
+ (reason: unknown) => reason,
+ );
+
+ expect(error).toBeInstanceOf(ContentContainerError);
+ expect(error).toMatchObject({ code });
+ expect(rows()).toEqual(before);
+ expect(emitted).toEqual([]);
+ });
+});
+
+describe("reads", () => {
+ it("lists one collection in tree order, with depths", async () => {
+ const { service } = harness();
+
+ expect((await service.list()).map(({ depth, id }) => [id, depth])).toEqual([
+ [BOARD.GENERAL, 1],
+ [BOARD.NEWS, 2],
+ [BOARD.RELEASES, 3],
+ [BOARD.HELP, 2],
+ [BOARD.BUGS, 2],
+ [BOARD.OFF_TOPIC, 1],
+ ]);
+ });
+
+ it("never finds another collection's container", async () => {
+ const { service } = harness();
+
+ await expect(service.findById(BOARD.ELSEWHERE)).resolves.toBeNull();
+ await expect(service.findBySlug("elsewhere")).resolves.toBeNull();
+ await expect(service.descendantIds(BOARD.ELSEWHERE)).resolves.toBeNull();
+ await expect(service.ancestors(BOARD.ELSEWHERE)).resolves.toEqual([]);
+ });
+
+ it("finds a container by id and by slug, with its depth", async () => {
+ const { service } = harness();
+
+ await expect(service.findById(BOARD.RELEASES)).resolves.toMatchObject({
+ depth: 3,
+ slug: "releases",
+ });
+ await expect(service.findBySlug("releases")).resolves.toMatchObject({
+ id: BOARD.RELEASES,
+ });
+ });
+
+ it("lists the direct children of a container, or the roots", async () => {
+ const { service } = harness();
+
+ expect((await service.children(BOARD.GENERAL)).map(({ id }) => id)).toEqual(
+ [BOARD.NEWS, BOARD.HELP, BOARD.BUGS],
+ );
+ expect((await service.children(null)).map(({ id }) => id)).toEqual([
+ BOARD.GENERAL,
+ BOARD.OFF_TOPIC,
+ ]);
+ });
+
+ it.each([
+ [
+ BOARD.GENERAL,
+ [BOARD.GENERAL, BOARD.NEWS, BOARD.HELP, BOARD.RELEASES, BOARD.BUGS],
+ ],
+ [BOARD.NEWS, [BOARD.NEWS, BOARD.RELEASES]],
+ [BOARD.RELEASES, [BOARD.RELEASES]],
+ ])("gives container %i and everything below it", async (id, expected) => {
+ const { service } = harness();
+
+ expect(new Set(await service.descendantIds(id))).toEqual(new Set(expected));
+ });
+
+ it("is null for the subtree of a container that does not exist", async () => {
+ const { service } = harness();
+
+ await expect(service.descendantIds(BOARD.MISSING)).resolves.toBeNull();
+ });
+
+ it.each([
+ [BOARD.RELEASES, [BOARD.GENERAL, BOARD.NEWS]],
+ [BOARD.HELP, [BOARD.GENERAL]],
+ [BOARD.GENERAL, []],
+ [BOARD.MISSING, []],
+ ])("gives the ancestors of %i, root first", async (id, expected) => {
+ const { service } = harness();
+
+ expect((await service.ancestors(id)).map(record => record.id)).toEqual(
+ expected,
+ );
+ });
+
+ it("strips every metadata field the definition does not publish", async () => {
+ const { service } = harness();
+ const record = await service.findById(BOARD.GENERAL);
+ if (!record) throw new Error("Expected the seeded board.");
+
+ expect(service.toPublic(record)).toEqual({
+ depth: 1,
+ id: BOARD.GENERAL,
+ metadata: { color: "#ffffff", description: "Say hi" },
+ name: "General",
+ parentId: null,
+ position: 0,
+ slug: "general",
+ });
+ });
+});
+
+describe("contentContainerUsageFrom", () => {
+ const threads = createContentModel(testThreadContentType);
+
+ const harnessWithThread = async (board: number) => {
+ const context = harness({
+ core: { contentModels: [{ model: threads, pluginId: PLUGIN_ID }] },
+ usage: null,
+ });
+ await context.memory.db.insert(threads.table).values({
+ author: 1,
+ board,
+ slug: "hello",
+ title: "Hello",
+ });
+
+ return context;
+ };
+
+ it("refuses to delete a container a registered content type still points at", async () => {
+ const { rows, service } = await harnessWithThread(BOARD.HELP);
+ const before = rows();
+
+ await expect(service.delete(BOARD.HELP)).rejects.toMatchObject({
+ code: "hasContent",
+ });
+ expect(rows()).toEqual(before);
+ });
+
+ it("deletes a container nothing points at", async () => {
+ const { rows, service } = await harnessWithThread(BOARD.BUGS);
+
+ await service.delete(BOARD.HELP);
+
+ expect(rows().some(candidate => candidate.id === BOARD.HELP)).toBe(false);
+ });
+});
diff --git a/packages/vitnode/src/content/containers/server/service.ts b/packages/vitnode/src/content/containers/server/service.ts
new file mode 100644
index 000000000..4a4f96f54
--- /dev/null
+++ b/packages/vitnode/src/content/containers/server/service.ts
@@ -0,0 +1,594 @@
+import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+import type { z } from "zod";
+
+import { and, eq, inArray, sql } from "drizzle-orm";
+
+import type { ContentDatabase } from "../../server/service";
+import type {
+ AnyContentContainerDefinition,
+ ContentContainerCreateInput,
+ ContentContainerErrorCode,
+ ContentContainerMoveInput,
+ ContentContainerPublicRecord,
+ ContentContainerRecord,
+ ContentContainerUpdateInput,
+} from "../types";
+
+import { core_content_containers } from "../../../database/content-containers";
+import { invalidatePageCacheTags } from "../../../page-cache/store.server";
+import { contentContainerTag } from "../../cache";
+import { contentDefinitionOf } from "../../server/model";
+import { slugify } from "../../slug";
+import { contentContainerFieldOf } from "../association";
+import {
+ CONTENT_CONTAINER_MAX_PER_DEFINITION,
+ CONTENT_CONTAINER_SLUG_MAX_LENGTH,
+} from "../const";
+import { ContentContainerError } from "../errors";
+import {
+ contentContainerAncestors,
+ contentContainerSubtreeIds,
+ orderContentContainers,
+ planContentContainerCreate,
+ planContentContainerDelete,
+ planContentContainerMove,
+} from "../tree";
+
+type ContainerRow = typeof core_content_containers.$inferSelect;
+
+export type ContentContainerUsageCheck = (
+ tx: ContentDatabase,
+ containerIds: readonly number[],
+) => Promise;
+
+export interface ContentContainerReferencingModel {
+ column: PgColumn;
+ table: PgTable;
+}
+
+export const contentContainerReferencingModels = (
+ c: Context,
+ definitionId: string,
+): ContentContainerReferencingModel[] => {
+ const models = c.get("core")?.contentModels ?? [];
+
+ return models.flatMap(({ model }) => {
+ const container = contentContainerFieldOf(contentDefinitionOf(model));
+ if (container?.field.definition.id !== definitionId) return [];
+
+ const column = (model.columns as Record)[container.name];
+
+ return column ? [{ column, table: model.table }] : [];
+ });
+};
+
+export const contentContainerUsageFrom =
+ (
+ models: readonly ContentContainerReferencingModel[],
+ ): ContentContainerUsageCheck =>
+ async (tx, containerIds) => {
+ if (containerIds.length === 0) return false;
+
+ for (const { column, table } of models) {
+ const [row] = await tx
+ .select({ value: column })
+ .from(table)
+ .where(inArray(column, [...containerIds]))
+ .limit(1);
+ if (row) return true;
+ }
+
+ return false;
+ };
+
+export interface ContentContainerService<
+ TDefinition extends AnyContentContainerDefinition,
+> {
+ ancestors: (id: number) => Promise[]>;
+ children: (
+ parentId: null | number,
+ ) => Promise[]>;
+ create: (
+ input: ContentContainerCreateInput,
+ ) => Promise>;
+ delete: (id: number) => Promise;
+ descendantIds: (id: number) => Promise;
+ findById: (id: number) => Promise | null>;
+ findBySlug: (
+ slug: string,
+ ) => Promise | null>;
+ list: () => Promise[]>;
+ move: (
+ id: number,
+ input: ContentContainerMoveInput,
+ ) => Promise>;
+ toPublic: (
+ record: ContentContainerRecord,
+ ) => ContentContainerPublicRecord;
+ update: (
+ id: number,
+ input: ContentContainerUpdateInput,
+ ) => Promise>;
+}
+
+const zodErrorMessage = (error: z.ZodError): string =>
+ error.issues
+ .map(issue =>
+ issue.path.length > 0
+ ? `${issue.path.join(".")}: ${issue.message}`
+ : issue.message,
+ )
+ .join("; ");
+
+export const contentContainerInputRefusal = (
+ error: z.ZodError,
+): { code: "invalidInput" | "invalidMetadata"; message: string } => ({
+ code: error.issues.some(issue => issue.path[0] === "metadata")
+ ? "invalidMetadata"
+ : "invalidInput",
+ message: zodErrorMessage(error),
+});
+
+export const createContentContainerService = <
+ TDefinition extends AnyContentContainerDefinition,
+>(
+ c: Context,
+ {
+ definition,
+ pluginId,
+ usage,
+ }: {
+ definition: TDefinition;
+ pluginId: string;
+ usage?: ContentContainerUsageCheck;
+ },
+): ContentContainerService => {
+ const definitionId = definition.id;
+ const fieldNames = Object.keys(definition.fields);
+ const rules = {
+ hierarchy: definition.hierarchy.enabled,
+ maxDepth: definition.hierarchy.maxDepth,
+ mode: definition.ordering.mode,
+ };
+ const inUse =
+ usage ??
+ contentContainerUsageFrom(
+ contentContainerReferencingModels(c, definitionId),
+ );
+
+ const refuse = (code: ContentContainerErrorCode, message?: string): never => {
+ throw new ContentContainerError({ code, definitionId, message });
+ };
+
+ const readAll = async (db: ContentDatabase): Promise =>
+ await db
+ .select()
+ .from(core_content_containers)
+ .where(eq(core_content_containers.definitionId, definitionId));
+
+ const lock = async (tx: ContentDatabase): Promise => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${`core_content_containers:${definitionId}`}))`,
+ );
+ };
+
+ const toRecord = (
+ row: ContainerRow,
+ depth: number,
+ ): ContentContainerRecord =>
+ ({
+ createdAt: row.createdAt,
+ depth,
+ id: row.id,
+ metadata: Object.fromEntries(
+ fieldNames.map(name => [name, row.metadata[name] ?? null]),
+ ),
+ name: row.name,
+ parentId: row.parentId,
+ position: row.position,
+ slug: row.slug,
+ updatedAt: row.updatedAt,
+ }) as ContentContainerRecord;
+
+ const ordered = (rows: readonly ContainerRow[]) =>
+ orderContentContainers(rows, definition.ordering.mode).map(row =>
+ toRecord(row, row.depth),
+ );
+
+ const recordOf = (
+ rows: readonly ContainerRow[],
+ id: number,
+ ): ContentContainerRecord | null =>
+ ordered(rows).find(record => record.id === id) ?? null;
+
+ const parseMetadata = (
+ schema: z.ZodType,
+ value: unknown,
+ ): Record => {
+ const parsed = schema.safeParse(value ?? {});
+ if (!parsed.success) {
+ return refuse("invalidMetadata", zodErrorMessage(parsed.error));
+ }
+
+ return parsed.data as Record;
+ };
+
+ const normalizeSlug = (value: string): string => {
+ const slug = slugify(value, CONTENT_CONTAINER_SLUG_MAX_LENGTH);
+ if (slug === "") return refuse("slugEmpty");
+
+ return slug;
+ };
+
+ const resolveSlug = (
+ rows: readonly ContainerRow[],
+ {
+ explicit,
+ from,
+ self,
+ }: { explicit?: string; from: string; self?: number },
+ ): string => {
+ const taken = new Set(
+ rows.filter(row => row.id !== self).map(row => row.slug),
+ );
+
+ if (explicit !== undefined) {
+ const slug = normalizeSlug(explicit);
+ if (taken.has(slug)) refuse("slugTaken");
+
+ return slug;
+ }
+
+ const base =
+ slugify(from, CONTENT_CONTAINER_SLUG_MAX_LENGTH) || "container";
+ let candidate = base;
+ for (let suffix = 2; taken.has(candidate); suffix++) {
+ const tail = `-${suffix}`;
+ candidate = `${base.slice(0, CONTENT_CONTAINER_SLUG_MAX_LENGTH - tail.length)}${tail}`;
+ }
+
+ return candidate;
+ };
+
+ const applyPlacements = async (
+ tx: ContentDatabase,
+ placements: readonly {
+ id: number;
+ parentId: null | number;
+ position: number;
+ }[],
+ ): Promise => {
+ for (const placement of placements) {
+ await tx
+ .update(core_content_containers)
+ .set({
+ parentId: placement.parentId,
+ position: placement.position,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(core_content_containers.id, placement.id),
+ eq(core_content_containers.definitionId, definitionId),
+ ),
+ );
+ }
+ };
+
+ const expire = async (mode: "hard" | "soft"): Promise => {
+ await invalidatePageCacheTags([contentContainerTag(definitionId)], {
+ mode,
+ });
+ };
+
+ const emit = async <
+ TName extends
+ | "content.container.created"
+ | "content.container.deleted"
+ | "content.container.moved"
+ | "content.container.updated",
+ >(
+ name: TName,
+ payload: Record,
+ ): Promise => {
+ await c
+ .get("events")
+ .emit(name, { definitionId, pluginId, ...payload } as never);
+ };
+
+ const parseInput = (
+ schema: TSchema,
+ input: unknown,
+ ): z.infer => {
+ const parsed = schema.safeParse(input);
+ if (!parsed.success) {
+ const { code, message } = contentContainerInputRefusal(parsed.error);
+
+ return refuse(code, message);
+ }
+
+ return parsed.data;
+ };
+
+ return {
+ ancestors: async id => {
+ const rows = await readAll(c.get("db"));
+ const records = new Map(ordered(rows).map(record => [record.id, record]));
+
+ return contentContainerAncestors(rows, id).flatMap(row => {
+ const record = records.get(row.id);
+
+ return record ? [record] : [];
+ });
+ },
+
+ children: async parentId =>
+ ordered(await readAll(c.get("db"))).filter(
+ record => record.parentId === parentId,
+ ),
+
+ create: async input => {
+ const values = parseInput(definition.schemas.create, input) as {
+ metadata?: Record;
+ name: string;
+ parentId?: null | number;
+ position?: number;
+ slug?: string;
+ };
+ const metadata = parseMetadata(
+ definition.schemas.metadata,
+ values.metadata,
+ );
+ const parentId = values.parentId ?? null;
+
+ const created = await c.get("db").transaction(async tx => {
+ await lock(tx);
+ const rows = await readAll(tx);
+ if (rows.length >= CONTENT_CONTAINER_MAX_PER_DEFINITION) {
+ refuse("limitReached");
+ }
+
+ const plan = planContentContainerCreate(
+ rows,
+ { parentId, position: values.position },
+ rules,
+ );
+ if (!plan.ok) return refuse(plan.code);
+
+ const slug = resolveSlug(rows, {
+ explicit: values.slug,
+ from: values.name,
+ });
+
+ await applyPlacements(tx, plan.shifted);
+ const [row] = await tx
+ .insert(core_content_containers)
+ .values({
+ definitionId,
+ metadata,
+ name: values.name,
+ parentId,
+ pluginId,
+ position: plan.position,
+ slug,
+ })
+ .returning();
+
+ return recordOf([...rows, row], row.id) ?? refuse("notFound");
+ });
+
+ await expire("soft");
+ await emit("content.container.created", {
+ containerId: created.id,
+ parentId: created.parentId,
+ });
+
+ return created;
+ },
+
+ delete: async id => {
+ const removed = await c.get("db").transaction(async tx => {
+ await lock(tx);
+ const rows = await readAll(tx);
+ const plan = planContentContainerDelete(rows, id);
+ if (!plan.ok) return refuse(plan.code);
+ if (await inUse(tx, [id])) refuse("hasContent");
+
+ const row =
+ rows.find(candidate => candidate.id === id) ?? refuse("notFound");
+ await tx
+ .delete(core_content_containers)
+ .where(
+ and(
+ eq(core_content_containers.id, id),
+ eq(core_content_containers.definitionId, definitionId),
+ ),
+ );
+ await applyPlacements(tx, plan.shifted);
+
+ return row;
+ });
+
+ await expire("hard");
+ await emit("content.container.deleted", {
+ containerId: removed.id,
+ parentId: removed.parentId,
+ });
+ },
+
+ descendantIds: async id => {
+ const rows = await readAll(c.get("db"));
+ if (!rows.some(row => row.id === id)) return null;
+
+ return [...contentContainerSubtreeIds(rows, id)];
+ },
+
+ findById: async id => {
+ if (!Number.isSafeInteger(id) || id <= 0) return null;
+ const rows = await readAll(c.get("db"));
+
+ return recordOf(rows, id);
+ },
+
+ findBySlug: async slug => {
+ const rows = await readAll(c.get("db"));
+ const row = rows.find(candidate => candidate.slug === slug);
+
+ return row ? recordOf(rows, row.id) : null;
+ },
+
+ list: async () => ordered(await readAll(c.get("db"))),
+
+ move: async (id, input) => {
+ const { parentId, position } = parseInput(
+ definition.schemas.move,
+ input,
+ ) as unknown as ContentContainerMoveInput;
+
+ const { changed, from, moved } = await c
+ .get("db")
+ .transaction(async tx => {
+ await lock(tx);
+ const rows = await readAll(tx);
+ const plan = planContentContainerMove(
+ rows,
+ { id, parentId, position },
+ rules,
+ );
+ if (!plan.ok) return refuse(plan.code);
+
+ const before = rows.find(row => row.id === id) ?? refuse("notFound");
+ await applyPlacements(tx, plan.placements);
+ const placed = new Map(plan.placements.map(item => [item.id, item]));
+ const after = rows.map(row => {
+ const placement = placed.get(row.id);
+
+ return placement ? { ...row, ...placement } : row;
+ });
+
+ return {
+ changed: plan.placements.length > 0,
+ from: before.parentId,
+ moved: recordOf(after, id) ?? refuse("notFound"),
+ };
+ });
+
+ if (!changed) return moved;
+
+ await expire("soft");
+ await emit("content.container.moved", {
+ containerId: moved.id,
+ fromParentId: from,
+ parentId: moved.parentId,
+ position: moved.position,
+ });
+
+ return moved;
+ },
+
+ toPublic: record =>
+ ({
+ depth: record.depth,
+ id: record.id,
+ metadata: Object.fromEntries(
+ definition.publicApi.fields.map(name => [
+ name,
+ (record.metadata as Record)[name] ?? null,
+ ]),
+ ),
+ name: record.name,
+ parentId: record.parentId,
+ position: record.position,
+ slug: record.slug,
+ }) as ContentContainerPublicRecord,
+
+ update: async (id, input) => {
+ const values = parseInput(definition.schemas.update, input) as {
+ metadata?: Record;
+ name?: string;
+ slug?: string;
+ };
+ const patch =
+ values.metadata === undefined
+ ? undefined
+ : parseMetadata(definition.schemas.metadataPatch, values.metadata);
+
+ const { changedFields, record, slugChanged } = await c
+ .get("db")
+ .transaction(async tx => {
+ await lock(tx);
+ const rows = await readAll(tx);
+ const row = rows.find(candidate => candidate.id === id);
+ if (!row) return refuse("notFound");
+
+ const slug =
+ values.slug === undefined
+ ? row.slug
+ : resolveSlug(rows, {
+ explicit: values.slug,
+ from: row.name,
+ self: id,
+ });
+ const metadata = patch
+ ? parseMetadata(definition.schemas.metadata, {
+ ...Object.fromEntries(
+ Object.entries(row.metadata).filter(([key]) =>
+ Object.hasOwn(definition.fields, key),
+ ),
+ ),
+ ...patch,
+ })
+ : row.metadata;
+ const name = values.name ?? row.name;
+
+ const changed = [
+ ...(name === row.name ? [] : ["name"]),
+ ...(slug === row.slug ? [] : ["slug"]),
+ ...Object.keys(patch ?? {}).filter(
+ key =>
+ JSON.stringify(row.metadata[key] ?? null) !==
+ JSON.stringify(metadata[key] ?? null),
+ ),
+ ];
+
+ const [updated] =
+ changed.length === 0
+ ? [row]
+ : await tx
+ .update(core_content_containers)
+ .set({ metadata, name, slug, updatedAt: new Date() })
+ .where(
+ and(
+ eq(core_content_containers.id, id),
+ eq(core_content_containers.definitionId, definitionId),
+ ),
+ )
+ .returning();
+
+ return {
+ changedFields: changed,
+ record:
+ recordOf(
+ rows.map(candidate =>
+ candidate.id === id ? updated : candidate,
+ ),
+ id,
+ ) ?? refuse("notFound"),
+ slugChanged: slug !== row.slug,
+ };
+ });
+
+ if (changedFields.length > 0) {
+ await expire(slugChanged ? "hard" : "soft");
+ await emit("content.container.updated", {
+ changedFields,
+ containerId: record.id,
+ parentId: record.parentId,
+ });
+ }
+
+ return record;
+ },
+ };
+};
diff --git a/packages/vitnode/src/content/containers/tree.test.ts b/packages/vitnode/src/content/containers/tree.test.ts
new file mode 100644
index 000000000..141f9043f
--- /dev/null
+++ b/packages/vitnode/src/content/containers/tree.test.ts
@@ -0,0 +1,402 @@
+import { describe, expect, it } from "vitest";
+
+import type {
+ ContentContainerPlacement,
+ ContentContainerTreeRow,
+ ContentContainerTreeRules,
+} from "./tree";
+
+import {
+ contentContainerAncestors,
+ contentContainerSubtreeIds,
+ contentContainerTree,
+ orderContentContainers,
+ planContentContainerCreate,
+ planContentContainerDelete,
+ planContentContainerMove,
+} from "./tree";
+
+const row = (
+ id: number,
+ name: string,
+ parentId: null | number,
+ position: number,
+): ContentContainerTreeRow => ({ id, name, parentId, position });
+
+const rows: ContentContainerTreeRow[] = [
+ row(6, "Help", null, 2),
+ row(4, "announcements", 1, 1),
+ row(2, "Archive", null, 1),
+ row(5, "Old rules", 3, 0),
+ row(1, "News", null, 0),
+ row(3, "Rules", 1, 0),
+];
+
+const rules: ContentContainerTreeRules = {
+ hierarchy: true,
+ maxDepth: 3,
+ mode: "manual",
+};
+
+const apply = (
+ source: readonly ContentContainerTreeRow[],
+ placements: readonly ContentContainerPlacement[],
+): ContentContainerTreeRow[] =>
+ source.map(current => {
+ const placement = placements.find(({ id }) => id === current.id);
+
+ return placement ? { ...current, ...placement } : current;
+ });
+
+const expectContiguousPositions = (
+ result: readonly ContentContainerTreeRow[],
+): void => {
+ const byParent = new Map();
+ for (const { parentId, position } of result) {
+ byParent.set(parentId, [...(byParent.get(parentId) ?? []), position]);
+ }
+
+ for (const positions of byParent.values()) {
+ expect(positions.toSorted((a, b) => a - b)).toEqual(
+ positions.map((_, index) => index),
+ );
+ }
+};
+
+const ids = (list: readonly { id: number }[]) => list.map(({ id }) => id);
+
+describe("orderContentContainers", () => {
+ it("lists rows in pre-order with their depth, siblings by position", () => {
+ expect(
+ orderContentContainers(rows).map(({ depth, id }) => [id, depth]),
+ ).toEqual([
+ [1, 1],
+ [3, 2],
+ [5, 3],
+ [4, 2],
+ [2, 1],
+ [6, 1],
+ ]);
+ });
+
+ it("sorts siblings by name, ignoring case, in alphabetical mode", () => {
+ expect(ids(orderContentContainers(rows, "alphabetical"))).toEqual([
+ 2, 6, 1, 4, 3, 5,
+ ]);
+ });
+
+ it("breaks a position tie by id", () => {
+ expect(
+ ids(orderContentContainers([row(9, "B", null, 0), row(7, "A", null, 0)])),
+ ).toEqual([7, 9]);
+ });
+
+ it("surfaces a row whose parent is missing at the root, with its subtree", () => {
+ const ordered = orderContentContainers([
+ row(1, "Root", null, 0),
+ row(2, "Orphan", 99, 0),
+ row(3, "Orphan child", 2, 0),
+ ]);
+
+ expect(ordered.map(({ depth, id }) => [id, depth])).toEqual([
+ [1, 1],
+ [2, 1],
+ [3, 2],
+ ]);
+ });
+
+ it("terminates on a cycle and still lists the reachable rows", () => {
+ const ordered = orderContentContainers([
+ row(1, "Root", null, 0),
+ row(2, "A", 3, 0),
+ row(3, "B", 2, 0),
+ row(4, "Self", 4, 0),
+ ]);
+
+ expect(ids(ordered)).toContain(1);
+ expect(new Set(ids(ordered)).size).toBe(ordered.length);
+ });
+});
+
+describe("contentContainerTree", () => {
+ it("nests rows under their parents", () => {
+ const tree = contentContainerTree(orderContentContainers(rows));
+
+ expect(ids(tree)).toEqual([1, 2, 6]);
+ expect(ids(tree[0].children)).toEqual([3, 4]);
+ expect(ids(tree[0].children[0].children)).toEqual([5]);
+ expect(tree[1].children).toEqual([]);
+ });
+
+ it("keeps a row whose parent is not in the list at the root", () => {
+ expect(
+ ids(
+ contentContainerTree([
+ { id: 1, parentId: null },
+ { id: 2, parentId: 99 },
+ ]),
+ ),
+ ).toEqual([1, 2]);
+ });
+});
+
+describe("contentContainerAncestors", () => {
+ it.each([
+ [5, [1, 3]],
+ [3, [1]],
+ [1, []],
+ [99, []],
+ ])("lists the ancestors of %i root first", (id, expected) => {
+ expect(ids(contentContainerAncestors(rows, id))).toEqual(expected);
+ });
+
+ it("terminates on a cycle", () => {
+ expect(
+ ids(contentContainerAncestors([row(1, "A", 2, 0), row(2, "B", 1, 0)], 1)),
+ ).toEqual([2]);
+ });
+});
+
+describe("contentContainerSubtreeIds", () => {
+ it.each([
+ [1, [1, 3, 4, 5]],
+ [3, [3, 5]],
+ [6, [6]],
+ ])("collects %i and everything below it", (id, expected) => {
+ expect(
+ [...contentContainerSubtreeIds(rows, id)].toSorted((a, b) => a - b),
+ ).toEqual(expected);
+ });
+});
+
+describe("planContentContainerCreate", () => {
+ const create = (
+ parentId: null | number,
+ position?: number,
+ overrides: Partial = {},
+ source: readonly ContentContainerTreeRow[] = rows,
+ ) =>
+ planContentContainerCreate(
+ source,
+ { parentId, position },
+ { ...rules, ...overrides },
+ );
+
+ const expectValid = (
+ plan: ReturnType,
+ parentId: null | number,
+ source: readonly ContentContainerTreeRow[] = rows,
+ ) => {
+ if (!plan.ok) throw new Error(`Expected a plan, got ${plan.code}`);
+ expectContiguousPositions([
+ ...apply(source, plan.shifted),
+ row(100, "New", parentId, plan.position),
+ ]);
+ };
+
+ it.each([
+ ["the root", null, 3],
+ ["a parent", 1, 2],
+ ["a parent without children", 2, 0],
+ ])("appends to %s when no position is given", (_label, parentId, at) => {
+ const plan = create(parentId);
+
+ expect(plan).toEqual({ ok: true, position: at, shifted: [] });
+ expectValid(plan, parentId);
+ });
+
+ it("shifts the siblings at and after an explicit position", () => {
+ const plan = create(1, 0);
+
+ expect(plan).toEqual({
+ ok: true,
+ position: 0,
+ shifted: [
+ { id: 3, parentId: 1, position: 1 },
+ { id: 4, parentId: 1, position: 2 },
+ ],
+ });
+ expectValid(plan, 1);
+ });
+
+ it("closes gaps in the sibling positions", () => {
+ const gappy = [row(1, "A", null, 0), row(2, "B", null, 7)];
+ const plan = create(null, 1, {}, gappy);
+
+ expect(plan).toEqual({
+ ok: true,
+ position: 1,
+ shifted: [{ id: 2, parentId: null, position: 2 }],
+ });
+ expectValid(plan, null, gappy);
+ });
+
+ it("ignores the position in alphabetical mode", () => {
+ const plan = create(1, 0, { mode: "alphabetical" });
+
+ expect(plan).toEqual({ ok: true, position: 2, shifted: [] });
+ });
+
+ it.each([
+ ["a position past the end", 1, 3, {}, "invalidPosition"],
+ ["a negative position", 1, -1, {}, "invalidPosition"],
+ ["a fractional position", 1, 0.5, {}, "invalidPosition"],
+ ["a missing parent", 99, undefined, {}, "parentNotFound"],
+ ["a parent at the deepest level", 5, undefined, {}, "maxDepth"],
+ [
+ "any parent when the hierarchy is disabled",
+ 1,
+ undefined,
+ { hierarchy: false, maxDepth: 1 },
+ "maxDepth",
+ ],
+ ] as const)("refuses %s", (_label, parentId, position, overrides, code) => {
+ expect(create(parentId, position, overrides)).toEqual({ code, ok: false });
+ });
+
+ it("still allows a root when the hierarchy is disabled", () => {
+ expect(create(null, 0, { hierarchy: false, maxDepth: 1 }).ok).toBe(true);
+ });
+});
+
+describe("planContentContainerMove", () => {
+ const move = (
+ id: number,
+ parentId: null | number,
+ position: number,
+ overrides: Partial = {},
+ ) =>
+ planContentContainerMove(
+ rows,
+ { id, parentId, position },
+ { ...rules, ...overrides },
+ );
+
+ const expectMoved = (
+ plan: ReturnType,
+ id: number,
+ parentId: null | number,
+ ) => {
+ if (!plan.ok) throw new Error(`Expected a plan, got ${plan.code}`);
+ const result = apply(rows, plan.placements);
+
+ expect(result.find(current => current.id === id)?.parentId).toBe(parentId);
+ expectContiguousPositions(result);
+ };
+
+ it("reorders siblings", () => {
+ const plan = move(4, 1, 0);
+
+ expect(plan).toEqual({
+ ok: true,
+ placements: [
+ { id: 4, parentId: 1, position: 0 },
+ { id: 3, parentId: 1, position: 1 },
+ ],
+ });
+ expectMoved(plan, 4, 1);
+ });
+
+ it("renumbers both sibling lists when moving to another parent", () => {
+ const plan = move(3, null, 0);
+
+ expect(plan).toEqual({
+ ok: true,
+ placements: [
+ { id: 3, parentId: null, position: 0 },
+ { id: 1, parentId: null, position: 1 },
+ { id: 2, parentId: null, position: 2 },
+ { id: 6, parentId: null, position: 3 },
+ { id: 4, parentId: 1, position: 0 },
+ ],
+ });
+ expectMoved(plan, 3, null);
+ });
+
+ it.each([
+ ["a leaf under a sibling", 4, 3, 1],
+ ["a subtree that just fits", 3, 2, 0],
+ ["a node to the end of the root", 5, null, 3],
+ ])("allows %s", (_label, id, parentId, position) => {
+ expectMoved(move(id, parentId, position), id, parentId);
+ });
+
+ it("returns no placements for a move to where it already is", () => {
+ expect(move(3, 1, 0)).toEqual({ ok: true, placements: [] });
+ });
+
+ it("appends regardless of position in alphabetical mode", () => {
+ const plan = move(3, null, 0, { mode: "alphabetical" });
+
+ expect(plan).toEqual({
+ ok: true,
+ placements: [
+ { id: 3, parentId: null, position: 3 },
+ { id: 4, parentId: 1, position: 0 },
+ ],
+ });
+ expectMoved(plan, 3, null);
+ });
+
+ it.each([
+ ["a missing container", 99, null, 0, {}, "notFound"],
+ ["a container under itself", 1, 1, 0, {}, "cycle"],
+ ["a container under its child", 1, 3, 0, {}, "cycle"],
+ ["a container under its grandchild", 1, 5, 0, {}, "cycle"],
+ ["a missing parent", 4, 99, 0, {}, "parentNotFound"],
+ ["a subtree that would end too deep", 3, 4, 0, {}, "maxDepth"],
+ ["a leaf under the deepest level", 4, 5, 0, {}, "maxDepth"],
+ [
+ "any parent when the hierarchy is disabled",
+ 6,
+ 2,
+ 0,
+ { hierarchy: false, maxDepth: 1 },
+ "maxDepth",
+ ],
+ ["a position past the end", 4, 2, 1, {}, "invalidPosition"],
+ ["a negative position", 4, 1, -1, {}, "invalidPosition"],
+ ["a position past the end among siblings", 4, 1, 2, {}, "invalidPosition"],
+ ] as const)(
+ "refuses %s",
+ (_label, id, parentId, position, overrides, code) => {
+ expect(move(id, parentId, position, overrides)).toEqual({
+ code,
+ ok: false,
+ });
+ },
+ );
+});
+
+describe("planContentContainerDelete", () => {
+ it("closes the gap among the siblings", () => {
+ const plan = planContentContainerDelete(rows, 2);
+
+ expect(plan).toEqual({
+ ok: true,
+ shifted: [{ id: 6, parentId: null, position: 1 }],
+ });
+ if (plan.ok) {
+ expectContiguousPositions(
+ apply(
+ rows.filter(current => current.id !== 2),
+ plan.shifted,
+ ),
+ );
+ }
+ });
+
+ it("shifts nothing for the last sibling", () => {
+ expect(planContentContainerDelete(rows, 4)).toEqual({
+ ok: true,
+ shifted: [],
+ });
+ });
+
+ it.each([
+ ["a container with children", 3, "hasChildren"],
+ ["a missing container", 99, "notFound"],
+ ] as const)("refuses %s", (_label, id, code) => {
+ expect(planContentContainerDelete(rows, id)).toEqual({ code, ok: false });
+ });
+});
diff --git a/packages/vitnode/src/content/containers/tree.ts b/packages/vitnode/src/content/containers/tree.ts
new file mode 100644
index 000000000..d03c6ef4a
--- /dev/null
+++ b/packages/vitnode/src/content/containers/tree.ts
@@ -0,0 +1,315 @@
+import type {
+ ContentContainerErrorCode,
+ ContentContainerOrderingMode,
+} from "./types";
+
+export interface ContentContainerTreeRow {
+ id: number;
+ name: string;
+ parentId: null | number;
+ position: number;
+}
+
+export type ContentContainerTreeNode = TRow & {
+ children: ContentContainerTreeNode[];
+};
+
+export interface ContentContainerPlacement {
+ id: number;
+ parentId: null | number;
+ position: number;
+}
+
+export type ContentContainerPlan =
+ | (TValue & { ok: true })
+ | { code: ContentContainerErrorCode; ok: false };
+
+const compareSiblings =
+ (mode: ContentContainerOrderingMode) =>
+ (a: ContentContainerTreeRow, b: ContentContainerTreeRow): number => {
+ if (mode === "alphabetical") {
+ const byName = a.name.localeCompare(b.name, undefined, {
+ sensitivity: "base",
+ });
+ if (byName !== 0) return byName;
+ } else if (a.position !== b.position) {
+ return a.position - b.position;
+ }
+
+ return a.id - b.id;
+ };
+
+const childrenByParent = (
+ rows: readonly TRow[],
+ mode: ContentContainerOrderingMode,
+): Map => {
+ const known = new Set(rows.map(row => row.id));
+ const byParent = new Map();
+
+ for (const row of rows) {
+ const parent =
+ row.parentId !== null && known.has(row.parentId) ? row.parentId : null;
+ const siblings = byParent.get(parent) ?? [];
+ siblings.push(row);
+ byParent.set(parent, siblings);
+ }
+
+ const compare = compareSiblings(mode);
+ for (const siblings of byParent.values()) siblings.sort(compare);
+
+ return byParent;
+};
+
+export const orderContentContainers = (
+ rows: readonly TRow[],
+ mode: ContentContainerOrderingMode = "manual",
+): (TRow & { depth: number })[] => {
+ const byParent = childrenByParent(rows, mode);
+ const ordered: (TRow & { depth: number })[] = [];
+ const visited = new Set();
+
+ const walk = (parent: null | number, depth: number): void => {
+ for (const row of byParent.get(parent) ?? []) {
+ if (visited.has(row.id)) continue;
+ visited.add(row.id);
+ ordered.push({ ...row, depth });
+ walk(row.id, depth + 1);
+ }
+ };
+ walk(null, 1);
+
+ return ordered;
+};
+
+export const contentContainerTree = <
+ TRow extends { id: number; parentId: null | number },
+>(
+ rows: readonly TRow[],
+): ContentContainerTreeNode[] => {
+ const nodes = new Map>();
+ const roots: ContentContainerTreeNode[] = [];
+
+ for (const row of rows) nodes.set(row.id, { ...row, children: [] });
+
+ for (const node of nodes.values()) {
+ const parent =
+ node.parentId === null ? undefined : nodes.get(node.parentId);
+ if (parent) parent.children.push(node);
+ else roots.push(node);
+ }
+
+ return roots;
+};
+
+export const contentContainerAncestors = (
+ rows: readonly TRow[],
+ id: number,
+): TRow[] => {
+ const byId = new Map(rows.map(row => [row.id, row]));
+ const ancestors: TRow[] = [];
+ const seen = new Set([id]);
+ let parentId = byId.get(id)?.parentId ?? null;
+
+ while (parentId !== null && !seen.has(parentId)) {
+ const parent = byId.get(parentId);
+ if (!parent) break;
+ seen.add(parentId);
+ ancestors.unshift(parent);
+ parentId = parent.parentId;
+ }
+
+ return ancestors;
+};
+
+const childIdsByParent = (
+ rows: readonly ContentContainerTreeRow[],
+): Map => {
+ const children = new Map();
+ for (const row of rows) {
+ if (row.parentId === null) continue;
+ children.set(row.parentId, [...(children.get(row.parentId) ?? []), row.id]);
+ }
+
+ return children;
+};
+
+export const contentContainerSubtreeIds = (
+ rows: readonly ContentContainerTreeRow[],
+ id: number,
+): Set => {
+ const children = childIdsByParent(rows);
+ const subtree = new Set();
+ const stack = [id];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ if (current === undefined || subtree.has(current)) continue;
+ subtree.add(current);
+ stack.push(...(children.get(current) ?? []));
+ }
+
+ return subtree;
+};
+
+export const contentContainerDepth = (
+ rows: readonly ContentContainerTreeRow[],
+ id: number,
+): number => contentContainerAncestors(rows, id).length + 1;
+
+const subtreeHeight = (
+ rows: readonly ContentContainerTreeRow[],
+ id: number,
+): number => {
+ const children = childIdsByParent(rows);
+ const height = (current: number, seen: Set): number => {
+ if (seen.has(current)) return 0;
+ const next = new Set(seen).add(current);
+
+ return (
+ 1 +
+ Math.max(
+ 0,
+ ...(children.get(current) ?? []).map(child => height(child, next)),
+ )
+ );
+ };
+
+ return height(id, new Set());
+};
+
+const renumber = (
+ siblings: readonly ContentContainerTreeRow[],
+ parentId: null | number,
+): ContentContainerPlacement[] =>
+ siblings.flatMap((row, position) =>
+ row.position === position && row.parentId === parentId
+ ? []
+ : [{ id: row.id, parentId, position }],
+ );
+
+const siblingsByPosition = (
+ rows: readonly ContentContainerTreeRow[],
+ parentId: null | number,
+): ContentContainerTreeRow[] =>
+ rows.filter(row => row.parentId === parentId).sort(compareSiblings("manual"));
+
+const checkParent = (
+ rows: readonly ContentContainerTreeRow[],
+ parentId: null | number,
+ { hierarchy, maxDepth }: { hierarchy: boolean; maxDepth: number },
+ subtree = 1,
+): ContentContainerErrorCode | null => {
+ if (parentId === null) return subtree > maxDepth ? "maxDepth" : null;
+ if (!hierarchy) return "maxDepth";
+ if (!rows.some(row => row.id === parentId)) return "parentNotFound";
+
+ return contentContainerDepth(rows, parentId) + subtree > maxDepth
+ ? "maxDepth"
+ : null;
+};
+
+export interface ContentContainerTreeRules {
+ hierarchy: boolean;
+ maxDepth: number;
+ mode: ContentContainerOrderingMode;
+}
+
+export const planContentContainerCreate = (
+ rows: readonly ContentContainerTreeRow[],
+ {
+ parentId,
+ position,
+ }: { parentId: null | number; position: number | undefined },
+ rules: ContentContainerTreeRules,
+): ContentContainerPlan<{
+ position: number;
+ shifted: ContentContainerPlacement[];
+}> => {
+ const problem = checkParent(rows, parentId, rules);
+ if (problem) return { code: problem, ok: false };
+
+ const siblings = siblingsByPosition(rows, parentId);
+ const at =
+ rules.mode === "alphabetical" || position === undefined
+ ? siblings.length
+ : position;
+ if (!Number.isInteger(at) || at < 0 || at > siblings.length) {
+ return { code: "invalidPosition", ok: false };
+ }
+
+ const shifted = siblings.flatMap((row, index) => {
+ const next = index < at ? index : index + 1;
+
+ return row.position === next
+ ? []
+ : [{ id: row.id, parentId, position: next }];
+ });
+
+ return { ok: true, position: at, shifted };
+};
+
+export const planContentContainerMove = (
+ rows: readonly ContentContainerTreeRow[],
+ {
+ id,
+ parentId,
+ position,
+ }: { id: number; parentId: null | number; position: number },
+ rules: ContentContainerTreeRules,
+): ContentContainerPlan<{ placements: ContentContainerPlacement[] }> => {
+ const node = rows.find(row => row.id === id);
+ if (!node) return { code: "notFound", ok: false };
+
+ if (parentId !== null) {
+ if (parentId === id) return { code: "cycle", ok: false };
+ if (!rows.some(row => row.id === parentId)) {
+ return { code: "parentNotFound", ok: false };
+ }
+ if (contentContainerSubtreeIds(rows, id).has(parentId)) {
+ return { code: "cycle", ok: false };
+ }
+ }
+
+ const problem = checkParent(rows, parentId, rules, subtreeHeight(rows, id));
+ if (problem) return { code: problem, ok: false };
+
+ const remaining = rows.filter(row => row.id !== id);
+ const target = siblingsByPosition(remaining, parentId);
+ const at = rules.mode === "alphabetical" ? target.length : position;
+ if (!Number.isInteger(at) || at < 0 || at > target.length) {
+ return { code: "invalidPosition", ok: false };
+ }
+
+ const placed = [...target];
+ placed.splice(at, 0, node);
+
+ const placements = renumber(placed, parentId);
+ if (node.parentId !== parentId) {
+ placements.push(
+ ...renumber(siblingsByPosition(remaining, node.parentId), node.parentId),
+ );
+ }
+
+ return { ok: true, placements };
+};
+
+export const planContentContainerDelete = (
+ rows: readonly ContentContainerTreeRow[],
+ id: number,
+): ContentContainerPlan<{ shifted: ContentContainerPlacement[] }> => {
+ const node = rows.find(row => row.id === id);
+ if (!node) return { code: "notFound", ok: false };
+ if (rows.some(row => row.parentId === id)) {
+ return { code: "hasChildren", ok: false };
+ }
+
+ return {
+ ok: true,
+ shifted: renumber(
+ siblingsByPosition(
+ rows.filter(row => row.id !== id),
+ node.parentId,
+ ),
+ node.parentId,
+ ),
+ };
+};
diff --git a/packages/vitnode/src/content/containers/types.ts b/packages/vitnode/src/content/containers/types.ts
new file mode 100644
index 000000000..c55d97f4d
--- /dev/null
+++ b/packages/vitnode/src/content/containers/types.ts
@@ -0,0 +1,183 @@
+import type { z } from "zod";
+
+import type {
+ ContentBooleanField,
+ ContentEnumField,
+ ContentFieldValue,
+ ContentNumberField,
+ ContentTextareaField,
+ ContentTextField,
+ ContentValuesOf,
+ Prettify,
+} from "../types";
+import type {
+ CONTENT_CONTAINER_ERROR_CODES,
+ CONTENT_CONTAINER_ORDERING_MODES,
+} from "./const";
+
+export type ContentContainerFieldDescriptor =
+ | ContentBooleanField
+ | ContentEnumField
+ | ContentNumberField
+ | ContentTextareaField
+ | ContentTextField;
+
+export type ContentContainerFieldMap = Record<
+ string,
+ ContentContainerFieldDescriptor
+>;
+
+export type ContentContainerOrderingMode =
+ (typeof CONTENT_CONTAINER_ORDERING_MODES)[number];
+
+export type ContentContainerErrorCode =
+ (typeof CONTENT_CONTAINER_ERROR_CODES)[number];
+
+export interface ContentContainerHierarchyInput {
+ enabled?: boolean;
+ maxDepth?: number;
+}
+
+export interface ContentContainerOrderingInput {
+ mode?: ContentContainerOrderingMode;
+}
+
+export interface ContentContainerPublicApiInput<
+ TField extends string = string,
+ TPath extends string = string,
+> {
+ fields?: readonly TField[];
+ path: TPath;
+}
+
+export interface ContentContainerAdminInput {
+ navigation?: boolean;
+ path?: string;
+ permissionModule?: string;
+}
+
+export interface ResolvedContentContainerPublicApi<
+ TField extends string = string,
+ TEnabled extends boolean = boolean,
+ TPath extends string = string,
+> {
+ enabled: TEnabled;
+ fields: TField[];
+ path: TPath;
+}
+
+export interface ContentContainerSchemas {
+ create: z.ZodObject;
+ metadata: z.ZodObject;
+ metadataPatch: z.ZodObject;
+ move: z.ZodObject;
+ publicRecord: z.ZodObject;
+ record: z.ZodObject;
+ update: z.ZodObject;
+}
+
+export interface ContentContainerDefinition<
+ TId extends string = string,
+ TFields extends ContentContainerFieldMap = ContentContainerFieldMap,
+ TPublicField extends string = string,
+ TPublicEnabled extends boolean = boolean,
+ TPublicPath extends string = string,
+> {
+ admin: {
+ navigation: { enabled: boolean };
+ path: string;
+ permissionModule: string;
+ };
+ fields: TFields;
+ hierarchy: { enabled: boolean; maxDepth: number };
+ id: TId;
+ kind: "contentContainer";
+ ordering: { mode: ContentContainerOrderingMode };
+ publicApi: ResolvedContentContainerPublicApi<
+ TPublicField,
+ TPublicEnabled,
+ TPublicPath
+ >;
+ schemas: ContentContainerSchemas;
+}
+
+export type AnyContentContainerDefinition = ContentContainerDefinition;
+
+type FieldsOf = TDefinition extends {
+ fields: infer TFields extends ContentContainerFieldMap;
+}
+ ? TFields
+ : ContentContainerFieldMap;
+
+type PublicFieldsOf = TDefinition extends {
+ publicApi: { fields: (infer TField extends string)[] };
+}
+ ? TField
+ : never;
+
+export type ContentContainerMetadata = Prettify<{
+ [K in keyof FieldsOf]: ContentFieldValue<
+ FieldsOf[K]
+ > | null;
+}>;
+
+export type ContentContainerMetadataInput = ContentValuesOf<
+ FieldsOf
+>;
+
+export type ContentContainerPublicMetadata = Prettify<
+ Pick<
+ ContentContainerMetadata,
+ keyof ContentContainerMetadata & PublicFieldsOf
+ >
+>;
+
+interface ContentContainerColumns {
+ depth: number;
+ id: number;
+ name: string;
+ parentId: null | number;
+ position: number;
+ slug: string;
+}
+
+export type ContentContainerRecord<
+ TDefinition = AnyContentContainerDefinition,
+> = Prettify<
+ ContentContainerColumns & {
+ createdAt: Date;
+ metadata: ContentContainerMetadata;
+ updatedAt: Date;
+ }
+>;
+
+export type ContentContainerPublicRecord<
+ TDefinition = AnyContentContainerDefinition,
+> = Prettify<
+ ContentContainerColumns & {
+ metadata: ContentContainerPublicMetadata;
+ }
+>;
+
+export interface ContentContainerCreateInput<
+ TDefinition = AnyContentContainerDefinition,
+> {
+ metadata?: ContentContainerMetadataInput;
+ name: string;
+ parentId?: null | number;
+ position?: number;
+ slug?: string;
+}
+
+export interface ContentContainerUpdateInput<
+ TDefinition = AnyContentContainerDefinition,
+> {
+ metadata?: Partial>;
+ name?: string;
+ slug?: string;
+}
+
+export interface ContentContainerMoveInput {
+ parentId: null | number;
+ position: number;
+}
diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts
index 8fd3841b6..2c93ba5b2 100644
--- a/packages/vitnode/src/content/define-fields.ts
+++ b/packages/vitnode/src/content/define-fields.ts
@@ -1,5 +1,6 @@
import type {
AnyContentTypeDefinition,
+ ContentContainerField,
ContentFieldDescriptor,
ContentFieldMap,
ContentFileField,
@@ -12,6 +13,7 @@ import {
} from "../blocks/const";
import { parseBlockId } from "../blocks/namespace";
import { CONTENT_FIELD_NAME_PATTERN } from "./const";
+import { isContentContainerDefinition } from "./containers/guard";
import {
editorialFields,
publicationFields,
@@ -55,6 +57,7 @@ const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => {
// the empty set is its default, and it has no column to default at all.
if (fieldValue.kind === "file") return fieldValue.multiple;
if (fieldValue.kind === "richText") return false;
+ if (fieldValue.kind === "container") return fieldValue.nullable;
return fieldValue.defaultValue !== undefined;
};
@@ -112,6 +115,7 @@ export const assertFieldName = (
const FIELD_KINDS = new Set([
"blocks",
"boolean",
+ "container",
"dateTime",
"enum",
"file",
@@ -211,6 +215,48 @@ const assertFileField = (
}
};
+const assertContainerField = (
+ id: string,
+ name: string,
+ fieldValue: ContentContainerField,
+): void => {
+ if (!isContentContainerDefinition(fieldValue.definition)) {
+ throw new ContentEngineError(
+ `Container field "${name}" has no container definition. Pass the object \`defineContentContainer\` returned: \`field.container({ definition: forumBoards })\`.`,
+ { contentTypeId: id },
+ );
+ }
+
+ if ((fieldValue.localized as boolean | undefined) === true) {
+ throw new ContentEngineError(
+ `Container field "${name}" is \`localized: true\`. A record sits in the same container in every language.`,
+ { contentTypeId: id },
+ );
+ }
+
+ if (fieldValue.nullable === fieldValue.required) {
+ throw new ContentEngineError(
+ `Container field "${name}" is ${fieldValue.required ? "required but nullable" : "optional but not nullable"}. Build it with \`field.container()\`, which keeps the two in step.`,
+ { contentTypeId: id },
+ );
+ }
+};
+
+export const assertSingleContainerField = (
+ id: string,
+ fields: ContentFieldMap,
+): void => {
+ const containers = Object.entries(fields).filter(
+ ([, fieldValue]) => fieldValue.kind === "container",
+ );
+ if (containers.length <= 1) return;
+
+ throw new ContentEngineError(
+ `Content type declares ${containers.length} container fields (${containers.map(([name]) => name).join(", ")}). A record belongs to one container - keep one, and use a relation for anything else it points at.`,
+ { contentTypeId: id },
+ );
+};
+
export const assertField = (
id: string,
name: string,
@@ -222,6 +268,10 @@ export const assertField = (
assertFileField(id, name, fieldValue);
}
+ if (fieldValue.kind === "container") {
+ assertContainerField(id, name, fieldValue);
+ }
+
if (!fieldValue.required && !fieldValue.nullable) {
if (!hasWritableFallback(fieldValue)) {
throw new ContentEngineError(
diff --git a/packages/vitnode/src/content/define-public-write.test.ts b/packages/vitnode/src/content/define-public-write.test.ts
index a2a9a173a..04d43adfe 100644
--- a/packages/vitnode/src/content/define-public-write.test.ts
+++ b/packages/vitnode/src/content/define-public-write.test.ts
@@ -6,10 +6,13 @@ import {
testPostContentType,
} from "@/tests/content-fixtures";
+import { defineContentContainer } from "./containers/define";
import { defineContentType } from "./define";
import { ContentEngineError } from "./errors";
import { field } from "./fields";
+const boards = defineContentContainer({ id: "test.boards" });
+
const fields = {
title: field.text({ required: true, maxLength: 200 }),
slug: field.slug({ source: "title" }),
@@ -42,6 +45,7 @@ const fields = {
fields: { question: field.text({ required: true }) },
}),
content: field.blocks(),
+ board: field.container({ definition: boards }),
};
const ownership = { field: "author" };
@@ -185,6 +189,11 @@ describe("publicWrite configuration", () => {
{ ownership, update: { fields: [name] } },
new RegExp(`"${name}" of kind "${kind}", which cannot be written`),
]),
+ [
+ "the container field in update",
+ { ownership, update: { fields: ["title", "board"] } },
+ /update.fields includes the container field "board"/,
+ ],
[
"a slug derived from another field",
{ ownership, update: { fields: ["slug"] } },
@@ -270,6 +279,27 @@ describe("publicWrite configuration", () => {
},
);
+ it("lets a member pick the container on create", () => {
+ const definition = define({
+ publicApi: {
+ path: "threads",
+ fields: ["title", "slug", "board"],
+ filterableFields: ["board"],
+ },
+ publicWrite: {
+ ownership,
+ create: { fields: ["title", "handle", "board"] },
+ },
+ });
+
+ expect(definition.publicWrite.create.fields).toEqual([
+ "title",
+ "handle",
+ "board",
+ ]);
+ expect(definition.publicApi.filterableFields).toEqual(["board"]);
+ });
+
it("needs a public API", () => {
expect(() =>
define({
diff --git a/packages/vitnode/src/content/define-public-write.ts b/packages/vitnode/src/content/define-public-write.ts
index 1e58d05cd..b0c5ed3fd 100644
--- a/packages/vitnode/src/content/define-public-write.ts
+++ b/packages/vitnode/src/content/define-public-write.ts
@@ -385,6 +385,17 @@ export const resolvePublicWrite = (
own?: unknown;
};
const list = listOf(id, "publicWrite.update.fields", names);
+ const container = list.find(
+ name =>
+ (fields[name] as ContentFieldDescriptor | undefined)?.kind ===
+ "container",
+ );
+ if (container !== undefined) {
+ throw new ContentEngineError(
+ `publicWrite.update.fields includes the container field "${container}". A member picks a container when they create a record; moving it afterwards is a staff edit (\`can_edit\` in the AdminCP), so an author cannot drop their topic into a board they could not post in.`,
+ { contentTypeId: id },
+ );
+ }
for (const name of list) {
assertWritableField(
id,
diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts
index 4fdac5df0..9418ab978 100644
--- a/packages/vitnode/src/content/define.test.ts
+++ b/packages/vitnode/src/content/define.test.ts
@@ -10,6 +10,7 @@ import {
CONTENT_REVISION_MAX_RETENTION,
CONTENT_REVISION_MIN_RETENTION,
} from "./const";
+import { defineContentContainer } from "./containers/define";
import { defineContentType } from "./define";
import { ContentEngineError } from "./errors";
import { field } from "./fields";
@@ -106,6 +107,73 @@ describe("defineContentType", () => {
});
});
+ describe("container fields", () => {
+ const boards = defineContentContainer({ id: "test.boards" });
+
+ it.each([true, false])("accepts a container with required %s", required => {
+ expect(() =>
+ define({
+ fields: {
+ title: field.text({ required: true }),
+ board: field.container({ definition: boards, required }),
+ },
+ }),
+ ).not.toThrow();
+ });
+
+ it.each([
+ [
+ "a definition that is not a container collection",
+ { board: field.container({ definition: {} as typeof boards }) },
+ /Container field "board" has no container definition/,
+ ],
+ [
+ "a localized container",
+ {
+ board: {
+ ...field.container({ definition: boards }),
+ localized: true,
+ },
+ },
+ /Container field "board" is `localized: true`/,
+ ],
+ [
+ "an optional container that is not nullable",
+ {
+ board: {
+ ...field.container({ definition: boards }),
+ nullable: false,
+ },
+ },
+ /"board" is optional but not nullable/,
+ ],
+ [
+ "a required container that is nullable",
+ {
+ board: {
+ ...field.container({ definition: boards, required: true }),
+ nullable: true,
+ },
+ },
+ /"board" is required but nullable/,
+ ],
+ [
+ "two container fields",
+ {
+ board: field.container({ definition: boards }),
+ section: field.container({ definition: boards }),
+ },
+ /declares 2 container fields \(board, section\)/,
+ ],
+ ])("rejects %s", (_label, fields, message) => {
+ expect(() =>
+ define({
+ fields: { title: field.text({ required: true }), ...fields },
+ }),
+ ).toThrow(message);
+ });
+ });
+
// `ON DELETE SET NULL` on a NOT NULL column is accepted by Postgres at
// CREATE TABLE time and only blows up years later, when someone finally
// deletes a referenced row.
diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts
index e55a9e88e..26f3d38ca 100644
--- a/packages/vitnode/src/content/define.ts
+++ b/packages/vitnode/src/content/define.ts
@@ -59,6 +59,7 @@ import {
assertFieldAiAssist,
assertFieldKind,
assertFieldName,
+ assertSingleContainerField,
assertSlugSources,
bindSelfRelations,
} from "./define-fields";
@@ -396,6 +397,7 @@ export const defineContentType = <
}
assertSlugSources(id, fieldMap);
+ assertSingleContainerField(id, fieldMap);
assertFieldAiAssist(id, fieldMap);
// First, because every resolver below is stated in terms of what it produces:
diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts
index 107c8bea7..08111c440 100644
--- a/packages/vitnode/src/content/fields.ts
+++ b/packages/vitnode/src/content/fields.ts
@@ -1,8 +1,10 @@
import type { BlockAllowedSpec } from "../blocks/types";
+import type { AnyContentContainerDefinition } from "./containers/types";
import type {
AnyContentTypeDefinition,
ContentBlocksField,
ContentBooleanField,
+ ContentContainerField,
ContentDateTimeField,
ContentEnumField,
ContentFieldAiAssist,
@@ -378,9 +380,28 @@ const blocks = <
required: false,
});
+const container = <
+ const TDefinition extends AnyContentContainerDefinition,
+ TRequired extends boolean = false,
+>(args: {
+ definition: TDefinition;
+ description?: string;
+ required?: TRequired;
+}): ContentContainerField => {
+ const required = (args.required ?? false) as TRequired;
+
+ return {
+ ...args,
+ kind: "container",
+ nullable: !required as TRequired extends true ? false : true,
+ required,
+ };
+};
+
export const field = {
blocks,
boolean,
+ container,
dateTime,
enum: enumField,
file,
diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts
index 17383a8ed..abd72d591 100644
--- a/packages/vitnode/src/content/index.ts
+++ b/packages/vitnode/src/content/index.ts
@@ -1,3 +1,11 @@
+export {
+ contentContainerAdminHref,
+ contentContainerI18nKeys,
+ contentContainerLabelsFrom,
+ contentContainerNamespace,
+ contentContainerNouns,
+} from "./admin/containers";
+export type { ContentContainerLabels } from "./admin/containers";
export {
contentEntityKey,
contentI18nKeys,
@@ -14,7 +22,9 @@ export {
setContentFrontendRegistry,
} from "./admin/registry";
export type {
+ ContentContainerFrontendRegistry,
ContentFrontendRegistry,
+ RegisteredFrontendContentContainer,
RegisteredFrontendContentType,
} from "./admin/registry";
@@ -51,6 +61,7 @@ export {
} from "./admin/upload";
export type { ContentUploadRejection } from "./admin/upload";
export {
+ contentContainerTag,
contentDeliveryRedirectTag,
contentDeliverySitemapTag,
contentDeliveryTag,
@@ -182,6 +193,58 @@ export {
isLocalizableFieldKind,
RESERVED_FILTER_KEYS,
} from "./const";
+export {
+ ContentContainerAssignmentError,
+ contentContainerFieldOf,
+} from "./containers/association";
+export type { ContentContainerFieldEntry } from "./containers/association";
+export {
+ CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH,
+ CONTENT_CONTAINER_ERROR_CODES,
+ CONTENT_CONTAINER_FIELD_KINDS,
+} from "./containers/const";
+export {
+ defineContentContainer,
+ isContentContainerDefinition,
+} from "./containers/define";
+export { ContentContainerError } from "./containers/errors";
+export {
+ contentContainerPermissionEntries,
+ validateContentContainers,
+} from "./containers/registry";
+export type { RegisteredContentContainer } from "./containers/registry";
+export {
+ contentContainerAncestors,
+ contentContainerSubtreeIds,
+ contentContainerTree,
+ orderContentContainers,
+ planContentContainerCreate,
+ planContentContainerDelete,
+ planContentContainerMove,
+} from "./containers/tree";
+export type {
+ ContentContainerPlacement,
+ ContentContainerPlan,
+ ContentContainerTreeNode,
+ ContentContainerTreeRow,
+ ContentContainerTreeRules,
+} from "./containers/tree";
+export type {
+ AnyContentContainerDefinition,
+ ContentContainerCreateInput,
+ ContentContainerDefinition,
+ ContentContainerErrorCode,
+ ContentContainerFieldDescriptor,
+ ContentContainerFieldMap,
+ ContentContainerMetadata,
+ ContentContainerMetadataInput,
+ ContentContainerMoveInput,
+ ContentContainerOrderingMode,
+ ContentContainerPublicMetadata,
+ ContentContainerPublicRecord,
+ ContentContainerRecord,
+ ContentContainerUpdateInput,
+} from "./containers/types";
export { defineContentType } from "./define";
export {
contentDeliveryAlternateHref,
@@ -551,6 +614,7 @@ export type {
SchedulableContentTypeDefinition,
SearchableContentTypeDefinition,
} from "./types";
+export type { ContentContainerField } from "./types";
export { contentVisibilityTransition, isContentHidden } from "./visibility";
export type {
ContentVisibilityAction,
diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts
index eef7a7197..9f9ef3ff6 100644
--- a/packages/vitnode/src/content/indexes.ts
+++ b/packages/vitnode/src/content/indexes.ts
@@ -248,6 +248,13 @@ export const resolveContentIndexes = ({
fieldValue.kind === "user",
)
.map(([name]) => named(tableName, { on: [name] })),
+ ...fieldEntries
+ .filter(([, fieldValue]) => fieldValue.kind === "container")
+ .map(([name]) =>
+ named(tableName, {
+ on: publication ? [name, "publishedAt"] : [name],
+ }),
+ ),
...CONTENT_SYSTEM_FIELDS.filter(name => name !== "id").map(name =>
named(tableName, { on: [name] }),
),
diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts
index 37373fcb5..cb2857bac 100644
--- a/packages/vitnode/src/content/schemas.ts
+++ b/packages/vitnode/src/content/schemas.ts
@@ -261,6 +261,8 @@ const baseSelectSchema = (
return blocksSelectSchema();
case "boolean":
return z.boolean();
+ case "container":
+ return referenceSchema();
case "dateTime":
return z.date();
case "enum":
@@ -356,6 +358,7 @@ const applyPresence = (
if (
fieldValue.kind !== "blocks" &&
+ fieldValue.kind !== "container" &&
fieldValue.kind !== "dateTime" &&
// A file field has no default and cannot have one: a `core_files.id` in a
// definition would name a different row on every installation.
@@ -535,6 +538,15 @@ const updateShape = (
}),
);
+export const contentFieldPatchObject = (
+ fields: ContentFieldMap,
+): z.ZodObject =>
+ z.strictObject(updateShape(fields, Object.keys(fields)));
+
+export const contentFieldSelectSchema = (
+ fieldValue: ContentFieldDescriptor,
+): z.ZodType => applyNullable(baseSelectSchema(fieldValue), fieldValue);
+
export const contentFieldUpdateSchema = (
fields: ContentFieldMap,
name: string,
@@ -576,6 +588,8 @@ const filterShape = (
switch (fieldValue.kind) {
case "boolean":
return [name, z.enum(["true", "false"]).optional()];
+ case "container":
+ return [name, z.coerce.number().int().positive().optional()];
case "enum":
return [name, z.enum(fieldValue.values).optional()];
case "number":
diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts
index 1aa3fd778..7c5bfd7b5 100644
--- a/packages/vitnode/src/content/server/column-builders.ts
+++ b/packages/vitnode/src/content/server/column-builders.ts
@@ -190,6 +190,21 @@ export const buildContentColumn = ({
defaultValue: fieldValue.defaultValue,
nullable,
});
+ case "container": {
+ if (!reference) {
+ throw new ContentEngineError(
+ `Field "${name}" is a container but the \`core_content_containers\` column was not resolved. This is an internal error.`,
+ { contentTypeId },
+ );
+ }
+
+ const column = integer().references(reference, {
+ onDelete: "restrict",
+ onUpdate: "cascade",
+ });
+
+ return nullable ? column : column.notNull();
+ }
case "dateTime": {
const column = nullable ? timestamp() : timestamp().notNull();
diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts
index ddb9f04f8..3d6b32a7b 100644
--- a/packages/vitnode/src/content/server/editorial-service.ts
+++ b/packages/vitnode/src/content/server/editorial-service.ts
@@ -53,6 +53,8 @@ import {
CONTENT_SYSTEM_FIELDS,
CONTENT_VISIBILITY_FIELDS,
} from "../const";
+import { ContentContainerAssignmentError } from "../containers/association";
+import { resolveContentContainerAssignment } from "../containers/server/assignment";
import {
ContentEngineError,
ContentRevisionNotRestorable,
@@ -955,6 +957,7 @@ export const createContentEditorialService = <
// assignment, and an upload that succeeded for one field proves nothing
// about another.
await assertContentFileReferences(c, definition, parsed, tx);
+ await resolveContentContainerAssignment(tx, definition, parsed);
const [row] = await tx
.insert(table)
@@ -1156,8 +1159,14 @@ export const createContentEditorialService = <
await assertContentFileReferences(c, definition, patch, tx, {
existing: async () => Promise.resolve(patch),
});
+ await resolveContentContainerAssignment(tx, definition, patch);
} catch (error) {
- if (!(error instanceof ContentFileReferenceError)) throw error;
+ if (
+ !(error instanceof ContentFileReferenceError) &&
+ !(error instanceof ContentContainerAssignmentError)
+ ) {
+ throw error;
+ }
throw new ContentRevisionNotRestorable({
contentTypeId,
@@ -1302,6 +1311,7 @@ export const createContentEditorialService = <
...(await store?.load(id, tx, galleryFieldNames)),
}),
});
+ await resolveContentContainerAssignment(tx, definition, patch);
// The version guard runs **first**, before a single junction or child
// row is touched. That ordering is the whole concurrency story: a writer
diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts
index 7ff38a342..42f3339f6 100644
--- a/packages/vitnode/src/content/server/http-errors.ts
+++ b/packages/vitnode/src/content/server/http-errors.ts
@@ -17,6 +17,7 @@ import {
CONTENT_DUPLICATE_CODES,
CONTENT_UNPROCESSABLE_CODES,
} from "../const";
+import { ContentContainerAssignmentError } from "../containers/association";
import {
ContentDeliverySlugReserved,
ContentDuplicateSlugConflict,
@@ -163,6 +164,14 @@ export const rethrowAsHttpError = (
});
}
+ if (error instanceof ContentContainerAssignmentError) {
+ throw contentFileRejected({
+ code: error.code,
+ field: error.field,
+ message: error.detail,
+ });
+ }
+
// Written for the client on purpose - "provide the slug explicitly" is
// useless if it never leaves the server.
if (error instanceof ContentInputError) {
diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts
index 3f771388f..3613c58d2 100644
--- a/packages/vitnode/src/content/server/index.ts
+++ b/packages/vitnode/src/content/server/index.ts
@@ -1,4 +1,20 @@
export { escapeLikePattern } from "../../lib/api/like-pattern";
+export {
+ buildContentContainerAdminRoutes,
+ zodContentContainerError,
+} from "../containers/server/admin-routes";
+export { resolveContentContainerAssignment } from "../containers/server/assignment";
+export type { ResolvedContentContainerContext } from "../containers/server/assignment";
+export { buildContentContainerPublicRoutes } from "../containers/server/public-routes";
+export {
+ contentContainerReferencingModels,
+ contentContainerUsageFrom,
+ createContentContainerService,
+} from "../containers/server/service";
+export type {
+ ContentContainerService,
+ ContentContainerUsageCheck,
+} from "../containers/server/service";
export { CONTENT_SYSTEM_ACTOR, resolveContentActor } from "./actor";
export {
buildContentColumn,
diff --git a/packages/vitnode/src/content/server/module.ts b/packages/vitnode/src/content/server/module.ts
index d99b67c1a..a23c0d95a 100644
--- a/packages/vitnode/src/content/server/module.ts
+++ b/packages/vitnode/src/content/server/module.ts
@@ -1,7 +1,9 @@
import type { BuildModuleReturn } from "../../api/lib/module";
+import type { AnyContentContainerDefinition } from "../containers/types";
import type { AnyContentModel } from "./model";
import { buildModule } from "../../api/lib/module";
+import { buildContentContainerAdminRoutes } from "../containers/server/admin-routes";
import { buildContentRoutes } from "./routes";
import {
createContentLocalizedSearchIndexer,
@@ -10,9 +12,11 @@ import {
import { assertContentReferences } from "./table";
export const buildContentAdminModule = ({
+ containers = [],
contentTypes,
pluginId,
}: {
+ containers?: AnyContentContainerDefinition[];
contentTypes: AnyContentModel[];
pluginId: P;
}): BuildModuleReturn, "content"> => {
@@ -29,11 +33,20 @@ export const buildContentAdminModule = ({
});
});
+ const containerModules = containers.map(definition =>
+ buildModule({
+ pluginId,
+ name: definition.admin.permissionModule,
+ routes: buildContentContainerAdminRoutes(definition, { pluginId }),
+ }),
+ );
+
return buildModule({
pluginId,
name: "content",
routes: [],
- modules,
+ modules: [...modules, ...containerModules],
+ contentContainers: containers,
contentTypes: contentTypes.map(model => model.definition),
// The models themselves, not just the definitions. Background work - the
// scheduled-publication task - needs the table and the editorial service,
diff --git a/packages/vitnode/src/content/server/public-module.ts b/packages/vitnode/src/content/server/public-module.ts
index 84c8f4a4d..0bede64a3 100644
--- a/packages/vitnode/src/content/server/public-module.ts
+++ b/packages/vitnode/src/content/server/public-module.ts
@@ -1,8 +1,10 @@
import type { BuildModuleReturn } from "../../api/lib/module";
+import type { AnyContentContainerDefinition } from "../containers/types";
import type { AnyContentTypeDefinition } from "../types";
import type { AnyContentModel } from "./model";
import { buildModule } from "../../api/lib/module";
+import { buildContentContainerPublicRoutes } from "../containers/server/public-routes";
import { buildContentPublicRoutes } from "./public-routes";
export type ContentPublicRoutes<
@@ -20,26 +22,50 @@ export type ContentPublicModuleOf
= Model extends {
: never
: never;
+export type ContentContainerPublicRoutes<
+ P extends string,
+ TDefinition extends AnyContentContainerDefinition,
+> = ReturnType>;
+
+export type ContentContainerPublicModuleOf<
+ P extends string,
+ TDefinition,
+> = TDefinition extends AnyContentContainerDefinition
+ ? TDefinition extends {
+ publicApi: { enabled: true; path: infer Path extends string };
+ }
+ ? BuildModuleReturn>
+ : never
+ : never;
+
export type ContentPublicModule<
P extends string,
Models extends readonly AnyContentModel[],
+ Containers extends readonly AnyContentContainerDefinition[] = readonly [],
> = BuildModuleReturn<
P,
"content",
[],
- ContentPublicModuleOf
[]
+ (
+ | ContentContainerPublicModuleOf
+ | ContentPublicModuleOf
+ )[]
>;
export const buildContentPublicModule = <
const P extends string,
const Models extends readonly AnyContentModel[],
+ const Containers extends readonly AnyContentContainerDefinition[] =
+ readonly [],
>({
+ containers = [] as unknown as Containers,
contentTypes,
pluginId,
}: {
+ containers?: Containers;
contentTypes: Models;
pluginId: P;
-}): ContentPublicModule, Models> => {
+}): ContentPublicModule, Models, Containers> => {
const modules = contentTypes
.filter(model => model.definition.publicApi.enabled)
.map(model =>
@@ -49,13 +75,23 @@ export const buildContentPublicModule = <
routes: buildContentPublicRoutes(model, { pluginId }),
}),
);
+ const containerModules = containers
+ .filter(definition => definition.publicApi.enabled)
+ .map(definition =>
+ buildModule({
+ pluginId,
+ name: definition.publicApi.path,
+ routes: buildContentContainerPublicRoutes(definition, { pluginId }),
+ }),
+ );
return buildModule({
pluginId,
name: "content",
routes: [],
- modules,
+ modules: [...modules, ...containerModules],
+ contentContainers: [...containers],
// No `contentTypes` - see the warning above.
publicContentTypes: contentTypes.map(model => model.definition),
- }) as ContentPublicModule, Models>;
+ }) as ContentPublicModule, Models, Containers>;
};
diff --git a/packages/vitnode/src/content/server/public-routes.test.ts b/packages/vitnode/src/content/server/public-routes.test.ts
index 3e3949a44..6d9f87fb2 100644
--- a/packages/vitnode/src/content/server/public-routes.test.ts
+++ b/packages/vitnode/src/content/server/public-routes.test.ts
@@ -1,10 +1,16 @@
+import type { Context } from "hono";
+
import { OpenAPIHono } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
import { describe, expect, it, vi } from "vitest";
+import { core_content_containers } from "@/database/content-containers";
import {
testCategoryContentType,
testPostContentType,
+ testThreadContentType,
} from "@/tests/content-fixtures";
+import { createMemoryDb } from "@/tests/memory-db";
import { createContentModel } from "./model";
import { buildContentPublicModule } from "./public-module";
@@ -153,6 +159,153 @@ describe("public list route", () => {
});
});
+describe("public list filtered by container", () => {
+ const threads = createContentModel(testThreadContentType);
+ const PUBLISHED = new Date("2026-08-01T09:00:00.000Z");
+
+ const BOARD = {
+ ELSEWHERE: 9,
+ GENERAL: 1,
+ MISSING: 99,
+ NEWS: 3,
+ OFF_TOPIC: 2,
+ RELEASES: 5,
+ } as const;
+ const THREAD_IN = {
+ [BOARD.GENERAL]: 11,
+ [BOARD.NEWS]: 12,
+ [BOARD.RELEASES]: 13,
+ [BOARD.OFF_TOPIC]: 14,
+ };
+
+ const board = (
+ id: number,
+ parentId: null | number,
+ definitionId: string = testThreadContentType.fields.board.definition.id,
+ ) => ({
+ createdAt: PUBLISHED,
+ definitionId,
+ id,
+ metadata: {},
+ name: `Board ${id}`,
+ parentId,
+ pluginId: PLUGIN_ID,
+ position: 0,
+ slug: `board-${id}`,
+ updatedAt: PUBLISHED,
+ });
+
+ const app = (() => {
+ const memory = createMemoryDb([
+ [
+ core_content_containers,
+ [
+ board(BOARD.GENERAL, null),
+ board(BOARD.NEWS, BOARD.GENERAL),
+ board(BOARD.RELEASES, BOARD.NEWS),
+ board(BOARD.OFF_TOPIC, null),
+ board(BOARD.ELSEWHERE, null, "test.sections"),
+ ],
+ ],
+ [
+ threads.table,
+ Object.entries(THREAD_IN).map(([boardId, id]) => ({
+ author: 1,
+ board: Number(boardId),
+ createdAt: PUBLISHED,
+ id,
+ publishedAt: PUBLISHED,
+ slug: `thread-${id}`,
+ status: "published",
+ title: `Thread ${id}`,
+ updatedAt: PUBLISHED,
+ })),
+ ],
+ ]);
+ const instance = new OpenAPIHono();
+ instance.use("*", async (c, next) => {
+ c.set("db", memory.db as unknown as Context["var"]["db"]);
+ await next();
+ });
+ instance.onError((error, c) =>
+ error instanceof HTTPException
+ ? error.getResponse()
+ : c.json({ error: String(error) }, 500),
+ );
+ for (const { handler, route } of buildContentPublicRoutes(threads, {
+ pluginId: PLUGIN_ID,
+ })) {
+ instance.openapi(route, handler);
+ }
+
+ return instance;
+ })();
+
+ const listed = async (query: string) => {
+ const response = await app.request(`/${query}`);
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as { edges: { id: number }[] };
+
+ return body.edges.map(edge => edge.id).sort((a, b) => a - b);
+ };
+
+ it.each([
+ ["one container", `?board=${BOARD.GENERAL}`, [THREAD_IN[BOARD.GENERAL]]],
+ [
+ "a container and its subtree",
+ `?board=${BOARD.GENERAL}&includeDescendants=true`,
+ [
+ THREAD_IN[BOARD.GENERAL],
+ THREAD_IN[BOARD.NEWS],
+ THREAD_IN[BOARD.RELEASES],
+ ],
+ ],
+ [
+ "a subtree further down",
+ `?board=${BOARD.NEWS}&includeDescendants=true`,
+ [THREAD_IN[BOARD.NEWS], THREAD_IN[BOARD.RELEASES]],
+ ],
+ [
+ "one container when includeDescendants is false",
+ `?board=${BOARD.NEWS}&includeDescendants=false`,
+ [THREAD_IN[BOARD.NEWS]],
+ ],
+ [
+ "everything when no container is named",
+ "?includeDescendants=true",
+ Object.values(THREAD_IN),
+ ],
+ ])("lists %s", async (_label, query, expected) => {
+ await expect(listed(query)).resolves.toEqual(
+ [...expected].sort((a, b) => a - b),
+ );
+ });
+
+ it.each([
+ ["does not exist", BOARD.MISSING],
+ ["belongs to another collection", BOARD.ELSEWHERE],
+ ])("is a 404 for a container that %s", async (_label, id) => {
+ expect((await app.request(`/?board=${id}`)).status).toBe(404);
+ expect(
+ (await app.request(`/?board=${id}&includeDescendants=true`)).status,
+ ).toBe(404);
+ });
+
+ it("gives a content type without containers no includeDescendants", async () => {
+ const { app: postsApp, service } = harness();
+ service.findMany.mockResolvedValue(emptyPage);
+
+ const response = await postsApp.request(
+ "/?includeDescendants=true&category=3",
+ );
+
+ expect(response.status).toBe(200);
+ expect(service.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ filters: { category: 3 } }),
+ );
+ });
+});
+
describe("public detail route", () => {
it("returns a published row", async () => {
const { app, service } = harness();
diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts
index 212a220c5..089f6ab8c 100644
--- a/packages/vitnode/src/content/server/public-routes.ts
+++ b/packages/vitnode/src/content/server/public-routes.ts
@@ -27,10 +27,13 @@ import {
zodPaginationQuery,
} from "../../api/lib/with-pagination";
import { contentTypeName } from "../admin/labels";
+import { contentContainerTag } from "../cache";
import {
CONTENT_LOCALE_MAX_LENGTH,
CONTENT_PUBLIC_MAX_PAGE_SIZE,
} from "../const";
+import { contentContainerFieldOf } from "../containers/association";
+import { createContentContainerService } from "../containers/server/service";
import { ContentEngineError } from "../errors";
import { parseContentId } from "../ids";
import { resolveContentPublicLocale } from "../locale";
@@ -103,7 +106,18 @@ export const buildContentPublicRoutes = <
orderBy: z.enum(orderable).optional(),
search: z.string().optional(),
});
- const listQuery = paginationQuery.extend(schemas.publicFilters.shape);
+ const containerField = contentContainerFieldOf(definition);
+ const containerFilter =
+ containerField &&
+ definition.publicApi.filterableFields.includes(containerField.name)
+ ? containerField
+ : null;
+ const listQuery = paginationQuery.extend({
+ ...schemas.publicFilters.shape,
+ ...(containerFilter
+ ? { includeDescendants: z.enum(["true", "false"]).optional() }
+ : {}),
+ });
const localeOnlyQuery = z.object(localeQuery);
const notFound = () =>
@@ -332,6 +346,10 @@ export const buildContentPublicRoutes = <
description: `Up to ${CONTENT_PUBLIC_MAX_PAGE_SIZE} published ${name} records`,
},
400: { description: "Invalid query parameters" },
+ 404: {
+ description:
+ "The requested locale is not served, or the container filter names a container that does not exist",
+ },
},
},
handler: async c => {
@@ -346,6 +364,27 @@ export const buildContentPublicRoutes = <
raw,
) as ContentPublicFilterInput;
+ if (containerFilter) {
+ const requested = (filters as Record)[
+ containerFilter.name
+ ];
+ if (typeof requested === "number") {
+ const containers = createContentContainerService(c, {
+ definition: containerFilter.field.definition,
+ pluginId,
+ });
+ const ids = await containers.descendantIds(requested);
+ if (!ids) {
+ throw new HTTPException(404, { message: "Container not found." });
+ }
+ if (raw.includeDescendants === "true") {
+ (filters as Record)[containerFilter.name] = {
+ in: ids,
+ };
+ }
+ }
+ }
+
// An explicit locale naming no language this install serves is the same
// 404 the detail route answers, not an empty list: an empty list would say
// "this language has no articles", which is a different and untrue thing.
@@ -366,7 +405,12 @@ export const buildContentPublicRoutes = <
query: { cursor, first, last, page, search },
});
- setPageCacheTags(c, contentListPageCacheTags(definition));
+ setPageCacheTags(c, [
+ ...contentListPageCacheTags(definition),
+ ...(containerFilter
+ ? [contentContainerTag(containerFilter.field.definition.id)]
+ : []),
+ ]);
return c.json(data, 200, localeHeaders(resolved.locale, resolved.source));
},
diff --git a/packages/vitnode/src/content/server/public-write-routes.test.ts b/packages/vitnode/src/content/server/public-write-routes.test.ts
index 1ccaff7b1..d31cd3b1f 100644
--- a/packages/vitnode/src/content/server/public-write-routes.test.ts
+++ b/packages/vitnode/src/content/server/public-write-routes.test.ts
@@ -6,10 +6,12 @@ import { HTTPException } from "hono/http-exception";
import { afterEach, describe, expect, it, vi } from "vitest";
import { core_content_revisions } from "@/database/content";
+import { core_content_containers } from "@/database/content-containers";
import { createTestCache } from "@/tests/cache";
import {
testEditorialTopicContentType,
testForumContentType,
+ testThreadContentType,
testTopicContentType,
} from "@/tests/content-fixtures";
import { createMemoryDb } from "@/tests/memory-db";
@@ -614,6 +616,137 @@ describe("editorial content types", () => {
});
});
+describe("a container field", () => {
+ const threads = createContentModel(testThreadContentType);
+ const BOARD = { ELSEWHERE: 9, GENERAL: 1, MISSING: 99 } as const;
+ const THREAD = 20;
+
+ const board = (id: number, slug: string, definitionId: string) => ({
+ createdAt: LIVE,
+ definitionId,
+ id,
+ metadata: {},
+ name: slug,
+ parentId: null,
+ pluginId: PLUGIN_ID,
+ position: 0,
+ slug,
+ updatedAt: LIVE,
+ });
+
+ const threadHarness = () =>
+ harness(threads as never, [
+ [
+ core_content_containers,
+ [
+ board(
+ BOARD.GENERAL,
+ "general",
+ testThreadContentType.fields.board.definition.id,
+ ),
+ board(BOARD.ELSEWHERE, "elsewhere", "test.sections"),
+ ],
+ ],
+ [
+ threads.table,
+ [
+ {
+ author: OWNER,
+ board: BOARD.GENERAL,
+ createdAt: LIVE,
+ id: THREAD,
+ publishedAt: LIVE,
+ slug: "first-thread",
+ status: "published",
+ title: "First thread",
+ updatedAt: LIVE,
+ },
+ ],
+ ],
+ ]);
+
+ it("creates a record inside the container the member picked", async () => {
+ const { rows, send } = threadHarness();
+
+ const response = await send("POST", "/", {
+ body: { values: { board: BOARD.GENERAL, title: "Hello world" } },
+ user: OWNER,
+ });
+
+ expect(response.status).toBe(201);
+ const record = (await response.json()) as { id: number };
+ expect(rows().find(row => row.id === record.id)).toMatchObject({
+ author: OWNER,
+ board: BOARD.GENERAL,
+ });
+ });
+
+ it.each([
+ ["does not exist", BOARD.MISSING],
+ ["belongs to another collection", BOARD.ELSEWHERE],
+ ])(
+ "refuses a container that %s, on that field",
+ async (_label, container) => {
+ const { events, rows, send } = threadHarness();
+ const before = rows();
+
+ const response = await send("POST", "/", {
+ body: { values: { board: container, title: "Hello world" } },
+ user: OWNER,
+ });
+
+ expect(response.status).toBe(400);
+ await expect(rejection(response)).resolves.toEqual({
+ code: CONTENT_PUBLIC_WRITE_CODES.invalidInput,
+ contentTypeId: threads.definition.id,
+ issues: [
+ {
+ code: CONTENT_PUBLIC_WRITE_ISSUE_CODES.unavailable,
+ field: "board",
+ message: expect.any(String),
+ },
+ ],
+ });
+ expect(rows()).toEqual(before);
+ expect(events()).toEqual([]);
+ },
+ );
+
+ it("keeps the record in its container on an update", async () => {
+ const { rows, send } = threadHarness();
+
+ const response = await send("PATCH", `/${THREAD}`, {
+ body: { values: { title: "Renamed thread" } },
+ user: OWNER,
+ });
+
+ expect(response.status).toBe(200);
+ expect(rows().find(row => row.id === THREAD)).toMatchObject({
+ board: BOARD.GENERAL,
+ title: "Renamed thread",
+ });
+ });
+
+ it("never lets an author move their record to another container", async () => {
+ const { events, rows, send } = threadHarness();
+ const before = rows();
+
+ const response = await send("PATCH", `/${THREAD}`, {
+ body: { values: { board: BOARD.GENERAL, title: "Renamed thread" } },
+ user: OWNER,
+ });
+
+ expect(response.status).toBe(400);
+ expect((await rejection(response)).issues).toContainEqual({
+ code: CONTENT_PUBLIC_WRITE_ISSUE_CODES.notWritable,
+ field: "board",
+ message: expect.any(String),
+ });
+ expect(rows()).toEqual(before);
+ expect(events()).toEqual([]);
+ });
+});
+
describe("generated surface", () => {
const operations = (app: OpenAPIHono) =>
Object.fromEntries(
diff --git a/packages/vitnode/src/content/server/public-write-service.ts b/packages/vitnode/src/content/server/public-write-service.ts
index a155ecdc8..643bc33d6 100644
--- a/packages/vitnode/src/content/server/public-write-service.ts
+++ b/packages/vitnode/src/content/server/public-write-service.ts
@@ -8,6 +8,7 @@ import type { Context } from "hono";
import { eq, getColumns, getColumnTable, inArray } from "drizzle-orm";
import { getTableConfig } from "drizzle-orm/pg-core";
+import type { ResolvedContentContainerContext } from "../containers/server/assignment";
import type { ContentId, ContentIdOf } from "../ids";
import type {
AnyContentTypeDefinition,
@@ -21,6 +22,8 @@ import type { ContentModel } from "./model";
import type { ContentPublicWriteActor } from "./public-write-authorization";
import type { ContentDatabase } from "./service";
+import { ContentContainerAssignmentError } from "../containers/association";
+import { resolveContentContainerAssignment } from "../containers/server/assignment";
import {
ContentEngineError,
ContentPublicWriteDenied,
@@ -54,6 +57,7 @@ export interface ContentPublicWriteService {
export interface ContentPublicationPolicyInput {
actor: ContentPublicWriteActor;
+ container: null | ResolvedContentContainerContext;
definition: AnyContentTypeDefinition;
operation: "create";
}
@@ -194,6 +198,26 @@ export const createContentPublicWriteService = <
}
};
+ const resolveContainer = async (
+ tx: ContentDatabase,
+ values: Record,
+ ): Promise => {
+ try {
+ return (
+ (await resolveContentContainerAssignment(tx, definition, values)) ??
+ null
+ );
+ } catch (error) {
+ if (!(error instanceof ContentContainerAssignmentError)) throw error;
+
+ throw new ContentPublicWriteInputError({
+ contentTypeId,
+ field: error.field,
+ message: error.detail,
+ });
+ }
+ };
+
const lockSelection = (): Record => ({
id: columns.id,
publishedAt: columns.publishedAt,
@@ -328,19 +352,26 @@ export const createContentPublicWriteService = <
}
const actor = requireActor();
const parsed = schemas.create.parse(values) as Record;
- const status = resolveContentPublicationPolicy({
- actor,
- definition,
- operation: "create",
- });
const stamped = { ...parsed, [ownerField]: actor.userId };
+ const prepare = async (tx: ContentDatabase) => {
+ await assertPublicRelationTargets(tx, parsed);
+ const container = await resolveContainer(tx, parsed);
+
+ return resolveContentPublicationPolicy({
+ actor,
+ container,
+ definition,
+ operation: "create",
+ });
+ };
+
if (editorial) {
const service = editorialService();
const { created, published } = await c
.get("db")
.transaction(async tx => {
- await assertPublicRelationTargets(tx, parsed);
+ const status = await prepare(tx);
const created = await service.create(stamped as never, {
actor,
tx,
@@ -359,17 +390,17 @@ export const createContentPublicWriteService = <
}
const service = model.service(c);
- const row = await c.get("db").transaction(async tx => {
- await assertPublicRelationTargets(tx, parsed);
+ const { row, status } = await c.get("db").transaction(async tx => {
+ const status = await prepare(tx);
const created = await service.create(stamped as never, { tx });
- if (status !== "published") return created;
+ if (status !== "published") return { row: created, status };
const result = await publicationMethods(definition, service).publish(
created.id as never,
{ tx },
);
- return result?.row ?? created;
+ return { row: result?.row ?? created, status };
});
await plainEffects("create", row, {
diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts
index 426430cc4..c9269d2a6 100644
--- a/packages/vitnode/src/content/server/query.ts
+++ b/packages/vitnode/src/content/server/query.ts
@@ -1,7 +1,16 @@
import type { SQL } from "drizzle-orm";
import type { PgColumn } from "drizzle-orm/pg-core";
-import { and, eq, ilike, isNotNull, isNull, or, sql } from "drizzle-orm";
+import {
+ and,
+ eq,
+ ilike,
+ inArray,
+ isNotNull,
+ isNull,
+ or,
+ sql,
+} from "drizzle-orm";
import type {
ContentFieldDescriptor,
@@ -204,6 +213,19 @@ export const buildFilterCondition = ({
continue;
}
+ if (
+ fieldValue.kind === "container" &&
+ typeof raw === "object" &&
+ Array.isArray((raw as { in?: unknown }).in)
+ ) {
+ const ids = (raw as { in: unknown[] }).in.filter(
+ (id): id is number =>
+ typeof id === "number" && Number.isSafeInteger(id),
+ );
+ conditions.push(ids.length > 0 ? inArray(column, ids) : eq(column, 0));
+ continue;
+ }
+
conditions.push(eq(column, filterValue(fieldValue, raw)));
}
diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts
index 8a2c84347..80031e3e6 100644
--- a/packages/vitnode/src/content/server/service.ts
+++ b/packages/vitnode/src/content/server/service.ts
@@ -57,6 +57,7 @@ import {
CONTENT_SYSTEM_FIELDS,
CONTENT_VISIBILITY_FIELDS,
} from "../const";
+import { resolveContentContainerAssignment } from "../containers/server/assignment";
import { ContentEngineError } from "../errors";
import { contentRelationStrategy, requireContentId } from "../ids";
import { partitionContentFields } from "../localization";
@@ -800,6 +801,7 @@ export const createContentService = <
// against the field it is being written to. No statement at all for a
// content type with no file fields.
await assertContentFileReferences(c, definition, parsed, tx);
+ await resolveContentContainerAssignment(tx, definition, parsed);
const [row] = await tx
.insert(table)
@@ -1120,6 +1122,10 @@ export const createContentService = <
}),
});
+ if (changedPaths.length > 0) {
+ await resolveContentContainerAssignment(tx, definition, patch);
+ }
+
if (changedCollections.length > 0) await store?.write(tx, id, patch);
// `updatedAt` has to move even when only a collection changed: it is
diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts
index 3aa1c232c..390336fc5 100644
--- a/packages/vitnode/src/content/server/table.ts
+++ b/packages/vitnode/src/content/server/table.ts
@@ -25,6 +25,7 @@ import type {
ContentTableFor,
} from "./types";
+import { core_content_containers } from "../../database/content-containers";
import { core_files } from "../../database/files";
import { core_users } from "../../database/users";
import {
@@ -153,6 +154,14 @@ const resolveReference = (
() => core_files.id,
);
}
+ if (fieldValue.kind === "container") {
+ return checkedReference(
+ contentTypeId,
+ name,
+ () => getTableName(core_content_containers),
+ () => core_content_containers.id,
+ );
+ }
if (fieldValue.kind !== "relation") return undefined;
const thunk = references[name];
diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts
index 7266d015a..22c07382c 100644
--- a/packages/vitnode/src/content/server/types.ts
+++ b/packages/vitnode/src/content/server/types.ts
@@ -73,7 +73,7 @@ type BaseBuilderFor = TField extends { kind: "relation" }
? PgVarcharBuilder>
: TField extends { integer: false; kind: "number" }
? PgDoublePrecisionBuilder
- : TField extends { kind: "number" | "user" }
+ : TField extends { kind: "container" | "number" | "user" }
? PgIntegerBuilder
: TField extends { kind: "textarea" }
? PgTextBuilder
diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts
index acc2cb381..1218b4a07 100644
--- a/packages/vitnode/src/content/types.ts
+++ b/packages/vitnode/src/content/types.ts
@@ -20,6 +20,7 @@ import type {
CONTENT_VISIBILITY_FIELDS,
CONTENT_VISIBILITY_FILTERS,
} from "./const";
+import type { AnyContentContainerDefinition } from "./containers/types";
import type { ContentFileDescriptor } from "./files";
import type {
ContentId,
@@ -231,6 +232,16 @@ export interface ContentUserField<
ordered: TOrdered;
}
+export interface ContentContainerField<
+ TRequired extends boolean = boolean,
+ TDefinition extends AnyContentContainerDefinition =
+ AnyContentContainerDefinition,
+> extends ContentFieldShared {
+ definition: TDefinition;
+ kind: "container";
+ localized?: false;
+}
+
interface ContentReferenceCollection {
kind: "file" | "relation" | "user";
multiple: true;
@@ -298,6 +309,7 @@ export interface ContentRepeatableField<
export type ContentFieldDescriptor =
| ContentBlocksField
| ContentBooleanField
+ | ContentContainerField
| ContentDateTimeField
| ContentEnumField
| ContentFileField
@@ -368,7 +380,7 @@ type ScalarFieldValue = TField extends { kind: "boolean" }
? TValue
: TField extends { kind: "relation" }
? ContentRelationIdOf
- : TField extends { kind: "file" | "number" | "user" }
+ : TField extends { kind: "container" | "file" | "number" | "user" }
? number
: TField extends { kind: "richText" }
? RichTextDocument
@@ -383,7 +395,7 @@ type ScalarFieldInput = TField extends { kind: "boolean" }
? TValue
: TField extends { kind: "relation" }
? ContentRelationIdOf
- : TField extends { kind: "file" | "number" | "user" }
+ : TField extends { kind: "container" | "file" | "number" | "user" }
? number
: TField extends { kind: "richText" }
? RichTextDocument
diff --git a/packages/vitnode/src/database/content-containers.ts b/packages/vitnode/src/database/content-containers.ts
new file mode 100644
index 000000000..a932be0bb
--- /dev/null
+++ b/packages/vitnode/src/database/content-containers.ts
@@ -0,0 +1,47 @@
+import {
+ type AnyPgColumn,
+ camelCase,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+
+import {
+ CONTENT_CONTAINER_NAME_MAX_LENGTH,
+ CONTENT_CONTAINER_SLUG_MAX_LENGTH,
+} from "../content/containers/const";
+
+export const core_content_containers = camelCase.table.withRLS(
+ "core_content_containers",
+ t => ({
+ id: t.serial().primaryKey(),
+ pluginId: t.varchar({ length: 255 }).notNull(),
+ definitionId: t.varchar({ length: 100 }).notNull(),
+ parentId: t
+ .integer()
+ .references((): AnyPgColumn => core_content_containers.id, {
+ onDelete: "restrict",
+ }),
+ name: t.varchar({ length: CONTENT_CONTAINER_NAME_MAX_LENGTH }).notNull(),
+ slug: t.varchar({ length: CONTENT_CONTAINER_SLUG_MAX_LENGTH }).notNull(),
+ position: t.integer().notNull().default(0),
+ metadata: t.jsonb().$type>().notNull().default({}),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+ t => [
+ uniqueIndex("core_content_containers_definition_slug_key").on(
+ t.definitionId,
+ t.slug,
+ ),
+ index("core_content_containers_definition_parent_position_idx").on(
+ t.definitionId,
+ t.parentId,
+ t.position,
+ ),
+ index("core_content_containers_parent_idx").on(t.parentId),
+ ],
+);
diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts
index 8013faa8b..1ed95f10c 100644
--- a/packages/vitnode/src/database/relations.ts
+++ b/packages/vitnode/src/database/relations.ts
@@ -3,6 +3,7 @@ import { defineRelations } from "drizzle-orm";
import * as admins from "./admins";
import * as ai from "./ai";
import * as content from "./content";
+import * as contentContainers from "./content-containers";
import * as cron from "./cron";
import * as dashboard from "./dashboard";
import * as files from "./files";
@@ -24,6 +25,7 @@ export const coreSchema = {
...admins,
...ai,
...content,
+ ...contentContainers,
...cron,
...dashboard,
...files,
diff --git a/packages/vitnode/src/lib/admin-nav-source.test-d.ts b/packages/vitnode/src/lib/admin-nav-source.test-d.ts
index 1f4878163..6f1a2f475 100644
--- a/packages/vitnode/src/lib/admin-nav-source.test-d.ts
+++ b/packages/vitnode/src/lib/admin-nav-source.test-d.ts
@@ -13,7 +13,7 @@ describe("AdminNavPluginSource", () => {
it("carries only what a sidebar reads", () => {
expectTypeOf().toEqualTypeOf<
- "admin" | "contentTypes" | "pluginId"
+ "admin" | "containers" | "contentTypes" | "pluginId"
>();
});
diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts
index 5a00dc685..438d69c98 100644
--- a/packages/vitnode/src/lib/plugin.ts
+++ b/packages/vitnode/src/lib/plugin.ts
@@ -1,5 +1,6 @@
import type { PermissionsStaffArgs } from "../api/lib/permission-staff";
import type { ItemAutoFormComponentProps } from "../components/form/auto-form";
+import type { AnyContentContainerDefinition } from "../content/containers/types";
import type { ContentId } from "../content/ids";
import type {
AnyContentTypeDefinition,
@@ -31,8 +32,14 @@ export type AdminNavContentType = Pick<
"definition" | "icon"
>;
+export interface ContentContainerFrontendRegistration {
+ definition: AnyContentContainerDefinition;
+ icon?: React.ReactNode;
+}
+
export interface AdminNavPluginSource {
admin?: { nav?: AdminNavDeclaration[] };
+ containers?: ContentContainerFrontendRegistration[];
contentTypes?: AdminNavContentType[];
pluginId: string;
}
@@ -119,6 +126,7 @@ export interface ContentTypeFrontendRegistration {
}
export interface ContentFrontendPluginSource {
+ containers?: ContentContainerFrontendRegistration[];
contentTypes?: ContentTypeFrontendRegistration[];
pluginId: string;
}
@@ -163,6 +171,7 @@ export interface BuildPluginReturn {
};
nav?: AdminNavDeclaration[];
};
+ containers?: ContentContainerFrontendRegistration[];
contentTypes?: ContentTypeFrontendRegistration[];
/**
* Every locale file this plugin ships, as literal `*.json` specifiers an
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index b0ef5fa2e..63bef28a6 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -1576,6 +1576,15 @@
"move_up": "Move entry {position} up",
"position": "{label} {position} of {total}",
"remove": "Remove entry {position}"
+ },
+ "container": {
+ "placeholder": "Select a place…",
+ "none": "None",
+ "empty": "There is nowhere to file this yet. Create the first one in its collection, then come back.",
+ "loadFailed": "The list of places could not be loaded. Check your connection and try again.",
+ "forbidden": "You can't see this collection, so you can't choose a place in it. Ask an administrator for permission to view it.",
+ "unregistered": "This field points at a collection no installed plugin registers, so there is nothing to choose from.",
+ "retry": "Try again"
}
},
"conflict": {
@@ -1756,6 +1765,101 @@
}
}
},
+ "containers": {
+ "list": {
+ "label": "{name} tree",
+ "summary": "{count, plural, one {# item} other {# items}}",
+ "depth": "up to {max} levels deep",
+ "flat": "one level",
+ "sorted": "sorted A–Z",
+ "collapseAll": "Collapse all",
+ "expandAll": "Expand all",
+ "showChildren": "Show what's inside {name}",
+ "hideChildren": "Hide what's inside {name}",
+ "actions": "More actions for {name}",
+ "dragHandle": "Drag to move {name}",
+ "edit": "Edit",
+ "addChild": "Add inside",
+ "moveUp": "Move up",
+ "moveDown": "Move down",
+ "moveToTop": "Move to top level",
+ "moveInside": "Move inside…",
+ "delete": "Delete",
+ "moveSuccess": "Moved",
+ "moveSuccessDesc": "\"{name}\" is in its new place.",
+ "moveFailed": "Could not move \"{name}\""
+ },
+ "dnd": {
+ "instructions": "Press space bar or enter to pick up an item. Use the up and down arrow keys to move it, and the left and right arrow keys to change how deep it sits. Press space bar or enter again to drop it, or escape to cancel.",
+ "started": "Picked up {name}.",
+ "overRoot": "{name} would move to the top level{sorted, select, true {} other {, place {position}}}.",
+ "overInside": "{name} would move inside {parent}{sorted, select, true {} other {, place {position}}}.",
+ "overInvalid": "{name} can't go here. {reason}",
+ "droppedRoot": "Dropped {name} at the top level{sorted, select, true {} other {, place {position}}}.",
+ "droppedInside": "Dropped {name} inside {parent}{sorted, select, true {} other {, place {position}}}.",
+ "droppedInvalid": "{name} can't go there, so it stays where it was. {reason}",
+ "unchanged": "{name} stays where it was.",
+ "cancelled": "Moving cancelled. {name} is back where it was."
+ },
+ "empty": {
+ "title": "No {name} yet",
+ "desc": "Create the first one, then nest more inside it to build your structure."
+ },
+ "error": {
+ "title": "Could not load {name}",
+ "desc": "Something went wrong while reading the list. Check your connection and try again.",
+ "forbidden": "You don't have permission to view this collection.",
+ "retry": "Try again"
+ },
+ "form": {
+ "name": "Name",
+ "slug": "Slug",
+ "slugDesc": "The address part. Leave it empty and it's made from the name.",
+ "parent": "Place it in",
+ "parentDesc": "The tree goes up to {max} levels deep.",
+ "parentRoot": "Top level",
+ "fields": "Details"
+ },
+ "create": {
+ "title": "Create {name}",
+ "desc": "Add a new {name} to the tree.",
+ "submit": "Create",
+ "success": "Created",
+ "successDesc": "\"{name}\" is in the tree."
+ },
+ "edit": {
+ "title": "Edit {name}",
+ "submit": "Save changes",
+ "success": "Saved",
+ "successDesc": "\"{name}\" has been updated."
+ },
+ "delete": {
+ "title": "Delete {name}",
+ "desc": "Delete \"{name}\"? This can't be undone.",
+ "confirm": "Delete",
+ "cancel": "Cancel",
+ "success": "{name} deleted",
+ "successDesc": "\"{name}\" is gone from the tree.",
+ "failed": "Could not delete \"{name}\""
+ },
+ "errors": {
+ "cycle": "It can't go inside itself or inside something it contains.",
+ "definitionMismatch": "That belongs to a different collection.",
+ "hasChildren": "It still has items inside. Move or delete them first.",
+ "hasContent": "Content is still filed here. Move or delete that content first.",
+ "invalidInput": "Some of the values aren't valid. Check the form and try again.",
+ "invalidMetadata": "Some of the details don't match what this collection expects.",
+ "invalidPosition": "That position is outside the list. Refresh and try again.",
+ "limitReached": "This collection is full. Delete something before adding more.",
+ "maxDepth": "That would nest it deeper than this collection allows.",
+ "notFound": "It no longer exists - someone may have deleted it. Refresh the list.",
+ "parentNotFound": "The place you picked no longer exists. Refresh the list.",
+ "slugEmpty": "The slug needs at least one letter or digit.",
+ "slugTaken": "Another item in this collection already uses this slug.",
+ "forbidden": "You don't have permission to do that.",
+ "unknown": "Something went wrong. Try again in a moment."
+ }
+ },
"editor": {
"title": "Editing page",
"available_blocks": "Widgets",
diff --git a/packages/vitnode/src/pages/admin/containers.tsx b/packages/vitnode/src/pages/admin/containers.tsx
new file mode 100644
index 000000000..d0bb89a3a
--- /dev/null
+++ b/packages/vitnode/src/pages/admin/containers.tsx
@@ -0,0 +1,48 @@
+import { notFound } from "@tanstack/react-router";
+
+import type { ContentFrontendRegistry } from "@/content/admin/registry";
+import type { PluginRoutePageProps } from "@/routing";
+import type { ContentContainerRouteData } from "@/tanstack/admin/containers/route";
+
+import { ContentContainerBreadcrumbContent } from "@/tanstack/admin/containers/breadcrumb";
+import { loadContentContainerRoute } from "@/tanstack/admin/containers/route";
+import { ContentContainerScreenContent } from "@/tanstack/admin/containers/screen";
+import { getContentRegistryLoader } from "@/tanstack/admin/content/registry-runtime";
+import { contentRouteSegments } from "@/tanstack/admin/content/route";
+import {
+ defineAdminRoute,
+ routeBreadcrumbGroup,
+} from "@/tanstack/plugin-routes";
+
+const registry: ContentFrontendRegistry = await getContentRegistryLoader()();
+
+const ContentContainerPage = ({
+ loaderData,
+}: PluginRoutePageProps) => {
+ const entry = registry.containers.byId(loaderData.definitionId);
+
+ if (!entry) {
+ // oxlint-disable-next-line typescript/only-throw-error
+ throw notFound();
+ }
+
+ return ;
+};
+
+export const route = defineAdminRoute({
+ load: async ({ context, params }) =>
+ await loadContentContainerRoute({
+ ...context,
+ registry,
+ segments: contentRouteSegments(params._splat),
+ }),
+ head: ({ loaderData }) => ({ ...loaderData }),
+
+ breadcrumb: routeBreadcrumbGroup(
+ function ContentContainerBreadcrumb({ loaderData }) {
+ return ;
+ },
+ ),
+});
+
+export default ContentContainerPage;
diff --git a/packages/vitnode/src/routes.test.ts b/packages/vitnode/src/routes.test.ts
index 51cf5b41f..1a9a0b244 100644
--- a/packages/vitnode/src/routes.test.ts
+++ b/packages/vitnode/src/routes.test.ts
@@ -67,13 +67,15 @@ describe("core's route tree", () => {
expect(new Set(keys).size).toBe(keys.length);
});
- /** One catch-all, and it is the Content Engine's. */
- it("declares exactly one catch-all", () => {
+ it("declares only the Content Engine's catch-alls", () => {
const splats = manifest.filter(route =>
route.segments.some(segment => segment.kind === "splat"),
);
- expect(splats.map(route => route.path)).toEqual(["/admin/content/*"]);
+ expect(splats.map(route => route.path).sort()).toEqual([
+ "/admin/containers/*",
+ "/admin/content/*",
+ ]);
});
/**
diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx
index f01a1040e..45787d0fd 100644
--- a/packages/vitnode/src/routes.tsx
+++ b/packages/vitnode/src/routes.tsx
@@ -392,4 +392,10 @@ export const routes = defineRoutes([
pendingComponent: TablePendingSkeleton,
search: contentListRouteSearch,
}),
+
+ page("/admin/containers/*", {
+ area: "admin",
+ component: lazy(() => import("./pages/admin/containers")),
+ pendingComponent: TablePendingSkeleton,
+ }),
]);
diff --git a/packages/vitnode/src/tanstack/admin/containers/breadcrumb.tsx b/packages/vitnode/src/tanstack/admin/containers/breadcrumb.tsx
new file mode 100644
index 000000000..03df1b8ef
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/containers/breadcrumb.tsx
@@ -0,0 +1,25 @@
+import type { ContentContainerRouteData } from "./route";
+
+import { RouteMessages } from "../../i18n/route-messages";
+import { AdminBreadcrumb } from "../breadcrumb";
+
+export type ContentContainerBreadcrumbProps = Partial<
+ Pick
+>;
+
+export const ContentContainerBreadcrumbContent = ({
+ adminPath,
+ namespaces,
+ title,
+}: ContentContainerBreadcrumbProps) => {
+ if (adminPath === undefined || title === undefined) return null;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/tanstack/admin/containers/route.tsx b/packages/vitnode/src/tanstack/admin/containers/route.tsx
new file mode 100644
index 000000000..53306f524
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/containers/route.tsx
@@ -0,0 +1,108 @@
+import { notFound } from "@tanstack/react-router";
+import { createTranslator } from "use-intl";
+
+import type { ContentLabelTranslator } from "@/content/admin/labels";
+import type {
+ ContentFrontendRegistry,
+ RegisteredFrontendContentContainer,
+} from "@/content/admin/registry";
+
+import { contentContainerLabelsFrom } from "@/content/admin/containers";
+import { CONTENT_PERMISSIONS } from "@/content/const";
+import { contentContainerListQuery } from "@/views/admin/views/containers/container-query";
+import { contentContainerApiTarget } from "@/views/admin/views/containers/container-request";
+
+import type { AdminScreenContext } from "../screen";
+
+import { intlQueryOptions } from "../../i18n/query";
+import { requireAdminPermission } from "../screen";
+
+export const ADMIN_CONTAINERS_NAMESPACES = [
+ "core.global",
+ "core.containers",
+] as const;
+
+export const contentContainerRouteNamespaces = (pluginId: string): string[] => [
+ ...ADMIN_CONTAINERS_NAMESPACES,
+ pluginId,
+];
+
+export const contentContainerRouteQuery = (
+ entry: Pick,
+) =>
+ contentContainerListQuery({
+ definitionId: entry.definition.id,
+ target: contentContainerApiTarget(entry),
+ });
+
+export interface ContentContainerRouteData {
+ adminPath: string;
+ definitionId: string;
+ description: string | undefined;
+ labels: { plural: string; singular: string; title: string };
+ namespaces: string[];
+ pluginId: string;
+ title: string;
+}
+
+export const loadContentContainerRoute = async ({
+ adminAccess,
+ locale,
+ queryClient,
+ registry,
+ segments,
+}: AdminScreenContext & {
+ registry: ContentFrontendRegistry;
+ segments: readonly string[];
+}): Promise => {
+ const entry = registry.containers.byAdminPath(segments.join("/"));
+
+ if (!entry) {
+ // oxlint-disable-next-line typescript/only-throw-error
+ throw notFound();
+ }
+
+ requireAdminPermission(adminAccess, {
+ module: entry.definition.admin.permissionModule,
+ permission: CONTENT_PERMISSIONS.view,
+ plugin: entry.pluginId,
+ });
+
+ const namespaces = contentContainerRouteNamespaces(entry.pluginId);
+
+ const [intl] = await Promise.all([
+ queryClient.query({
+ ...intlQueryOptions({ locale, namespaces }),
+ staleTime: "static",
+ }),
+ queryClient
+ .query({ ...contentContainerRouteQuery(entry), staleTime: "static" })
+ .catch(() => undefined),
+ ]);
+
+ const t = createTranslator({
+ locale,
+ messages: intl.messages,
+ onError: () => undefined,
+ }) as unknown as ContentLabelTranslator;
+
+ const labels = contentContainerLabelsFrom(
+ entry.definition,
+ entry.pluginId,
+ t,
+ );
+
+ return {
+ adminPath: entry.definition.admin.path,
+ definitionId: entry.definition.id,
+ description: labels.desc,
+ labels: {
+ plural: labels.plural,
+ singular: labels.singular,
+ title: labels.title,
+ },
+ namespaces,
+ pluginId: entry.pluginId,
+ title: labels.title,
+ };
+};
diff --git a/packages/vitnode/src/tanstack/admin/containers/screen.tsx b/packages/vitnode/src/tanstack/admin/containers/screen.tsx
new file mode 100644
index 000000000..d2af1fcec
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/containers/screen.tsx
@@ -0,0 +1,112 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import type { ContentLabelTranslator } from "@/content/admin/labels";
+import type { RegisteredFrontendContentContainer } from "@/content/admin/registry";
+import type { ContentContainerSave } from "@/views/admin/views/containers/container-form-content";
+import type { ContentContainerMutationResult } from "@/views/admin/views/containers/container-request";
+
+import { contentContainerLabelsFrom } from "@/content/admin/containers";
+import { ContentContainerManager } from "@/views/admin/views/containers/container-manager";
+import { invalidateContentContainers } from "@/views/admin/views/containers/container-query";
+import {
+ contentContainerApiTarget,
+ createContentContainer,
+ deleteContentContainer,
+ moveContentContainer,
+ updateContentContainer,
+} from "@/views/admin/views/containers/container-request";
+
+import type { ContentContainerRouteData } from "./route";
+
+import { RouteMessages } from "../../i18n/route-messages";
+import { contentContainerRouteQuery } from "./route";
+
+export interface ContentContainerScreenProps extends ContentContainerRouteData {
+ entry: RegisteredFrontendContentContainer;
+}
+
+const useContentContainerMutations = (
+ entry: RegisteredFrontendContentContainer,
+) => {
+ const queryClient = useQueryClient();
+
+ return React.useMemo(() => {
+ const target = contentContainerApiTarget(entry);
+ const settle = async (result: ContentContainerMutationResult) => {
+ if ("data" in result) {
+ await invalidateContentContainers(queryClient, entry.definition.id);
+ }
+
+ return result;
+ };
+
+ const onSave: ContentContainerSave = async ({ body, id, parentId }) =>
+ await settle(
+ id === undefined
+ ? await createContentContainer(target, {
+ ...body,
+ name: body.name ?? "",
+ parentId: parentId ?? null,
+ })
+ : await updateContentContainer(target, id, body),
+ );
+
+ return {
+ onDelete: async (id: number) =>
+ await settle(await deleteContentContainer(target, id)),
+ onMove: async ({
+ id,
+ ...move
+ }: {
+ id: number;
+ parentId: null | number;
+ position: number;
+ }) => await settle(await moveContentContainer(target, id, move)),
+ onSave,
+ };
+ }, [entry, queryClient]);
+};
+
+const ContentContainerScreenBody = ({
+ entry,
+}: {
+ entry: RegisteredFrontendContentContainer;
+}) => {
+ const t = useTranslations();
+ const query = useQuery(contentContainerRouteQuery(entry));
+ const mutations = useContentContainerMutations(entry);
+ const labels = contentContainerLabelsFrom(
+ entry.definition,
+ entry.pluginId,
+ t as unknown as ContentLabelTranslator,
+ );
+
+ return (
+ {
+ void query.refetch();
+ }}
+ pluginId={entry.pluginId}
+ records={query.data}
+ {...mutations}
+ />
+ );
+};
+
+export const ContentContainerScreenContent = ({
+ entry,
+ namespaces,
+}: ContentContainerScreenProps) => (
+
+
+
+
+
+);
diff --git a/packages/vitnode/src/tanstack/admin/content/route.tsx b/packages/vitnode/src/tanstack/admin/content/route.tsx
index 78b3cd45c..1860b0663 100644
--- a/packages/vitnode/src/tanstack/admin/content/route.tsx
+++ b/packages/vitnode/src/tanstack/admin/content/route.tsx
@@ -12,6 +12,8 @@ import type { ContentRouteLabels } from "@/views/admin/views/content/content-lab
import { resolveContentAdminRoute } from "@/content/index";
import { CONTENT_PERMISSIONS } from "@/content/index";
+import { contentContainerListQuery } from "@/views/admin/views/containers/container-query";
+import { contentContainerApiTarget } from "@/views/admin/views/containers/container-request";
import {
contentLabelsFrom,
contentRouteLabels,
@@ -19,6 +21,7 @@ import {
} from "@/views/admin/views/content/content-labels";
import type { AdminScreenContext } from "../screen";
+import type { AdminAccessState } from "../session-api";
import type {
ContentListParams,
UncheckedContentListSearch,
@@ -26,6 +29,7 @@ import type {
import { intlQueryOptions } from "../../i18n/query";
import { requireAdminPermission } from "../screen";
+import { hasAdminPermission } from "../state";
import { contentListPageQuery } from "./query";
import { contentListRouteParams } from "./route-search";
@@ -78,6 +82,49 @@ export interface ContentAdminRouteData {
title: string;
}
+const preloadListContainers = async ({
+ adminAccess,
+ entry,
+ queryClient,
+ registry,
+}: Pick & {
+ adminAccess: AdminAccessState;
+ entry: RegisteredFrontendContentType;
+ registry: ContentFrontendRegistry;
+}): Promise => {
+ const { fields } = entry.definition;
+ const reads = entry.definition.admin.list.columns.flatMap(name => {
+ const descriptor = fields[name] as (typeof fields)[string] | undefined;
+ if (descriptor?.kind !== "container") return [];
+
+ const collection = registry.containers.byId(descriptor.definition.id);
+ if (
+ !collection ||
+ !hasAdminPermission(adminAccess, {
+ module: collection.definition.admin.permissionModule,
+ permission: CONTENT_PERMISSIONS.view,
+ plugin: collection.pluginId,
+ })
+ ) {
+ return [];
+ }
+
+ return [
+ queryClient
+ .query({
+ ...contentContainerListQuery({
+ definitionId: collection.definition.id,
+ target: contentContainerApiTarget(collection),
+ }),
+ staleTime: "static",
+ })
+ .catch(() => undefined),
+ ];
+ });
+
+ await Promise.all(reads);
+};
+
export const loadContentAdminRoute = async ({
adminAccess,
locale,
@@ -127,6 +174,9 @@ export const loadContentAdminRoute = async ({
staleTime: "static",
})
: undefined,
+ listParams
+ ? preloadListContainers({ adminAccess, entry, queryClient, registry })
+ : undefined,
]);
const t = createTranslator({
diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts
index 4a90625e8..5db187759 100644
--- a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts
+++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts
@@ -228,7 +228,7 @@ describe("the universal fetcher resolves a route from the plugin id", () => {
path: "/",
});
- expectTypeOf(list.status).toEqualTypeOf<200 | 400>();
+ expectTypeOf(list.status).toEqualTypeOf<200 | 400 | 404>();
const detail = await fetcher({
plugin: "@acme/notes",
diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts
index 2f5abcfbe..d21faebe7 100644
--- a/packages/vitnode/src/tests/content-fixtures.ts
+++ b/packages/vitnode/src/tests/content-fixtures.ts
@@ -1,5 +1,6 @@
import type { LocaleRoutingConfig } from "@/lib/i18n/locale-routing";
+import { defineContentContainer } from "@/content/containers/define";
import { defineContentType } from "@/content/define";
import { field } from "@/content/fields";
import { createLocaleRouting } from "@/lib/i18n/locale-routing";
@@ -934,3 +935,42 @@ export const testEditorialTopicContentType = defineContentType({
update: { fields: ["title"] },
},
});
+
+export const testBoardContainers = defineContentContainer({
+ id: "test.boards",
+ fields: {
+ description: field.textarea({ maxLength: 500, nullable: true }),
+ color: field.text({ maxLength: 7, nullable: true }),
+ locked: field.boolean({ defaultValue: false }),
+ notes: field.textarea({ maxLength: 500, nullable: true }),
+ },
+ hierarchy: { maxDepth: 3 },
+ publicApi: { path: "boards", fields: ["description", "color"] },
+});
+
+export const testSectionContainers = defineContentContainer({
+ id: "test.sections",
+ publicApi: { path: "sections" },
+});
+
+export const testThreadContentType = defineContentType({
+ id: "test.thread",
+ tableName: "test_threads",
+ publication: true,
+ fields: {
+ title: field.text({ required: true, minLength: 3, maxLength: 200 }),
+ slug: field.slug({ source: "title" }),
+ author: field.user(),
+ board: field.container({ definition: testBoardContainers, required: true }),
+ },
+ publicApi: {
+ path: "threads",
+ fields: ["id", "title", "slug", "board", "publishedAt"],
+ filterableFields: ["board"],
+ },
+ publicWrite: {
+ ownership: { field: "author" },
+ create: { fields: ["title", "board"], status: "published" },
+ update: { fields: ["title"] },
+ },
+});
diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
index 7d0190abd..79e8368eb 100644
--- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
+++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
@@ -1,5 +1,6 @@
import {
FileTextIcon,
+ FolderTreeIcon,
LayoutDashboardIcon,
ServerIcon,
ShieldUserIcon,
@@ -16,6 +17,11 @@ import type { VitNodeConfig } from "@/vitnode.config";
import { hasStaffPermission } from "@/api/lib/staff-permission";
import { CONFIG_PLUGIN } from "@/config";
+import {
+ contentContainerAdminHref,
+ contentContainerNamespace,
+ contentContainerNouns,
+} from "@/content/admin/containers";
import {
contentEntityKey,
type ContentLabelTranslator,
@@ -48,6 +54,7 @@ export type AdminNavTranslator = ContentLabelTranslator;
export type AdminNavTitle =
| { contentTypeId: string; kind: "content"; pluginId: string }
+ | { definitionId: string; kind: "container"; pluginId: string }
| {
key: string;
kind: "key";
@@ -81,6 +88,10 @@ export interface AdminNavGroupDeclaration {
/** One un-translated title, resolved. */
const resolveTitle = (title: AdminNavTitle, t: AdminNavTranslator): string => {
if (title.kind === "key") return t(title.key);
+ if (title.kind === "container") {
+ return contentContainerNouns({ id: title.definitionId }, title.pluginId, t)
+ .title;
+ }
// The same resolution the screen's own heading uses, so the sidebar and the
// page it opens cannot disagree about what the records are called.
@@ -286,6 +297,26 @@ const contentNavItems = (
},
}));
+const containerNavItems = (
+ plugin: VitNodeConfig["plugins"][number],
+): AdminNavItemDeclaration[] =>
+ (plugin.containers ?? [])
+ .filter(({ definition }) => definition.admin.navigation.enabled)
+ .map(({ definition, icon }) => ({
+ href: contentContainerAdminHref(definition),
+ icon: icon ?? ,
+ permission: {
+ module: definition.admin.permissionModule,
+ permission: CONTENT_PERMISSIONS.view,
+ plugin: plugin.pluginId,
+ },
+ title: {
+ definitionId: definition.id,
+ kind: "container" as const,
+ pluginId: plugin.pluginId,
+ },
+ }));
+
const declaredNavItems = (
plugin: VitNodeConfig["plugins"][number],
): AdminNavItemDeclaration[] =>
@@ -339,7 +370,11 @@ export const adminNavDeclarations = (
// readily as a branch, and asking for `@vitnode/blog` instead would ship
// every string that plugin has in order to render one heading.
title: key(`${plugin.pluginId}.title`, `${plugin.pluginId}.title`),
- items: [...contentNavItems(plugin), ...declaredNavItems(plugin)],
+ items: [
+ ...contentNavItems(plugin),
+ ...containerNavItems(plugin),
+ ...declaredNavItems(plugin),
+ ],
})),
];
@@ -354,10 +389,17 @@ export const adminNavDeclarations = (
* `.label` - and the branch holding both is exactly what a screen already loads
* for that content type.
*/
-const titleNamespace = (title: AdminNavTitle): string =>
- title.kind === "key"
- ? title.namespace
- : `${title.pluginId}.content.${contentEntityKey(title.contentTypeId)}`;
+const titleNamespace = (title: AdminNavTitle): string => {
+ if (title.kind === "key") return title.namespace;
+ if (title.kind === "container") {
+ return contentContainerNamespace(
+ { id: title.definitionId },
+ title.pluginId,
+ );
+ }
+
+ return `${title.pluginId}.content.${contentEntityKey(title.contentTypeId)}`;
+};
/**
* Every message namespace a set of declarations needs, and not one more.
diff --git a/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx b/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx
new file mode 100644
index 000000000..5698c1d8a
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx
@@ -0,0 +1,193 @@
+import React from "react";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import {
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { Spinner } from "@/components/ui/spinner";
+
+import type { ContentContainerFormProps } from "./container-form-content";
+import type {
+ AdminContentContainer,
+ ContentContainerMutationResult,
+} from "./container-request";
+
+import { contentContainerErrorKey } from "./container-query";
+
+const ContentContainerFormContent = React.lazy(async () =>
+ import("./container-form-content").then(module => ({
+ default: module.ContentContainerFormContent,
+ })),
+);
+
+export const ContentContainerSheet = ({
+ formKey,
+ onOpenChange,
+ open,
+ ...form
+}: Omit & {
+ container: AdminContentContainer | undefined;
+ formKey: number;
+ mode: "create" | "edit";
+ onOpenChange: (open: boolean) => void;
+ open: boolean;
+}) => {
+ const t = useTranslations("core.containers");
+ const { container, mode, ...rest } = form;
+ const isEdit = mode === "edit";
+
+ return (
+
+
+
+
+ {t(isEdit ? "edit.title" : "create.title", {
+ name: form.labels.singular,
+ })}
+
+
+ {isEdit && container
+ ? container.name
+ : t("create.desc", { name: form.labels.singular })}
+
+
+
+ {isEdit && !container ? null : (
+
+
+
+ }
+ >
+
+
+ )}
+
+
+ );
+};
+
+const DeleteBody = ({
+ childCount,
+ container,
+ onDelete,
+ onOpenChange,
+ singular,
+}: {
+ childCount: number;
+ container: AdminContentContainer;
+ onDelete: (id: number) => Promise>;
+ onOpenChange: (open: boolean) => void;
+ singular: string;
+}) => {
+ const t = useTranslations("core.containers");
+ const [isPending, startTransition] = React.useTransition();
+ const [refusal, setRefusal] = React.useState(() =>
+ childCount > 0 ? t("errors.hasChildren") : null,
+ );
+ const refusalId = React.useId();
+
+ const onConfirm = () => {
+ startTransition(async () => {
+ const result = await onDelete(container.id);
+
+ if ("error" in result) {
+ const message = t(contentContainerErrorKey(result.error));
+ setRefusal(message);
+ toast.error(t("delete.failed", { name: container.name }), {
+ description: message,
+ });
+
+ return;
+ }
+
+ toast.success(t("delete.success", { name: singular }), {
+ description: t("delete.successDesc", { name: container.name }),
+ });
+ onOpenChange(false);
+ });
+ };
+
+ return (
+ <>
+
+
+ {t("delete.title", { name: singular })}
+
+
+ {t("delete.desc", { name: container.name })}
+
+
+
+ {refusal ? (
+
+ {refusal}
+
+ ) : null}
+
+
+ {t("delete.cancel")}
+ 0}
+ isLoading={isPending}
+ onClick={onConfirm}
+ type="button"
+ variant="destructive"
+ >
+ {t("delete.confirm")}
+
+
+ >
+ );
+};
+
+export const ContentContainerDeleteDialog = ({
+ container,
+ onOpenChange,
+ open,
+ ...props
+}: Omit, "container"> & {
+ container: AdminContentContainer | undefined;
+ open: boolean;
+}) => (
+
+
+ {container ? (
+
+ ) : null}
+
+
+);
diff --git a/packages/vitnode/src/views/admin/views/containers/container-form-content.tsx b/packages/vitnode/src/views/admin/views/containers/container-form-content.tsx
new file mode 100644
index 000000000..2ed073e7c
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/containers/container-form-content.tsx
@@ -0,0 +1,306 @@
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+import { z } from "zod";
+
+import type {
+ AutoFormOnSubmit,
+ ItemAutoFormComponentProps,
+} from "@/components/form/auto-form";
+import type { ContentContainerLabels } from "@/content/admin/containers";
+import type {
+ ContentFormFieldSpec,
+ ContentFormSpec,
+} from "@/content/admin/spec";
+import type { ContentContainerTreeRules } from "@/content/containers/tree";
+import type { AnyContentContainerDefinition } from "@/content/containers/types";
+import type { ContentFieldDescriptor } from "@/content/types";
+
+import { AutoForm, setFormFieldError } from "@/components/form/auto-form";
+import { AutoFormSheetFooter } from "@/components/form/auto-form-sheet-footer";
+import { AutoFormInput } from "@/components/form/fields/input";
+import { useDialog } from "@/components/ui/dialog";
+import {
+ buildFormSchemaFromSpec,
+ projectFormField,
+} from "@/content/admin/spec";
+import {
+ CONTENT_CONTAINER_NAME_MAX_LENGTH,
+ CONTENT_CONTAINER_SLUG_MAX_LENGTH,
+} from "@/content/containers/const";
+import { planContentContainerCreate } from "@/content/containers/tree";
+import { ContentField } from "@/views/admin/views/content/lib/field-component";
+
+import type {
+ AdminContentContainer,
+ ContentContainerMutationResult,
+ ContentContainerWriteBody,
+} from "./container-request";
+
+import { contentContainerErrorKey } from "./container-query";
+import {
+ AutoFormContainerSelect,
+ containerSelectOptions,
+} from "./container-select";
+
+export interface ContentContainerSaveArgs {
+ body: ContentContainerWriteBody;
+ id?: number;
+ parentId?: null | number;
+}
+
+export type ContentContainerSave = (
+ args: ContentContainerSaveArgs,
+) => Promise>;
+
+export interface ContentContainerFormProps {
+ container?: AdminContentContainer;
+ definition: AnyContentContainerDefinition;
+ labels: Pick;
+ onSave: ContentContainerSave;
+ onSaved?: () => void;
+ parentId?: null | number;
+ pluginId: string;
+ records: readonly AdminContentContainer[];
+ rules: ContentContainerTreeRules;
+}
+
+const SLUG_ERRORS: ReadonlySet = new Set(["slugEmpty", "slugTaken"]);
+
+const noOptions = async () => await Promise.resolve([]);
+
+const metadataSpec = (
+ definition: AnyContentContainerDefinition,
+ labels: ContentContainerFormProps["labels"],
+ pluginId: string,
+): ContentFormSpec => ({
+ contentTypeId: definition.id,
+ defaultLocale: null,
+ fields: Object.entries(definition.fields).map(([name, descriptor]) =>
+ projectFormField(
+ name,
+ descriptor as ContentFieldDescriptor,
+ labels.labelEnum,
+ fieldName => labels.labelField(fieldName),
+ ),
+ ),
+ permissionModule: definition.admin.permissionModule,
+ pluginId,
+ sections: [],
+ titleField: null,
+});
+
+const metadataForApi = (
+ fields: readonly ContentFormFieldSpec[],
+ values: Record,
+): Record =>
+ Object.fromEntries(
+ fields.flatMap(field => {
+ const value = values[field.name];
+ if (value === undefined) return [];
+ if (
+ value === "" &&
+ field.nullable &&
+ (field.kind === "text" || field.kind === "textarea")
+ ) {
+ return [[field.name, null]];
+ }
+
+ return [[field.name, value]];
+ }),
+ );
+
+export const ContentContainerFormContent = ({
+ container,
+ definition,
+ labels,
+ onSave,
+ onSaved,
+ parentId: initialParentId = null,
+ pluginId,
+ records,
+ rules,
+}: ContentContainerFormProps) => {
+ const t = useTranslations("core.containers");
+ const tError = useTranslations("core.global.errors");
+ const { setIsDirty, setOpen } = useDialog();
+ const isEdit = container !== undefined;
+
+ const spec = metadataSpec(definition, labels, pluginId);
+ const metadataShape = buildFormSchemaFromSpec(
+ spec,
+ container?.metadata,
+ ).shape;
+
+ const parents = isEdit
+ ? []
+ : records.filter(
+ record =>
+ planContentContainerCreate(
+ records,
+ { parentId: record.id, position: undefined },
+ rules,
+ ).ok,
+ );
+ const showParent = !isEdit && rules.hierarchy;
+ const parentIds = new Set(parents.map(record => record.id));
+
+ const formSchema = z.object({
+ name: z
+ .string()
+ .trim()
+ .min(1)
+ .max(CONTENT_CONTAINER_NAME_MAX_LENGTH)
+ .default(container?.name ?? ""),
+ slug: z
+ .string()
+ .trim()
+ .max(CONTENT_CONTAINER_SLUG_MAX_LENGTH)
+ .default(container?.slug ?? ""),
+ ...(showParent
+ ? {
+ parentId: z
+ .number()
+ .int()
+ .positive()
+ .nullable()
+ .default(
+ initialParentId !== null && parentIds.has(initialParentId)
+ ? initialParentId
+ : null,
+ ),
+ }
+ : {}),
+ ...metadataShape,
+ });
+
+ const onSubmit: AutoFormOnSubmit = async (
+ values,
+ form,
+ ) => {
+ const { name, slug, ...rest } = values as Record & {
+ name: string;
+ slug: string;
+ };
+ const parentId = showParent
+ ? ((rest.parentId as null | number | undefined) ?? null)
+ : null;
+ const body: ContentContainerWriteBody = {
+ metadata: metadataForApi(spec.fields, rest),
+ name,
+ ...(slug === "" ? {} : { slug }),
+ };
+
+ const result = await onSave(
+ isEdit ? { body, id: container.id } : { body, parentId },
+ );
+
+ if ("error" in result) {
+ const message = t(contentContainerErrorKey(result.error));
+ if (result.error.code && SLUG_ERRORS.has(result.error.code)) {
+ setFormFieldError(form, "slug", message);
+
+ return;
+ }
+
+ toast.error(tError("title"), { description: message });
+
+ return;
+ }
+
+ toast.success(t(isEdit ? "edit.success" : "create.success"), {
+ description: t(isEdit ? "edit.successDesc" : "create.successDesc", {
+ name,
+ }),
+ });
+ setIsDirty?.(false);
+ setOpen?.(false);
+ onSaved?.();
+ };
+
+ const metadataFields = spec.fields.map(fieldSpec => ({
+ component: (props: ItemAutoFormComponentProps) => (
+
+ ),
+ id: fieldSpec.name,
+ }));
+
+ return (
+ (
+
+ ),
+ id: "name",
+ },
+ {
+ component: props => (
+
+ ),
+ id: "slug",
+ },
+ ...(showParent
+ ? [
+ {
+ component: (props: ItemAutoFormComponentProps) => (
+ ({
+ ...option,
+ depth: option.depth + 1,
+ })),
+ ]}
+ />
+ ),
+ id: "parentId",
+ },
+ ]
+ : []),
+ ...metadataFields,
+ ]}
+ formSchema={formSchema}
+ layout={rendered => (
+ <>
+
+ {rendered.name}
+ {rendered.slug}
+ {showParent ? rendered.parentId : null}
+ {spec.fields.length > 0 ? (
+
+ {spec.fields.map(fieldSpec => (
+
+ {rendered[fieldSpec.name]}
+
+ ))}
+
+ ) : null}
+
+
+ >
+ )}
+ onSubmit={onSubmit}
+ />
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/containers/container-manager.tsx b/packages/vitnode/src/views/admin/views/containers/container-manager.tsx
new file mode 100644
index 000000000..df5d55f67
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/containers/container-manager.tsx
@@ -0,0 +1,702 @@
+import type {
+ Announcements,
+ DragEndEvent,
+ DragMoveEvent,
+ DragOverEvent,
+ DragStartEvent,
+ KeyboardCoordinateGetter,
+ UniqueIdentifier,
+} from "@dnd-kit/core";
+
+import {
+ closestCenter,
+ DndContext,
+ DragOverlay,
+ KeyboardSensor,
+ MeasuringStrategy,
+ TouchSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { cn } from "cn";
+import { FolderTreeIcon, PlusIcon, RotateCwIcon } from "lucide-react";
+import { useReducedMotion } from "motion/react";
+import React from "react";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { ContentContainerLabels } from "@/content/admin/containers";
+import type { AnyContentContainerDefinition } from "@/content/containers/types";
+
+import { useAdminStaffPermission } from "@/components/staff-permission/provider";
+import { Button } from "@/components/ui/button";
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@/components/ui/empty";
+import { PageTitle } from "@/components/ui/page-title";
+import { Skeleton } from "@/components/ui/skeleton";
+import { CONTENT_PERMISSIONS } from "@/content/const";
+import { planContentContainerCreate } from "@/content/containers/tree";
+import { DragLift } from "@/lib/dnd/drag-lift";
+import {
+ DRAG_OVERLAY_LIFT_CLASS,
+ LIFTED_DROP_ANIMATION,
+} from "@/lib/dnd/drag-motion";
+import { FinePointerSensor } from "@/lib/dnd/sensors";
+import { isAdminRequestError } from "@/views/admin/admin-request";
+
+import type { ContentContainerSave } from "./container-form-content";
+import type {
+ AdminContentContainer,
+ ContentContainerMutationResult,
+} from "./container-request";
+import type { ContainerDropProjection } from "./container-tree";
+
+import {
+ ContentContainerDeleteDialog,
+ ContentContainerSheet,
+} from "./container-dialogs";
+import { contentContainerErrorKey } from "./container-query";
+import {
+ ContainerDragChip,
+ type ContainerRowActions,
+ type ContainerRowMoves,
+ SortableContainerRow,
+} from "./container-row";
+import {
+ applyContainerPlacements,
+ canRearrangeContainers,
+ checkContainerMove,
+ CONTAINER_INDENTATION_PX,
+ containerChildCounts,
+ containerDescendants,
+ containerParentTargets,
+ containerSiblings,
+ containerTreeRules,
+ projectContainerDrop,
+ rootContainerMove,
+ shiftContainerMove,
+ visibleContainers,
+} from "./container-tree";
+
+export interface ContentContainerManagerProps {
+ definition: AnyContentContainerDefinition;
+ error?: unknown;
+ icon?: React.ReactNode;
+ isPending: boolean;
+ labels: ContentContainerLabels;
+ onDelete: (id: number) => Promise>;
+ onMove: (move: {
+ id: number;
+ parentId: null | number;
+ position: number;
+ }) => Promise>;
+ onRetry: () => void;
+ onSave: ContentContainerSave;
+ pluginId: string;
+ records: AdminContentContainer[] | undefined;
+}
+
+const containerKeyboardCoordinates: KeyboardCoordinateGetter = (
+ event,
+ args,
+) => {
+ if (event.code === "ArrowLeft" || event.code === "ArrowRight") {
+ event.preventDefault();
+
+ return {
+ x:
+ args.currentCoordinates.x +
+ (event.code === "ArrowRight"
+ ? CONTAINER_INDENTATION_PX
+ : -CONTAINER_INDENTATION_PX),
+ y: args.currentCoordinates.y,
+ };
+ }
+
+ return sortableKeyboardCoordinates(event, args);
+};
+
+const subscribeToNothing = () => () => undefined;
+const getAnnouncementContainer = () => document.body;
+const getNoAnnouncementContainer = () => null;
+
+const ContainerListSkeleton = () => (
+
+ {["w-48", "w-40", "w-56", "w-36"].map((width, index) => (
+
+ {index % 2 === 1 ? : null}
+
+
+
+ ))}
+
+);
+
+const ContainerListError = ({
+ error,
+ onRetry,
+ plural,
+}: {
+ error: unknown;
+ onRetry: () => void;
+ plural: string;
+}) => {
+ const t = useTranslations("core.containers.error");
+ const forbidden = isAdminRequestError(error) && error.status === 403;
+
+ return (
+
+
+
+
+
+ {t("title", { name: plural })}
+
+ {t(forbidden ? "forbidden" : "desc")}
+
+
+ {forbidden ? null : (
+
+
+
+ {t("retry")}
+
+
+ )}
+
+ );
+};
+
+const useOverlay = () => {
+ const [state, setState] = React.useState<{
+ id: null | number;
+ isOpen: boolean;
+ parentId: null | number;
+ session: number;
+ }>({ id: null, isOpen: false, parentId: null, session: 0 });
+
+ return {
+ ...state,
+ setOpen: (isOpen: boolean) => {
+ setState(current => ({ ...current, isOpen }));
+ },
+ show: (next: { id?: number; parentId?: null | number }) => {
+ setState(current => ({
+ id: next.id ?? null,
+ isOpen: true,
+ parentId: next.parentId ?? null,
+ session: current.session + 1,
+ }));
+ },
+ };
+};
+
+export const ContentContainerManager = ({
+ definition,
+ error,
+ icon,
+ isPending,
+ labels,
+ onDelete,
+ onMove,
+ onRetry,
+ onSave,
+ pluginId,
+ records,
+}: ContentContainerManagerProps) => {
+ const t = useTranslations("core.containers");
+ const reduceMotion = useReducedMotion();
+ const rules = containerTreeRules(definition);
+ const permissionOf = (permission: string) => ({
+ module: definition.admin.permissionModule,
+ permission,
+ plugin: pluginId,
+ });
+ const canCreate = useAdminStaffPermission(
+ permissionOf(CONTENT_PERMISSIONS.create),
+ );
+ const canEdit = useAdminStaffPermission(
+ permissionOf(CONTENT_PERMISSIONS.edit),
+ );
+ const canDelete = useAdminStaffPermission(
+ permissionOf(CONTENT_PERMISSIONS.delete),
+ );
+ const canMove = canEdit && canRearrangeContainers(rules);
+ const permissions = { canCreate, canDelete, canEdit, canMove };
+
+ const [ordered, setOrdered] = React.useState(records ?? []);
+ const [synced, setSynced] = React.useState(records);
+ if (synced !== records) {
+ setSynced(records);
+ setOrdered(records ?? []);
+ }
+
+ const [collapsed, setCollapsed] = React.useState>(
+ () => new Set(),
+ );
+ const [activeId, setActiveId] = React.useState(null);
+ const [overId, setOverId] = React.useState(null);
+ const [offsetLeft, setOffsetLeft] = React.useState(0);
+ const [isMoving, startMoving] = React.useTransition();
+ const sheet = useOverlay();
+ const [sheetMode, setSheetMode] = React.useState<"create" | "edit">("create");
+ const deleting = useOverlay();
+ const announcementContainer = React.useSyncExternalStore(
+ subscribeToNothing,
+ getAnnouncementContainer,
+ getNoAnnouncementContainer,
+ );
+
+ const childCounts = React.useMemo(
+ () => containerChildCounts(ordered),
+ [ordered],
+ );
+ const shown = React.useMemo(
+ () => visibleContainers(ordered, collapsed),
+ [collapsed, ordered],
+ );
+ const activeDescendants = React.useMemo(
+ () => (activeId === null ? [] : containerDescendants(ordered, activeId)),
+ [activeId, ordered],
+ );
+ const visible = React.useMemo(() => {
+ if (activeDescendants.length === 0) return shown;
+ const travelling = new Set(activeDescendants.map(record => record.id));
+
+ return shown.filter(record => !travelling.has(record.id));
+ }, [activeDescendants, shown]);
+ const parentIds = ordered
+ .filter(record => (childCounts.get(record.id) ?? 0) > 0)
+ .map(record => record.id);
+
+ const activeRecord =
+ activeId === null
+ ? undefined
+ : ordered.find(record => record.id === activeId);
+ const nameOf = (id: UniqueIdentifier) =>
+ ordered.find(record => record.id === Number(id))?.name ?? String(id);
+
+ const projectionFor = (
+ target: null | number,
+ ): ContainerDropProjection | null =>
+ activeId !== null && target !== null
+ ? projectContainerDrop({
+ activeId,
+ offsetLeft,
+ overId: target,
+ records: ordered,
+ rules,
+ visible,
+ })
+ : null;
+ const projection = projectionFor(overId);
+
+ const sensors = useSensors(
+ useSensor(FinePointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(TouchSensor, {
+ activationConstraint: { delay: 150, tolerance: 5 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: containerKeyboardCoordinates,
+ }),
+ );
+
+ const expand = (id: number) => {
+ setCollapsed(current => {
+ if (!current.has(id)) return current;
+ const next = new Set(current);
+ next.delete(id);
+
+ return next;
+ });
+ };
+
+ const commitMove = (move: {
+ id: number;
+ parentId: null | number;
+ position: number;
+ }) => {
+ const name = nameOf(move.id);
+ const check = checkContainerMove(ordered, move, rules);
+ if (check.kind === "unchanged") return;
+ if (check.kind === "invalid") {
+ toast.error(t("list.moveFailed", { name }), {
+ description: t(`errors.${check.code}`),
+ });
+
+ return;
+ }
+
+ const previous = ordered;
+ setOrdered(applyContainerPlacements(ordered, check.placements, rules));
+ if (move.parentId !== null) expand(move.parentId);
+
+ startMoving(async () => {
+ const result = await onMove(move);
+
+ if ("error" in result) {
+ setOrdered(previous);
+ toast.error(t("list.moveFailed", { name }), {
+ description: t(contentContainerErrorKey(result.error)),
+ });
+
+ return;
+ }
+
+ toast.success(t("list.moveSuccess"), {
+ description: t("list.moveSuccessDesc", { name }),
+ });
+ });
+ };
+
+ const describeDrop = (
+ kind: "dropped" | "over",
+ id: UniqueIdentifier,
+ target: null | number,
+ ): string => {
+ const name = nameOf(id);
+ const drop = projectionFor(target);
+ if (!drop || drop.check.kind === "unchanged") {
+ return t("dnd.unchanged", { name });
+ }
+ if (drop.check.kind === "invalid") {
+ return t(`dnd.${kind}Invalid`, {
+ name,
+ reason: t(`errors.${drop.check.code}`),
+ });
+ }
+
+ const values = {
+ name,
+ position: drop.position + 1,
+ sorted: String(rules.mode === "alphabetical"),
+ };
+
+ return drop.parentId === null
+ ? t(`dnd.${kind}Root`, values)
+ : t(`dnd.${kind}Inside`, { ...values, parent: nameOf(drop.parentId) });
+ };
+
+ const announcements: Announcements = {
+ onDragCancel: ({ active }) =>
+ t("dnd.cancelled", { name: nameOf(active.id) }),
+ onDragEnd: ({ active, over }) =>
+ over
+ ? describeDrop("dropped", active.id, Number(over.id))
+ : t("dnd.cancelled", { name: nameOf(active.id) }),
+ onDragOver: ({ active, over }) =>
+ over ? describeDrop("over", active.id, Number(over.id)) : undefined,
+ onDragStart: ({ active }) => t("dnd.started", { name: nameOf(active.id) }),
+ };
+
+ const reset = () => {
+ setActiveId(null);
+ setOverId(null);
+ setOffsetLeft(0);
+ };
+
+ const onDragStart = ({ active }: DragStartEvent) => {
+ setActiveId(Number(active.id));
+ setOverId(Number(active.id));
+ setOffsetLeft(0);
+ };
+
+ const onDragMove = ({ delta }: DragMoveEvent) => {
+ setOffsetLeft(delta.x);
+ };
+
+ const onDragOver = ({ over }: DragOverEvent) => {
+ setOverId(over ? Number(over.id) : null);
+ };
+
+ const onDragEnd = ({ over }: DragEndEvent) => {
+ const dragged = activeId;
+ const drop = over ? projectionFor(Number(over.id)) : null;
+ reset();
+
+ if (dragged === null || !drop) return;
+ if (drop.check.kind === "invalid") {
+ toast.error(t("list.moveFailed", { name: nameOf(dragged) }), {
+ description: t(`errors.${drop.check.code}`),
+ });
+
+ return;
+ }
+
+ commitMove({
+ id: dragged,
+ parentId: drop.parentId,
+ position: drop.position,
+ });
+ };
+
+ const actions: ContainerRowActions = {
+ onAddChild: parentId => {
+ expand(parentId);
+ setSheetMode("create");
+ sheet.show({ parentId });
+ },
+ onDelete: id => {
+ deleting.show({ id });
+ },
+ onEdit: id => {
+ setSheetMode("edit");
+ sheet.show({ id });
+ },
+ onMoveInside: (id, parentId) => {
+ commitMove({ id, parentId, position: childCounts.get(parentId) ?? 0 });
+ },
+ onMoveToRoot: id => {
+ const move = rootContainerMove(ordered, id);
+ if (move) commitMove(move);
+ },
+ onShift: (id, step) => {
+ const move = shiftContainerMove(ordered, id, step);
+ if (move) commitMove(move);
+ },
+ onToggle: id => {
+ setCollapsed(current => {
+ const next = new Set(current);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+
+ return next;
+ });
+ },
+ };
+
+ const movesOf = (record: AdminContentContainer): ContainerRowMoves => {
+ const canAddChild =
+ canCreate &&
+ planContentContainerCreate(
+ ordered,
+ { parentId: record.id, position: undefined },
+ rules,
+ ).ok;
+
+ if (!canMove || isMoving) {
+ return {
+ canAddChild,
+ canMoveDown: false,
+ canMoveToRoot: false,
+ canMoveUp: false,
+ parents: [],
+ };
+ }
+
+ const siblings = containerSiblings(ordered, record.parentId);
+ const at = siblings.findIndex(entry => entry.id === record.id);
+ const manual = rules.mode === "manual";
+
+ return {
+ canAddChild,
+ canMoveDown: manual && at < siblings.length - 1,
+ canMoveToRoot: rules.hierarchy && record.parentId !== null,
+ canMoveUp: manual && at > 0,
+ parents: containerParentTargets(ordered, record.id, rules),
+ };
+ };
+
+ const openCreate = () => {
+ setSheetMode("create");
+ sheet.show({});
+ };
+
+ const createButton = canCreate ? (
+
+
+ {t("create.title", { name: labels.singular })}
+
+ ) : null;
+
+ const renderBody = () => {
+ if (records === undefined && isPending) return ;
+
+ if (records === undefined) {
+ return (
+
+ );
+ }
+
+ if (ordered.length === 0) {
+ return (
+
+
+
+
+
+
+ {t("empty.title", { name: labels.plural })}
+
+
+ {t("empty.desc")}
+
+
+ {createButton ? {createButton} : null}
+
+ );
+ }
+
+ return (
+
+
+
+ {[
+ t("list.summary", { count: ordered.length }),
+ rules.hierarchy
+ ? t("list.depth", { max: rules.maxDepth })
+ : t("list.flat"),
+ ...(rules.mode === "alphabetical" ? [t("list.sorted")] : []),
+ ].join(" · ")}
+
+ {parentIds.length > 0 ? (
+ {
+ setCollapsed(
+ collapsed.size > 0 ? new Set() : new Set(parentIds),
+ );
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ {collapsed.size > 0 ? t("list.expandAll") : t("list.collapseAll")}
+
+ ) : null}
+
+
+
+ record.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {visible.map(record => {
+ const isActive = record.id === activeId;
+
+ return (
+
+ );
+ })}
+
+
+
+
+ {activeRecord ? (
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+ };
+
+ const sheetContainer =
+ sheet.id === null
+ ? undefined
+ : ordered.find(record => record.id === sheet.id);
+ const deletingContainer =
+ deleting.id === null
+ ? undefined
+ : ordered.find(record => record.id === deleting.id);
+
+ return (
+ <>
+
+ {records === undefined || ordered.length === 0 ? null : createButton}
+
+
+ {renderBody()}
+
+
+
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/containers/container-query.ts b/packages/vitnode/src/views/admin/views/containers/container-query.ts
new file mode 100644
index 000000000..fa50f7c86
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/containers/container-query.ts
@@ -0,0 +1,61 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { queryOptions } from "@tanstack/react-query";
+
+import type { ContentContainerErrorCode } from "@/content/containers/types";
+
+import { RECORD_STALE_TIME } from "@/lib/query-freshness";
+import { isAdminRequestError } from "@/views/admin/admin-request";
+import { adminQueryRoot } from "@/views/admin/table/query";
+
+import type { ContentContainerApiTarget } from "./container-request";
+
+import { fetchContentContainers } from "./container-request";
+
+export const contentContainerQueryRoot = (definitionId: string) =>
+ [...adminQueryRoot("containers"), definitionId] as const;
+
+export const contentContainerListQueryKey = (definitionId: string) =>
+ [...contentContainerQueryRoot(definitionId), "list"] as const;
+
+export const contentContainerListQuery = ({
+ definitionId,
+ target,
+}: {
+ definitionId: string;
+ target: ContentContainerApiTarget;
+}) =>
+ queryOptions({
+ queryFn: async ({ signal }) =>
+ await fetchContentContainers(target, { signal }),
+ queryKey: contentContainerListQueryKey(definitionId),
+ retry: (failureCount, error) =>
+ !(isAdminRequestError(error) && error.status < 500) && failureCount < 2,
+ staleTime: RECORD_STALE_TIME,
+ });
+
+export const invalidateContentContainers = async (
+ queryClient: QueryClient,
+ definitionId: string,
+): Promise => {
+ await queryClient.invalidateQueries({
+ queryKey: contentContainerQueryRoot(definitionId),
+ });
+};
+
+export type ContentContainerErrorKey =
+ `errors.${"forbidden" | "unknown" | ContentContainerErrorCode}`;
+
+export const contentContainerErrorKey = ({
+ code,
+ status,
+}: {
+ code?: ContentContainerErrorCode;
+ status: number;
+}): ContentContainerErrorKey => {
+ if (code !== undefined) return `errors.${code}`;
+ if (status === 403) return "errors.forbidden";
+ if (status === 404) return "errors.notFound";
+
+ return "errors.unknown";
+};
diff --git a/packages/vitnode/src/views/admin/views/containers/container-request.ts b/packages/vitnode/src/views/admin/views/containers/container-request.ts
new file mode 100644
index 000000000..d849ae4b8
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/containers/container-request.ts
@@ -0,0 +1,164 @@
+import { z } from "zod";
+
+import type { ContentContainerErrorCode } from "@/content/containers/types";
+
+import { CONTENT_CONTAINER_ERROR_CODES } from "@/content/containers/const";
+import { rawFetcher } from "@/tanstack/fetcher";
+import { AdminRequestError } from "@/views/admin/admin-request";
+
+export interface ContentContainerApiTarget {
+ permissionModule: string;
+ pluginId: string;
+}
+
+export const contentContainerApiTarget = ({
+ definition,
+ pluginId,
+}: {
+ definition: { admin: { permissionModule: string } };
+ pluginId: string;
+}): ContentContainerApiTarget => ({
+ permissionModule: definition.admin.permissionModule,
+ pluginId,
+});
+
+const adminContentContainerSchema = z.object({
+ createdAt: z.string(),
+ depth: z.number().int().positive(),
+ id: z.number().int().positive(),
+ metadata: z.record(z.string(), z.unknown()),
+ name: z.string(),
+ parentId: z.number().int().positive().nullable(),
+ position: z.number().int().min(0),
+ slug: z.string(),
+ updatedAt: z.string(),
+});
+
+export type AdminContentContainer = z.infer;
+
+const listSchema = z.object({ items: z.array(adminContentContainerSchema) });
+
+const errorSchema = z.object({ code: z.enum(CONTENT_CONTAINER_ERROR_CODES) });
+
+const describe = (target: ContentContainerApiTarget) =>
+ `the ${target.permissionModule} containers of ${target.pluginId}`;
+
+const requestArgs = (target: ContentContainerApiTarget) => ({
+ module: `content/${target.permissionModule}`,
+ pluginId: target.pluginId,
+ prefixPath: "/admin",
+});
+
+export const fetchContentContainers = async (
+ target: ContentContainerApiTarget,
+ { signal }: { signal?: AbortSignal } = {},
+): Promise => {
+ const response = await rawFetcher({
+ ...requestArgs(target),
+ method: "get",
+ options: { signal },
+ path: "/",
+ });
+
+ if (!response.ok) {
+ throw new AdminRequestError(response.status, describe(target));
+ }
+
+ const parsed = listSchema.safeParse(await response.json());
+ if (!parsed.success) {
+ throw new AdminRequestError(
+ response.status,
+ describe(target),
+ "the API returned a response this collection does not describe",
+ );
+ }
+
+ return parsed.data.items;
+};
+
+export type ContentContainerMutationResult =
+ | { data: T }
+ | { error: { code?: ContentContainerErrorCode; status: number } };
+
+const readError = async (
+ response: Response,
+): Promise => {
+ try {
+ const parsed = errorSchema.safeParse(await response.json());
+
+ return parsed.success ? parsed.data.code : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+const send = async (
+ target: ContentContainerApiTarget,
+ request: {
+ body?: unknown;
+ method: "delete" | "patch" | "post";
+ params?: Record;
+ path: string;
+ },
+): Promise> => {
+ let response: Response;
+ try {
+ response = await rawFetcher({ ...requestArgs(target), ...request });
+ } catch {
+ return { error: { status: 500 } };
+ }
+
+ if (!response.ok) {
+ return {
+ error: { code: await readError(response), status: response.status },
+ };
+ }
+
+ return { data: null };
+};
+
+export interface ContentContainerWriteBody {
+ metadata?: Record;
+ name?: string;
+ slug?: string;
+}
+
+export const createContentContainer = async (
+ target: ContentContainerApiTarget,
+ body: ContentContainerWriteBody & { name: string; parentId: null | number },
+): Promise> =>
+ await send(target, { body, method: "post", path: "/" });
+
+export const updateContentContainer = async (
+ target: ContentContainerApiTarget,
+ id: number,
+ body: ContentContainerWriteBody,
+): Promise> =>
+ await send(target, {
+ body,
+ method: "patch",
+ params: { id },
+ path: "/{id}",
+ });
+
+export const moveContentContainer = async (
+ target: ContentContainerApiTarget,
+ id: number,
+ body: { parentId: null | number; position: number },
+): Promise> =>
+ await send(target, {
+ body,
+ method: "post",
+ params: { id },
+ path: "/{id}/move",
+ });
+
+export const deleteContentContainer = async (
+ target: ContentContainerApiTarget,
+ id: number,
+): Promise> =>
+ await send(target, {
+ method: "delete",
+ params: { id },
+ path: "/{id}",
+ });
diff --git a/packages/vitnode/src/views/admin/views/containers/container-row.tsx b/packages/vitnode/src/views/admin/views/containers/container-row.tsx
new file mode 100644
index 000000000..7c03c6727
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/containers/container-row.tsx
@@ -0,0 +1,449 @@
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { cn } from "cn";
+import {
+ ArrowDownIcon,
+ ArrowUpIcon,
+ ChevronRightIcon,
+ CornerDownRightIcon,
+ CornerLeftUpIcon,
+ FolderIcon,
+ GripVerticalIcon,
+ MoreHorizontalIcon,
+ PencilIcon,
+ PlusIcon,
+ Trash2Icon,
+} from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+import type { AdminContentContainer } from "./container-request";
+
+import { containerGuideLevels } from "./container-select";
+
+export interface ContainerRowPermissions {
+ canCreate: boolean;
+ canDelete: boolean;
+ canEdit: boolean;
+ canMove: boolean;
+}
+
+export interface ContainerRowActions {
+ onAddChild: (parentId: number) => void;
+ onDelete: (id: number) => void;
+ onEdit: (id: number) => void;
+ onMoveInside: (id: number, parentId: number) => void;
+ onMoveToRoot: (id: number) => void;
+ onShift: (id: number, step: -1 | 1) => void;
+ onToggle: (id: number) => void;
+}
+
+export interface ContainerRowMoves {
+ canAddChild: boolean;
+ canMoveDown: boolean;
+ canMoveToRoot: boolean;
+ canMoveUp: boolean;
+ parents: readonly AdminContentContainer[];
+}
+
+const IndentGuides = ({ depth }: { depth: number }) =>
+ containerGuideLevels(depth).map(level => (
+
+ ));
+
+const ContainerGlyph = ({ icon }: { icon?: React.ReactNode }) => (
+
+ {icon ?? }
+
+);
+
+const ContainerRowMenu = ({
+ actions,
+ container,
+ moves,
+ permissions,
+}: {
+ actions: ContainerRowActions;
+ container: AdminContentContainer;
+ moves: ContainerRowMoves;
+ permissions: ContainerRowPermissions;
+}) => {
+ const t = useTranslations("core.containers.list");
+ const { id, name } = container;
+ const showMoves =
+ permissions.canMove &&
+ (moves.canMoveUp ||
+ moves.canMoveDown ||
+ moves.canMoveToRoot ||
+ moves.parents.length > 0);
+
+ return (
+
+
+ }
+ >
+
+
+
+
+ {permissions.canEdit ? (
+ {
+ actions.onEdit(id);
+ }}
+ >
+
+ {t("edit")}
+
+ ) : null}
+ {moves.canAddChild ? (
+ {
+ actions.onAddChild(id);
+ }}
+ >
+
+ {t("addChild")}
+
+ ) : null}
+
+ {showMoves ? (
+ <>
+ {permissions.canEdit || moves.canAddChild ? (
+
+ ) : null}
+ {moves.canMoveUp || moves.canMoveDown ? (
+ <>
+ {
+ actions.onShift(id, -1);
+ }}
+ >
+
+ {t("moveUp")}
+
+ {
+ actions.onShift(id, 1);
+ }}
+ >
+
+ {t("moveDown")}
+
+ >
+ ) : null}
+ {moves.canMoveToRoot ? (
+ {
+ actions.onMoveToRoot(id);
+ }}
+ >
+
+ {t("moveToTop")}
+
+ ) : null}
+ {moves.parents.length > 0 ? (
+
+
+
+ {t("moveInside")}
+
+
+ {moves.parents.map(parent => (
+ {
+ actions.onMoveInside(id, parent.id);
+ }}
+ >
+
+ {parent.name}
+
+ ))}
+
+
+ ) : null}
+ >
+ ) : null}
+
+ {permissions.canDelete ? (
+ <>
+ {permissions.canEdit || moves.canAddChild || showMoves ? (
+
+ ) : null}
+ {
+ actions.onDelete(id);
+ }}
+ variant="destructive"
+ >
+
+ {t("delete")}
+
+ >
+ ) : null}
+
+
+ );
+};
+
+const ContainerRowExpander = ({
+ childCount,
+ isCollapsed,
+ name,
+ onToggle,
+}: {
+ childCount: number;
+ isCollapsed: boolean;
+ name: string;
+ onToggle: () => void;
+}) => {
+ const t = useTranslations("core.containers.list");
+
+ if (childCount === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+};
+
+const ContainerRowLabel = ({
+ childCount,
+ container,
+ icon,
+ isCollapsed,
+}: {
+ childCount: number;
+ container: AdminContentContainer;
+ icon?: React.ReactNode;
+ isCollapsed: boolean;
+}) => (
+ <>
+
+ {container.name}
+ {isCollapsed && childCount > 0 ? (
+
+ +{childCount}
+
+ ) : null}
+
+ {container.slug}
+
+ >
+);
+
+export const SortableContainerRow = ({
+ actions,
+ childCount,
+ container,
+ depth,
+ disabled,
+ icon,
+ isCollapsed,
+ isInvalidDrop,
+ moves,
+ permissions,
+}: {
+ actions: ContainerRowActions;
+ childCount: number;
+ container: AdminContentContainer;
+ depth: number;
+ disabled: boolean;
+ icon?: React.ReactNode;
+ isCollapsed: boolean;
+ isInvalidDrop: boolean;
+ moves: ContainerRowMoves;
+ permissions: ContainerRowPermissions;
+}) => {
+ const t = useTranslations("core.containers.list");
+ const {
+ attributes,
+ isDragging,
+ listeners,
+ setActivatorNodeRef,
+ setNodeRef,
+ transform,
+ transition,
+ } = useSortable({
+ disabled: !permissions.canMove || disabled,
+ id: container.id,
+ });
+ const showMenu =
+ permissions.canEdit ||
+ permissions.canDelete ||
+ moves.canAddChild ||
+ permissions.canMove;
+
+ return (
+
+
+
+
+ {permissions.canMove ? (
+
+
+
+ ) : null}
+
+ {
+ actions.onToggle(container.id);
+ }}
+ />
+
+ {permissions.canEdit ? (
+ {
+ actions.onEdit(container.id);
+ }}
+ type="button"
+ >
+ {t("edit")}
+
+
+ ) : (
+
+
+
+ )}
+
+ {showMenu ? (
+
+ ) : null}
+
+
+ );
+};
+
+export const ContainerDragChip = ({
+ container,
+ depth,
+ descendants,
+ icon,
+ isInvalidDrop,
+}: {
+ container: AdminContentContainer;
+ depth: number;
+ descendants: number;
+ icon?: React.ReactNode;
+ isInvalidDrop: boolean;
+}) => (
+
+ {containerGuideLevels(depth).map(level => (
+
+ ))}
+
+
+
+
+
+
+ {container.name}
+ {descendants > 0 ? (
+
+ +{descendants}
+
+ ) : null}
+
+
+
+);
diff --git a/packages/vitnode/src/views/admin/views/containers/container-select.tsx b/packages/vitnode/src/views/admin/views/containers/container-select.tsx
new file mode 100644
index 000000000..a8e9cadfc
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/containers/container-select.tsx
@@ -0,0 +1,113 @@
+import { useTranslations } from "use-intl";
+
+import type { ItemAutoFormComponentProps } from "@/components/form/auto-form";
+
+import { AutoFormDesc } from "@/components/form/common/desc";
+import { AutoFormLabel } from "@/components/form/common/label";
+import { FormControl, FormMessage } from "@/components/ui/form";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+import type { AdminContentContainer } from "./container-request";
+
+export const containerGuideLevels = (depth: number): number[] =>
+ Array.from({ length: Math.max(0, depth - 1) }, (_, at) => at + 1);
+
+const NONE = "__none__";
+
+export interface ContainerSelectOption {
+ depth: number;
+ id: null | number;
+ label: string;
+}
+
+export const containerSelectOptions = (
+ records: readonly Pick[],
+): ContainerSelectOption[] =>
+ records.map(record => ({
+ depth: record.depth,
+ id: record.id,
+ label: record.name,
+ }));
+
+const keyOf = (id: null | number) => (id === null ? NONE : String(id));
+
+export const AutoFormContainerSelect = ({
+ description,
+ disabled,
+ field,
+ label,
+ labelRight,
+ options,
+ otherProps: { isOptional },
+ placeholder,
+}: Pick<
+ ItemAutoFormComponentProps,
+ "description" | "field" | "label" | "labelRight" | "otherProps"
+> & {
+ disabled?: boolean;
+ options: readonly ContainerSelectOption[];
+ placeholder?: string;
+}) => {
+ const t = useTranslations("core.global");
+ const items = options.map(option => ({
+ label: option.label,
+ value: keyOf(option.id),
+ }));
+ const current = field.value as null | number | undefined;
+ const value =
+ current === null || current === undefined
+ ? options.some(option => option.id === null)
+ ? NONE
+ : null
+ : String(current);
+
+ return (
+ <>
+ {!!label && (
+
+ {label}
+
+ )}
+
+
+ {
+ if (typeof next !== "string") return;
+ field.onChange(next === NONE ? null : Number(next));
+ }}
+ value={value}
+ >
+
+
+
+
+
+ {options.map(option => (
+
+ {containerGuideLevels(option.depth).map(level => (
+
+ ))}
+ {option.label}
+
+ ))}
+
+
+
+
+ {!!description && {description} }
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/containers/container-tree.ts b/packages/vitnode/src/views/admin/views/containers/container-tree.ts
new file mode 100644
index 000000000..affb228e2
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/containers/container-tree.ts
@@ -0,0 +1,252 @@
+import { arrayMove } from "@dnd-kit/sortable";
+
+import type {
+ ContentContainerPlacement,
+ ContentContainerTreeRules,
+} from "@/content/containers/tree";
+import type {
+ AnyContentContainerDefinition,
+ ContentContainerErrorCode,
+} from "@/content/containers/types";
+
+import {
+ contentContainerAncestors,
+ orderContentContainers,
+ planContentContainerMove,
+} from "@/content/containers/tree";
+
+import type { AdminContentContainer } from "./container-request";
+
+export const CONTAINER_INDENTATION_PX = 24;
+
+export const containerTreeRules = (
+ definition: Pick,
+): ContentContainerTreeRules => ({
+ hierarchy: definition.hierarchy.enabled,
+ maxDepth: definition.hierarchy.maxDepth,
+ mode: definition.ordering.mode,
+});
+
+export const canRearrangeContainers = (rules: ContentContainerTreeRules) =>
+ rules.mode === "manual" || rules.hierarchy;
+
+export const containerChildCounts = (
+ records: readonly AdminContentContainer[],
+): Map => {
+ const counts = new Map();
+ for (const record of records) {
+ if (record.parentId === null) continue;
+ counts.set(record.parentId, (counts.get(record.parentId) ?? 0) + 1);
+ }
+
+ return counts;
+};
+
+export const containerDescendants = (
+ records: readonly AdminContentContainer[],
+ id: number,
+): AdminContentContainer[] => {
+ const at = records.findIndex(record => record.id === id);
+ if (at === -1) return [];
+
+ const { depth } = records[at];
+ const descendants: AdminContentContainer[] = [];
+ for (const record of records.slice(at + 1)) {
+ if (record.depth <= depth) break;
+ descendants.push(record);
+ }
+
+ return descendants;
+};
+
+export const visibleContainers = (
+ records: readonly AdminContentContainer[],
+ collapsed: ReadonlySet,
+): AdminContentContainer[] => {
+ const visible: AdminContentContainer[] = [];
+ let hiddenBelow: null | number = null;
+
+ for (const record of records) {
+ if (hiddenBelow !== null && record.depth > hiddenBelow) continue;
+ hiddenBelow = collapsed.has(record.id) ? record.depth : null;
+ visible.push(record);
+ }
+
+ return visible;
+};
+
+export type ContainerMoveCheck =
+ | { code: ContentContainerErrorCode; kind: "invalid" }
+ | { kind: "move"; placements: ContentContainerPlacement[] }
+ | { kind: "unchanged" };
+
+export const checkContainerMove = (
+ records: readonly AdminContentContainer[],
+ move: { id: number; parentId: null | number; position: number },
+ rules: ContentContainerTreeRules,
+): ContainerMoveCheck => {
+ const current = records.find(record => record.id === move.id);
+ if (rules.mode === "alphabetical" && current?.parentId === move.parentId) {
+ return { kind: "unchanged" };
+ }
+
+ const plan = planContentContainerMove(records, move, rules);
+ if (!plan.ok) return { code: plan.code, kind: "invalid" };
+
+ return plan.placements.length === 0
+ ? { kind: "unchanged" }
+ : { kind: "move", placements: plan.placements };
+};
+
+export const applyContainerPlacements = (
+ records: readonly AdminContentContainer[],
+ placements: readonly ContentContainerPlacement[],
+ rules: ContentContainerTreeRules,
+): AdminContentContainer[] => {
+ const byId = new Map(placements.map(placement => [placement.id, placement]));
+
+ return orderContentContainers(
+ records.map(record => {
+ const placement = byId.get(record.id);
+
+ return placement
+ ? {
+ ...record,
+ parentId: placement.parentId,
+ position: placement.position,
+ }
+ : record;
+ }),
+ rules.mode,
+ );
+};
+
+export interface ContainerDropProjection {
+ check: ContainerMoveCheck;
+ depth: number;
+ parentId: null | number;
+ position: number;
+}
+
+const parentAt = (
+ entries: readonly AdminContentContainer[],
+ index: number,
+ depth: number,
+): null | number => {
+ if (depth <= 1) return null;
+
+ for (let at = index - 1; at >= 0; at -= 1) {
+ if (entries[at].depth === depth - 1) return entries[at].id;
+ if (entries[at].depth < depth - 1) break;
+ }
+
+ return null;
+};
+
+export const projectContainerDrop = ({
+ activeId,
+ offsetLeft,
+ overId,
+ records,
+ rules,
+ visible,
+}: {
+ activeId: number;
+ offsetLeft: number;
+ overId: number;
+ records: readonly AdminContentContainer[];
+ rules: ContentContainerTreeRules;
+ visible: readonly AdminContentContainer[];
+}): ContainerDropProjection | null => {
+ const from = visible.findIndex(record => record.id === activeId);
+ const to = visible.findIndex(record => record.id === overId);
+ if (from === -1 || to === -1) return null;
+
+ const moved = arrayMove([...visible], from, to);
+ const active = visible[from];
+ const previous = moved[to - 1] as AdminContentContainer | undefined;
+ const next = moved[to + 1] as AdminContentContainer | undefined;
+
+ const projected =
+ active.depth + Math.round(offsetLeft / CONTAINER_INDENTATION_PX);
+ const maxDepth = rules.hierarchy ? (previous ? previous.depth + 1 : 1) : 1;
+ const minDepth = rules.hierarchy && next ? next.depth : 1;
+ const depth = Math.max(1, Math.min(Math.max(projected, minDepth), maxDepth));
+ const parentId = parentAt(moved, to, depth);
+ const position = moved
+ .slice(0, to)
+ .filter(
+ record => record.id !== activeId && record.parentId === parentId,
+ ).length;
+
+ return {
+ check: checkContainerMove(
+ records,
+ { id: activeId, parentId, position },
+ rules,
+ ),
+ depth,
+ parentId,
+ position,
+ };
+};
+
+export const containerSiblings = (
+ records: readonly AdminContentContainer[],
+ parentId: null | number,
+): AdminContentContainer[] =>
+ records.filter(record => record.parentId === parentId);
+
+export const shiftContainerMove = (
+ records: readonly AdminContentContainer[],
+ id: number,
+ step: -1 | 1,
+): null | { id: number; parentId: null | number; position: number } => {
+ const record = records.find(entry => entry.id === id);
+ if (!record) return null;
+
+ const siblings = containerSiblings(records, record.parentId);
+ const at = siblings.findIndex(entry => entry.id === id);
+ const position = at + step;
+ if (at === -1 || position < 0 || position >= siblings.length) return null;
+
+ return { id, parentId: record.parentId, position };
+};
+
+export const rootContainerMove = (
+ records: readonly AdminContentContainer[],
+ id: number,
+): null | { id: number; parentId: null; position: number } => {
+ const record = records.find(entry => entry.id === id);
+ if (record?.parentId === null || record === undefined) return null;
+
+ const root = contentContainerAncestors(records, id)[0];
+ const roots = containerSiblings(records, null);
+
+ return {
+ id,
+ parentId: null,
+ position: root ? roots.findIndex(entry => entry.id === root.id) + 1 : 0,
+ };
+};
+
+export const containerParentTargets = (
+ records: readonly AdminContentContainer[],
+ id: number,
+ rules: ContentContainerTreeRules,
+): AdminContentContainer[] => {
+ if (!rules.hierarchy) return [];
+
+ const record = records.find(entry => entry.id === id);
+ const counts = containerChildCounts(records);
+
+ return records.filter(
+ target =>
+ target.id !== record?.parentId &&
+ checkContainerMove(
+ records,
+ { id, parentId: target.id, position: counts.get(target.id) ?? 0 },
+ rules,
+ ).kind === "move",
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx
index a83c65e21..deceaa020 100644
--- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx
+++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx
@@ -22,6 +22,7 @@ const TEXTAREA_PREVIEW_LINES = 8;
const TOKEN_KINDS = new Set([
"boolean",
+ "container",
"dateTime",
"number",
"relation",
@@ -115,6 +116,9 @@ const Value = ({
case "boolean":
return {value === true ? "✓" : "—"} ;
+ case "container":
+ return {String(value)} ;
+
case "dateTime":
return ;
diff --git a/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx b/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx
index 987dd5e08..0d850dff0 100644
--- a/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx
+++ b/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx
@@ -42,6 +42,7 @@ interface SkeletonFieldSource {
const controlOfKind: Record = {
boolean: "switch",
+ container: "input",
dateTime: "input",
enum: "input",
file: "media",
diff --git a/packages/vitnode/src/views/admin/views/content/lib/container-field.tsx b/packages/vitnode/src/views/admin/views/content/lib/container-field.tsx
new file mode 100644
index 000000000..4a6226b2a
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/content/lib/container-field.tsx
@@ -0,0 +1,144 @@
+import { useQuery } from "@tanstack/react-query";
+import { RotateCwIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import type { ItemAutoFormComponentProps } from "@/components/form/auto-form";
+import type { ContentFormFieldSpec } from "@/content/admin/spec";
+
+import { AutoFormLabel } from "@/components/form/common/label";
+import { Button } from "@/components/ui/button";
+import { FormMessage } from "@/components/ui/form";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ contentFrontendRegistry,
+ hasContentFrontendRegistry,
+} from "@/content/admin/registry";
+import { isAdminRequestError } from "@/views/admin/admin-request";
+import { contentContainerListQuery } from "@/views/admin/views/containers/container-query";
+import { contentContainerApiTarget } from "@/views/admin/views/containers/container-request";
+import {
+ AutoFormContainerSelect,
+ containerSelectOptions,
+} from "@/views/admin/views/containers/container-select";
+
+export const useContentContainerOptions = (
+ definitionId: string | undefined,
+) => {
+ const entry =
+ definitionId !== undefined && hasContentFrontendRegistry()
+ ? contentFrontendRegistry().containers.byId(definitionId)
+ : undefined;
+ const query = useQuery({
+ ...contentContainerListQuery({
+ definitionId: definitionId ?? "",
+ target: entry
+ ? contentContainerApiTarget(entry)
+ : { permissionModule: "", pluginId: "" },
+ }),
+ enabled: entry !== undefined,
+ });
+
+ return { entry, query };
+};
+
+const ContainerFieldProblem = ({
+ label,
+ message,
+ onRetry,
+}: {
+ label: string;
+ message: string;
+ onRetry?: () => void;
+}) => {
+ const t = useTranslations("core.content.form.container");
+
+ return (
+ <>
+ {label}
+
+
+ {message}
+
+ {onRetry ? (
+
+
+ {t("retry")}
+
+ ) : null}
+
+
+ >
+ );
+};
+
+export const ContentContainerField = ({
+ spec,
+ ...props
+}: ItemAutoFormComponentProps & { spec: ContentFormFieldSpec }) => {
+ const t = useTranslations("core.content.form.container");
+ const { entry, query } = useContentContainerOptions(
+ spec.containerDefinitionId,
+ );
+
+ if (!entry) {
+ return (
+
+ );
+ }
+
+ if (query.isPending) {
+ return (
+ <>
+ {spec.label}
+
+ >
+ );
+ }
+
+ if (query.isError) {
+ const forbidden =
+ isAdminRequestError(query.error) && query.error.status === 403;
+
+ return (
+ {
+ void query.refetch();
+ }
+ }
+ />
+ );
+ }
+
+ const options = containerSelectOptions(query.data);
+
+ if (options.length === 0) {
+ return ;
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx
index 7360739b7..c173b48b7 100644
--- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx
+++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx
@@ -23,6 +23,7 @@ import { AutoFormSwitch } from "@/components/form/fields/switch";
import { AutoFormTextarea } from "@/components/form/fields/textarea";
import { ContentFormFieldSkeleton } from "../form/skeleton";
+import { ContentContainerField } from "./container-field";
import { ContentGroupField } from "./group-field";
import { ContentOptionSwatch } from "./option-swatch";
import { contentOptionsQueryKey } from "./options-query";
@@ -222,6 +223,9 @@ export const ContentField = ({
case "boolean":
return ;
+ case "container":
+ return ;
+
case "dateTime":
return ;
diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx
index 612731648..e69d0c6b9 100644
--- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx
+++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx
@@ -23,6 +23,7 @@ import { isContentPublished } from "@/content/publication";
import { isRichTextDocument } from "@/content/rich-text/document";
import { richTextToPlainText } from "@/content/rich-text/plain-text";
+import { useContentContainerOptions } from "../lib/container-field";
import { ContentHiddenIcon } from "../lib/hidden-badge";
export interface ContentRowData extends Record {
@@ -152,6 +153,23 @@ const ContentReferenceList = ({
);
};
+const ContentContainerCellValue = ({
+ definitionId,
+ value,
+}: {
+ definitionId: string | undefined;
+ value: number;
+}) => {
+ const { query } = useContentContainerOptions(definitionId);
+ const name = query.data?.find(container => container.id === value)?.name;
+
+ return name === undefined ? (
+ #{value}
+ ) : (
+ {name}
+ );
+};
+
export interface ContentStatusLabels {
draft: string;
published: string;
@@ -220,6 +238,16 @@ const ContentCellValue = ({
);
+ case "container":
+ return typeof value === "number" ? (
+
+ ) : (
+
+ );
+
case "dateTime":
return ;
diff --git a/plugins/example/src/admin/content.tsx b/plugins/example/src/admin/content.tsx
index 0ddb8ed67..e2c9c32b2 100644
--- a/plugins/example/src/admin/content.tsx
+++ b/plugins/example/src/admin/content.tsx
@@ -4,6 +4,7 @@ import { CONFIG_PLUGIN } from "@/const";
import {
exampleArticleNav,
+ exampleBoardsNav,
exampleCategoryNav,
exampleDiscussionNav,
exampleEventNav,
@@ -13,6 +14,7 @@ import {
export const adminContent = {
pluginId: CONFIG_PLUGIN.pluginId,
+ containers: [exampleBoardsNav],
contentTypes: [
exampleArticleNav,
exampleCategoryNav,
diff --git a/plugins/example/src/admin/nav.tsx b/plugins/example/src/admin/nav.tsx
index 70ceaaa57..a4e24cf8a 100644
--- a/plugins/example/src/admin/nav.tsx
+++ b/plugins/example/src/admin/nav.tsx
@@ -4,6 +4,7 @@ import {
CalendarDaysIcon,
FlaskConicalIcon,
FolderIcon,
+ FolderTreeIcon,
LayoutTemplateIcon,
MessagesSquareIcon,
NotebookPenIcon,
@@ -12,6 +13,7 @@ import {
import { CONFIG_PLUGIN } from "@/const";
import { articleContentType } from "@/content/article";
+import { boardContainers } from "@/content/boards";
import { categoryContentType } from "@/content/category";
import { discussionContentType } from "@/content/discussion";
import { eventContentType } from "@/content/event";
@@ -50,8 +52,14 @@ export const exampleDiscussionNav = {
icon: ,
};
+export const exampleBoardsNav = {
+ definition: boardContainers,
+ icon: ,
+};
+
export const adminNav = {
pluginId: CONFIG_PLUGIN.pluginId,
+ containers: [exampleBoardsNav],
contentTypes: [
exampleArticleNav,
exampleCategoryNav,
diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts
index 8a8a5c813..4466c5b2c 100644
--- a/plugins/example/src/api/modules/admin/admin.module.ts
+++ b/plugins/example/src/api/modules/admin/admin.module.ts
@@ -2,6 +2,7 @@ import { buildModule } from "@vitnode/core/api/lib/module";
import { buildContentAdminModule } from "@vitnode/core/content/server";
import { CONFIG_PLUGIN } from "@/const";
+import { boardContainers } from "@/content/boards";
import { advancedArticleContent } from "@/database/advanced-articles";
import { articleContent } from "@/database/articles";
import { categoryContent } from "@/database/categories";
@@ -18,6 +19,7 @@ export const adminModule = buildModule({
modules: [
buildContentAdminModule({
pluginId: CONFIG_PLUGIN.pluginId,
+ containers: [boardContainers],
// The localized article is registered on the API side only, so its
// generated CRUD *and* translation routes exist and its staff permissions
// are derived - but it gets no AdminCP screen yet, because a form that
diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts
index e34b6197f..1a68b8c30 100644
--- a/plugins/example/src/config.api.ts
+++ b/plugins/example/src/config.api.ts
@@ -7,6 +7,7 @@ import { articleExcerptAiAction } from "@/api/ai/actions";
import { exampleCategoryScope } from "@/api/lib/moderation";
import { adminModule } from "@/api/modules/admin/admin.module";
import { CONFIG_PLUGIN } from "@/const";
+import { boardContainers } from "@/content/boards";
import { settingsPage } from "@/content/settings-page";
import { advancedArticleContent } from "@/database/advanced-articles";
import { articleContent } from "@/database/articles";
@@ -37,6 +38,7 @@ export const exampleApiPlugin = () =>
adminModule,
buildContentPublicModule({
pluginId: CONFIG_PLUGIN.pluginId,
+ containers: [boardContainers],
contentTypes: [
advancedArticleContent,
articleContent,
diff --git a/plugins/example/src/content/boards.ts b/plugins/example/src/content/boards.ts
new file mode 100644
index 000000000..da79e8da0
--- /dev/null
+++ b/plugins/example/src/content/boards.ts
@@ -0,0 +1,22 @@
+import { defineContentContainer, field } from "@vitnode/core/content";
+
+export const boardContainers = defineContentContainer({
+ id: "example.boards",
+
+ fields: {
+ description: field.textarea({ maxLength: 500, nullable: true }),
+ color: field.text({ maxLength: 7, nullable: true }),
+ icon: field.text({ maxLength: 64, nullable: true }),
+ locked: field.boolean({ defaultValue: false }),
+ },
+
+ hierarchy: { maxDepth: 3 },
+ ordering: { mode: "manual" },
+
+ publicApi: {
+ path: "boards",
+ fields: ["description", "color", "icon"],
+ },
+
+ admin: { path: "example/boards" },
+});
diff --git a/plugins/example/src/content/discussion.ts b/plugins/example/src/content/discussion.ts
index 5e1c0ecf5..f955e27b3 100644
--- a/plugins/example/src/content/discussion.ts
+++ b/plugins/example/src/content/discussion.ts
@@ -1,5 +1,7 @@
import { defineContentType, field } from "@vitnode/core/content";
+import { boardContainers } from "@/content/boards";
+
export const discussionContentType = defineContentType({
id: "example.discussion",
tableName: "example_discussions",
@@ -9,6 +11,7 @@ export const discussionContentType = defineContentType({
slug: field.slug({ source: "title" }),
body: field.richText({ required: true }),
author: field.user(),
+ board: field.container({ definition: boardContainers, required: true }),
},
publication: true,
@@ -17,12 +20,13 @@ export const discussionContentType = defineContentType({
publicApi: {
path: "discussions",
- fields: ["id", "title", "slug", "body", "publishedAt"],
+ fields: ["id", "title", "slug", "body", "board", "publishedAt"],
+ filterableFields: ["board"],
},
publicWrite: {
ownership: { field: "author" },
- create: { fields: ["title", "body"], status: "published" },
+ create: { fields: ["title", "body", "board"], status: "published" },
update: { fields: ["title", "body"] },
},
@@ -34,6 +38,7 @@ export const discussionContentType = defineContentType({
"status",
"title",
"slug",
+ "board",
"author",
"publishedAt",
"updatedAt",
diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json
index 5561df84a..3e037b557 100644
--- a/plugins/example/src/locales/en.json
+++ b/plugins/example/src/locales/en.json
@@ -105,7 +105,21 @@
"body": "Message",
"author": "Author",
"publishedAt": "Published",
- "updatedAt": "Updated"
+ "updatedAt": "Updated",
+ "board": "Board"
+ }
+ }
+ },
+ "containers": {
+ "boards": {
+ "label": "{count, plural, one {Board} other {Boards}}",
+ "title": "Boards",
+ "desc": "The board tree members file their discussions into. Drag to reorder or nest boards.",
+ "fields": {
+ "description": "Description",
+ "color": "Color",
+ "icon": "Icon",
+ "locked": "Locked"
}
}
},
@@ -151,11 +165,41 @@
"desc": "Limit this permission to articles in some categories."
}
},
+ "boards": {
+ "title": "Boards",
+ "desc": "Find the right place for your question, or start a new discussion.",
+ "tree": "All boards",
+ "children": "Boards inside",
+ "discussions": "Discussions",
+ "new_discussion": "New discussion",
+ "pagination": "Discussion pages",
+ "newest": "Newest discussions",
+ "older": "Older discussions",
+ "empty": {
+ "title": "No boards yet",
+ "desc": "An admin has not created any boards. Once they do, they show up here.",
+ "home": "Back to home"
+ },
+ "empty_board": {
+ "title": "Nothing here yet",
+ "desc": "Be the first to start a discussion in this board."
+ },
+ "not_found": {
+ "title": "Board not found",
+ "desc": "It may have been removed, or the link is wrong.",
+ "back": "See all boards"
+ }
+ },
"discussions": {
"new": {
"title": "Start a discussion",
"desc": "Ask a question or share an idea. Everyone can read it as soon as you post it.",
- "submit": "Post discussion"
+ "submit": "Post discussion",
+ "no_boards": {
+ "title": "No boards yet",
+ "desc": "Discussions live in boards, and an admin has not created one yet. Check back soon.",
+ "action": "Browse boards"
+ }
},
"edit": {
"title": "Edit discussion",
@@ -179,9 +223,17 @@
"created": {
"title": "Discussion posted",
"desc": "It is live. Use Edit whenever you want to change it."
- }
+ },
+ "board": "Board",
+ "boards": "Boards"
},
"form": {
+ "board": {
+ "label": "Board",
+ "desc": "Pick where the discussion belongs. You can't move it later.",
+ "placeholder": "Choose a board",
+ "required": "Pick a board"
+ },
"title": {
"label": "Title",
"min": "Use at least 3 characters",
@@ -219,7 +271,8 @@
"unknown": "Something went wrong on our side. Please try again.",
"issues": {
"not_writable": "This field can't be changed here.",
- "unavailable": "The selected item is no longer available."
+ "unavailable": "The selected item is no longer available.",
+ "board_unavailable": "This board is no longer available. Pick another one."
}
}
}
@@ -260,6 +313,11 @@
"@vitnode/example:discussion:can_publish": "Publish and unpublish discussions",
"@vitnode/example:discussion:can_restore": "Restore an earlier version of a discussion",
"@vitnode/example:discussion:can_hide": "Hide and unhide discussions",
+ "@vitnode/example:example_boards": "Boards",
+ "@vitnode/example:example_boards:can_view": "View boards",
+ "@vitnode/example:example_boards:can_create": "Create boards",
+ "@vitnode/example:example_boards:can_edit": "Edit and move boards",
+ "@vitnode/example:example_boards:can_delete": "Delete boards",
"ai_actions": {
"@vitnode/example": {
"article_excerpt": {
diff --git a/plugins/example/src/pages/board-page.tsx b/plugins/example/src/pages/board-page.tsx
new file mode 100644
index 000000000..9dc0cee8a
--- /dev/null
+++ b/plugins/example/src/pages/board-page.tsx
@@ -0,0 +1,300 @@
+import { DateFormat } from "@vitnode/core/components/date-format";
+import {
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@vitnode/core/components/ui/breadcrumb";
+import { buttonVariants } from "@vitnode/core/components/ui/button";
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@vitnode/core/components/ui/empty";
+import {
+ definePluginRoute,
+ type PluginRoutePageProps,
+} from "@vitnode/core/routing";
+import { contentPageItem } from "@vitnode/core/tanstack/content";
+import { fetcher } from "@vitnode/core/tanstack/fetcher";
+import { MessagesSquareIcon, SearchXIcon } from "lucide-react";
+import { Fragment } from "react";
+import { useTranslations } from "use-intl";
+import { z } from "zod";
+
+import { CONFIG_PLUGIN } from "@/const";
+
+import type { BoardSearch } from "./board-search";
+
+import { boardHref, BOARDS_HREF } from "./board-search";
+import { BoardColorDot, BoardTree } from "./board-tree";
+import { discussionHref, discussionNewHref } from "./discussion-search";
+
+const PAGE_SIZE = 20;
+
+const zodDiscussionRow = z.object({
+ id: z.number(),
+ publishedAt: z.string().nullable(),
+ slug: z.string().nullable(),
+ title: z.string(),
+});
+
+const loadBoard = async (idOrSlug: string, { cursor }: BoardSearch) => {
+ const detail = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ method: "get",
+ module: "content/boards",
+ path: "/{idOrSlug}",
+ args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } },
+ });
+
+ if (detail.status !== 200 && detail.status !== 404) {
+ throw new Error("Loading the board failed.");
+ }
+
+ const { ancestors, children, container } = contentPageItem(
+ detail.status === 200 ? await detail.json() : null,
+ );
+
+ const filters = {
+ board: String(container.id),
+ includeDescendants: "true",
+ };
+
+ const list = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ method: "get",
+ module: "content/discussions",
+ path: "/",
+ args: {
+ query: {
+ ...filters,
+ first: String(PAGE_SIZE),
+ ...(cursor ? { cursor } : {}),
+ },
+ },
+ });
+
+ if (list.status !== 200) throw new Error("Loading the discussions failed.");
+
+ const { edges, pageInfo } = await list.json();
+
+ return {
+ ancestors,
+ children,
+ container,
+ discussions: z.array(zodDiscussionRow).parse(edges),
+ isFirstPage: cursor === undefined,
+ nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null,
+ };
+};
+
+type BoardPageData = Awaited>;
+
+const BoardNotFound = () => {
+ const t = useTranslations("@vitnode/example.boards.not_found");
+
+ return (
+
+ );
+};
+
+const BoardsCrumb = () => {
+ const t = useTranslations("@vitnode/example.boards");
+
+ return <>{t("title")}>;
+};
+
+export const route = definePluginRoute({
+ load: async ({ params, search }) => await loadBoard(params.slug, search),
+
+ head: ({ loaderData }) => ({
+ description: loaderData?.container.metadata.description ?? undefined,
+ title: loaderData?.container.name,
+ }),
+
+ breadcrumb: {
+ group: ({ loaderData }) => (
+ <>
+
+
+
+
+
+ {loaderData.ancestors.map(ancestor => (
+
+
+
+
+ {ancestor.name}
+
+
+
+ ))}
+
+
+ {loaderData.container.name}
+
+ >
+ ),
+ },
+
+ notFound: BoardNotFound,
+});
+
+const BoardPage = ({
+ loaderData,
+}: PluginRoutePageProps) => {
+ const t = useTranslations("@vitnode/example.boards");
+ const { children, container, discussions, isFirstPage, nextCursor } =
+ loaderData;
+
+ return (
+
+
+
+ {children.length > 0 ? (
+
+
+ {t("children")}
+
+ ({ ...child, children: [] }))}
+ />
+
+ ) : null}
+
+
+
+ {t("discussions")}
+
+
+ {discussions.length === 0 ? (
+
+
+
+
+
+ }>{t("empty_board.title")}
+ {t("empty_board.desc")}
+
+
+
+ {t("new_discussion")}
+
+
+
+ ) : (
+
+ {discussions.map(discussion => (
+
+ {discussion.slug === null ? (
+
+ {discussion.title}
+
+ ) : (
+
+ {discussion.title}
+
+ )}
+ {discussion.publishedAt ? (
+
+
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ {isFirstPage && nextCursor === null ? null : (
+
+ {isFirstPage ? null : (
+
+ {t("newest")}
+
+ )}
+ {nextCursor === null ? null : (
+
+ {t("older")}
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default BoardPage;
diff --git a/plugins/example/src/pages/board-search.ts b/plugins/example/src/pages/board-search.ts
new file mode 100644
index 000000000..28dcd9f56
--- /dev/null
+++ b/plugins/example/src/pages/board-search.ts
@@ -0,0 +1,15 @@
+export interface BoardSearch {
+ cursor?: string;
+}
+
+const CURSOR = /^[A-Za-z0-9_-]{1,512}$/;
+
+export const boardSearch = (input: Record): BoardSearch =>
+ typeof input.cursor === "string" && CURSOR.test(input.cursor)
+ ? { cursor: input.cursor }
+ : {};
+
+export const BOARDS_HREF = "/boards";
+
+export const boardHref = (slug: string, { cursor }: BoardSearch = {}): string =>
+ `/boards/${encodeURIComponent(slug)}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`;
diff --git a/plugins/example/src/pages/board-tree.tsx b/plugins/example/src/pages/board-tree.tsx
new file mode 100644
index 000000000..8c6ac9f15
--- /dev/null
+++ b/plugins/example/src/pages/board-tree.tsx
@@ -0,0 +1,63 @@
+import type { ContentContainerTreeNode } from "@vitnode/core/content";
+
+import type { Board } from "./boards-data";
+
+import { boardHref } from "./board-search";
+import { boardColor } from "./boards-data";
+
+export const BoardColorDot = ({ board }: { board: Board }) => {
+ const color = boardColor(board);
+
+ return (
+
+ );
+};
+
+const BoardTreeItems = ({
+ nodes,
+}: {
+ nodes: ContentContainerTreeNode[];
+}) => (
+
+);
+
+export const BoardTree = ({
+ label,
+ nodes,
+}: {
+ label: string;
+ nodes: ContentContainerTreeNode[];
+}) => (
+
+
+
+);
diff --git a/plugins/example/src/pages/boards-data.ts b/plugins/example/src/pages/boards-data.ts
new file mode 100644
index 000000000..40b668e05
--- /dev/null
+++ b/plugins/example/src/pages/boards-data.ts
@@ -0,0 +1,29 @@
+import { fetcher } from "@vitnode/core/tanstack/fetcher";
+
+import { CONFIG_PLUGIN } from "@/const";
+
+export const fetchBoards = async () => {
+ const res = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ method: "get",
+ module: "content/boards",
+ path: "/",
+ args: { query: {} },
+ });
+
+ if (res.status !== 200) throw new Error("Loading the boards failed.");
+
+ const { items } = await res.json();
+
+ return items;
+};
+
+export type Board = Awaited>[number];
+
+const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
+
+export const boardColor = (board: Pick): null | string =>
+ typeof board.metadata.color === "string" &&
+ HEX_COLOR.test(board.metadata.color)
+ ? board.metadata.color
+ : null;
diff --git a/plugins/example/src/pages/boards-page.tsx b/plugins/example/src/pages/boards-page.tsx
new file mode 100644
index 000000000..f4dc2ff5a
--- /dev/null
+++ b/plugins/example/src/pages/boards-page.tsx
@@ -0,0 +1,90 @@
+import { buttonVariants } from "@vitnode/core/components/ui/button";
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@vitnode/core/components/ui/empty";
+import { contentContainerTree } from "@vitnode/core/content";
+import {
+ definePluginRoute,
+ type PluginRoutePageProps,
+} from "@vitnode/core/routing";
+import { FolderTreeIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { BoardTree } from "./board-tree";
+import { fetchBoards } from "./boards-data";
+import { discussionNewHref } from "./discussion-search";
+
+const loadBoards = async () => ({
+ tree: contentContainerTree(await fetchBoards()),
+});
+
+type BoardsPageData = Awaited>;
+
+const BoardsBreadcrumb = () => {
+ const t = useTranslations("@vitnode/example.boards");
+
+ return {t("title")} ;
+};
+
+export const route = definePluginRoute({
+ load: loadBoards,
+
+ head: ({ t }) => ({
+ description: t("@vitnode/example.boards.desc"),
+ title: t("@vitnode/example.boards.title"),
+ }),
+
+ breadcrumb: BoardsBreadcrumb,
+});
+
+const BoardsPage = ({ loaderData }: PluginRoutePageProps) => {
+ const t = useTranslations("@vitnode/example.boards");
+
+ return (
+
+
+
+ {loaderData.tree.length === 0 ? (
+
+
+
+
+
+ }>{t("empty.title")}
+ {t("empty.desc")}
+
+
+
+ {t("empty.home")}
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default BoardsPage;
diff --git a/plugins/example/src/pages/discussion-form.tsx b/plugins/example/src/pages/discussion-form.tsx
index 5e2a79443..48c57d11c 100644
--- a/plugins/example/src/pages/discussion-form.tsx
+++ b/plugins/example/src/pages/discussion-form.tsx
@@ -1,4 +1,7 @@
-import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form";
+import type {
+ AutoFormOnSubmit,
+ ItemAutoFormComponentProps,
+} from "@vitnode/core/components/form/auto-form";
import type { ContentPublicWriteRejection } from "@vitnode/core/content";
import {
@@ -7,6 +10,7 @@ import {
} from "@vitnode/core/components/form/auto-form";
import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor";
import { AutoFormInput } from "@vitnode/core/components/form/fields/input";
+import { AutoFormSelect } from "@vitnode/core/components/form/fields/select";
import {
emptyRichTextDocument,
isRichTextEmpty,
@@ -18,7 +22,7 @@ import { toast } from "sonner";
import { useTranslations } from "use-intl";
import { z } from "zod";
-const DISCUSSION_FIELDS = ["title", "body"] as const;
+const DISCUSSION_FIELDS = ["title", "body", "board"] as const;
type DiscussionField = (typeof DISCUSSION_FIELDS)[number];
@@ -47,9 +51,36 @@ export type DiscussionFormSchema = ReturnType<
typeof createDiscussionFormSchema
>;
-export type DiscussionFormApi = Parameters<
- AutoFormOnSubmit
->[1];
+export interface DiscussionBoardChoice {
+ options: { label: string; value: string }[];
+ selected?: string;
+}
+
+const createDiscussionCreateFormSchema = (
+ t: (
+ key: "board.required" | "body.required" | "title.max" | "title.min",
+ ) => string,
+ ids: [string, ...string[]],
+ selected: string | undefined,
+) => {
+ const board = z.enum(ids, { error: t("board.required") });
+
+ return createDiscussionFormSchema(t, {
+ body: emptyRichTextDocument(),
+ title: "",
+ }).extend({
+ board:
+ selected !== undefined && ids.includes(selected)
+ ? board.default(selected)
+ : board,
+ });
+};
+
+export type DiscussionCreateFormSchema = ReturnType<
+ typeof createDiscussionCreateFormSchema
+>;
+
+type DiscussionFormApi = Parameters[0];
const isDiscussionField = (field: null | string): field is DiscussionField =>
DISCUSSION_FIELDS.some(name => name === field);
@@ -59,6 +90,8 @@ const ISSUE_MESSAGE_KEYS = {
unavailable: "issues.unavailable",
} as const;
+const BOARD_ISSUE_MESSAGE_KEY = "issues.board_unavailable";
+
const isKnownIssueCode = (
code: string,
): code is keyof typeof ISSUE_MESSAGE_KEYS => code in ISSUE_MESSAGE_KEYS;
@@ -78,12 +111,18 @@ export const useDiscussionRejection = () => {
await response.json().catch(() => null),
);
- const messageOf = (code: string) =>
- t(isKnownIssueCode(code) ? ISSUE_MESSAGE_KEYS[code] : "invalid");
+ const messageOf = (code: string, field?: DiscussionField) =>
+ t(
+ field === "board" && code === "unavailable"
+ ? BOARD_ISSUE_MESSAGE_KEY
+ : isKnownIssueCode(code)
+ ? ISSUE_MESSAGE_KEYS[code]
+ : "invalid",
+ );
const unmatched = (rejection?.issues ?? []).filter(issue => {
if (!isDiscussionField(issue.field)) return true;
- setFormFieldError(form, issue.field, messageOf(issue.code));
+ setFormFieldError(form, issue.field, messageOf(issue.code, issue.field));
return false;
});
@@ -102,6 +141,30 @@ export const useDiscussionRejection = () => {
};
};
+const useDiscussionFields = () => {
+ const t = useTranslations("@vitnode/example.discussions.form");
+
+ return [
+ {
+ id: "title",
+ component: (props: ItemAutoFormComponentProps) => (
+
+ ),
+ },
+ {
+ id: "body",
+ component: (props: ItemAutoFormComponentProps) => (
+
+ ),
+ },
+ ];
+};
+
export const DiscussionForm = ({
defaultValues = { body: emptyRichTextDocument(), title: "" },
isLeaving = false,
@@ -114,31 +177,62 @@ export const DiscussionForm = ({
submitLabel: string;
}) => {
const t = useTranslations("@vitnode/example.discussions.form");
- const formSchema = createDiscussionFormSchema(t, defaultValues);
+ const fields = useDiscussionFields();
+
+ return (
+
+ );
+};
+
+export const DiscussionCreateForm = ({
+ boards,
+ isLeaving = false,
+ onSubmit,
+ submitLabel,
+}: {
+ boards: DiscussionBoardChoice;
+ isLeaving?: boolean;
+ onSubmit: AutoFormOnSubmit;
+ submitLabel: string;
+}) => {
+ const t = useTranslations("@vitnode/example.discussions.form");
+ const fields = useDiscussionFields();
+ const [first, ...rest] = boards.options.map(option => option.value);
+ if (first === undefined) return null;
return (
(
-
- ),
- },
- {
- id: "body",
+ id: "board",
component: props => (
-
),
},
+ ...fields,
]}
- formSchema={formSchema}
+ formSchema={createDiscussionCreateFormSchema(
+ t,
+ [first, ...rest],
+ boards.selected,
+ )}
onSubmit={onSubmit}
submitButtonProps={{
children: submitLabel,
diff --git a/plugins/example/src/pages/discussion-new-page.tsx b/plugins/example/src/pages/discussion-new-page.tsx
index 476d8677d..0395b0c16 100644
--- a/plugins/example/src/pages/discussion-new-page.tsx
+++ b/plugins/example/src/pages/discussion-new-page.tsx
@@ -2,28 +2,55 @@ import {
type AutoFormOnSubmit,
setFormFieldError,
} from "@vitnode/core/components/form/auto-form";
-import { definePluginRoute } from "@vitnode/core/routing";
+import { buttonVariants } from "@vitnode/core/components/ui/button";
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@vitnode/core/components/ui/empty";
+import {
+ definePluginRoute,
+ type PluginRoutePageProps,
+} from "@vitnode/core/routing";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
+import { FolderTreeIcon } from "lucide-react";
import { useState } from "react";
import { useTranslations } from "use-intl";
import { CONFIG_PLUGIN } from "@/const";
+import type { DiscussionNewSearch } from "./discussion-search";
+
+import { BOARDS_HREF } from "./board-search";
+import { fetchBoards } from "./boards-data";
import {
- DiscussionForm,
- type DiscussionFormSchema,
+ DiscussionCreateForm,
+ type DiscussionCreateFormSchema,
useDiscussionRejection,
} from "./discussion-form";
import { discussionEditHref, discussionHref } from "./discussion-search";
import { DiscussionShell } from "./discussion-shell";
+const loadBoardOptions = async () =>
+ (await fetchBoards()).map(board => ({
+ label: `${"— ".repeat(board.depth - 1)}${board.name}`,
+ value: String(board.id),
+ }));
+
+type DiscussionNewData = Awaited>;
+
const DiscussionNewBreadcrumb = () => {
const t = useTranslations("@vitnode/example.discussions.new");
return {t("title")} ;
};
-export const route = definePluginRoute({
+export const route = definePluginRoute({
+ load: loadBoardOptions,
+
head: ({ t }) => ({
description: t("@vitnode/example.discussions.new.desc"),
robots: "noindex, nofollow",
@@ -33,13 +60,16 @@ export const route = definePluginRoute({
breadcrumb: DiscussionNewBreadcrumb,
});
-const DiscussionNewPage = () => {
+const DiscussionNewPage = ({
+ loaderData: options,
+ search,
+}: PluginRoutePageProps) => {
const t = useTranslations("@vitnode/example.discussions");
const rejectDiscussion = useDiscussionRejection();
const [isLeaving, setIsLeaving] = useState(false);
- const onSubmit: AutoFormOnSubmit = async (
- values,
+ const onSubmit: AutoFormOnSubmit = async (
+ { board, ...values },
form,
) => {
const res = await fetcher({
@@ -47,7 +77,7 @@ const DiscussionNewPage = () => {
method: "post",
module: "content/discussions",
path: "/",
- args: { body: { values } },
+ args: { body: { values: { ...values, board: Number(board) } } },
});
if (res.status === 201) {
@@ -74,11 +104,36 @@ const DiscussionNewPage = () => {
return (
-
+ {options.length === 0 ? (
+
+
+
+
+
+ }>{t("new.no_boards.title")}
+ {t("new.no_boards.desc")}
+
+
+
+ {t("new.no_boards.action")}
+
+
+
+ ) : (
+
+ )}
);
};
diff --git a/plugins/example/src/pages/discussion-page.tsx b/plugins/example/src/pages/discussion-page.tsx
index 4fe0f628b..5907e391c 100644
--- a/plugins/example/src/pages/discussion-page.tsx
+++ b/plugins/example/src/pages/discussion-page.tsx
@@ -17,10 +17,12 @@ import { CONFIG_PLUGIN } from "@/const";
import type { DiscussionSearch } from "./discussion-search";
+import { boardHref, BOARDS_HREF } from "./board-search";
import { ContentArticle } from "./content-article";
-import { discussionEditHref } from "./discussion-search";
+import { discussionEditHref, discussionNewHref } from "./discussion-search";
const zodDiscussion = z.object({
+ board: z.number(),
body: zodRichTextDocument,
id: z.number(),
publishedAt: z.string().nullable(),
@@ -36,9 +38,23 @@ const loadDiscussion = async (slug: string) => {
args: { params: { slug: encodeURIComponent(slug) } },
});
- return contentPageItem(
+ const item = contentPageItem(
detail.status === 200 ? zodDiscussion.parse(await detail.json()) : null,
);
+
+ const board = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ method: "get",
+ module: "content/boards",
+ path: "/{idOrSlug}",
+ args: { params: { idOrSlug: String(item.board) } },
+ });
+
+ if (board.status !== 200) return { ...item, boardTrail: [] };
+
+ const { ancestors, container } = await board.json();
+
+ return { ...item, boardTrail: [...ancestors, container] };
};
type DiscussionPageData = Awaited>;
@@ -71,6 +87,29 @@ const DiscussionPage = ({
return (
+ {item.boardTrail.length > 0 ? (
+
+
+
+
+ {t("boards")}
+
+
+ {item.boardTrail.map(board => (
+
+ /
+
+ {board.name}
+
+
+ ))}
+
+
+ ) : null}
+
{t("new")}
diff --git a/plugins/example/src/pages/discussion-search.ts b/plugins/example/src/pages/discussion-search.ts
index ebb7fe84a..2f0a2e9e4 100644
--- a/plugins/example/src/pages/discussion-search.ts
+++ b/plugins/example/src/pages/discussion-search.ts
@@ -15,3 +15,29 @@ export const discussionHref = (
export const discussionEditHref = (id: number | string): string =>
`/discussions/${encodeURIComponent(String(id))}/edit`;
+
+const positiveId = (raw: unknown): number | undefined => {
+ const value =
+ typeof raw === "number"
+ ? raw
+ : typeof raw === "string" && /^[1-9]\d{0,15}$/.test(raw)
+ ? Number(raw)
+ : Number.NaN;
+
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
+};
+
+export interface DiscussionNewSearch {
+ board?: number;
+}
+
+export const discussionNewSearch = (
+ input: Record,
+): DiscussionNewSearch => {
+ const board = positiveId(input.board);
+
+ return board === undefined ? {} : { board };
+};
+
+export const discussionNewHref = (board?: number): string =>
+ board === undefined ? "/discussions/new" : `/discussions/new?board=${board}`;
diff --git a/plugins/example/src/routes.ts b/plugins/example/src/routes.ts
index 53918aa58..d5e9311fb 100644
--- a/plugins/example/src/routes.ts
+++ b/plugins/example/src/routes.ts
@@ -5,11 +5,19 @@ import {
lazy,
page,
} from "@vitnode/core/routing";
+import {
+ FeedPendingSkeleton,
+ RoutePendingSkeleton,
+} from "@vitnode/core/tanstack/pending";
import type { ExampleSearch } from "./pages/example-page";
+import { boardSearch } from "./pages/board-search";
import { browseSearch } from "./pages/browse-search";
-import { discussionSearch } from "./pages/discussion-search";
+import {
+ discussionNewSearch,
+ discussionSearch,
+} from "./pages/discussion-search";
import { zonesSearch } from "./pages/zones-search";
export const routes = definePluginRoutes([
@@ -71,10 +79,24 @@ export const routes = definePluginRoutes([
messages: ["@vitnode/example.articles"],
}),
+ page("/boards", {
+ component: lazy(() => import("./pages/boards-page")),
+ messages: ["@vitnode/example.boards"],
+ pendingComponent: RoutePendingSkeleton,
+ }),
+
+ page("/boards/:slug", {
+ component: lazy(() => import("./pages/board-page")),
+ messages: ["@vitnode/example.boards"],
+ pendingComponent: FeedPendingSkeleton,
+ search: boardSearch,
+ }),
+
page("/discussions/new", {
component: lazy(() => import("./pages/discussion-new-page")),
messages: ["@vitnode/example.discussions"],
requires: "authenticated",
+ search: discussionNewSearch,
}),
page("/discussions/:id/edit", {