@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.
- package/README.md +28 -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 +80 -24
- 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/dist/ui/editor/Rte.d.ts.map +1 -1
- package/dist/ui/editor/Rte.js +2 -12
- package/dist/ui/editor/Rte.js.map +1 -1
- package/dist/ui/editor/extensions.d.ts +3 -0
- package/dist/ui/editor/extensions.d.ts.map +1 -0
- package/dist/ui/editor/extensions.js +18 -0
- package/dist/ui/editor/extensions.js.map +1 -0
- package/dist/ui/editor/serialize.d.ts.map +1 -1
- package/dist/ui/editor/serialize.js +4 -2
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/editor/trim-boundary-marks.d.ts +11 -0
- package/dist/ui/editor/trim-boundary-marks.d.ts.map +1 -0
- package/dist/ui/editor/trim-boundary-marks.js +157 -0
- package/dist/ui/editor/trim-boundary-marks.js.map +1 -0
- 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 +72 -26
- package/src/routes/tags.ts +36 -3
- package/src/schema/migrations.ts +15 -1
- package/src/schema/types.ts +1 -0
- package/src/ui/editor/Rte.tsx +2 -12
- package/src/ui/editor/extensions.ts +18 -0
- package/src/ui/editor/serialize.ts +4 -2
- package/src/ui/editor/trim-boundary-marks.ts +181 -0
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).
|
package/src/routes/content.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
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 })
|
package/src/routes/tags.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 })
|
package/src/schema/migrations.ts
CHANGED
|
@@ -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
|
|
package/src/schema/types.ts
CHANGED
package/src/ui/editor/Rte.tsx
CHANGED
|
@@ -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
|
+
}
|