@growth-labs/cms 0.1.1 → 0.1.2

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 (62) hide show
  1. package/README.md +6 -2
  2. package/dist/engine/publisher.d.ts +14 -3
  3. package/dist/engine/publisher.d.ts.map +1 -1
  4. package/dist/engine/publisher.js +11 -5
  5. package/dist/engine/publisher.js.map +1 -1
  6. package/dist/routes/content.d.ts +1 -1
  7. package/dist/routes/content.d.ts.map +1 -1
  8. package/dist/routes/content.js +30 -23
  9. package/dist/routes/content.js.map +1 -1
  10. package/dist/schema/insights-ingest.d.ts +16 -16
  11. package/dist/schema/migrations.d.ts +5 -3
  12. package/dist/schema/migrations.d.ts.map +1 -1
  13. package/dist/schema/migrations.js +148 -4
  14. package/dist/schema/migrations.js.map +1 -1
  15. package/dist/schema/types.d.ts +2 -2
  16. package/dist/schema/types.d.ts.map +1 -1
  17. package/dist/ui/commands.d.ts +2 -1
  18. package/dist/ui/commands.d.ts.map +1 -1
  19. package/dist/ui/commands.js +1 -2
  20. package/dist/ui/commands.js.map +1 -1
  21. package/dist/ui/components/SharePickers.d.ts.map +1 -1
  22. package/dist/ui/components/SharePickers.js +11 -7
  23. package/dist/ui/components/SharePickers.js.map +1 -1
  24. package/dist/ui/editor/ContentForm.d.ts +1 -1
  25. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  26. package/dist/ui/editor/ContentForm.js +5 -5
  27. package/dist/ui/editor/ContentForm.js.map +1 -1
  28. package/dist/ui/editor/content-payload.js +2 -2
  29. package/dist/ui/editor/content-payload.js.map +1 -1
  30. package/dist/ui/preview/draft-page.js +5 -4
  31. package/dist/ui/preview/draft-page.js.map +1 -1
  32. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  33. package/dist/ui/screens/EditorScreen.js +2 -0
  34. package/dist/ui/screens/EditorScreen.js.map +1 -1
  35. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  36. package/dist/ui/screens/LibraryScreen.js +4 -2
  37. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  38. package/dist/ui/screens/SocialShareScreen.js +1 -1
  39. package/dist/ui/screens/SocialShareScreen.js.map +1 -1
  40. package/dist/ui/screens/content-view.d.ts +2 -1
  41. package/dist/ui/screens/content-view.d.ts.map +1 -1
  42. package/dist/ui/screens/content-view.js.map +1 -1
  43. package/dist/ui/screens/public-url.d.ts.map +1 -1
  44. package/dist/ui/screens/public-url.js +2 -0
  45. package/dist/ui/screens/public-url.js.map +1 -1
  46. package/migrations/0001_create_cms_tables.sql +4 -4
  47. package/migrations/0017_content_pages.sql +138 -0
  48. package/package.json +1 -1
  49. package/src/engine/publisher.ts +45 -9
  50. package/src/routes/content.ts +40 -26
  51. package/src/schema/migrations.ts +148 -4
  52. package/src/schema/types.ts +2 -2
  53. package/src/ui/commands.ts +3 -1
  54. package/src/ui/components/SharePickers.tsx +13 -10
  55. package/src/ui/editor/ContentForm.tsx +7 -7
  56. package/src/ui/editor/content-payload.ts +2 -2
  57. package/src/ui/preview/draft-page.tsx +7 -4
  58. package/src/ui/screens/EditorScreen.tsx +2 -0
  59. package/src/ui/screens/LibraryScreen.tsx +4 -2
  60. package/src/ui/screens/SocialShareScreen.tsx +1 -0
  61. package/src/ui/screens/content-view.ts +3 -1
  62. package/src/ui/screens/public-url.ts +2 -0
@@ -19,12 +19,17 @@
19
19
  // timestamps; the video copy hard-codes `processing_state = 'ready'` so a
20
20
  // duplicate is NOT re-queued to Foundry. Any future `content_items` column
21
21
  // addition requires a matching edit here or the duplicate silently drops it.
22
+ import type { ContentType } from '../schema/types.js'
22
23
  import type { D1Database } from './d1.js'
23
24
  import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
24
25
  import { slugify } from './slug.js'
25
26
 
26
- type ContentType = 'article' | 'video' | 'podcast' | 'newsletter'
27
27
  type ContentVisibility = 'free' | 'premium'
28
+ type BodyBackedContentType = 'article' | 'newsletter' | 'page'
29
+
30
+ function isBodyBackedContentType(type: ContentType): type is BodyBackedContentType {
31
+ return type === 'article' || type === 'newsletter' || type === 'page'
32
+ }
28
33
 
29
34
  /**
30
35
  * The processing_state a freshly created video starts in. Mirrors
@@ -137,10 +142,39 @@ export interface NewsletterInput extends BaseContentInput {
137
142
  }
138
143
  }
139
144
 
140
- export type CreateContentInput = ArticleInput | VideoInput | PodcastInput | NewsletterInput
145
+ export interface PageInput extends BaseContentInput {
146
+ type: 'page'
147
+ content: {
148
+ bodyMarkdown: string
149
+ bodyHtml?: string | null
150
+ subtitle?: string | null
151
+ wordCount?: number | null
152
+ readTimeMinutes?: number | null
153
+ editorTakeaways?: string[] | null
154
+ }
155
+ }
156
+
157
+ export type CreateContentInput =
158
+ | ArticleInput
159
+ | VideoInput
160
+ | PodcastInput
161
+ | NewsletterInput
162
+ | PageInput
163
+
164
+ type BodyBackedContentInput = ArticleInput | NewsletterInput | PageInput
165
+
166
+ function isBodyBackedContentInput(input: CreateContentInput): input is BodyBackedContentInput {
167
+ return isBodyBackedContentType(input.type)
168
+ }
141
169
 
142
170
  export interface UpdateContentInput extends Partial<BaseContentInput> {
143
- content?: Partial<ArticleInput['content'] | VideoInput['content'] | PodcastInput['content']>
171
+ content?: Partial<
172
+ | ArticleInput['content']
173
+ | VideoInput['content']
174
+ | PodcastInput['content']
175
+ | NewsletterInput['content']
176
+ | PageInput['content']
177
+ >
144
178
  relations?: Array<{
145
179
  relatedId: string
146
180
  rank: number
@@ -342,7 +376,7 @@ export async function createContent(
342
376
  )
343
377
  .run()
344
378
 
345
- if (input.type === 'article' || input.type === 'newsletter') {
379
+ if (isBodyBackedContentInput(input)) {
346
380
  const wc = input.content.wordCount ?? countWords(input.content.bodyMarkdown)
347
381
  const rt = input.content.readTimeMinutes ?? estimateReadTime(wc)
348
382
  await db
@@ -530,7 +564,7 @@ export async function updateContentItem(
530
564
  }
531
565
 
532
566
  if (input.content) {
533
- if (existing.type === 'article' || existing.type === 'newsletter') {
567
+ if (isBodyBackedContentType(existing.type)) {
534
568
  const current = await db
535
569
  .prepare(
536
570
  `
@@ -551,7 +585,9 @@ export async function updateContentItem(
551
585
  }>()
552
586
  if (!current) return { slug }
553
587
 
554
- const articleContent = input.content as Partial<ArticleInput['content']>
588
+ const articleContent = input.content as Partial<
589
+ ArticleInput['content'] | NewsletterInput['content'] | PageInput['content']
590
+ >
555
591
  const bodyMarkdownProvided = hasOwn(articleContent, 'bodyMarkdown')
556
592
  const wordCountProvided = hasOwn(articleContent, 'wordCount')
557
593
  const readTimeProvided = hasOwn(articleContent, 'readTimeMinutes')
@@ -779,7 +815,7 @@ export async function evaluateContentBodyForPublish(
779
815
  body_html: string | null
780
816
  }>()
781
817
 
782
- if (!row || (row.type !== 'article' && row.type !== 'newsletter')) {
818
+ if (!row || !isBodyBackedContentType(row.type)) {
783
819
  return null
784
820
  }
785
821
 
@@ -906,7 +942,7 @@ export async function duplicateContentItem(
906
942
  .bind(newId, newSlug, sourceId)
907
943
  .run()
908
944
 
909
- if (source.type === 'article' || source.type === 'newsletter') {
945
+ if (isBodyBackedContentType(source.type)) {
910
946
  // INSERT…SELECT copy #2 — article_content. Body + editor takeaways + the
911
947
  // generated faq_json carry across so the duplicate keeps its SEO/FAQ work.
912
948
  await db
@@ -988,7 +1024,7 @@ export async function getContentSnapshot(
988
1024
  const type = base.type as ContentType
989
1025
  let content: Record<string, unknown> | null = null
990
1026
 
991
- if (type === 'article' || type === 'newsletter') {
1027
+ if (isBodyBackedContentType(type)) {
992
1028
  content = await db
993
1029
  .prepare('SELECT * FROM article_content WHERE content_id = ? LIMIT 1')
994
1030
  .bind(contentId)
@@ -126,7 +126,7 @@ export interface ContentRouteConfig extends CmsRouteConfig {
126
126
  const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
127
127
  const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
128
128
 
129
- type ContentType = 'article' | 'video' | 'podcast' | 'newsletter'
129
+ type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
130
130
 
131
131
  /**
132
132
  * Map a content `action` verb (the ?action= query value) to the capability the
@@ -157,15 +157,18 @@ function mapActionToCapability(action: string | null, type: ContentType): Conten
157
157
  }
158
158
  }
159
159
 
160
- const parseTypes = (value: string | null): ContentType[] | undefined => {
161
- if (!value) return undefined
162
- const allowed: ContentType[] = ['article', 'video', 'podcast', 'newsletter']
160
+ const parseTypes = (
161
+ value: string | null,
162
+ ): { ok: true; types: ContentType[] | undefined } | { ok: false; invalidTypes: string[] } => {
163
+ if (!value) return { ok: true, types: undefined }
164
+ const allowed: ContentType[] = ['article', 'video', 'podcast', 'newsletter', 'page']
163
165
  const parts = value
164
166
  .split(',')
165
167
  .map((item) => item.trim())
166
- .filter(Boolean) as ContentType[]
167
- const filtered = parts.filter((part) => allowed.includes(part))
168
- return filtered.length > 0 ? filtered : undefined
168
+ .filter(Boolean)
169
+ const invalidTypes = parts.filter((part) => !allowed.includes(part as ContentType))
170
+ if (invalidTypes.length > 0) return { ok: false, invalidTypes }
171
+ return { ok: true, types: parts.length > 0 ? (parts as ContentType[]) : undefined }
169
172
  }
170
173
 
171
174
  const parseStatus = (value: string | null): ContentStatus | undefined => {
@@ -214,6 +217,15 @@ function slugEnum(values: readonly string[] | null) {
214
217
  : z.string().min(1)
215
218
  }
216
219
 
220
+ const BodyContentCreateSchema = z.object({
221
+ bodyMarkdown: z.string().min(1),
222
+ bodyHtml: z.string().optional().nullable(),
223
+ subtitle: z.string().optional().nullable(),
224
+ wordCount: z.number().int().optional().nullable(),
225
+ readTimeMinutes: z.number().int().optional().nullable(),
226
+ editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
227
+ })
228
+
217
229
  function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
218
230
  const category = slugEnum(resolved.primaryCategorySlugs)
219
231
  const topic = slugEnum(resolved.primaryTopicSlugs)
@@ -241,14 +253,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
241
253
  const CreateSchema = z.discriminatedUnion('type', [
242
254
  BaseSchema.extend({
243
255
  type: z.literal('article'),
244
- content: z.object({
245
- bodyMarkdown: z.string().min(1),
246
- bodyHtml: z.string().optional().nullable(),
247
- subtitle: z.string().optional().nullable(),
248
- wordCount: z.number().int().optional().nullable(),
249
- readTimeMinutes: z.number().int().optional().nullable(),
250
- editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
251
- }),
256
+ content: BodyContentCreateSchema,
252
257
  }),
253
258
  BaseSchema.extend({
254
259
  type: z.literal('video'),
@@ -275,14 +280,11 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
275
280
  }),
276
281
  BaseSchema.extend({
277
282
  type: z.literal('newsletter'),
278
- content: z.object({
279
- bodyMarkdown: z.string().min(1),
280
- bodyHtml: z.string().optional().nullable(),
281
- subtitle: z.string().optional().nullable(),
282
- wordCount: z.number().int().optional().nullable(),
283
- readTimeMinutes: z.number().int().optional().nullable(),
284
- editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
285
- }),
283
+ content: BodyContentCreateSchema,
284
+ }),
285
+ BaseSchema.extend({
286
+ type: z.literal('page'),
287
+ content: BodyContentCreateSchema,
286
288
  }),
287
289
  ])
288
290
 
@@ -348,6 +350,7 @@ function getContentUpdateSchema(type: string) {
348
350
  switch (type) {
349
351
  case 'article':
350
352
  case 'newsletter':
353
+ case 'page':
351
354
  return ArticleContentUpdateSchema
352
355
  case 'video':
353
356
  return VideoContentUpdateSchema
@@ -521,7 +524,7 @@ function parseLocalDateTimeInZoneToUnixSeconds(value: string, timeZone: string):
521
524
  // --- read-side payload helpers (ported verbatim) --------------------------
522
525
 
523
526
  async function getContentPayload(ctx: RouteContext, type: string, id: string) {
524
- if (type === 'article' || type === 'newsletter') {
527
+ if (type === 'article' || type === 'newsletter' || type === 'page') {
525
528
  return ctx.db
526
529
  .prepare(
527
530
  `SELECT body_markdown, body_html, word_count, read_time_minutes, subtitle,
@@ -760,7 +763,14 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
760
763
  const url = new URL(ctx.request.url)
761
764
  const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 50)
762
765
  const status = parseStatus(url.searchParams.get('status'))
763
- const types = parseTypes(url.searchParams.get('types'))
766
+ const parsedTypes = parseTypes(url.searchParams.get('types'))
767
+ if (!parsedTypes.ok) {
768
+ return json(
769
+ { error: 'Invalid content type filter', invalidTypes: parsedTypes.invalidTypes },
770
+ 400,
771
+ )
772
+ }
773
+ const types = parsedTypes.types
764
774
  const cursor = parseCursor(url.searchParams.get('cursor'))
765
775
  const q = url.searchParams.get('q') || null
766
776
  const author = url.searchParams.get('author') || null
@@ -1080,7 +1090,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1080
1090
  // the same check but allow empty bodies (the auto-save path needs to
1081
1091
  // persist work in progress); publish-time validation rejects empty
1082
1092
  // bodies separately.
1083
- if (existing.type === 'article' || existing.type === 'newsletter') {
1093
+ if (
1094
+ existing.type === 'article' ||
1095
+ existing.type === 'newsletter' ||
1096
+ existing.type === 'page'
1097
+ ) {
1084
1098
  const articleContent = contentParsed.data as {
1085
1099
  bodyMarkdown?: string
1086
1100
  bodyHtml?: string | null
@@ -22,8 +22,8 @@
22
22
  export const CMS_MIGRATION_SQL = `
23
23
  CREATE TABLE content_items (
24
24
  id TEXT PRIMARY KEY,
25
- type TEXT NOT NULL CHECK (type IN ('article','video','podcast','newsletter')),
26
- status TEXT NOT NULL CHECK (status IN ('draft','scheduled','published','archived')),
25
+ type TEXT NOT NULL CHECK (type IN ('article','video','podcast','newsletter','page')),
26
+ status TEXT NOT NULL CHECK (status IN ('draft','scheduled','review','published','archived')),
27
27
  visibility TEXT NOT NULL CHECK (visibility IN ('free','premium')),
28
28
  slug TEXT NOT NULL UNIQUE,
29
29
  title TEXT NOT NULL,
@@ -216,8 +216,10 @@ interface D1Like {
216
216
  * the parent rebuild, then restores the children from the stash. The explicit
217
217
  * DELETE/restore is the load-bearing, FK-action-independent fix; the prepended
218
218
  * `PRAGMA defer_foreign_keys = ON` is defense-in-depth only (it does not reliably
219
- * span the per-statement exec runner). On an empty DB the stash/delete/restore is
220
- * a no-op and the temp tables are dropped, so the net table set is unchanged.
219
+ * span the per-statement exec runner). Do not add SQL `BEGIN`/`COMMIT` here:
220
+ * D1's JavaScript API rejects explicit transaction statements and requires its
221
+ * storage transaction API instead. On an empty DB the stash/delete/restore is a
222
+ * no-op and the temp tables are dropped, so the net table set is unchanged.
221
223
  */
222
224
  export const CMS_MIGRATIONS_0002_PLUS: { id: string; sql: string }[] = [
223
225
  {
@@ -569,6 +571,148 @@ CREATE TABLE content_insight_dismissals (
569
571
  dismissed_by TEXT,
570
572
  PRIMARY KEY (site_id, intent_key)
571
573
  );
574
+ `,
575
+ },
576
+ {
577
+ id: '0017_content_pages',
578
+ sql: `
579
+ PRAGMA defer_foreign_keys = ON;
580
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_content_items AS SELECT
581
+ id, type, status, visibility, slug, title, seo_title, description, excerpt,
582
+ byline, channel, primary_topic, featured, author_id, hero_image_id,
583
+ hero_image_alt, hero_image_caption, social_image_id, canonical_url,
584
+ publish_at, published_at, created_at, updated_at, publish_tz,
585
+ ai_locked_fields, published_revision_id, deleted_at, board_position,
586
+ seo_score, seo_focus_keyword, ai_og_image_id
587
+ FROM content_items;
588
+ DROP TABLE IF EXISTS content_items_new;
589
+ CREATE TABLE content_items_new (
590
+ id TEXT PRIMARY KEY,
591
+ type TEXT NOT NULL CHECK (type IN ('article','video','podcast','newsletter','page')),
592
+ status TEXT NOT NULL CHECK (status IN ('draft','scheduled','review','published','archived')),
593
+ visibility TEXT NOT NULL CHECK (visibility IN ('free','premium')),
594
+ slug TEXT NOT NULL UNIQUE,
595
+ title TEXT NOT NULL,
596
+ seo_title TEXT,
597
+ description TEXT,
598
+ excerpt TEXT,
599
+ byline TEXT,
600
+ channel TEXT,
601
+ primary_topic TEXT,
602
+ featured INTEGER NOT NULL DEFAULT 0,
603
+ author_id TEXT,
604
+ hero_image_id TEXT,
605
+ hero_image_alt TEXT,
606
+ hero_image_caption TEXT,
607
+ social_image_id TEXT,
608
+ canonical_url TEXT,
609
+ publish_at INTEGER,
610
+ published_at INTEGER,
611
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
612
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
613
+ publish_tz TEXT NOT NULL DEFAULT 'Europe/Paris',
614
+ ai_locked_fields TEXT NOT NULL DEFAULT '[]',
615
+ published_revision_id TEXT,
616
+ deleted_at INTEGER,
617
+ board_position INTEGER,
618
+ seo_score INTEGER,
619
+ seo_focus_keyword TEXT,
620
+ ai_og_image_id TEXT,
621
+ FOREIGN KEY (author_id) REFERENCES authors(id)
622
+ );
623
+ INSERT INTO content_items_new (
624
+ id, type, status, visibility, slug, title, seo_title, description, excerpt,
625
+ byline, channel, primary_topic, featured, author_id, hero_image_id,
626
+ hero_image_alt, hero_image_caption, social_image_id, canonical_url,
627
+ publish_at, published_at, created_at, updated_at, publish_tz,
628
+ ai_locked_fields, published_revision_id, deleted_at, board_position,
629
+ seo_score, seo_focus_keyword, ai_og_image_id
630
+ ) SELECT
631
+ id, type, status, visibility, slug, title, seo_title, description, excerpt,
632
+ byline, channel, primary_topic, featured, author_id, hero_image_id,
633
+ hero_image_alt, hero_image_caption, social_image_id, canonical_url,
634
+ publish_at, published_at, created_at, updated_at, publish_tz,
635
+ ai_locked_fields, published_revision_id, deleted_at, board_position,
636
+ seo_score, seo_focus_keyword, ai_og_image_id
637
+ FROM _cms0017_bk_content_items;
638
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_article_content AS SELECT * FROM article_content;
639
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_video_content AS SELECT * FROM video_content;
640
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_podcast_content AS SELECT * FROM podcast_content;
641
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_content_revisions AS SELECT * FROM content_revisions;
642
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_content_tag_links AS SELECT * FROM content_tag_links;
643
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_content_relations AS SELECT * FROM content_relations;
644
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_content_contributors AS SELECT * FROM content_contributors;
645
+ CREATE TABLE IF NOT EXISTS _cms0017_bk_content_slug_redirects AS SELECT * FROM content_slug_redirects;
646
+ CREATE TABLE IF NOT EXISTS content_items (
647
+ id TEXT PRIMARY KEY,
648
+ type TEXT NOT NULL CHECK (type IN ('article','video','podcast','newsletter','page')),
649
+ status TEXT NOT NULL CHECK (status IN ('draft','scheduled','review','published','archived')),
650
+ visibility TEXT NOT NULL CHECK (visibility IN ('free','premium')),
651
+ slug TEXT NOT NULL UNIQUE,
652
+ title TEXT NOT NULL,
653
+ seo_title TEXT,
654
+ description TEXT,
655
+ excerpt TEXT,
656
+ byline TEXT,
657
+ channel TEXT,
658
+ primary_topic TEXT,
659
+ featured INTEGER NOT NULL DEFAULT 0,
660
+ author_id TEXT,
661
+ hero_image_id TEXT,
662
+ hero_image_alt TEXT,
663
+ hero_image_caption TEXT,
664
+ social_image_id TEXT,
665
+ canonical_url TEXT,
666
+ publish_at INTEGER,
667
+ published_at INTEGER,
668
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
669
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
670
+ publish_tz TEXT NOT NULL DEFAULT 'Europe/Paris',
671
+ ai_locked_fields TEXT NOT NULL DEFAULT '[]',
672
+ published_revision_id TEXT,
673
+ deleted_at INTEGER,
674
+ board_position INTEGER,
675
+ seo_score INTEGER,
676
+ seo_focus_keyword TEXT,
677
+ ai_og_image_id TEXT,
678
+ FOREIGN KEY (author_id) REFERENCES authors(id)
679
+ );
680
+ DELETE FROM article_content;
681
+ DELETE FROM video_content;
682
+ DELETE FROM podcast_content;
683
+ DELETE FROM content_revisions;
684
+ DELETE FROM content_tag_links;
685
+ DELETE FROM content_relations;
686
+ DELETE FROM content_contributors;
687
+ DELETE FROM content_slug_redirects;
688
+ DROP TABLE IF EXISTS content_items;
689
+ ALTER TABLE content_items_new RENAME TO content_items;
690
+ INSERT INTO article_content SELECT * FROM _cms0017_bk_article_content;
691
+ INSERT INTO video_content SELECT * FROM _cms0017_bk_video_content;
692
+ INSERT INTO podcast_content SELECT * FROM _cms0017_bk_podcast_content;
693
+ INSERT INTO content_revisions SELECT * FROM _cms0017_bk_content_revisions;
694
+ INSERT INTO content_tag_links SELECT * FROM _cms0017_bk_content_tag_links;
695
+ INSERT INTO content_relations SELECT * FROM _cms0017_bk_content_relations;
696
+ INSERT INTO content_contributors SELECT * FROM _cms0017_bk_content_contributors;
697
+ INSERT INTO content_slug_redirects SELECT * FROM _cms0017_bk_content_slug_redirects;
698
+ DROP TABLE _cms0017_bk_article_content;
699
+ DROP TABLE _cms0017_bk_video_content;
700
+ DROP TABLE _cms0017_bk_podcast_content;
701
+ DROP TABLE _cms0017_bk_content_revisions;
702
+ DROP TABLE _cms0017_bk_content_tag_links;
703
+ DROP TABLE _cms0017_bk_content_relations;
704
+ DROP TABLE _cms0017_bk_content_contributors;
705
+ DROP TABLE _cms0017_bk_content_slug_redirects;
706
+ DROP TABLE _cms0017_bk_content_items;
707
+ CREATE INDEX IF NOT EXISTS idx_content_items_status ON content_items(status);
708
+ CREATE INDEX IF NOT EXISTS idx_content_items_type ON content_items(type);
709
+ CREATE INDEX IF NOT EXISTS idx_content_items_publish_at ON content_items(publish_at DESC);
710
+ CREATE INDEX IF NOT EXISTS idx_content_items_channel ON content_items(channel);
711
+ CREATE INDEX IF NOT EXISTS idx_content_items_author_id ON content_items(author_id);
712
+ CREATE INDEX IF NOT EXISTS idx_content_items_topic_status_type_published
713
+ ON content_items(primary_topic, status, type, published_at DESC);
714
+ CREATE INDEX IF NOT EXISTS idx_content_items_published_at ON content_items(published_at DESC);
715
+ CREATE INDEX IF NOT EXISTS idx_content_items_deleted_at ON content_items(deleted_at);
572
716
  `,
573
717
  },
574
718
  ]
@@ -9,8 +9,8 @@
9
9
  // Nullability: a column declared NOT NULL maps to a required, non-nullable
10
10
  // field; a nullable column maps to `T | null` (D1 returns SQL NULL as JS null).
11
11
 
12
- /** content_items.type CHECK domain (includes 'newsletter' per prod 0024). */
13
- export type ContentType = 'article' | 'video' | 'podcast' | 'newsletter'
12
+ /** content_items.type CHECK domain. */
13
+ export type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
14
14
 
15
15
  /** content_items.status CHECK domain. */
16
16
  export type ContentStatus = 'draft' | 'scheduled' | 'review' | 'published' | 'archived'
@@ -1,5 +1,6 @@
1
1
  // src/ui/commands.ts — the ⌘K command set + the pure filter. "create"/"jump"
2
2
  // are static; "doc" entries are fed in from the /admin/api/search results.
3
+ import type { ContentType } from '../schema/types.js'
3
4
  import { ROUTES, type Route } from './hash-router.js'
4
5
 
5
6
  export type Command =
@@ -8,7 +9,7 @@ export type Command =
8
9
  label: string
9
10
  icon: string
10
11
  hint: string
11
- contentType: 'article' | 'video' | 'podcast'
12
+ contentType: ContentType
12
13
  }
13
14
  | { kind: 'jump'; label: string; icon: string; hint: string; route: Route }
14
15
  | { kind: 'doc'; label: string; icon: string; hint: string; id: string }
@@ -44,6 +45,7 @@ export const baseCommands: Command[] = [
44
45
  { kind: 'create', label: 'New article', icon: 'doc', hint: 'Create', contentType: 'article' },
45
46
  { kind: 'create', label: 'New video', icon: 'video', hint: 'Create', contentType: 'video' },
46
47
  { kind: 'create', label: 'New podcast', icon: 'mic', hint: 'Create', contentType: 'podcast' },
48
+ { kind: 'create', label: 'New page', icon: 'doc', hint: 'Create', contentType: 'page' },
47
49
  ...ROUTES.map(
48
50
  (r): Command => ({
49
51
  kind: 'jump',
@@ -64,7 +64,7 @@ interface ShareLinksListResponse {
64
64
  // SOURCE OF TRUTH: packages/cms/src/ui/screens/library-data.ts LibraryRow
65
65
  interface ContentRow {
66
66
  id: string
67
- type: 'article' | 'video' | 'podcast' | 'newsletter'
67
+ type: 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
68
68
  slug: string
69
69
  title: string
70
70
  authorId: string | null
@@ -185,6 +185,17 @@ export function ContentPicker({ siteBase, dispatch }: ContentPickerProps) {
185
185
  setResults([])
186
186
  }
187
187
 
188
+ const iconForContentType = (type: ContentRow['type']): Parameters<typeof Icon>[0]['name'] => {
189
+ const icons: Record<ContentRow['type'], Parameters<typeof Icon>[0]['name']> = {
190
+ article: 'doc',
191
+ video: 'video',
192
+ podcast: 'mic',
193
+ newsletter: 'mail',
194
+ page: 'doc',
195
+ }
196
+ return icons[type]
197
+ }
198
+
188
199
  return (
189
200
  <div>
190
201
  <FieldLabel>Content (pre-fill)</FieldLabel>
@@ -265,15 +276,7 @@ export function ContentPicker({ siteBase, dispatch }: ContentPickerProps) {
265
276
  style={dropdownItemStyle}
266
277
  >
267
278
  <Icon
268
- name={
269
- (row.type === 'article'
270
- ? 'doc'
271
- : row.type === 'video'
272
- ? 'video'
273
- : row.type === 'podcast'
274
- ? 'mic'
275
- : 'mail') as Parameters<typeof Icon>[0]['name']
276
- }
279
+ name={iconForContentType(row.type)}
277
280
  size={13}
278
281
  style={{ color: 'var(--ink-muted)', flexShrink: 0 }}
279
282
  />
@@ -1,7 +1,7 @@
1
- // src/ui/editor/ContentForm.tsx — four-type content form with Tiptap RTE.
1
+ // src/ui/editor/ContentForm.tsx — content form with Tiptap RTE.
2
2
  //
3
3
  // Handles: title, dek (excerpt), type-specific body block:
4
- // - article / newsletter → <Rte/> (Tiptap body)
4
+ // - article / newsletter / page → <Rte/> (Tiptap body)
5
5
  // - video → media/video block (URL input; the actual R2 multipart upload
6
6
  // is wired through the existing /media/multipart routes; here
7
7
  // we store a source URL or R2 key)
@@ -44,7 +44,7 @@ import { docToMarkdown } from './serialize.js'
44
44
  export interface ContentFormProps {
45
45
  /** Existing content id (null = new unsaved doc). */
46
46
  docId: string | null
47
- /** Content type (article | video | podcast | newsletter). */
47
+ /** Content type (article | video | podcast | newsletter | page). */
48
48
  contentType: ContentType
49
49
  /** Called with the live resolved id once the doc is saved for the first time. */
50
50
  onDocCreated?: (id: string) => void
@@ -825,7 +825,7 @@ export function ContentForm({
825
825
  />
826
826
  </div>
827
827
  ) : (
828
- /* article | newsletter */
828
+ /* article | newsletter | page */
829
829
  <>
830
830
  {contentType === 'article' && (
831
831
  <div style={{ marginBottom: 14 }}>
@@ -855,8 +855,8 @@ export function ContentForm({
855
855
  )}
856
856
  </div>
857
857
 
858
- {/* Footer: word count + read time (only for article/newsletter) */}
859
- {(contentType === 'article' || contentType === 'newsletter') && (
858
+ {/* Footer: word count + read time (only for prose content) */}
859
+ {(contentType === 'article' || contentType === 'newsletter' || contentType === 'page') && (
860
860
  <div style={footer}>
861
861
  <Icon name="edit" size={12} style={{ color: 'var(--ink-faint)' }} />
862
862
  <span>
@@ -1216,7 +1216,7 @@ function contentResponseToFormState(
1216
1216
  next.heroImageId = heroImageId
1217
1217
  next.heroImageUrl = heroImageUrl || (heroImageId ? mediaUrlFromId(heroImageId) : null)
1218
1218
 
1219
- if (contentType === 'article' || contentType === 'newsletter') {
1219
+ if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
1220
1220
  next.body = toStringValue(content.body_markdown) || toStringValue(content.body_html)
1221
1221
  }
1222
1222
 
@@ -28,7 +28,7 @@ export function canCreateContentDraft(
28
28
  draft: ContentDraftFields,
29
29
  ): boolean {
30
30
  if (draft.title.trim().length < 3) return false
31
- if (contentType === 'article' || contentType === 'newsletter') {
31
+ if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
32
32
  return draft.body.trim().length > 0
33
33
  }
34
34
  if (contentType === 'video') return draft.videoUrl.trim().length > 0
@@ -81,7 +81,7 @@ export function buildContentUpdatePayload(
81
81
  payload.heroImageId = heroImageId
82
82
  }
83
83
 
84
- if (contentType === 'article' || contentType === 'newsletter') {
84
+ if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
85
85
  const wordCount = countWords(draft.body)
86
86
  payload.content = {
87
87
  bodyMarkdown: draft.body,
@@ -111,7 +111,7 @@ export function DraftPage({ doc, device }: DraftPageProps) {
111
111
  </main>
112
112
 
113
113
  {/* Premium paywall overlay */}
114
- {layout.paywall && <PaywallOverlay />}
114
+ {layout.paywall && <PaywallOverlay type={doc.type} />}
115
115
  </div>
116
116
  </div>
117
117
  )
@@ -193,7 +193,7 @@ function BodyPreview({ body, type }: { body: string; type: ContentType }) {
193
193
  )
194
194
  }
195
195
 
196
- // article / newsletter — render body as preformatted prose preview
196
+ // article / newsletter / page — render body as preformatted prose preview
197
197
  return <div style={proseStyle}>{body}</div>
198
198
  }
199
199
 
@@ -220,7 +220,10 @@ function EmptyBody({ type }: { type: ContentType }) {
220
220
  )
221
221
  }
222
222
 
223
- function PaywallOverlay() {
223
+ function PaywallOverlay({ type }: { type: ContentType }) {
224
+ const subscribeCopy =
225
+ type === 'page' ? 'Subscribe to read the full page.' : 'Subscribe to read the full article.'
226
+
224
227
  return (
225
228
  <div
226
229
  style={{
@@ -253,7 +256,7 @@ function PaywallOverlay() {
253
256
  Premium content
254
257
  </div>
255
258
  <div style={{ fontSize: 12, color: 'var(--ink-muted)', marginBottom: 14 }}>
256
- Subscribe to read the full article.
259
+ {subscribeCopy}
257
260
  </div>
258
261
  <button
259
262
  type="button"
@@ -51,6 +51,7 @@ const CONTENT_TYPE_LABELS: Record<ContentType, string> = {
51
51
  video: 'Video',
52
52
  podcast: 'Podcast',
53
53
  newsletter: 'Newsletter',
54
+ page: 'Page',
54
55
  }
55
56
 
56
57
  const STATUS_PILL_COLORS: Record<ContentStatus, string> = {
@@ -472,6 +473,7 @@ function contentTypeIcon(type: ContentType): import('../icons.js').IconName {
472
473
  video: 'video',
473
474
  podcast: 'mic',
474
475
  newsletter: 'mail',
476
+ page: 'doc',
475
477
  }
476
478
  return map[type]
477
479
  }
@@ -50,6 +50,7 @@ const TYPE_OPTIONS: Array<{ value: ContentType | ''; label: string }> = [
50
50
  { value: 'video', label: 'Video' },
51
51
  { value: 'podcast', label: 'Podcast' },
52
52
  { value: 'newsletter', label: 'Newsletter' },
53
+ { value: 'page', label: 'Page' },
53
54
  ]
54
55
 
55
56
  const CONTENT_TYPE_ICONS: Record<ContentType, string> = {
@@ -57,6 +58,7 @@ const CONTENT_TYPE_ICONS: Record<ContentType, string> = {
57
58
  video: 'video',
58
59
  podcast: 'mic',
59
60
  newsletter: 'mail',
61
+ page: 'doc',
60
62
  }
61
63
 
62
64
  // ---------------------------------------------------------------------------
@@ -261,7 +263,7 @@ export function LibraryScreen({ openDoc }: LibraryScreenProps) {
261
263
  <div>
262
264
  <h1 className="page-title">Content</h1>
263
265
  <div className="page-sub">
264
- {lib.counts.all} items across articles, video, podcasts and newsletters.
266
+ {lib.counts.all} items across articles, video, podcasts, newsletters and pages.
265
267
  </div>
266
268
  </div>
267
269
  <div style={{ display: 'flex', gap: 8 }}>
@@ -509,7 +511,7 @@ function NewContentMenu({ openDoc }: { openDoc: LibraryScreenProps['openDoc'] })
509
511
  return () => document.removeEventListener('mousedown', handler)
510
512
  }, [open])
511
513
 
512
- const types: ContentType[] = ['article', 'video', 'podcast', 'newsletter']
514
+ const types: ContentType[] = ['article', 'video', 'podcast', 'newsletter', 'page']
513
515
 
514
516
  return (
515
517
  <div ref={ref} style={{ position: 'relative' }}>
@@ -411,6 +411,7 @@ function PerModeFields({
411
411
  <option value="video">Video</option>
412
412
  <option value="podcast">Podcast</option>
413
413
  <option value="newsletter">Newsletter</option>
414
+ <option value="page">Page</option>
414
415
  </select>
415
416
  </div>
416
417
  </div>