@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
|
@@ -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
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
'
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
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
|
-
|
|
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,
|