@growth-labs/cms 0.5.10 → 0.5.12

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 (74) hide show
  1. package/README.md +28 -0
  2. package/dist/engine/index.d.ts +3 -2
  3. package/dist/engine/index.d.ts.map +1 -1
  4. package/dist/engine/index.js +3 -2
  5. package/dist/engine/index.js.map +1 -1
  6. package/dist/engine/publication.d.ts.map +1 -1
  7. package/dist/engine/publication.js +23 -5
  8. package/dist/engine/publication.js.map +1 -1
  9. package/dist/engine/published-content.d.ts.map +1 -1
  10. package/dist/engine/published-content.js +14 -5
  11. package/dist/engine/published-content.js.map +1 -1
  12. package/dist/engine/publisher.d.ts +3 -1
  13. package/dist/engine/publisher.d.ts.map +1 -1
  14. package/dist/engine/publisher.js +39 -31
  15. package/dist/engine/publisher.js.map +1 -1
  16. package/dist/engine/revisions.d.ts +3 -0
  17. package/dist/engine/revisions.d.ts.map +1 -1
  18. package/dist/engine/revisions.js +7 -1
  19. package/dist/engine/revisions.js.map +1 -1
  20. package/dist/engine/tags.d.ts +9 -2
  21. package/dist/engine/tags.d.ts.map +1 -1
  22. package/dist/engine/tags.js +32 -21
  23. package/dist/engine/tags.js.map +1 -1
  24. package/dist/engine/taxonomy.d.ts +31 -0
  25. package/dist/engine/taxonomy.d.ts.map +1 -0
  26. package/dist/engine/taxonomy.js +125 -0
  27. package/dist/engine/taxonomy.js.map +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/routes/content.d.ts.map +1 -1
  32. package/dist/routes/content.js +80 -24
  33. package/dist/routes/content.js.map +1 -1
  34. package/dist/routes/tags.d.ts.map +1 -1
  35. package/dist/routes/tags.js +36 -3
  36. package/dist/routes/tags.js.map +1 -1
  37. package/dist/schema/migrations.d.ts.map +1 -1
  38. package/dist/schema/migrations.js +15 -1
  39. package/dist/schema/migrations.js.map +1 -1
  40. package/dist/schema/types.d.ts +1 -0
  41. package/dist/schema/types.d.ts.map +1 -1
  42. package/dist/schema/types.js.map +1 -1
  43. package/dist/ui/editor/Rte.d.ts.map +1 -1
  44. package/dist/ui/editor/Rte.js +2 -12
  45. package/dist/ui/editor/Rte.js.map +1 -1
  46. package/dist/ui/editor/extensions.d.ts +3 -0
  47. package/dist/ui/editor/extensions.d.ts.map +1 -0
  48. package/dist/ui/editor/extensions.js +18 -0
  49. package/dist/ui/editor/extensions.js.map +1 -0
  50. package/dist/ui/editor/serialize.d.ts.map +1 -1
  51. package/dist/ui/editor/serialize.js +4 -2
  52. package/dist/ui/editor/serialize.js.map +1 -1
  53. package/dist/ui/editor/trim-boundary-marks.d.ts +11 -0
  54. package/dist/ui/editor/trim-boundary-marks.d.ts.map +1 -0
  55. package/dist/ui/editor/trim-boundary-marks.js +157 -0
  56. package/dist/ui/editor/trim-boundary-marks.js.map +1 -0
  57. package/migrations/0023_content_tag_link_labels.sql +11 -0
  58. package/package.json +1 -1
  59. package/src/engine/index.ts +14 -0
  60. package/src/engine/publication.ts +27 -12
  61. package/src/engine/published-content.ts +14 -8
  62. package/src/engine/publisher.ts +76 -39
  63. package/src/engine/revisions.ts +10 -1
  64. package/src/engine/tags.ts +39 -19
  65. package/src/engine/taxonomy.ts +226 -0
  66. package/src/index.ts +2 -0
  67. package/src/routes/content.ts +72 -26
  68. package/src/routes/tags.ts +36 -3
  69. package/src/schema/migrations.ts +15 -1
  70. package/src/schema/types.ts +1 -0
  71. package/src/ui/editor/Rte.tsx +2 -12
  72. package/src/ui/editor/extensions.ts +18 -0
  73. package/src/ui/editor/serialize.ts +4 -2
  74. package/src/ui/editor/trim-boundary-marks.ts +181 -0
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { D1Database } from './d1.js'
6
6
  import { slugify } from './slug.js'
7
+ import { validateSingleDeclaredTag } from './taxonomy.js'
7
8
 
8
9
  export interface TagSummary {
9
10
  id: string
@@ -12,6 +13,16 @@ export interface TagSummary {
12
13
  count: number
13
14
  }
14
15
 
16
+ export const DEFAULT_TAG_LIST_LIMIT = 100
17
+ export const MAX_TAG_LIST_LIMIT = 500
18
+ export const DEFAULT_TAG_SEARCH_LIMIT = 50
19
+
20
+ function normalizeLimit(value: number | undefined, fallback: number, max: number): number {
21
+ const limit = value ?? fallback
22
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) return fallback
23
+ return limit
24
+ }
25
+
15
26
  export async function getTag(db: D1Database, tagId: string): Promise<TagSummary | null> {
16
27
  const row = await db
17
28
  .prepare(
@@ -27,64 +38,73 @@ export async function getTag(db: D1Database, tagId: string): Promise<TagSummary
27
38
  }
28
39
 
29
40
  export async function createTag(db: D1Database, label: string): Promise<TagSummary | null> {
30
- const slug = slugify(label)
31
- const trimmed = label.trim()
32
- if (!slug || !trimmed) return null
41
+ const tag = validateSingleDeclaredTag(label)
33
42
  const existing = await db
34
43
  .prepare('SELECT id FROM content_tags WHERE slug = ? LIMIT 1')
35
- .bind(slug)
44
+ .bind(tag.slug)
36
45
  .first<{ id: string }>()
37
46
  if (existing) return null
38
47
  const id = crypto.randomUUID()
39
48
  await db
40
49
  .prepare('INSERT INTO content_tags (id, slug, label) VALUES (?, ?, ?)')
41
- .bind(id, slug, trimmed)
50
+ .bind(id, tag.slug, tag.label)
42
51
  .run()
43
- return { id, slug, label: trimmed, count: 0 }
52
+ return { id, slug: tag.slug, label: tag.label, count: 0 }
44
53
  }
45
54
 
46
- export async function listTags(db: D1Database): Promise<TagSummary[]> {
55
+ export async function listTags(
56
+ db: D1Database,
57
+ options: { limit?: number } = {},
58
+ ): Promise<TagSummary[]> {
59
+ const limit = normalizeLimit(options.limit, DEFAULT_TAG_LIST_LIMIT, MAX_TAG_LIST_LIMIT)
47
60
  const result = await db
48
61
  .prepare(
49
62
  `SELECT t.id, t.slug, t.label, COUNT(l.content_id) AS count
50
63
  FROM content_tags t
51
64
  LEFT JOIN content_tag_links l ON l.tag_id = t.id
52
- GROUP BY t.id ORDER BY count DESC, t.label ASC`,
65
+ GROUP BY t.id ORDER BY count DESC, t.label ASC LIMIT ?`,
53
66
  )
67
+ .bind(limit)
54
68
  .all<{ id: string; slug: string; label: string; count: number }>()
55
69
  return result.results || []
56
70
  }
57
71
 
58
- export async function searchTags(db: D1Database, q: string): Promise<TagSummary[]> {
72
+ export async function searchTags(
73
+ db: D1Database,
74
+ q: string,
75
+ options: { limit?: number } = {},
76
+ ): Promise<TagSummary[]> {
59
77
  const needle = `${slugify(q)}%`
78
+ if (needle === '%') return []
79
+ const limit = normalizeLimit(options.limit, DEFAULT_TAG_SEARCH_LIMIT, MAX_TAG_LIST_LIMIT)
60
80
  const result = await db
61
81
  .prepare(
62
82
  `SELECT t.id, t.slug, t.label, COUNT(l.content_id) AS count
63
83
  FROM content_tags t
64
84
  LEFT JOIN content_tag_links l ON l.tag_id = t.id
65
- WHERE t.slug LIKE ? GROUP BY t.id ORDER BY t.label ASC LIMIT 50`,
85
+ WHERE t.slug LIKE ? GROUP BY t.id ORDER BY t.label ASC LIMIT ?`,
66
86
  )
67
- .bind(needle)
87
+ .bind(needle, limit)
68
88
  .all<{ id: string; slug: string; label: string; count: number }>()
69
89
  return result.results || []
70
90
  }
71
91
 
72
92
  export async function renameTag(db: D1Database, tagId: string, label: string): Promise<boolean> {
73
- const slug = slugify(label)
74
- const trimmed = label.trim()
75
- if (!slug || !trimmed) return false
93
+ const tag = validateSingleDeclaredTag(label)
76
94
  // content_tags.slug is UNIQUE — a rename whose target slug is already taken by
77
95
  // a DIFFERENT tag would throw a constraint error. Fail gracefully (the
78
96
  // Promise<boolean> contract) instead, matching the empty-slug early return.
79
97
  const clash = await db
80
98
  .prepare('SELECT id FROM content_tags WHERE slug = ? AND id != ? LIMIT 1')
81
- .bind(slug, tagId)
99
+ .bind(tag.slug, tagId)
82
100
  .first<{ id: string }>()
83
101
  if (clash) return false
84
- const res = await db
85
- .prepare('UPDATE content_tags SET label = ?, slug = ? WHERE id = ?')
86
- .bind(trimmed, slug, tagId)
87
- .run()
102
+ const [res] = await db.batch([
103
+ db
104
+ .prepare('UPDATE content_tags SET label = ?, slug = ? WHERE id = ?')
105
+ .bind(tag.label, tag.slug, tagId),
106
+ db.prepare('UPDATE content_tag_links SET label = ? WHERE tag_id = ?').bind(tag.label, tagId),
107
+ ])
88
108
  return Boolean(res.meta && (res.meta as { changes?: number }).changes)
89
109
  }
90
110
 
@@ -0,0 +1,226 @@
1
+ import { slugify } from './slug.js'
2
+
3
+ /** Maximum number of tags per content item. Covers the measured fleet max (16) with headroom. */
4
+ export const MAX_ITEM_TAG_COUNT = 32
5
+ /** Maximum UTF-8 byte length for a single declared tag display label. */
6
+ export const MAX_TAG_LABEL_BYTES = 128
7
+ /** Maximum total UTF-8 bytes across all declared tag labels for one content item. */
8
+ export const MAX_ITEM_TAGS_TOTAL_BYTES = 2_048
9
+
10
+ export type TagInputErrorCode =
11
+ | 'tags_not_array'
12
+ | 'tag_not_string'
13
+ | 'tag_blank'
14
+ | 'tag_untrimmed'
15
+ | 'tag_not_nfc'
16
+ | 'tag_label_too_long'
17
+ | 'tag_non_slugifiable'
18
+ | 'tag_duplicate'
19
+ | 'tag_count_exceeded'
20
+ | 'tags_total_bytes_exceeded'
21
+ | 'tag_canonical_invalid'
22
+ | 'tag_labels_mismatch'
23
+ | 'tag_label_mismatch'
24
+
25
+ export interface TaxonomyTag {
26
+ slug: string
27
+ label: string
28
+ }
29
+
30
+ export class TagInputError extends Error {
31
+ constructor(
32
+ readonly code: TagInputErrorCode,
33
+ message: string,
34
+ readonly limit: number,
35
+ readonly actual: number,
36
+ readonly index: number | null = null,
37
+ ) {
38
+ super(message)
39
+ this.name = 'TagInputError'
40
+ }
41
+ }
42
+
43
+ export function utf8ByteLength(value: string): number {
44
+ return new TextEncoder().encode(value).byteLength
45
+ }
46
+
47
+ function fail(
48
+ code: TagInputErrorCode,
49
+ message: string,
50
+ limit: number,
51
+ actual: number,
52
+ index: number | null = null,
53
+ ): never {
54
+ throw new TagInputError(code, message, limit, actual, index)
55
+ }
56
+
57
+ function assertTagCount(tags: readonly unknown[]): void {
58
+ if (tags.length > MAX_ITEM_TAG_COUNT) {
59
+ fail(
60
+ 'tag_count_exceeded',
61
+ `tag count exceeds the ${MAX_ITEM_TAG_COUNT}-item limit`,
62
+ MAX_ITEM_TAG_COUNT,
63
+ tags.length,
64
+ )
65
+ }
66
+ }
67
+
68
+ function validateDeclaredLabel(value: unknown, index: number): TaxonomyTag {
69
+ if (typeof value !== 'string') {
70
+ fail('tag_not_string', 'each tag must be a string', MAX_ITEM_TAG_COUNT, -1, index)
71
+ }
72
+ if (!value.trim()) {
73
+ fail('tag_blank', 'tag must not be blank or whitespace-only', MAX_ITEM_TAG_COUNT, -1, index)
74
+ }
75
+ if (value !== value.trim()) {
76
+ fail(
77
+ 'tag_untrimmed',
78
+ 'tag must not contain surrounding whitespace',
79
+ MAX_ITEM_TAG_COUNT,
80
+ -1,
81
+ index,
82
+ )
83
+ }
84
+ if (value !== value.normalize('NFC')) {
85
+ fail('tag_not_nfc', 'tag must be NFC-normalized', MAX_ITEM_TAG_COUNT, -1, index)
86
+ }
87
+ const labelBytes = utf8ByteLength(value)
88
+ if (labelBytes > MAX_TAG_LABEL_BYTES) {
89
+ fail(
90
+ 'tag_label_too_long',
91
+ `tag label exceeds the ${MAX_TAG_LABEL_BYTES}-byte limit`,
92
+ MAX_TAG_LABEL_BYTES,
93
+ labelBytes,
94
+ index,
95
+ )
96
+ }
97
+ const slug = slugify(value)
98
+ if (!slug) {
99
+ fail(
100
+ 'tag_non_slugifiable',
101
+ 'tag label must produce a non-empty slug after canonicalization',
102
+ MAX_ITEM_TAG_COUNT,
103
+ -1,
104
+ index,
105
+ )
106
+ }
107
+ return { slug, label: value }
108
+ }
109
+
110
+ function validateCanonicalSlug(value: unknown, index: number): string {
111
+ if (typeof value !== 'string') {
112
+ fail('tag_not_string', 'each canonical tag must be a string', MAX_ITEM_TAG_COUNT, -1, index)
113
+ }
114
+ if (
115
+ !value ||
116
+ value !== value.trim() ||
117
+ value !== value.normalize('NFC') ||
118
+ slugify(value) !== value
119
+ ) {
120
+ fail(
121
+ 'tag_canonical_invalid',
122
+ 'canonical tag must be a normalized slug',
123
+ MAX_ITEM_TAG_COUNT,
124
+ -1,
125
+ index,
126
+ )
127
+ }
128
+ const slugBytes = utf8ByteLength(value)
129
+ if (slugBytes > MAX_TAG_LABEL_BYTES) {
130
+ fail(
131
+ 'tag_label_too_long',
132
+ `canonical tag exceeds the ${MAX_TAG_LABEL_BYTES}-byte limit`,
133
+ MAX_TAG_LABEL_BYTES,
134
+ slugBytes,
135
+ index,
136
+ )
137
+ }
138
+ return value
139
+ }
140
+
141
+ function validatePairs(pairs: TaxonomyTag[]): TaxonomyTag[] {
142
+ const seen = new Set<string>()
143
+ let totalBytes = 0
144
+ for (let index = 0; index < pairs.length; index += 1) {
145
+ const pair = pairs[index] as TaxonomyTag
146
+ if (seen.has(pair.slug)) {
147
+ fail(
148
+ 'tag_duplicate',
149
+ `duplicate tag after canonicalization: slug "${pair.slug}"`,
150
+ MAX_ITEM_TAG_COUNT,
151
+ -1,
152
+ index,
153
+ )
154
+ }
155
+ seen.add(pair.slug)
156
+ totalBytes += utf8ByteLength(pair.label)
157
+ if (totalBytes > MAX_ITEM_TAGS_TOTAL_BYTES) {
158
+ fail(
159
+ 'tags_total_bytes_exceeded',
160
+ `total tag bytes exceed the ${MAX_ITEM_TAGS_TOTAL_BYTES}-byte limit`,
161
+ MAX_ITEM_TAGS_TOTAL_BYTES,
162
+ totalBytes,
163
+ index,
164
+ )
165
+ }
166
+ }
167
+ return pairs
168
+ }
169
+
170
+ export function validateDeclaredTagList(tags: unknown): TaxonomyTag[] {
171
+ if (!Array.isArray(tags)) {
172
+ fail('tags_not_array', 'tags must be an array', MAX_ITEM_TAG_COUNT, -1)
173
+ }
174
+ assertTagCount(tags)
175
+ return validatePairs(tags.map((tag, index) => validateDeclaredLabel(tag, index)))
176
+ }
177
+
178
+ export function validateSingleDeclaredTag(label: unknown): TaxonomyTag {
179
+ return validateDeclaredTagList([label])[0] as TaxonomyTag
180
+ }
181
+
182
+ export function validateRevisionTaxonomy(input: {
183
+ tags: unknown
184
+ tagLabels?: unknown
185
+ allowLegacyLabels?: boolean
186
+ }): TaxonomyTag[] {
187
+ const tags = input.tags
188
+ if (!Array.isArray(tags)) {
189
+ fail('tags_not_array', 'revision tags must be an array', MAX_ITEM_TAG_COUNT, -1)
190
+ }
191
+ assertTagCount(tags)
192
+ const labels = input.tagLabels === undefined && input.allowLegacyLabels ? tags : input.tagLabels
193
+ if (!Array.isArray(labels) || labels.length !== tags.length) {
194
+ fail(
195
+ 'tag_labels_mismatch',
196
+ 'revision tagLabels must be parallel to tags',
197
+ tags.length,
198
+ Array.isArray(labels) ? labels.length : -1,
199
+ )
200
+ }
201
+ const pairs = tags.map((tag, index) => {
202
+ const slug = validateCanonicalSlug(tag, index)
203
+ const label = validateDeclaredLabel(labels[index], index)
204
+ if (label.slug !== slug) {
205
+ fail(
206
+ 'tag_label_mismatch',
207
+ 'revision tag label does not canonicalize to its parallel tag',
208
+ MAX_ITEM_TAG_COUNT,
209
+ -1,
210
+ index,
211
+ )
212
+ }
213
+ return { slug, label: label.label }
214
+ })
215
+ return validatePairs(pairs)
216
+ }
217
+
218
+ export function validateStoredTaxonomyRows(
219
+ rows: readonly { slug: unknown; label: unknown }[],
220
+ ): TaxonomyTag[] {
221
+ return validateRevisionTaxonomy({
222
+ tags: rows.map((row) => row.slug),
223
+ tagLabels: rows.map((row) => row.label),
224
+ allowLegacyLabels: false,
225
+ })
226
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@
30
30
 
31
31
  // The publishing engine — the content data-access core (WS7-05). Also
32
32
  // available at the `@growth-labs/cms/engine` subpath.
33
+ // Tag taxonomy bounds (MAX_ITEM_TAG_COUNT, MAX_TAG_LABEL_BYTES, MAX_ITEM_TAGS_TOTAL_BYTES)
34
+ // and TagInputError are re-exported via engine/index.js.
33
35
  export * from './engine/index.js'
34
36
 
35
37
  // Injected-provider interfaces + null implementations (spec §3.3).
@@ -36,6 +36,7 @@ import {
36
36
  evaluateContentBodyForPublish,
37
37
  getContentItem,
38
38
  getContentRelations,
39
+ getContentTagPairs,
39
40
  publishContent,
40
41
  scheduleContent,
41
42
  unpublishContent,
@@ -50,6 +51,7 @@ import {
50
51
  } from '../engine/revisions.js'
51
52
  import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
52
53
  import { softDeleteContent } from '../engine/soft-delete.js'
54
+ import { TagInputError } from '../engine/taxonomy.js'
53
55
  import type { ContentStatus } from '../schema/types.js'
54
56
  import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
55
57
  import type { CmsRouteConfig } from './config.js'
@@ -582,13 +584,7 @@ async function getContentPayload(ctx: RouteContext, type: string, id: string) {
582
584
  }
583
585
 
584
586
  async function getContentTags(ctx: RouteContext, id: string): Promise<string[]> {
585
- const result = await ctx.db
586
- .prepare(
587
- 'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ? ORDER BY t.label ASC',
588
- )
589
- .bind(id)
590
- .all<{ slug: string }>()
591
- return (result.results || []).map((row) => row.slug)
587
+ return (await getContentTagPairs(ctx.db, id)).map((row) => row.slug)
592
588
  }
593
589
 
594
590
  async function getMediaAssetUrls(db: D1Database, ids: string[]): Promise<Record<string, string>> {
@@ -612,6 +608,19 @@ async function readJson(ctx: RouteContext): Promise<unknown> {
612
608
  return ctx.request.json().catch(() => null)
613
609
  }
614
610
 
611
+ function tagInputErrorResponse(error: TagInputError): Response {
612
+ return json(
613
+ {
614
+ error: 'Invalid tags',
615
+ code: error.code,
616
+ limit: error.limit,
617
+ actual: error.actual,
618
+ index: error.index,
619
+ },
620
+ 400,
621
+ )
622
+ }
623
+
615
624
  export interface ContentRouteHandlers {
616
625
  /** GET /content — cursor-paginated list (q, author, status, types filters; excludes deleted). */
617
626
  list(ctx: RouteContext): Promise<Response>
@@ -1034,6 +1043,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1034
1043
  try {
1035
1044
  created = await createContent(ctx.db, parsed.data)
1036
1045
  } catch (error) {
1046
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1037
1047
  const conflict = verifiedMediaDurationConflictResponse(error)
1038
1048
  if (conflict) return conflict
1039
1049
  throw error
@@ -1073,17 +1083,30 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1073
1083
  const item = await getContentItem(ctx.db, id)
1074
1084
  if (!item) return json({ error: 'Not found' }, 404)
1075
1085
 
1076
- const [content, tags, relations, author] = await Promise.all([
1077
- getContentPayload(ctx, item.type, id),
1078
- getContentTags(ctx, id),
1079
- getContentRelations(ctx.db, id),
1080
- item.author_id
1081
- ? ctx.db
1082
- .prepare('SELECT name, slug FROM authors WHERE id = ? LIMIT 1')
1083
- .bind(item.author_id)
1084
- .first<{ name: string | null; slug: string | null }>()
1085
- : Promise.resolve(null),
1086
- ])
1086
+ let content: unknown
1087
+ let tags: string[]
1088
+ let relations: Awaited<ReturnType<typeof getContentRelations>>
1089
+ let author: { name: string | null; slug: string | null } | null
1090
+ try {
1091
+ const resolvedContent = await Promise.all([
1092
+ getContentPayload(ctx, item.type, id),
1093
+ getContentTags(ctx, id),
1094
+ getContentRelations(ctx.db, id),
1095
+ item.author_id
1096
+ ? ctx.db
1097
+ .prepare('SELECT name, slug FROM authors WHERE id = ? LIMIT 1')
1098
+ .bind(item.author_id)
1099
+ .first<{ name: string | null; slug: string | null }>()
1100
+ : Promise.resolve(null),
1101
+ ])
1102
+ content = resolvedContent[0]
1103
+ tags = resolvedContent[1]
1104
+ relations = resolvedContent[2]
1105
+ author = resolvedContent[3]
1106
+ } catch (error) {
1107
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1108
+ throw error
1109
+ }
1087
1110
  const contentRecord = content as Record<string, unknown> | null
1088
1111
  const thumbnailImageId =
1089
1112
  typeof contentRecord?.thumbnail_image_id === 'string'
@@ -1193,6 +1216,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1193
1216
  try {
1194
1217
  updated = await updateContentItem(ctx.db, id, parsed.data)
1195
1218
  } catch (error) {
1219
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1196
1220
  const conflict = verifiedMediaDurationConflictResponse(error)
1197
1221
  if (conflict) return conflict
1198
1222
  throw error
@@ -1332,7 +1356,6 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1332
1356
  422,
1333
1357
  )
1334
1358
  }
1335
-
1336
1359
  if (!existing.excerpt?.trim()) {
1337
1360
  let failureReason: string | null = null
1338
1361
  if (!resolved.hooks.llm) {
@@ -1370,8 +1393,19 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1370
1393
  }
1371
1394
  }
1372
1395
  }
1373
- await publishContent(ctx.db, id, now)
1374
- await createRevision(ctx.db, id, ctx.userId || null)
1396
+ try {
1397
+ await getContentTagPairs(ctx.db, id)
1398
+ } catch (error) {
1399
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1400
+ throw error
1401
+ }
1402
+ try {
1403
+ await publishContent(ctx.db, id, now)
1404
+ await createRevision(ctx.db, id, ctx.userId || null)
1405
+ } catch (error) {
1406
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1407
+ throw error
1408
+ }
1375
1409
 
1376
1410
  // Write first-party activity log row (cms_activity_log, P5 Task 3).
1377
1411
  // Wrapped so a logging failure never breaks the publish response.
@@ -1554,10 +1588,16 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1554
1588
  }
1555
1589
 
1556
1590
  if (action === 'snapshot') {
1557
- const revision = await createRevisionWithDelta(ctx.db, id, {
1558
- createdBy: ctx.userId ?? null,
1559
- tag: 'autosave',
1560
- })
1591
+ let revision: Awaited<ReturnType<typeof createRevisionWithDelta>>
1592
+ try {
1593
+ revision = await createRevisionWithDelta(ctx.db, id, {
1594
+ createdBy: ctx.userId ?? null,
1595
+ tag: 'autosave',
1596
+ })
1597
+ } catch (error) {
1598
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1599
+ throw error
1600
+ }
1561
1601
  if (!revision) return json({ error: 'Not found' }, 404)
1562
1602
  return json({ status: 'snapshot', revisionId: revision.id })
1563
1603
  }
@@ -1885,7 +1925,13 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1885
1925
  const rev = ctx.params.rev
1886
1926
  if (!rev) return json({ error: 'Missing rev' }, 400)
1887
1927
 
1888
- const ok = await restoreRevision(ctx.db, id, rev, { createdBy: ctx.userId ?? null })
1928
+ let ok: Awaited<ReturnType<typeof restoreRevision>>
1929
+ try {
1930
+ ok = await restoreRevision(ctx.db, id, rev, { createdBy: ctx.userId ?? null })
1931
+ } catch (error) {
1932
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1933
+ throw error
1934
+ }
1889
1935
  if (!ok) return json({ error: 'Not found' }, 404)
1890
1936
 
1891
1937
  return json({ restored: true })
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from 'zod'
6
6
  import { createTag, deleteTag, getTag, listTags, renameTag, searchTags } from '../engine/tags.js'
7
+ import { TagInputError } from '../engine/taxonomy.js'
7
8
  import type { CmsRouteConfig } from './config.js'
8
9
  import { resolveConfig } from './config.js'
9
10
  import { json, type RouteContext } from './context.js'
@@ -12,6 +13,25 @@ const TagWriteSchema = z.object({
12
13
  label: z.string().min(1),
13
14
  })
14
15
 
16
+ function parseLimit(value: string | null): number | undefined {
17
+ if (value === null) return undefined
18
+ const limit = Number(value)
19
+ return Number.isSafeInteger(limit) ? limit : undefined
20
+ }
21
+
22
+ function tagInputErrorResponse(error: TagInputError): Response {
23
+ return json(
24
+ {
25
+ error: 'Invalid tag',
26
+ code: error.code,
27
+ limit: error.limit,
28
+ actual: error.actual,
29
+ index: error.index,
30
+ },
31
+ 400,
32
+ )
33
+ }
34
+
15
35
  export interface TagRouteHandlers {
16
36
  /**
17
37
  * GET /tags or GET /tags?q=<query>
@@ -42,8 +62,9 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
42
62
 
43
63
  const url = new URL(ctx.request.url)
44
64
  const q = url.searchParams.get('q')
65
+ const limit = parseLimit(url.searchParams.get('limit'))
45
66
 
46
- const tags = q ? await searchTags(ctx.db, q) : await listTags(ctx.db)
67
+ const tags = q ? await searchTags(ctx.db, q, { limit }) : await listTags(ctx.db, { limit })
47
68
  return json({ tags })
48
69
  },
49
70
 
@@ -55,7 +76,13 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
55
76
  if (!parsed.success) {
56
77
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
57
78
  }
58
- const tag = await createTag(ctx.db, parsed.data.label)
79
+ let tag: Awaited<ReturnType<typeof createTag>>
80
+ try {
81
+ tag = await createTag(ctx.db, parsed.data.label)
82
+ } catch (error) {
83
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
84
+ throw error
85
+ }
59
86
  if (!tag) return json({ error: 'Tag already exists or label is invalid' }, 409)
60
87
  return json({ tag }, 201)
61
88
  },
@@ -73,7 +100,13 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
73
100
  if (!parsed.success) {
74
101
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
75
102
  }
76
- const ok = await renameTag(ctx.db, id, parsed.data.label)
103
+ let ok: Awaited<ReturnType<typeof renameTag>>
104
+ try {
105
+ ok = await renameTag(ctx.db, id, parsed.data.label)
106
+ } catch (error) {
107
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
108
+ throw error
109
+ }
77
110
  if (!ok) return json({ error: 'Tag slug already exists or label is invalid' }, 409)
78
111
  const tag = await getTag(ctx.db, id)
79
112
  return json({ tag })
@@ -811,7 +811,21 @@ ALTER TABLE content_insight_dismissals
811
811
  ADD COLUMN reason_code TEXT CHECK (reason_code IS NULL OR reason_code IN ('off_topic', 'already_covered', 'cant_win', 'wrong_format', 'not_now', 'other'));
812
812
  ALTER TABLE content_insight_dismissals
813
813
  ADD COLUMN note TEXT CHECK (note IS NULL OR length(note) <= 500);
814
- `,
814
+ `,
815
+ },
816
+ {
817
+ id: '0023_content_tag_link_labels',
818
+ sql: `
819
+ ALTER TABLE content_tag_links
820
+ ADD COLUMN label TEXT;
821
+ UPDATE content_tag_links
822
+ SET label = (
823
+ SELECT label
824
+ FROM content_tags
825
+ WHERE content_tags.id = content_tag_links.tag_id
826
+ )
827
+ WHERE label IS NULL;
828
+ `,
815
829
  },
816
830
  ]
817
831
 
@@ -167,6 +167,7 @@ export interface ContentTagRow {
167
167
  export interface ContentTagLinkRow {
168
168
  content_id: string
169
169
  tag_id: string
170
+ label: string | null
170
171
  }
171
172
 
172
173
  export interface ContentRelationRow {
@@ -14,14 +14,10 @@
14
14
  // Runtime/visual verification is done via the Playwright smoke (SMOKE.md P2 section).
15
15
 
16
16
  import { Editor } from '@tiptap/core'
17
- import Image from '@tiptap/extension-image'
18
- import Link from '@tiptap/extension-link'
19
- import Underline from '@tiptap/extension-underline'
20
- import StarterKit from '@tiptap/starter-kit'
21
17
  import { useEffect, useRef, useState } from 'react'
22
18
  import { Icon } from '../icons.js'
19
+ import { createRteExtensions } from './extensions.js'
23
20
  import { docToMarkdown, markdownToDoc } from './serialize.js'
24
- import { TweetEmbed } from './tweet-embed.js'
25
21
 
26
22
  // ---------------------------------------------------------------------------
27
23
  // Toolbar config
@@ -114,13 +110,7 @@ export function Rte({
114
110
 
115
111
  const editor = new Editor({
116
112
  element: el,
117
- extensions: [
118
- StarterKit,
119
- Underline,
120
- Link.configure({ openOnClick: false }),
121
- Image,
122
- TweetEmbed,
123
- ],
113
+ extensions: createRteExtensions(),
124
114
  content: markdownToDoc(initialValueRef.current),
125
115
  editable: !readOnly,
126
116
  onUpdate({ editor: e }) {
@@ -0,0 +1,18 @@
1
+ import Image from '@tiptap/extension-image'
2
+ import Link from '@tiptap/extension-link'
3
+ import Underline from '@tiptap/extension-underline'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import { TrimBoundaryMarks } from './trim-boundary-marks.js'
6
+ import { TweetEmbed } from './tweet-embed.js'
7
+
8
+ /** The single extension set used by Masthead and its behavioral editor tests. */
9
+ export function createRteExtensions() {
10
+ return [
11
+ StarterKit.configure({ link: false, underline: false }),
12
+ Underline,
13
+ Link.configure({ openOnClick: false }),
14
+ TrimBoundaryMarks,
15
+ Image,
16
+ TweetEmbed,
17
+ ]
18
+ }