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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/hidden-collections-sidebar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@emdash-cms/core": minor
"@emdash-cms/admin": minor
---

Adds a `hidden` flag to collections that omits their auto-generated entry from the admin sidebar. Hidden collections keep working everywhere else — REST API, MCP, plugin hooks, and their editor at `/_emdash/admin/content/<slug>` — so a plugin that owns a collection end to end can point editors at its own admin UI instead of a raw CRUD list. Set it in a seed file (`"hidden": true`) or via the schema API.
8 changes: 8 additions & 0 deletions docs/src/content/docs/themes/seed-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,16 @@ Each collection definition creates a content type in the database:
| `description` | `string` | No | Admin UI description |
| `icon` | `string` | No | Lucide icon name |
| `supports` | `array` | No | Features: `"drafts"`, `"revisions"` |
| `hidden` | `boolean`| No | Omit the collection's admin sidebar entry |
| `fields` | `array` | Yes | Field definitions |

<Aside type="tip" title="Hiding a collection from the sidebar">
`hidden: true` only removes the auto-generated sidebar link. The collection keeps working
everywhere else — REST API, MCP tools, plugin hooks, and its editor at
`/_emdash/admin/content/<slug>`. Use it for collections a plugin owns end to end (autopopulated
entries, its own admin page), so editors aren't sent to a raw CRUD list they never need.
</Aside>

### Field Properties

| Property | Type | Required | Description |
Expand Down
19 changes: 17 additions & 2 deletions packages/admin/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,24 @@ export function filterNavItemsByRole<T extends { minRole?: number }>(
return items.filter((item) => !item.minRole || userRole >= item.minRole);
}

/**
* Manifest collections that get an auto-generated sidebar entry, in manifest
* order. Pure function — exported so tests can pin the `hidden` contract
* without rendering the sidebar.
*
* A hidden collection is still shipped in the manifest and stays fully
* routable at `/content/:collection`; it only loses its nav link, so a plugin
* that owns the collection end to end can steer editors to its own admin UI.
*/
export function visibleCollectionEntries<T extends { hidden?: boolean }>(
collections: Record<string, T>,
): Array<[string, T]> {
return Object.entries(collections).filter(([, config]) => !config.hidden);
}

export interface SidebarNavProps {
manifest: {
collections: Record<string, { label: string }>;
collections: Record<string, { label: string; hidden?: boolean }>;
plugins: Record<
string,
{
Expand Down Expand Up @@ -205,7 +220,7 @@ export function SidebarNav({ manifest }: SidebarNavProps) {
const contentItems: NavItem[] = [
{ to: "/", label: t`Dashboard`, icon: ADMIN_NAV_ICONS.dashboard },
];
for (const [name, config] of Object.entries(manifest.collections)) {
for (const [name, config] of visibleCollectionEntries(manifest.collections)) {
contentItems.push({
to: "/content/$collection",
label: config.label,
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export interface AdminManifest {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
hidden?: boolean;
fields: Record<
string,
{
Expand Down
4 changes: 4 additions & 0 deletions packages/admin/src/lib/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface SchemaCollection {
source?: string;
urlPattern?: string;
hasSeo: boolean;
/** Sidebar entry omitted in the admin; the collection stays reachable by URL */
hidden: boolean;
commentsEnabled: boolean;
commentsModeration: "all" | "first_time" | "none";
commentsClosedAfterDays: number;
Expand Down Expand Up @@ -83,6 +85,7 @@ export interface CreateCollectionInput {
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
hidden?: boolean;
}

export interface UpdateCollectionInput {
Expand All @@ -93,6 +96,7 @@ export interface UpdateCollectionInput {
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
hidden?: boolean;
commentsEnabled?: boolean;
commentsModeration?: "all" | "first_time" | "none";
commentsClosedAfterDays?: number;
Expand Down
25 changes: 25 additions & 0 deletions packages/admin/tests/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
resolveNavIcon,
resolvePluginPageLabel,
toPhosphorIconName,
visibleCollectionEntries,
} from "../../src/components/Sidebar";
import { render } from "../utils/render.tsx";

Expand Down Expand Up @@ -105,6 +106,30 @@ describe("filterNavItemsByRole", () => {
});
});

describe("visibleCollectionEntries", () => {
const collections = {
posts: { label: "Posts" },
contact_submissions: { label: "Contact Submissions", hidden: true },
lead_notes: { label: "Lead Notes", hidden: true },
pages: { label: "Pages", hidden: false },
};

it("drops collections flagged hidden", () => {
expect(visibleCollectionEntries(collections).map(([slug]) => slug)).toEqual(["posts", "pages"]);
});

it("keeps manifest order for visible collections", () => {
expect(visibleCollectionEntries({ b: { label: "B" }, a: { label: "A" } })).toEqual([
["b", { label: "B" }],
["a", { label: "A" }],
]);
});

it("treats a missing hidden flag as visible", () => {
expect(visibleCollectionEntries({ posts: { label: "Posts" } })).toHaveLength(1);
});
});

describe("resolvePluginPageLabel", () => {
// Simulates a plugin that loaded its Lingui catalog into the shared i18n
// instance: known msgids translate, unknown ones return the msgid itself
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/api/schemas/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const createCollectionBody = z
source: z.string().regex(collectionSourcePattern).optional(),
urlPattern: z.string().optional(),
hasSeo: z.boolean().optional(),
hidden: z.boolean().optional(),
})
.meta({ id: "CreateCollectionBody" });

Expand All @@ -98,6 +99,7 @@ export const updateCollectionBody = z
supports: z.array(collectionSupportValues).optional(),
urlPattern: z.string().nullish(),
hasSeo: z.boolean().optional(),
hidden: z.boolean().optional(),
commentsEnabled: z.boolean().optional(),
commentsModeration: z.enum(["all", "first_time", "none"]).optional(),
commentsClosedAfterDays: z.number().int().min(0).optional(),
Expand Down Expand Up @@ -179,6 +181,7 @@ export const collectionSchema = z
source: z.string().nullable(),
urlPattern: z.string().nullable(),
hasSeo: z.boolean(),
hidden: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/routes/api/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import type { PortableTextBlock } from "emdash";
description: c.description,
icon: c.icon,
supports: c.supports,
hidden: c.hidden,
fields: c.fields.map((f) => ({
slug: f.slug,
label: f.label,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export interface ManifestCollection {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
/**
* Omit the auto-generated sidebar entry in the admin. The collection is
* still listed in the manifest so its routes, editor, and API keep working
* — only the navigation link is dropped.
*/
hidden?: boolean;
fields: Record<
string,
{
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/cli/commands/export-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ async function exportCollections(db: Kysely<Database>): Promise<SeedCollection[]
icon: collection.icon || undefined,
supports: collection.supports.length > 0 ? collection.supports : undefined,
urlPattern: collection.urlPattern || undefined,
hidden: collection.hidden || undefined,
fields: fields.map(
(field): SeedField => ({
slug: field.slug,
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/database/migrations/054_collection_hidden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Kysely } from "kysely";

import { columnExists } from "../dialect-helpers.js";

/**
* Migration: hide a collection from the admin sidebar.
*
* Adds `hidden` to `_emdash_collections`. A hidden collection stays fully
* functional (REST API, MCP, plugin hooks, direct `/content/:collection`
* URLs) — only its auto-generated sidebar entry is omitted, so plugins that
* own a collection end to end can steer editors to their own admin UI.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
if (!(await columnExists(db, "_emdash_collections", "hidden"))) {
await db.schema
.alterTable("_emdash_collections")
.addColumn("hidden", "integer", (col) => col.notNull().defaultTo(0))
.execute();
}
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.alterTable("_emdash_collections").dropColumn("hidden").execute();
}
2 changes: 2 additions & 0 deletions packages/core/src/database/migrations/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import * as m050 from "./050_media_usage_index_status.js";
import * as m051 from "./051_content_taxonomies_denorm.js";
import * as m052 from "./052_media_usage_read_index.js";
import * as m053 from "./053_plugin_mcp_tools.js";
import * as m054 from "./054_collection_hidden.js";

const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
"001_initial": m001,
Expand Down Expand Up @@ -110,6 +111,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
"051_content_taxonomies_denorm": m051,
"052_media_usage_read_index": m052,
"053_plugin_mcp_tools": m053,
"054_collection_hidden": m054,
});

/** Total number of registered migrations. Exported for use in tests. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export interface CollectionTable {
search_config: string | null; // JSON: { enabled: boolean, weights: Record<string, number> }
has_seo: number; // 0 or 1 — opt-in SEO fields for this collection
url_pattern: string | null; // URL pattern with {slug} placeholder (e.g. "/blog/{slug}")
hidden: Generated<number>; // 0 or 1 — omit the auto-generated admin sidebar entry
comments_enabled: Generated<number>; // 0 or 1
comments_moderation: Generated<string>; // 'all' | 'first_time' | 'none'
comments_closed_after_days: Generated<number>; // 0 = never close
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2362,6 +2362,7 @@ export class EmDashRuntime {
supports: collection.supports || [],
hasSeo: collection.hasSeo,
urlPattern: collection.urlPattern,
...(collection.hidden ? { hidden: true } : {}),
fields,
};
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/schema/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export class SchemaRegistry {
supports: JSON.stringify(supports),
source: input.source ?? "manual",
has_seo: hasSeo ? 1 : 0,
hidden: input.hidden ? 1 : 0,
comments_enabled: input.commentsEnabled ? 1 : 0,
url_pattern: input.urlPattern ?? null,
})
Expand Down Expand Up @@ -354,6 +355,7 @@ export class SchemaRegistry {
supports: JSON.stringify(supports),
source: "seed",
has_seo: hasSeo ? 1 : 0,
hidden: input.hidden ? 1 : 0,
comments_enabled: input.commentsEnabled ? 1 : 0,
url_pattern: input.urlPattern ?? null,
})
Expand Down Expand Up @@ -416,6 +418,7 @@ export class SchemaRegistry {
? (input.urlPattern ?? null)
: (existing.urlPattern ?? null),
has_seo: hasSeo ? 1 : 0,
hidden: input.hidden !== undefined ? (input.hidden ? 1 : 0) : existing.hidden ? 1 : 0,
comments_enabled:
input.commentsEnabled !== undefined
? input.commentsEnabled
Expand Down Expand Up @@ -1228,6 +1231,7 @@ export class SchemaRegistry {
source: row.source && isCollectionSource(row.source) ? row.source : undefined,
hasSeo: row.has_seo === 1,
urlPattern: row.url_pattern ?? undefined,
hidden: row.hidden === 1,
commentsEnabled: row.comments_enabled === 1,
commentsModeration:
moderation === "all" || moderation === "first_time" || moderation === "none"
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ export interface Collection {
hasSeo: boolean;
/** URL pattern with {slug} placeholder (e.g. "/{slug}", "/blog/{slug}") */
urlPattern?: string;
/**
* Omit this collection's auto-generated entry from the admin sidebar.
* The collection stays fully functional everywhere else (API, MCP, hooks,
* direct `/content/:collection` URLs) — this only hides the nav link, so a
* plugin that owns the collection can point editors at its own admin UI.
*/
hidden: boolean;
/** Whether comments are enabled for this collection */
commentsEnabled: boolean;
/** Moderation strategy: "all" | "first_time" | "none" */
Expand Down Expand Up @@ -219,6 +226,8 @@ export interface CreateCollectionInput {
source?: CollectionSource;
urlPattern?: string;
hasSeo?: boolean;
/** Omit the auto-generated admin sidebar entry (defaults to false) */
hidden?: boolean;
commentsEnabled?: boolean;
}

Expand All @@ -233,6 +242,8 @@ export interface UpdateCollectionInput {
supports?: CollectionSupport[];
urlPattern?: string;
hasSeo?: boolean;
/** Omit the auto-generated admin sidebar entry */
hidden?: boolean;
commentsEnabled?: boolean;
commentsModeration?: "all" | "first_time" | "none";
commentsClosedAfterDays?: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/seed/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export async function applySeed(
icon: collection.icon,
supports: collection.supports || [],
urlPattern: collection.urlPattern,
hidden: collection.hidden,
commentsEnabled: collection.commentsEnabled,
});
result.collections.updated++;
Expand Down Expand Up @@ -247,6 +248,7 @@ export async function applySeed(
icon: collection.icon,
supports: collection.supports || [],
urlPattern: collection.urlPattern,
hidden: collection.hidden,
commentsEnabled: collection.commentsEnabled,
},
fields,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/seed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export interface SeedCollection {
icon?: string;
supports?: ("drafts" | "revisions" | "preview" | "scheduling" | "search" | "seo")[];
urlPattern?: string;
/**
* Omit this collection from the admin sidebar. It stays reachable through
* the API, MCP, plugin hooks, and direct `/content/:collection` URLs.
*/
hidden?: boolean;
/** Enable comments on this collection */
commentsEnabled?: boolean;
fields: SeedField[];
Expand Down
38 changes: 38 additions & 0 deletions packages/core/tests/unit/schema/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,44 @@ describe("SchemaRegistry", () => {
expect(updated.supports).toEqual(["drafts"]);
});

it("collections are visible in the sidebar by default", async () => {
const collection = await registry.createCollection({ slug: "posts", label: "Posts" });

expect(collection.hidden).toBe(false);
});

it("creates a collection hidden from the sidebar", async () => {
const collection = await registry.createCollection({
slug: "contact_submissions",
label: "Contact Submissions",
hidden: true,
});

expect(collection.hidden).toBe(true);
// A hidden collection is only hidden from the sidebar — it must still
// be listed by the registry so its routes, editor, API, and MCP tools
// keep resolving.
const listed = await registry.listCollections();
expect(listed.map((c) => c.slug)).toContain("contact_submissions");
expect(await registry.getCollection("contact_submissions")).not.toBeNull();
});

it("toggles hidden on an existing collection", async () => {
await registry.createCollection({ slug: "posts", label: "Posts" });

expect((await registry.updateCollection("posts", { hidden: true })).hidden).toBe(true);
expect((await registry.updateCollection("posts", { hidden: false })).hidden).toBe(false);
});

it("preserves hidden when an update omits it", async () => {
await registry.createCollection({ slug: "posts", label: "Posts", hidden: true });

const updated = await registry.updateCollection("posts", { label: "Blog Posts" });

expect(updated.label).toBe("Blog Posts");
expect(updated.hidden).toBe(true);
});

it("should throw when updating non-existent collection", async () => {
await expect(registry.updateCollection("nonexistent", { label: "Test" })).rejects.toThrow(
SchemaError,
Expand Down
Loading
Loading