create-nextblock 0.14.2 → 0.14.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
  3. package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
  4. package/templates/nextblock-template/app/actions/postActions.ts +3 -0
  5. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
  6. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
  7. package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
  8. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
  9. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
  10. package/templates/nextblock-template/app/api/view/route.ts +114 -0
  11. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
  12. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  13. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  14. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  15. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  16. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  18. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  19. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  20. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  21. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  22. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  23. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  24. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  25. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  26. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  27. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  28. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  29. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  30. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  31. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  32. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  33. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  34. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  35. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  36. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  37. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  38. package/templates/nextblock-template/app/layout.tsx +49 -0
  39. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  40. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  41. package/templates/nextblock-template/app/page.tsx +6 -3
  42. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  43. package/templates/nextblock-template/app/providers.tsx +16 -4
  44. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  45. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  46. package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
  47. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  48. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  49. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
  50. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
  51. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  52. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  53. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  54. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  55. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  56. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  57. package/templates/nextblock-template/lib/search/server.ts +3 -0
  58. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
  59. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  60. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  61. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  62. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  63. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  64. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  65. package/templates/nextblock-template/next-env.d.ts +2 -2
  66. package/templates/nextblock-template/package.json +1 -1
  67. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  68. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.14.2",
3
+ "version": "0.14.5",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  // app/[slug]/page.tsx
2
2
  import React from 'react';
3
3
  import { getSsgSupabaseClient } from "@nextblock-cms/db/server";
4
+ import { buildPublishedAtOrFilter } from "@nextblock-cms/utils";
4
5
  import { notFound } from "next/navigation";
5
6
  import type { Metadata } from 'next';
6
7
  import PageClientContent from "./PageClientContent";
@@ -55,7 +56,8 @@ export async function generateStaticParams(): Promise<ResolvedPageParams[]> {
55
56
  const { data: pages, error } = await supabase
56
57
  .from("pages")
57
58
  .select("slug")
58
- .eq("status", "published");
59
+ .eq("status", "published")
60
+ .or(buildPublishedAtOrFilter());
59
61
 
60
62
  if (error || !pages) {
61
63
  console.error("SSG: Error fetching page slugs for static params:", error);
@@ -105,6 +107,8 @@ export async function generateMetadata(
105
107
  .select('language_id, slug')
106
108
  .eq('translation_group_id', pageData.translation_group_id)
107
109
  .eq('status', 'published')
110
+ // Never advertise a scheduled translation via hreflang.
111
+ .or(buildPublishedAtOrFilter())
108
112
  ]);
109
113
 
110
114
  const { data: languages } = languagesResult;
@@ -180,7 +184,8 @@ export default async function DynamicPage({ params: paramsPromise }: PageProps)
180
184
  .from("pages")
181
185
  .select("slug, languages!inner(code)")
182
186
  .eq("translation_group_id", pageData.translation_group_id)
183
- .eq("status", "published");
187
+ .eq("status", "published")
188
+ .or(buildPublishedAtOrFilter());
184
189
 
185
190
  if (translations) {
186
191
  translations.forEach((translation: PageTranslation) => {
@@ -1,6 +1,7 @@
1
1
  // app/[slug]/page.utils.ts
2
2
  import { createClient, getSsgSupabaseClient } from "@nextblock-cms/db/server";
3
3
  import type { Database } from "@nextblock-cms/db";
4
+ import { buildPublishedAtOrFilter } from "@nextblock-cms/utils";
4
5
  import { draftMode } from "next/headers";
5
6
  import { resolveMediaUrl } from "../../lib/media/resolveMediaUrl";
6
7
  import { getContentDraft } from "../../lib/visual-editing/draft-content";
@@ -96,7 +97,7 @@ function applyDraftToPage(page: SelectedPageType, draft: ContentDraftRow): Selec
96
97
  slug: draftString(draft, "slug", page.slug),
97
98
  language_id: languageId,
98
99
  language_details: languageId === page.language_id ? page.language_details : null,
99
- status: draftString(draft, "status", page.status) as PageType["status"],
100
+ // Visibility is never taken from a draft it lives on the row.
100
101
  meta_title: draftNullableString(draft, "meta_title", page.meta_title),
101
102
  meta_description: draftNullableString(draft, "meta_description", page.meta_description),
102
103
  custom_canonical: draftNullableString(draft, "custom_canonical", page.custom_canonical),
@@ -280,7 +281,11 @@ export async function getPageDataBySlug(
280
281
  .order('order', { foreignTable: 'blocks', ascending: true });
281
282
 
282
283
  if (!isDraftModeEnabled) {
283
- preferredQuery = preferredQuery.eq("status", "published");
284
+ // A future published_at means "scheduled" — withheld from the public until
285
+ // it passes. Preview (draft mode) deliberately ignores the schedule.
286
+ preferredQuery = preferredQuery
287
+ .eq("status", "published")
288
+ .or(buildPublishedAtOrFilter());
284
289
  }
285
290
 
286
291
  const { data: preferredData, error: preferredError } = await preferredQuery.maybeSingle();
@@ -298,7 +303,7 @@ export async function getPageDataBySlug(
298
303
  .order('order', { foreignTable: 'blocks', ascending: true });
299
304
 
300
305
  if (!isDraftModeEnabled) {
301
- pageQuery = pageQuery.eq("status", "published");
306
+ pageQuery = pageQuery.eq("status", "published").or(buildPublishedAtOrFilter());
302
307
  }
303
308
 
304
309
  const { data: candidatePagesData, error: pageError } = await pageQuery;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { cache } from 'react';
4
4
  import { createClient } from '@nextblock-cms/db/server';
5
+ import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
5
6
  import { revalidatePath } from 'next/cache';
6
7
  import type { Database } from '@nextblock-cms/db';
7
8
  import type { PostWithMediaDimensions } from '../../components/blocks/types';
@@ -105,6 +106,8 @@ async function fetchPublishedPostsPage(languageId: number, page: number, limit:
105
106
  { count: 'exact' }
106
107
  )
107
108
  .eq('status', 'published')
109
+ // Without this a scheduled post is listed here while its own URL 404s.
110
+ .or(buildPublishedAtOrFilter())
108
111
  .eq('language_id', languageId)
109
112
  .order('published_at', { ascending: false })
110
113
  .range(offset, offset + limit - 1);
@@ -0,0 +1,210 @@
1
+ "use server";
2
+
3
+ /**
4
+ * Visibility actions — the ONLY writes that can change what the public sees.
5
+ *
6
+ * These deliberately bypass the Live Draft pipeline (`content_drafts` /
7
+ * `product_drafts`) entirely: they read nothing from a draft, create nothing, and
8
+ * leave any pending content edits exactly where they are. Publishing a page and
9
+ * publishing your unsaved edits are two different decisions, so they are two
10
+ * different code paths.
11
+ *
12
+ * Visibility is stored as the (status, published_at) pair — see
13
+ * `libs/utils/src/lib/publishing.ts` for how "scheduled" is derived from it.
14
+ */
15
+
16
+ import { createClient } from "@nextblock-cms/db/server";
17
+ import { revalidatePath } from "next/cache";
18
+ import {
19
+ LIVE_STATUS,
20
+ isFutureSchedule,
21
+ resolveVisibilityState,
22
+ type PublishableType,
23
+ type VisibilityState,
24
+ } from "@nextblock-cms/utils";
25
+ import { getHomepageTranslationGroupId } from "../lib/homepage";
26
+
27
+ export interface VisibilityResult {
28
+ error?: string;
29
+ state?: VisibilityState;
30
+ publishedAt?: string | null;
31
+ }
32
+
33
+ /** What the caller wants the content to become. */
34
+ export type VisibilityIntent =
35
+ | { action: "publish" }
36
+ | { action: "schedule"; publishedAt: string }
37
+ | { action: "unpublish" }
38
+ | { action: "archive" };
39
+
40
+ const TABLE: Record<PublishableType, "pages" | "posts" | "products"> = {
41
+ page: "pages",
42
+ post: "posts",
43
+ product: "products",
44
+ };
45
+
46
+ const CMS_LIST_PATH: Record<PublishableType, string> = {
47
+ page: "/cms/pages",
48
+ post: "/cms/posts",
49
+ product: "/cms/products",
50
+ };
51
+
52
+ function publicPath(type: PublishableType, slug: string): string {
53
+ if (type === "post") return `/article/${slug}`;
54
+ if (type === "product") return `/product/${slug}`;
55
+ return `/${slug}`;
56
+ }
57
+
58
+ /**
59
+ * Turn an intent into the columns to write.
60
+ *
61
+ * "Publish" always clears `published_at`. Leaving a past timestamp behind would be
62
+ * harmless for the public gate but would keep rendering as a stale schedule in the
63
+ * CMS; leaving a FUTURE one would mean "Publish now" silently did nothing.
64
+ */
65
+ function buildUpdate(type: PublishableType, intent: VisibilityIntent) {
66
+ const liveStatus = LIVE_STATUS[type];
67
+
68
+ switch (intent.action) {
69
+ case "publish":
70
+ return { status: liveStatus, published_at: null };
71
+ case "schedule":
72
+ return { status: liveStatus, published_at: intent.publishedAt };
73
+ case "unpublish":
74
+ return { status: "draft", published_at: null };
75
+ case "archive":
76
+ return { status: "archived", published_at: null };
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Set a page/post/product's visibility directly on its row.
82
+ *
83
+ * `id` is a number for pages and posts, a uuid string for products.
84
+ */
85
+ export async function setContentVisibility(
86
+ type: PublishableType,
87
+ id: number | string,
88
+ intent: VisibilityIntent,
89
+ ): Promise<VisibilityResult> {
90
+ const supabase = createClient();
91
+ const {
92
+ data: { user },
93
+ } = await supabase.auth.getUser();
94
+ if (!user) {
95
+ return { error: "You must be signed in to change visibility." };
96
+ }
97
+
98
+ const { data: profile } = await supabase
99
+ .from("profiles")
100
+ .select("role")
101
+ .eq("id", user.id)
102
+ .single();
103
+ if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
104
+ return { error: "You do not have permission to change visibility." };
105
+ }
106
+
107
+ if (intent.action === "schedule" && !isFutureSchedule(intent.publishedAt)) {
108
+ return { error: "Pick a date and time in the future, or publish now instead." };
109
+ }
110
+
111
+ const update = buildUpdate(type, intent);
112
+ const { data: row, error } = await supabase
113
+ .from(TABLE[type])
114
+ .update({ ...update, updated_at: new Date().toISOString() } as never)
115
+ .eq("id", id as never)
116
+ .select("slug, status, published_at, translation_group_id")
117
+ .single();
118
+
119
+ if (error || !row) {
120
+ return { error: error?.message || "Could not update visibility." };
121
+ }
122
+
123
+ const updated = row as unknown as {
124
+ slug: string | null;
125
+ status: string;
126
+ published_at: string | null;
127
+ translation_group_id: string | null;
128
+ };
129
+
130
+ revalidatePath(CMS_LIST_PATH[type]);
131
+ revalidatePath(`${CMS_LIST_PATH[type]}/${id}/edit`);
132
+
133
+ if (updated.slug) {
134
+ revalidatePath(publicPath(type, updated.slug));
135
+ }
136
+
137
+ // Every language variation of the homepage is also served at "/", whatever its
138
+ // slug, so taking one down (or putting it up) has to bust that cache too.
139
+ if (type === "page") {
140
+ const homepageGroupId = await getHomepageTranslationGroupId(supabase);
141
+ if (
142
+ (homepageGroupId && updated.translation_group_id === homepageGroupId) ||
143
+ updated.slug === "home"
144
+ ) {
145
+ revalidatePath("/");
146
+ }
147
+ }
148
+
149
+ if (type === "post") {
150
+ revalidatePath("/articles");
151
+ }
152
+
153
+ return {
154
+ state: resolveVisibilityState({
155
+ status: updated.status,
156
+ publishedAt: updated.published_at,
157
+ liveStatus: LIVE_STATUS[type],
158
+ }),
159
+ publishedAt: updated.published_at,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Sibling-language versions of the same content, with their visibility state.
165
+ *
166
+ * Status is per-row, so publishing the French page does nothing for English. The
167
+ * publish dialog shows this list to catch the half-published translation — the
168
+ * single most common multilingual mistake.
169
+ */
170
+ export interface SiblingVisibility {
171
+ id: number | string;
172
+ languageId: number;
173
+ title: string;
174
+ state: VisibilityState;
175
+ }
176
+
177
+ export async function getSiblingVisibility(
178
+ type: PublishableType,
179
+ translationGroupId: string,
180
+ excludeId: number | string,
181
+ ): Promise<SiblingVisibility[]> {
182
+ if (!translationGroupId) return [];
183
+
184
+ const supabase = createClient();
185
+ const { data, error } = await supabase
186
+ .from(TABLE[type])
187
+ .select("id, title, language_id, status, published_at")
188
+ .eq("translation_group_id", translationGroupId);
189
+
190
+ if (error || !data) return [];
191
+
192
+ return (data as unknown as Array<{
193
+ id: number | string;
194
+ title: string;
195
+ language_id: number;
196
+ status: string;
197
+ published_at: string | null;
198
+ }>)
199
+ .filter((row) => String(row.id) !== String(excludeId))
200
+ .map((row) => ({
201
+ id: row.id,
202
+ languageId: row.language_id,
203
+ title: row.title,
204
+ state: resolveVisibilityState({
205
+ status: row.status,
206
+ publishedAt: row.published_at,
207
+ liveStatus: LIVE_STATUS[type],
208
+ }),
209
+ }));
210
+ }
@@ -18,13 +18,20 @@ const cacheMocks = vi.hoisted(() => ({
18
18
 
19
19
  vi.mock("next/cache", () => cacheMocks);
20
20
  vi.mock("server-only", () => ({}));
21
- vi.mock("../cms/revisions/utils", () => ({
21
+ const revisionMocks = vi.hoisted(() => ({
22
+ createPageRevision: vi.fn(),
23
+ createPostRevision: vi.fn(),
22
24
  getFullPageContent: vi.fn(),
23
25
  getFullPostContent: vi.fn(),
24
26
  }));
27
+
28
+ vi.mock("../cms/revisions/utils", () => ({
29
+ getFullPageContent: revisionMocks.getFullPageContent,
30
+ getFullPostContent: revisionMocks.getFullPostContent,
31
+ }));
25
32
  vi.mock("../cms/revisions/service", () => ({
26
- createPageRevision: vi.fn(),
27
- createPostRevision: vi.fn(),
33
+ createPageRevision: revisionMocks.createPageRevision,
34
+ createPostRevision: revisionMocks.createPostRevision,
28
35
  }));
29
36
  vi.mock("../../lib/visual-editing/draft-content", () => draftContentMocks);
30
37
  vi.mock("../../lib/visual-editing/product-drafts", () => ({
@@ -176,4 +183,77 @@ describe("visual editing server actions", () => {
176
183
  expect(result).toEqual({ error: "No draft exists for this content." });
177
184
  expect(from).toHaveBeenCalledWith("content_drafts");
178
185
  });
186
+
187
+ describe("publishing a page draft", () => {
188
+ const previousContent = { meta: { title: "Before" }, blocks: [] };
189
+ const nextContent = { meta: { title: "After" }, blocks: [] };
190
+
191
+ /**
192
+ * Publishing is the only path that writes the live tables, so it is also the only
193
+ * place a page/post revision gets recorded. The chain here mirrors the real one:
194
+ * read the draft, update the row, swap the blocks, delete the draft.
195
+ */
196
+ function mockPublishChain() {
197
+ const chain: any = {
198
+ delete: vi.fn(() => chain),
199
+ eq: vi.fn(() => chain),
200
+ error: null,
201
+ insert: vi.fn(() => chain),
202
+ maybeSingle: vi.fn().mockResolvedValue({ data: baseDraft, error: null }),
203
+ select: vi.fn(() => chain),
204
+ update: vi.fn(() => chain),
205
+ };
206
+ const from = vi.fn(() => chain);
207
+ draftContentMocks.getCurrentUserCanEdit.mockResolvedValue({
208
+ canEdit: true,
209
+ supabase: { from },
210
+ user: { id: "user-1" },
211
+ });
212
+ return { chain, from };
213
+ }
214
+
215
+ it("records a revision from the pre-publish state", async () => {
216
+ mockPublishChain();
217
+ revisionMocks.getFullPageContent
218
+ .mockResolvedValueOnce(previousContent)
219
+ .mockResolvedValueOnce(nextContent);
220
+ revisionMocks.createPageRevision.mockResolvedValue({
221
+ recorded: true,
222
+ success: true,
223
+ version: 2,
224
+ });
225
+
226
+ const result = await publishVisualEditingDraft("page", 2);
227
+
228
+ expect(result).toEqual({ success: true });
229
+ expect(revisionMocks.createPageRevision).toHaveBeenCalledWith(
230
+ 2,
231
+ "user-1",
232
+ previousContent,
233
+ nextContent
234
+ );
235
+ });
236
+
237
+ it("still completes the publish when the revision fails, and warns instead of erroring", async () => {
238
+ const { chain } = mockPublishChain();
239
+ revisionMocks.getFullPageContent
240
+ .mockResolvedValueOnce(previousContent)
241
+ .mockResolvedValueOnce(nextContent);
242
+ revisionMocks.createPageRevision.mockResolvedValue({
243
+ error: "Failed to insert page revision: permission denied",
244
+ });
245
+
246
+ const result = await publishVisualEditingDraft("page", 2);
247
+
248
+ // The page row and its blocks are already live at this point. Aborting would strand
249
+ // the draft row and leave the public route un-revalidated, which is worse than a
250
+ // missing history entry — so this is a partial success, not a failure.
251
+ expect(result).toMatchObject({
252
+ success: true,
253
+ warning: expect.stringContaining("history was not recorded"),
254
+ });
255
+ expect(chain.delete).toHaveBeenCalled();
256
+ expect(cacheMocks.revalidatePath).toHaveBeenCalledWith("/about");
257
+ });
258
+ });
179
259
  });
@@ -164,7 +164,15 @@ function addNumberMeta(
164
164
  }
165
165
  }
166
166
 
167
- async function publishPageDraft(draft: ContentDraftRow, authorId: string) {
167
+ /**
168
+ * Applies a page draft to the live tables and records the revision.
169
+ *
170
+ * Returns a warning string when the content went live but the revision did not record —
171
+ * never throws for that case. Once the page row and its blocks have been rewritten, the
172
+ * publish has happened; aborting here would leave the draft row undeleted and the public
173
+ * route un-revalidated, which is strictly worse than a missing history entry.
174
+ */
175
+ async function publishPageDraft(draft: ContentDraftRow, authorId: string): Promise<string | null> {
168
176
  const auth = await getCurrentUserCanEdit();
169
177
  const supabase = auth.supabase;
170
178
  const previousContent = await getFullPageContent(draft.parent_id);
@@ -172,7 +180,11 @@ async function publishPageDraft(draft: ContentDraftRow, authorId: string) {
172
180
  addStringMeta(pageUpdate, draft, "title");
173
181
  addStringMeta(pageUpdate, draft, "slug");
174
182
  addNumberMeta(pageUpdate, draft, "language_id");
175
- addStringMeta(pageUpdate, draft, "status");
183
+ // `status`/`published_at` are deliberately NOT copied from the draft. Visibility
184
+ // is owned by the top-bar control (setPageVisibility) and written straight to the
185
+ // row; publishing a draft that still carries an old status would silently roll a
186
+ // page back to draft — or push one live — behind the editor's back. Older drafts
187
+ // may still hold those keys; they are inert.
176
188
  addNullableStringMeta(pageUpdate, draft, "meta_title");
177
189
  addNullableStringMeta(pageUpdate, draft, "meta_description");
178
190
  addNullableStringMeta(pageUpdate, draft, "custom_canonical");
@@ -204,12 +216,15 @@ async function publishPageDraft(draft: ContentDraftRow, authorId: string) {
204
216
  }
205
217
 
206
218
  const nextContent = await getFullPageContent(draft.parent_id);
207
- if (previousContent && nextContent) {
208
- await createPageRevision(draft.parent_id, authorId, previousContent, nextContent);
219
+ if (!previousContent || !nextContent) {
220
+ return "the page content could not be read back";
209
221
  }
222
+ const revision = await createPageRevision(draft.parent_id, authorId, previousContent, nextContent);
223
+ return "error" in revision ? revision.error : null;
210
224
  }
211
225
 
212
- async function publishPostDraft(draft: ContentDraftRow, authorId: string) {
226
+ /** See publishPageDraft same contract. */
227
+ async function publishPostDraft(draft: ContentDraftRow, authorId: string): Promise<string | null> {
213
228
  const auth = await getCurrentUserCanEdit();
214
229
  const supabase = auth.supabase;
215
230
  const previousContent = await getFullPostContent(draft.parent_id);
@@ -217,14 +232,14 @@ async function publishPostDraft(draft: ContentDraftRow, authorId: string) {
217
232
  addStringMeta(postUpdate, draft, "title");
218
233
  addStringMeta(postUpdate, draft, "slug");
219
234
  addNumberMeta(postUpdate, draft, "language_id");
220
- addStringMeta(postUpdate, draft, "status");
235
+ // See publishPageDraft: visibility (`status`/`published_at`) never rides along
236
+ // with a content draft.
221
237
  addNullableStringMeta(postUpdate, draft, "meta_title");
222
238
  addNullableStringMeta(postUpdate, draft, "meta_description");
223
239
  addNullableStringMeta(postUpdate, draft, "custom_canonical");
224
240
  addNullableStringMeta(postUpdate, draft, "label");
225
241
  addNullableStringMeta(postUpdate, draft, "excerpt");
226
242
  addNullableStringMeta(postUpdate, draft, "subtitle");
227
- addNullableStringMeta(postUpdate, draft, "published_at");
228
243
  addNullableStringMeta(postUpdate, draft, "feature_image_id");
229
244
 
230
245
  const { error: postError } = await supabase
@@ -253,9 +268,11 @@ async function publishPostDraft(draft: ContentDraftRow, authorId: string) {
253
268
  }
254
269
 
255
270
  const nextContent = await getFullPostContent(draft.parent_id);
256
- if (previousContent && nextContent) {
257
- await createPostRevision(draft.parent_id, authorId, previousContent, nextContent);
271
+ if (!previousContent || !nextContent) {
272
+ return "the post content could not be read back";
258
273
  }
274
+ const revision = await createPostRevision(draft.parent_id, authorId, previousContent, nextContent);
275
+ return "error" in revision ? revision.error : null;
259
276
  }
260
277
 
261
278
  export async function publishVisualEditingDraft(parentType: NextblockDocumentType, parentId: number) {
@@ -283,11 +300,10 @@ export async function publishVisualEditingDraft(parentType: NextblockDocumentTyp
283
300
 
284
301
  const draft = normalizeContentDraftRow(data);
285
302
 
286
- if (parentType === "page") {
287
- await publishPageDraft(draft, auth.user.id);
288
- } else {
289
- await publishPostDraft(draft, auth.user.id);
290
- }
303
+ const revisionWarning =
304
+ parentType === "page"
305
+ ? await publishPageDraft(draft, auth.user.id)
306
+ : await publishPostDraft(draft, auth.user.id);
291
307
 
292
308
  const { error: deleteError } = await (auth.supabase as any)
293
309
  .from("content_drafts")
@@ -304,6 +320,10 @@ export async function publishVisualEditingDraft(parentType: NextblockDocumentTyp
304
320
  }
305
321
  revalidateVisualEditingPath(parentType === "page" ? `/cms/pages/${parentId}/edit` : `/cms/posts/${parentId}/edit`);
306
322
 
323
+ if (revisionWarning) {
324
+ return { success: true, warning: `Published, but history was not recorded: ${revisionWarning}` };
325
+ }
326
+
307
327
  return { success: true };
308
328
  } catch (error) {
309
329
  return {
@@ -41,6 +41,11 @@ import {
41
41
  } from '@nextblock-cms/cortex';
42
42
  import { validateBlockContent } from '../../../../lib/blocks/blockRegistry';
43
43
  import { importExternalImageToMedia } from '../../../cms/media/import-external-image';
44
+ import {
45
+ captureRevisionBaseline,
46
+ commitRevisionFromBaseline,
47
+ } from '../../../cms/revisions/service';
48
+ import type { AnyFullContent } from '../../../cms/revisions/utils';
44
49
 
45
50
  export const dynamic = 'force-dynamic';
46
51
 
@@ -72,6 +77,43 @@ async function importExternalImageForCortex(input: {
72
77
  return { id: result.media.id };
73
78
  }
74
79
 
80
+ /**
81
+ * Bridges the app's revision engine into the Cortex tool context so that content the agent
82
+ * writes straight to the live tables lands in Revision History like any other edit.
83
+ *
84
+ * Every AI write goes through here, including the ones inside a multi-step action plan —
85
+ * the callback lives on the tool context, not on the route, so it also covers the executors
86
+ * that run in-stream rather than via the Confirm button.
87
+ *
88
+ * `authorId` is the admin whose session authorised the request; the agent's Supabase client
89
+ * is service-role, so without this the revision row would land with a null author.
90
+ */
91
+ function createCortexRevisionRecorder(authorId: string | null) {
92
+ return async function recordRevision(input: {
93
+ baseline?: unknown;
94
+ contentType: 'page' | 'post' | 'product';
95
+ entityId: number | string;
96
+ phase: 'capture' | 'commit';
97
+ }): Promise<unknown> {
98
+ if (input.phase === 'capture') {
99
+ return captureRevisionBaseline(input.contentType, input.entityId);
100
+ }
101
+
102
+ const result = await commitRevisionFromBaseline(
103
+ input.contentType,
104
+ input.entityId,
105
+ authorId,
106
+ (input.baseline ?? null) as AnyFullContent | null
107
+ );
108
+
109
+ if ('error' in result) {
110
+ console.error('Cortex AI: revision not recorded —', result.error);
111
+ }
112
+
113
+ return undefined;
114
+ };
115
+ }
116
+
75
117
  const globalAgentMessageSchema = z.strictObject({
76
118
  content: z.string().min(1).max(8000),
77
119
  role: z.enum(['system', 'user', 'assistant']),
@@ -909,6 +951,7 @@ export async function POST(request: Request) {
909
951
  importExternalImage: importExternalImageForCortex,
910
952
  latestUserMessage: confirmedToolCall.confirmationPhrase,
911
953
  pageContext,
954
+ recordRevision: createCortexRevisionRecorder(adminAccess.userId),
912
955
  supabase: getServiceRoleSupabaseClient(),
913
956
  validateBlockContent,
914
957
  },
@@ -935,6 +978,7 @@ export async function POST(request: Request) {
935
978
  importExternalImage: importExternalImageForCortex,
936
979
  latestUserMessage,
937
980
  pageContext,
981
+ recordRevision: createCortexRevisionRecorder(adminAccess.userId),
938
982
  supabase: getServiceRoleSupabaseClient(),
939
983
  validateBlockContent,
940
984
  },
@@ -980,6 +1024,7 @@ export async function POST(request: Request) {
980
1024
  importExternalImage: importExternalImageForCortex,
981
1025
  latestUserMessage,
982
1026
  pageContext,
1027
+ recordRevision: createCortexRevisionRecorder(adminAccess.userId),
983
1028
  supabase: getServiceRoleSupabaseClient(),
984
1029
  validateBlockContent,
985
1030
  });