@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
@@ -0,0 +1,157 @@
1
+ import { Extension } from '@tiptap/core';
2
+ import { Plugin } from '@tiptap/pm/state';
3
+ const BOUNDARY_MARKS = ['link', 'underline'];
4
+ function markKey(mark) {
5
+ return `${mark.type}:${JSON.stringify(mark.attrs ?? {})}`;
6
+ }
7
+ function sameMarks(a, b) {
8
+ return JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
9
+ }
10
+ function mergeAdjacentText(nodes) {
11
+ const merged = [];
12
+ for (const node of nodes) {
13
+ const previous = merged.at(-1);
14
+ if (previous?.type === 'text' &&
15
+ node.type === 'text' &&
16
+ sameMarks(previous.marks, node.marks)) {
17
+ previous.text = (previous.text ?? '') + (node.text ?? '');
18
+ }
19
+ else {
20
+ merged.push(node);
21
+ }
22
+ }
23
+ return merged;
24
+ }
25
+ function trimJsonMark(nodes, markType) {
26
+ const output = [];
27
+ let index = 0;
28
+ while (index < nodes.length) {
29
+ const node = nodes[index];
30
+ const mark = node.type === 'text'
31
+ ? node.marks?.find((candidate) => candidate.type === markType)
32
+ : undefined;
33
+ if (!mark) {
34
+ output.push(node);
35
+ index += 1;
36
+ continue;
37
+ }
38
+ const key = markKey(mark);
39
+ const run = [];
40
+ while (index < nodes.length) {
41
+ const candidate = nodes[index];
42
+ const candidateMark = candidate.type === 'text'
43
+ ? candidate.marks?.find((item) => item.type === markType && markKey(item) === key)
44
+ : undefined;
45
+ if (!candidateMark)
46
+ break;
47
+ run.push(candidate);
48
+ index += 1;
49
+ }
50
+ const text = run.map((item) => item.text ?? '').join('');
51
+ const leading = text.match(/^\s+/u)?.[0].length ?? 0;
52
+ const trailing = text.match(/\s+$/u)?.[0].length ?? 0;
53
+ const markedEnd = Math.max(leading, text.length - trailing);
54
+ let offset = 0;
55
+ for (const item of run) {
56
+ const itemText = item.text ?? '';
57
+ const itemStart = offset;
58
+ const itemEnd = offset + itemText.length;
59
+ const cuts = [itemStart, Math.max(itemStart, leading), Math.min(itemEnd, markedEnd), itemEnd]
60
+ .filter((value, cutIndex, all) => value >= itemStart && value <= itemEnd && all.indexOf(value) === cutIndex)
61
+ .sort((a, b) => a - b);
62
+ for (let cutIndex = 0; cutIndex < cuts.length - 1; cutIndex += 1) {
63
+ const from = cuts[cutIndex];
64
+ const to = cuts[cutIndex + 1];
65
+ if (from === to)
66
+ continue;
67
+ const keepMark = from >= leading && to <= markedEnd;
68
+ const marks = keepMark
69
+ ? item.marks
70
+ : item.marks?.filter((candidate) => candidate.type !== markType);
71
+ output.push({
72
+ ...item,
73
+ text: itemText.slice(from - itemStart, to - itemStart),
74
+ ...(marks?.length ? { marks } : { marks: undefined }),
75
+ });
76
+ }
77
+ offset = itemEnd;
78
+ }
79
+ }
80
+ return mergeAdjacentText(output);
81
+ }
82
+ /**
83
+ * Remove link and underline marks from leading/trailing whitespace in every
84
+ * inline mark run. Underline follows the same rule as links: whitespace is
85
+ * layout, not editorial emphasis, and pasted underline is the visible half of
86
+ * the malformed-link report.
87
+ */
88
+ export function normalizeBoundaryMarks(node) {
89
+ if (!node.content)
90
+ return node;
91
+ let content = node.content.map(normalizeBoundaryMarks);
92
+ for (const markType of BOUNDARY_MARKS)
93
+ content = trimJsonMark(content, markType);
94
+ return { ...node, content };
95
+ }
96
+ function matchingMark(node, markType, previous) {
97
+ const mark = markType.isInSet(node.marks);
98
+ if (!mark)
99
+ return undefined;
100
+ return previous && !mark.eq(previous) ? undefined : mark;
101
+ }
102
+ /** Keep the live Tiptap document visually consistent after toolbar and paste transactions. */
103
+ export const TrimBoundaryMarks = Extension.create({
104
+ name: 'trimBoundaryMarks',
105
+ addProseMirrorPlugins() {
106
+ return [
107
+ new Plugin({
108
+ appendTransaction: (transactions, _oldState, newState) => {
109
+ if (!transactions.some((transaction) => transaction.docChanged))
110
+ return null;
111
+ const transaction = newState.tr;
112
+ newState.doc.descendants((parent, parentPosition) => {
113
+ if (!parent.isTextblock)
114
+ return true;
115
+ for (const markName of BOUNDARY_MARKS) {
116
+ const markType = newState.schema.marks[markName];
117
+ if (!markType)
118
+ continue;
119
+ const children = [];
120
+ parent.forEach((child, offset) => {
121
+ if (child.isText)
122
+ children.push({ node: child, from: parentPosition + 1 + offset });
123
+ });
124
+ let index = 0;
125
+ while (index < children.length) {
126
+ const first = children[index];
127
+ const mark = matchingMark(first.node, markType);
128
+ if (!mark) {
129
+ index += 1;
130
+ continue;
131
+ }
132
+ const run = [];
133
+ while (index < children.length &&
134
+ matchingMark(children[index].node, markType, mark)) {
135
+ run.push(children[index]);
136
+ index += 1;
137
+ }
138
+ const text = run.map((item) => item.node.text ?? '').join('');
139
+ const leading = text.match(/^\s+/u)?.[0].length ?? 0;
140
+ const trailing = text.match(/\s+$/u)?.[0].length ?? 0;
141
+ const runFrom = run[0].from;
142
+ const runTo = run.at(-1).from + (run.at(-1).node.text?.length ?? 0);
143
+ if (leading)
144
+ transaction.removeMark(runFrom, runFrom + leading, markType);
145
+ if (trailing)
146
+ transaction.removeMark(runTo - trailing, runTo, markType);
147
+ }
148
+ }
149
+ return false;
150
+ });
151
+ return transaction.steps.length ? transaction : null;
152
+ },
153
+ }),
154
+ ];
155
+ },
156
+ });
157
+ //# sourceMappingURL=trim-boundary-marks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trim-boundary-marks.js","sourceRoot":"","sources":["../../../src/ui/editor/trim-boundary-marks.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ -- Preserve each content item's declared display label for a canonical tag slug.
2
+ ALTER TABLE content_tag_links
3
+ ADD COLUMN label TEXT;
4
+
5
+ UPDATE content_tag_links
6
+ SET label = (
7
+ SELECT label
8
+ FROM content_tags
9
+ WHERE content_tags.id = content_tag_links.tag_id
10
+ )
11
+ WHERE label IS NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/cms",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "growth-labs-cms-migrations": "./dist/cli/migrations.js"
@@ -208,6 +208,10 @@ export {
208
208
  getContentItem,
209
209
  getContentRelations,
210
210
  getContentSnapshot,
211
+ getContentTagPairs,
212
+ MAX_ITEM_TAG_COUNT,
213
+ MAX_ITEM_TAGS_TOTAL_BYTES,
214
+ MAX_TAG_LABEL_BYTES,
211
215
  type NewsletterInput,
212
216
  type PodcastInput,
213
217
  PublishContentValidationError,
@@ -215,6 +219,7 @@ export {
215
219
  scheduleContent,
216
220
  setContentRelations,
217
221
  setContentTags,
222
+ TagInputError,
218
223
  type UpdateContentInput,
219
224
  unpublishContent,
220
225
  updateContentItem,
@@ -308,12 +313,21 @@ export {
308
313
  } from './soft-delete.js'
309
314
  // Tag CRUD engine — listTags / searchTags / renameTag / deleteTag.
310
315
  export {
316
+ createTag,
311
317
  deleteTag,
312
318
  listTags,
313
319
  renameTag,
314
320
  searchTags,
315
321
  type TagSummary,
316
322
  } from './tags.js'
323
+ export {
324
+ type TaxonomyTag,
325
+ utf8ByteLength,
326
+ validateDeclaredTagList,
327
+ validateRevisionTaxonomy,
328
+ validateSingleDeclaredTag,
329
+ validateStoredTaxonomyRows,
330
+ } from './taxonomy.js'
317
331
  // SSRF guard for outbound webhook URLs — single source of truth for create + fire time.
318
332
  export { type UrlGuardResult, validateWebhookUrl } from './url-guard.js'
319
333
  // Article-body validator (single source of truth for ingest formatting QA).
@@ -22,6 +22,7 @@ import {
22
22
  } from './publisher.js'
23
23
  import { getRevision } from './revisions.js'
24
24
  import { slugify } from './slug.js'
25
+ import { TagInputError, validateDeclaredTagList } from './taxonomy.js'
25
26
 
26
27
  export type PublicationOperation = 'publish' | 'rollback'
27
28
  export type PublicationSurfacePhase = 'prepare' | 'commit' | 'abort'
@@ -2249,18 +2250,32 @@ function validateArchiveItem(item: ContentArchiveItem, maxBodyBytes: number): vo
2249
2250
  -1,
2250
2251
  )
2251
2252
  }
2252
- if (
2253
- item.tags !== undefined &&
2254
- (!Array.isArray(item.tags) ||
2255
- item.tags.length > 10 ||
2256
- !item.tags.every((tag) => typeof tag === 'string'))
2257
- ) {
2258
- throw new ContentArchiveImportLimitError(
2259
- 'archive_tags_invalid',
2260
- 'tags must contain at most 10 strings',
2261
- 10,
2262
- Array.isArray(item.tags) ? item.tags.length : -1,
2263
- )
2253
+ if (item.tags !== undefined) {
2254
+ const ARCHIVE_TAG_CODE: Record<string, string> = {
2255
+ tags_not_array: 'archive_tags_invalid',
2256
+ tag_not_string: 'archive_tag_not_string',
2257
+ tag_blank: 'archive_tag_blank',
2258
+ tag_untrimmed: 'archive_tag_untrimmed',
2259
+ tag_not_nfc: 'archive_tag_not_nfc',
2260
+ tag_label_too_long: 'archive_tag_too_long',
2261
+ tag_non_slugifiable: 'archive_tag_non_slugifiable',
2262
+ tag_duplicate: 'archive_tag_duplicate',
2263
+ tag_count_exceeded: 'archive_tag_count_exceeded',
2264
+ tags_total_bytes_exceeded: 'archive_tags_total_bytes_exceeded',
2265
+ }
2266
+ try {
2267
+ validateDeclaredTagList(item.tags)
2268
+ } catch (error) {
2269
+ if (error instanceof TagInputError) {
2270
+ throw new ContentArchiveImportLimitError(
2271
+ ARCHIVE_TAG_CODE[error.code] ?? 'archive_tags_invalid',
2272
+ error.message,
2273
+ error.limit,
2274
+ error.actual,
2275
+ )
2276
+ }
2277
+ throw error
2278
+ }
2264
2279
  }
2265
2280
  if (item.bodyMarkdown !== undefined && typeof item.bodyMarkdown !== 'string') {
2266
2281
  throw new ContentArchiveImportLimitError(
@@ -6,6 +6,7 @@ import {
6
6
  } from './content-metadata.js'
7
7
  import type { D1Database } from './d1.js'
8
8
  import type { ContentRevisionPayload } from './revisions.js'
9
+ import { TagInputError, validateRevisionTaxonomy } from './taxonomy.js'
9
10
 
10
11
  const CONTENT_TYPES = new Set<ContentType>(['article', 'video', 'podcast', 'newsletter', 'page'])
11
12
  const CONTENT_STATUSES = new Set(['draft', 'scheduled', 'review', 'published', 'archived'])
@@ -168,14 +169,19 @@ function validatePayload(raw: string, row: PublishedContentRow): ContentRevision
168
169
  if (decoded.content !== null && !isPlainRecord(decoded.content)) {
169
170
  return invalidPayload('published revision content must be an object or null')
170
171
  }
171
- if (!Array.isArray(decoded.tags) || !decoded.tags.every((tag) => typeof tag === 'string')) {
172
- return invalidPayload('published revision tags must be strings')
173
- }
174
- if (
175
- decoded.tags.length > 10 ||
176
- decoded.tags.some((tag) => tag.length === 0 || tag.length > 256)
177
- ) {
178
- return invalidPayload('published revision tags exceed their canonical bounds')
172
+ try {
173
+ const taxonomy = validateRevisionTaxonomy({
174
+ tags: decoded.tags,
175
+ tagLabels: decoded.tagLabels,
176
+ allowLegacyLabels: true,
177
+ })
178
+ decoded.tags = taxonomy.map((tag) => tag.slug)
179
+ decoded.tagLabels = taxonomy.map((tag) => tag.label)
180
+ } catch (error) {
181
+ if (error instanceof TagInputError) {
182
+ return invalidPayload(`published revision taxonomy is invalid: ${error.code}`)
183
+ }
184
+ throw error
179
185
  }
180
186
  if (
181
187
  !Array.isArray(decoded.related) ||
@@ -24,6 +24,22 @@ import { type ContentMetadata, serializeContentMetadata } from './content-metada
24
24
  import type { D1Database } from './d1.js'
25
25
  import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
26
26
  import { slugify } from './slug.js'
27
+ import {
28
+ MAX_ITEM_TAG_COUNT,
29
+ type TaxonomyTag,
30
+ validateDeclaredTagList,
31
+ validateStoredTaxonomyRows,
32
+ } from './taxonomy.js'
33
+
34
+ export {
35
+ MAX_ITEM_TAG_COUNT,
36
+ MAX_ITEM_TAGS_TOTAL_BYTES,
37
+ MAX_TAG_LABEL_BYTES,
38
+ TagInputError,
39
+ validateDeclaredTagList,
40
+ validateRevisionTaxonomy,
41
+ validateSingleDeclaredTag,
42
+ } from './taxonomy.js'
27
43
 
28
44
  type ContentVisibility = 'free' | 'premium'
29
45
  type BodyBackedContentType = 'article' | 'newsletter' | 'page'
@@ -449,14 +465,6 @@ export async function ensureUniqueSlug(
449
465
  }
450
466
  }
451
467
 
452
- function normalizeTags(tags?: string[]): string[] {
453
- if (!tags) return []
454
- return tags
455
- .map((tag) => slugify(tag))
456
- .filter(Boolean)
457
- .slice(0, 10)
458
- }
459
-
460
468
  function serializeTakeaways(value: string[] | null | undefined): string | null {
461
469
  if (!value) return null
462
470
  const normalized = value
@@ -466,11 +474,18 @@ function serializeTakeaways(value: string[] | null | undefined): string | null {
466
474
  return normalized.length > 0 ? JSON.stringify(normalized) : null
467
475
  }
468
476
 
469
- async function ensureTags(db: D1Database, tags: string[]): Promise<{ id: string; slug: string }[]> {
470
- const normalized = normalizeTags(tags)
471
- const results: { id: string; slug: string }[] = []
472
-
473
- for (const tag of normalized) {
477
+ async function ensureTags(
478
+ db: D1Database,
479
+ pairs: TaxonomyTag[],
480
+ ): Promise<
481
+ Array<{
482
+ id: string
483
+ slug: string
484
+ label: string
485
+ }>
486
+ > {
487
+ const results: Array<{ id: string; slug: string; label: string }> = []
488
+ for (const { slug, label } of pairs) {
474
489
  // Concurrency-safe upsert. A plain SELECT-then-INSERT races under parallel
475
490
  // import shards: two shards can both miss the same shared tag slug and both
476
491
  // INSERT it, and the second hits `UNIQUE constraint failed: content_tags.slug`
@@ -482,20 +497,19 @@ async function ensureTags(db: D1Database, tags: string[]): Promise<{ id: string;
482
497
  .prepare(
483
498
  'INSERT INTO content_tags (id, slug, label) VALUES (?, ?, ?) ON CONFLICT(slug) DO NOTHING',
484
499
  )
485
- .bind(crypto.randomUUID(), tag, tag)
500
+ .bind(crypto.randomUUID(), slug, label)
486
501
  .run()
487
502
 
488
503
  const row = await db
489
504
  .prepare('SELECT id, slug FROM content_tags WHERE slug = ? LIMIT 1')
490
- .bind(tag)
505
+ .bind(slug)
491
506
  .first<{ id: string; slug: string }>()
492
507
 
493
508
  if (!row) {
494
- throw new Error(`content tag "${tag}" could not be resolved after upsert`)
509
+ throw new Error(`content tag "${slug}" could not be resolved after upsert`)
495
510
  }
496
- results.push(row)
511
+ results.push({ ...row, label })
497
512
  }
498
-
499
513
  return results
500
514
  }
501
515
 
@@ -504,19 +518,44 @@ export async function setContentTags(
504
518
  contentId: string,
505
519
  tags: string[],
506
520
  ): Promise<void> {
507
- const normalized = normalizeTags(tags)
508
- await db.prepare('DELETE FROM content_tag_links WHERE content_id = ?').bind(contentId).run()
509
- if (normalized.length === 0) return
510
-
511
- const tagRows = await ensureTags(db, normalized)
512
-
513
- const insert = db.prepare('INSERT INTO content_tag_links (content_id, tag_id) VALUES (?, ?)')
521
+ const pairs = validateDeclaredTagList(tags)
522
+ await setContentTagPairs(db, contentId, pairs)
523
+ }
514
524
 
525
+ async function setContentTagPairs(
526
+ db: D1Database,
527
+ contentId: string,
528
+ pairs: TaxonomyTag[],
529
+ ): Promise<void> {
530
+ await db.prepare('DELETE FROM content_tag_links WHERE content_id = ?').bind(contentId).run()
531
+ if (pairs.length === 0) return
532
+ const tagRows = await ensureTags(db, pairs)
533
+ const insert = db.prepare(
534
+ 'INSERT INTO content_tag_links (content_id, tag_id, label) VALUES (?, ?, ?)',
535
+ )
515
536
  for (const tag of tagRows) {
516
- await insert.bind(contentId, tag.id).run()
537
+ await insert.bind(contentId, tag.id, tag.label).run()
517
538
  }
518
539
  }
519
540
 
541
+ export async function getContentTagPairs(
542
+ db: D1Database,
543
+ contentId: string,
544
+ ): Promise<TaxonomyTag[]> {
545
+ const tags = await db
546
+ .prepare(
547
+ `SELECT t.slug, COALESCE(l.label, t.label) AS label
548
+ FROM content_tag_links l
549
+ JOIN content_tags t ON t.id = l.tag_id
550
+ WHERE l.content_id = ?
551
+ ORDER BY t.slug ASC
552
+ LIMIT ?`,
553
+ )
554
+ .bind(contentId, MAX_ITEM_TAG_COUNT + 1)
555
+ .all<{ slug: string; label: string }>()
556
+ return validateStoredTaxonomyRows(tags.results || [])
557
+ }
558
+
520
559
  export async function setContentRelations(
521
560
  db: D1Database,
522
561
  contentId: string,
@@ -567,6 +606,7 @@ export async function createContent(
567
606
  input.content.durationSeconds,
568
607
  )
569
608
  : null
609
+ const tagPairs = input.tags === undefined ? null : validateDeclaredTagList(input.tags)
570
610
  const id = crypto.randomUUID()
571
611
  const slug = await ensureUniqueSlug(db, input.slug)
572
612
  const visibility = input.visibility || 'free'
@@ -672,8 +712,8 @@ export async function createContent(
672
712
  .run()
673
713
  }
674
714
 
675
- if (input.tags) {
676
- await setContentTags(db, id, input.tags)
715
+ if (tagPairs) {
716
+ await setContentTagPairs(db, id, tagPairs)
677
717
  }
678
718
 
679
719
  return { id, slug }
@@ -694,6 +734,7 @@ export async function updateContentItem(
694
734
  id: string,
695
735
  input: UpdateContentInput,
696
736
  ): Promise<{ slug: string } | null> {
737
+ const tagPairs = input.tags === undefined ? null : validateDeclaredTagList(input.tags)
697
738
  const existing = await getContentItem(db, id)
698
739
  if (!existing) return null
699
740
 
@@ -912,7 +953,7 @@ export async function updateContentItem(
912
953
  .run()
913
954
 
914
955
  if (input.tags !== undefined) {
915
- await setContentTags(db, id, input.tags)
956
+ await setContentTagPairs(db, id, tagPairs ?? [])
916
957
  }
917
958
 
918
959
  if (input.relations !== undefined) {
@@ -1278,11 +1319,11 @@ export async function duplicateContentItem(
1278
1319
  .run()
1279
1320
  }
1280
1321
 
1281
- // INSERT…SELECT copy #4 — content_tag_links (carry the same tags).
1322
+ // INSERT…SELECT copy #4 — content_tag_links (carry the same canonical tags and labels).
1282
1323
  await db
1283
1324
  .prepare(
1284
- `INSERT INTO content_tag_links (content_id, tag_id)
1285
- SELECT ?, tag_id FROM content_tag_links WHERE content_id = ?`,
1325
+ `INSERT INTO content_tag_links (content_id, tag_id, label)
1326
+ SELECT ?, tag_id, label FROM content_tag_links WHERE content_id = ?`,
1286
1327
  )
1287
1328
  .bind(newId, sourceId)
1288
1329
  .run()
@@ -1347,12 +1388,7 @@ export async function getContentSnapshot(
1347
1388
  .first<Record<string, unknown>>()
1348
1389
  }
1349
1390
 
1350
- const tags = await db
1351
- .prepare(
1352
- '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.slug ASC',
1353
- )
1354
- .bind(contentId)
1355
- .all<{ slug: string }>()
1391
+ const tags = await getContentTagPairs(db, contentId)
1356
1392
 
1357
1393
  const related = await db
1358
1394
  .prepare(
@@ -1364,7 +1400,8 @@ export async function getContentSnapshot(
1364
1400
  return {
1365
1401
  item: base,
1366
1402
  content,
1367
- tags: (tags.results || []).map((row) => row.slug),
1403
+ tags: tags.map((row) => row.slug),
1404
+ tagLabels: tags.map((row) => row.label),
1368
1405
  related: related.results || [],
1369
1406
  }
1370
1407
  }
@@ -8,12 +8,16 @@
8
8
  import { parseContentMetadata } from './content-metadata.js'
9
9
  import type { D1Database } from './d1.js'
10
10
  import { countWords, getContentItem, getContentSnapshot, updateContentItem } from './publisher.js'
11
+ import { validateRevisionTaxonomy } from './taxonomy.js'
11
12
 
12
13
  /** The one canonical revision snapshot shape (spec §4 "ContentRevisionPayload"). */
13
14
  export interface ContentRevisionPayload {
14
15
  item: Record<string, unknown>
15
16
  content: Record<string, unknown> | null
17
+ /** Canonical tag slugs, ordered ascending. */
16
18
  tags: string[]
19
+ /** NFC-normalized display labels parallel to tags[]; preserves original casing through revisions. */
20
+ tagLabels?: string[]
17
21
  related: Array<{ related_id: string; rank: number; reason: string | null }>
18
22
  }
19
23
 
@@ -212,6 +216,11 @@ export async function restoreRevision(
212
216
  }
213
217
  }
214
218
  const restoredTakeaways = parseTakeaways(body?.editor_takeaways)
219
+ const restoredTags = validateRevisionTaxonomy({
220
+ tags: target.payload.tags,
221
+ tagLabels: target.payload.tagLabels,
222
+ allowLegacyLabels: true,
223
+ }).map((tag) => tag.label)
215
224
  // Restore intentionally rolls back CONTENT fields (title/body/tags/relations/
216
225
  // featured/seo/hero/etc.) to the snapshot, but NOT publication state
217
226
  // (status / publish_at / published_at / published_revision_id) — that is by
@@ -237,7 +246,7 @@ export async function restoreRevision(
237
246
  metadata: parseContentMetadata(
238
247
  typeof item.metadata_json === 'string' ? item.metadata_json : '{}',
239
248
  ),
240
- tags: target.payload.tags,
249
+ tags: restoredTags,
241
250
  relations: target.payload.related.map((r) => ({
242
251
  relatedId: r.related_id,
243
252
  rank: r.rank,