@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.
- package/README.md +22 -0
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +3 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +23 -5
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +14 -5
- package/dist/engine/published-content.js.map +1 -1
- package/dist/engine/publisher.d.ts +3 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +39 -31
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/revisions.d.ts +3 -0
- package/dist/engine/revisions.d.ts.map +1 -1
- package/dist/engine/revisions.js +7 -1
- package/dist/engine/revisions.js.map +1 -1
- package/dist/engine/tags.d.ts +9 -2
- package/dist/engine/tags.d.ts.map +1 -1
- package/dist/engine/tags.js +32 -21
- package/dist/engine/tags.js.map +1 -1
- package/dist/engine/taxonomy.d.ts +31 -0
- package/dist/engine/taxonomy.d.ts.map +1 -0
- package/dist/engine/taxonomy.js +125 -0
- package/dist/engine/taxonomy.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +98 -27
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/tags.d.ts.map +1 -1
- package/dist/routes/tags.js +36 -3
- package/dist/routes/tags.js.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +15 -1
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +1 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/migrations/0023_content_tag_link_labels.sql +11 -0
- package/package.json +1 -1
- package/src/engine/index.ts +14 -0
- package/src/engine/publication.ts +27 -12
- package/src/engine/published-content.ts +14 -8
- package/src/engine/publisher.ts +76 -39
- package/src/engine/revisions.ts +10 -1
- package/src/engine/tags.ts +39 -19
- package/src/engine/taxonomy.ts +226 -0
- package/src/index.ts +2 -0
- package/src/routes/content.ts +91 -30
- package/src/routes/tags.ts +36 -3
- package/src/schema/migrations.ts +15 -1
- 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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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) ||
|
package/src/engine/publisher.ts
CHANGED
|
@@ -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(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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(),
|
|
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(
|
|
505
|
+
.bind(slug)
|
|
491
506
|
.first<{ id: string; slug: string }>()
|
|
492
507
|
|
|
493
508
|
if (!row) {
|
|
494
|
-
throw new Error(`content tag "${
|
|
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
|
|
508
|
-
await db
|
|
509
|
-
|
|
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 (
|
|
676
|
-
await
|
|
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
|
|
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:
|
|
1403
|
+
tags: tags.map((row) => row.slug),
|
|
1404
|
+
tagLabels: tags.map((row) => row.label),
|
|
1368
1405
|
related: related.results || [],
|
|
1369
1406
|
}
|
|
1370
1407
|
}
|
package/src/engine/revisions.ts
CHANGED
|
@@ -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:
|
|
249
|
+
tags: restoredTags,
|
|
241
250
|
relations: target.payload.related.map((r) => ({
|
|
242
251
|
relatedId: r.related_id,
|
|
243
252
|
rank: r.rank,
|
package/src/engine/tags.ts
CHANGED
|
@@ -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
|
|
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,
|
|
50
|
+
.bind(id, tag.slug, tag.label)
|
|
42
51
|
.run()
|
|
43
|
-
return { id, slug, label:
|
|
52
|
+
return { id, slug: tag.slug, label: tag.label, count: 0 }
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
export async function listTags(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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).
|