@growth-labs/cms 0.5.11 → 0.5.13

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 (56) hide show
  1. package/README.md +22 -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 +98 -27
  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/migrations/0023_content_tag_link_labels.sql +11 -0
  44. package/package.json +1 -1
  45. package/src/engine/index.ts +14 -0
  46. package/src/engine/publication.ts +27 -12
  47. package/src/engine/published-content.ts +14 -8
  48. package/src/engine/publisher.ts +76 -39
  49. package/src/engine/revisions.ts +10 -1
  50. package/src/engine/tags.ts +39 -19
  51. package/src/engine/taxonomy.ts +226 -0
  52. package/src/index.ts +2 -0
  53. package/src/routes/content.ts +91 -30
  54. package/src/routes/tags.ts +36 -3
  55. package/src/schema/migrations.ts +15 -1
  56. package/src/schema/types.ts +1 -0
@@ -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,
@@ -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).