@neatico/visual-core 0.14.3 → 0.14.26
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/dist/cjs/composition.js +62 -36
- package/dist/cjs/compositionScenes.js +91 -38
- package/dist/cjs/content.js +538 -120
- package/dist/cjs/designBlueprint.js +94 -19
- package/dist/cjs/designSystem.js +464 -77
- package/dist/cjs/editorial.js +76 -20
- package/dist/cjs/fontCatalog.js +75 -23
- package/dist/cjs/layoutContracts.js +57 -11
- package/dist/cjs/layouts.js +5 -92
- package/dist/cjs/pagination.js +16 -5
- package/dist/cjs/socialLayouts.js +127 -387
- package/dist/cjs/text.js +12 -2
- package/dist/composition.d.ts +4 -4
- package/dist/composition.js +62 -36
- package/dist/compositionScenes.d.ts +2 -2
- package/dist/compositionScenes.js +91 -38
- package/dist/content.d.ts +55 -20
- package/dist/content.js +539 -121
- package/dist/designBlueprint.d.ts +4 -4
- package/dist/designBlueprint.js +95 -20
- package/dist/designSystem.d.ts +11 -7
- package/dist/designSystem.js +465 -79
- package/dist/editorial.d.ts +1 -1
- package/dist/editorial.js +77 -21
- package/dist/fontCatalog.d.ts +1 -1
- package/dist/fontCatalog.js +75 -23
- package/dist/layoutContracts.d.ts +3 -3
- package/dist/layoutContracts.js +58 -12
- package/dist/layouts.d.ts +2 -2
- package/dist/layouts.js +5 -92
- package/dist/pagination.js +16 -5
- package/dist/socialLayouts.d.ts +40 -232
- package/dist/socialLayouts.js +127 -387
- package/dist/text.js +12 -2
- package/package.json +1 -1
package/dist/cjs/content.js
CHANGED
|
@@ -6,6 +6,13 @@ exports.validateContentPlan = validateContentPlan;
|
|
|
6
6
|
exports.contentBlockText = contentBlockText;
|
|
7
7
|
exports.validateEditableContentPage = validateEditableContentPage;
|
|
8
8
|
const compositionScenes_js_1 = require("./compositionScenes.js");
|
|
9
|
+
function emojiField(value, path) {
|
|
10
|
+
if (value === undefined || value === '')
|
|
11
|
+
return {};
|
|
12
|
+
if (typeof value !== 'string' || Array.from(value).length > 16 || !/^[\p{Extended_Pictographic}\p{Emoji_Component}\u200d\uFE0F]+$/u.test(value) || !/\p{Extended_Pictographic}/u.test(value))
|
|
13
|
+
return fail('CONTENT_INVALID_EMOJI', path);
|
|
14
|
+
return { emoji: value };
|
|
15
|
+
}
|
|
9
16
|
class ContentContractError extends Error {
|
|
10
17
|
code;
|
|
11
18
|
path;
|
|
@@ -16,63 +23,183 @@ class ContentContractError extends Error {
|
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
25
|
exports.ContentContractError = ContentContractError;
|
|
19
|
-
const fail = (code, path) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
const fail = (code, path) => {
|
|
27
|
+
throw new ContentContractError(code, path);
|
|
28
|
+
};
|
|
29
|
+
/** AI-selected exact title phrases; no positional or last-words heuristic. */
|
|
30
|
+
const titleEmphasis = (value, title, path) => {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return {};
|
|
33
|
+
if (!Array.isArray(value) || value.length > 3 || typeof title !== "string")
|
|
34
|
+
return fail("CONTENT_INVALID_EMPHASIS", path);
|
|
35
|
+
const phrases = [];
|
|
36
|
+
for (const phrase of value) {
|
|
37
|
+
if (typeof phrase !== "string" ||
|
|
38
|
+
!phrase.trim() ||
|
|
39
|
+
phrase !== phrase.trim() ||
|
|
40
|
+
phrase.length > 80 ||
|
|
41
|
+
!title.includes(phrase) ||
|
|
42
|
+
phrases.some((p) => p.includes(phrase) || phrase.includes(p)))
|
|
43
|
+
return fail("CONTENT_INVALID_EMPHASIS", path);
|
|
44
|
+
phrases.push(phrase);
|
|
45
|
+
}
|
|
46
|
+
return { titleEmphasis: phrases };
|
|
47
|
+
};
|
|
48
|
+
const sceneField = (value, path) => {
|
|
49
|
+
if (value === undefined)
|
|
50
|
+
return {};
|
|
51
|
+
if (typeof value !== "string" ||
|
|
52
|
+
!compositionScenes_js_1.COMPOSITION_SCENES.some((scene) => scene.id === value))
|
|
53
|
+
return fail("CONTENT_INVALID_SCENE", path);
|
|
54
|
+
return { scene: value };
|
|
55
|
+
};
|
|
23
56
|
const object = (raw, path) => {
|
|
24
|
-
if (!raw || typeof raw !==
|
|
25
|
-
return fail(
|
|
57
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
58
|
+
return fail("CONTENT_INVALID_OBJECT", path);
|
|
26
59
|
return raw;
|
|
27
60
|
};
|
|
28
61
|
const text = (raw, path, max = 1000, empty = false) => {
|
|
29
|
-
if (typeof raw !==
|
|
30
|
-
return fail(
|
|
62
|
+
if (typeof raw !== "string" || raw.length > max || (!empty && !raw.trim()))
|
|
63
|
+
return fail("CONTENT_INVALID_TEXT", path);
|
|
31
64
|
return raw;
|
|
32
65
|
};
|
|
33
66
|
const list = (raw, path, min, max) => {
|
|
34
67
|
if (!Array.isArray(raw) || raw.length < min || raw.length > max)
|
|
35
|
-
return fail(
|
|
68
|
+
return fail("CONTENT_INVALID_LIST", path);
|
|
36
69
|
return raw;
|
|
37
70
|
};
|
|
38
71
|
const identity = (raw, path) => {
|
|
39
72
|
const id = text(raw, path, 80);
|
|
40
73
|
if (!/^[a-z][a-z0-9-]*$/.test(id))
|
|
41
|
-
fail(
|
|
74
|
+
fail("CONTENT_INVALID_ID", path);
|
|
42
75
|
return id;
|
|
43
76
|
};
|
|
77
|
+
/** Publication metadata is optional and source-backed; never infer engagement figures. */
|
|
78
|
+
function publication(raw, path, sourceText) {
|
|
79
|
+
if (raw === undefined)
|
|
80
|
+
return {};
|
|
81
|
+
const v = object(raw, path);
|
|
82
|
+
const field = (value, key, max = 100) => {
|
|
83
|
+
const result = text(value, `${path}.${key}`, max);
|
|
84
|
+
if (sourceText !== undefined && !sourceText.includes(result))
|
|
85
|
+
fail("CONTENT_UNSUPPORTED_PUBLICATION", `${path}.${key}`);
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
const result = {};
|
|
89
|
+
if (v.tags !== undefined)
|
|
90
|
+
result.tags = list(v.tags, `${path}.tags`, 0, 3).map((tag, i) => field(tag, `tags.${i}`, 32));
|
|
91
|
+
if (v.date !== undefined)
|
|
92
|
+
result.date = field(v.date, "date", 40);
|
|
93
|
+
if (v.source !== undefined)
|
|
94
|
+
result.source = field(v.source, "source", 120);
|
|
95
|
+
if (v.metrics !== undefined)
|
|
96
|
+
result.metrics = list(v.metrics, `${path}.metrics`, 0, 3).map((raw, i) => {
|
|
97
|
+
const m = object(raw, `${path}.metrics.${i}`);
|
|
98
|
+
return {
|
|
99
|
+
label: field(m.label, `metrics.${i}.label`, 24),
|
|
100
|
+
value: field(m.value, `metrics.${i}.value`, 24),
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
if (v.author !== undefined) {
|
|
104
|
+
const a = object(v.author, `${path}.author`);
|
|
105
|
+
result.author = {
|
|
106
|
+
name: field(a.name, "author.name", 60),
|
|
107
|
+
...(a.role === undefined
|
|
108
|
+
? {}
|
|
109
|
+
: { role: field(a.role, "author.role", 80) }),
|
|
110
|
+
...(a.avatarAssetId === undefined
|
|
111
|
+
? {}
|
|
112
|
+
: {
|
|
113
|
+
avatarAssetId: identity(a.avatarAssetId, `${path}.author.avatarAssetId`),
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { publication: result };
|
|
118
|
+
}
|
|
119
|
+
/** Identity copy is source-backed; asset IDs are editor-owned, never AI URLs. */
|
|
120
|
+
function bookend(raw, path, sourceText) {
|
|
121
|
+
if (raw === undefined)
|
|
122
|
+
return {};
|
|
123
|
+
const v = object(raw, path), result = {};
|
|
124
|
+
for (const key of [
|
|
125
|
+
"organization",
|
|
126
|
+
"presenter",
|
|
127
|
+
"role",
|
|
128
|
+
"date",
|
|
129
|
+
"contact",
|
|
130
|
+
]) {
|
|
131
|
+
if (v[key] === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
const value = text(v[key], `${path}.${key}`, key === "contact" ? 160 : 80);
|
|
134
|
+
if (sourceText !== undefined && !sourceText.includes(value))
|
|
135
|
+
fail("CONTENT_UNSUPPORTED_IDENTITY", `${path}.${key}`);
|
|
136
|
+
result[key] = value;
|
|
137
|
+
}
|
|
138
|
+
if (v.logoAssetId !== undefined) {
|
|
139
|
+
if (sourceText !== undefined)
|
|
140
|
+
fail("CONTENT_UNSUPPORTED_ASSET", `${path}.logoAssetId`);
|
|
141
|
+
result.logoAssetId = identity(v.logoAssetId, `${path}.logoAssetId`);
|
|
142
|
+
}
|
|
143
|
+
return { bookend: result };
|
|
144
|
+
}
|
|
145
|
+
/** Optional comparison facts stay aligned to their rows. Values/prices are source-verbatim. */
|
|
146
|
+
function comparisonColumn(raw, path, sourceText, allowIncomplete = false) {
|
|
147
|
+
const draft = (value, key, max, empty = false) => text(value, key, allowIncomplete ? 20000 : max, empty || allowIncomplete);
|
|
148
|
+
const col = object(raw, path), points = list(col.points, `${path}.points`, 1, 8).map((v, i) => draft(v, `${path}.points.${i}`, 500));
|
|
149
|
+
const fact = (v, p) => {
|
|
150
|
+
const result = draft(v, p, 80, true);
|
|
151
|
+
if (sourceText !== undefined && result && !sourceText.includes(result))
|
|
152
|
+
fail("CONTENT_UNSUPPORTED_COMPARISON", p);
|
|
153
|
+
return result;
|
|
154
|
+
};
|
|
155
|
+
const result = {
|
|
156
|
+
title: draft(col.title, `${path}.title`, 160),
|
|
157
|
+
points,
|
|
158
|
+
};
|
|
159
|
+
if (col.price !== undefined)
|
|
160
|
+
result.price = fact(col.price, `${path}.price`);
|
|
161
|
+
if (col.values !== undefined)
|
|
162
|
+
result.values = list(col.values, `${path}.values`, points.length, points.length).map((v, i) => fact(v, `${path}.values.${i}`));
|
|
163
|
+
if (col.statuses !== undefined)
|
|
164
|
+
result.statuses = list(col.statuses, `${path}.statuses`, points.length, points.length).map((v, i) => {
|
|
165
|
+
if (!["included", "excluded", "partial", "unknown"].includes(String(v)))
|
|
166
|
+
fail("CONTENT_INVALID_COMPARISON_STATUS", `${path}.statuses.${i}`);
|
|
167
|
+
return v;
|
|
168
|
+
});
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
44
171
|
/** Lossless ranges. File-name placeholders are references, never fabricated assets. */
|
|
45
172
|
function parseSource(input) {
|
|
46
|
-
if (typeof input !==
|
|
47
|
-
fail(
|
|
173
|
+
if (typeof input !== "string" || !input.trim() || input.length > 20000)
|
|
174
|
+
fail("SOURCE_INVALID_LENGTH", "source");
|
|
48
175
|
const segments = [];
|
|
49
|
-
const lines = [...input.matchAll(/[^\n]*(?:\n|$)/g)].filter(match => match[0].length);
|
|
176
|
+
const lines = [...input.matchAll(/[^\n]*(?:\n|$)/g)].filter((match) => match[0].length);
|
|
50
177
|
for (let i = 0; i < lines.length; i++) {
|
|
51
178
|
const line = lines[i], raw = line[0], trimmed = raw.trim();
|
|
52
179
|
if (!trimmed)
|
|
53
180
|
continue;
|
|
54
|
-
let start = line.index, end = start + raw.replace(/\r?\n$/,
|
|
181
|
+
let start = line.index, end = start + raw.replace(/\r?\n$/, "").length, kind = "text", language;
|
|
55
182
|
const fence = trimmed.match(/^(`{3,}|~{3,})([\w+-]*)\s*$/);
|
|
56
183
|
if (fence) {
|
|
57
|
-
kind =
|
|
58
|
-
language = fence[2] ||
|
|
184
|
+
kind = "code";
|
|
185
|
+
language = fence[2] || "text";
|
|
59
186
|
start = line.index + raw.length;
|
|
60
187
|
let j = i + 1;
|
|
61
188
|
for (; j < lines.length; j++)
|
|
62
189
|
if (lines[j][0].trim() === fence[1])
|
|
63
190
|
break;
|
|
64
191
|
end = j < lines.length ? lines[j].index : input.length;
|
|
65
|
-
if (input.slice(start, end).endsWith(
|
|
192
|
+
if (input.slice(start, end).endsWith("\n"))
|
|
66
193
|
end--;
|
|
67
|
-
if (input.slice(start, end).endsWith(
|
|
194
|
+
if (input.slice(start, end).endsWith("\r"))
|
|
68
195
|
end--;
|
|
69
196
|
i = j < lines.length ? j : lines.length - 1;
|
|
70
197
|
}
|
|
71
198
|
else if (/^curl(?:\s|$)/i.test(trimmed)) {
|
|
72
|
-
kind =
|
|
73
|
-
language =
|
|
199
|
+
kind = "code";
|
|
200
|
+
language = "bash";
|
|
74
201
|
// Track multiline quoted arguments and escaped physical newlines without executing code.
|
|
75
|
-
let quote =
|
|
202
|
+
let quote = "", escaped = false, j = i;
|
|
76
203
|
for (; j < lines.length; j++) {
|
|
77
204
|
const current = lines[j][0];
|
|
78
205
|
for (const char of current) {
|
|
@@ -80,18 +207,18 @@ function parseSource(input) {
|
|
|
80
207
|
escaped = false;
|
|
81
208
|
continue;
|
|
82
209
|
}
|
|
83
|
-
if (char ===
|
|
210
|
+
if (char === "\\" && quote !== "'") {
|
|
84
211
|
escaped = true;
|
|
85
212
|
continue;
|
|
86
213
|
}
|
|
87
214
|
if (quote) {
|
|
88
215
|
if (char === quote)
|
|
89
|
-
quote =
|
|
216
|
+
quote = "";
|
|
90
217
|
}
|
|
91
218
|
else if (char === '"' || char === "'")
|
|
92
219
|
quote = char;
|
|
93
220
|
}
|
|
94
|
-
end = lines[j].index + current.replace(/\r?\n$/,
|
|
221
|
+
end = lines[j].index + current.replace(/\r?\n$/, "").length;
|
|
95
222
|
if (!quote && !/\\\s*(?:\r?\n)?$/.test(current))
|
|
96
223
|
break;
|
|
97
224
|
escaped = false;
|
|
@@ -99,144 +226,435 @@ function parseSource(input) {
|
|
|
99
226
|
i = Math.min(j, lines.length - 1);
|
|
100
227
|
}
|
|
101
228
|
else if (/^(?:!\[[^\]]*\]\([^\n]+\)|\[[^\]]*\]\(https?:\/\/[^\s)]+\.(?:png|jpe?g|webp|gif|svg)(?:[?#][^\s)]*)?\)|[^\s<>]+\.(?:png|jpe?g|webp|gif|svg))$/i.test(trimmed))
|
|
102
|
-
kind =
|
|
229
|
+
kind = "image-reference";
|
|
103
230
|
if (end > start)
|
|
104
|
-
segments.push({
|
|
231
|
+
segments.push({
|
|
232
|
+
id: `source-${segments.length + 1}`,
|
|
233
|
+
kind,
|
|
234
|
+
text: input.slice(start, end),
|
|
235
|
+
start,
|
|
236
|
+
end,
|
|
237
|
+
...(language ? { language } : {}),
|
|
238
|
+
});
|
|
105
239
|
}
|
|
106
240
|
if (segments.length > 800)
|
|
107
|
-
fail(
|
|
241
|
+
fail("SOURCE_TOO_MANY_SEGMENTS", "source");
|
|
108
242
|
return { version: 1, text: input, segments };
|
|
109
243
|
}
|
|
110
244
|
/** Validate untrusted AI output; strip unknown properties and never trust generated code or asset URLs. */
|
|
111
245
|
function validateContentPlan(raw, source, expectedKind) {
|
|
112
|
-
const root = object(raw,
|
|
113
|
-
if (root.version !== 1 ||
|
|
114
|
-
|
|
115
|
-
|
|
246
|
+
const root = object(raw, "plan");
|
|
247
|
+
if (root.version !== 1 ||
|
|
248
|
+
!["presentation", "social"].includes(String(root.kind)) ||
|
|
249
|
+
(expectedKind && root.kind !== expectedKind))
|
|
250
|
+
fail("CONTENT_INVALID_VERSION", "plan");
|
|
251
|
+
const sources = new Map(source.segments.map((segment) => [segment.id, segment]));
|
|
116
252
|
const evidence = (raw, path) => list(raw, path, 1, 32).map((entry, index) => {
|
|
117
253
|
const item = object(entry, `${path}.${index}`), sourceId = text(item.sourceId, `${path}.${index}.sourceId`, 80);
|
|
118
254
|
const segment = sources.get(sourceId);
|
|
119
255
|
if (!segment)
|
|
120
|
-
fail(
|
|
121
|
-
const quote = item.quote === undefined
|
|
256
|
+
fail("CONTENT_UNKNOWN_SOURCE", path);
|
|
257
|
+
const quote = item.quote === undefined
|
|
258
|
+
? undefined
|
|
259
|
+
: text(item.quote, `${path}.${index}.quote`, 20000);
|
|
122
260
|
if (quote && !segment.text.includes(quote))
|
|
123
|
-
fail(
|
|
261
|
+
fail("CONTENT_UNSUPPORTED_QUOTE", path);
|
|
124
262
|
return { sourceId, ...(quote ? { quote } : {}) };
|
|
125
263
|
});
|
|
126
264
|
const ids = new Set();
|
|
127
|
-
const unique = (raw, path) => {
|
|
128
|
-
|
|
265
|
+
const unique = (raw, path) => {
|
|
266
|
+
const id = identity(raw, path);
|
|
267
|
+
if (ids.has(id))
|
|
268
|
+
fail("CONTENT_DUPLICATE_ID", path);
|
|
269
|
+
ids.add(id);
|
|
270
|
+
return id;
|
|
271
|
+
};
|
|
129
272
|
const block = (raw, path) => {
|
|
130
|
-
const value = object(raw, path), base = {
|
|
273
|
+
const value = object(raw, path), base = {
|
|
274
|
+
id: unique(value.id, `${path}.id`),
|
|
275
|
+
evidence: evidence(value.evidence, `${path}.evidence`),
|
|
276
|
+
};
|
|
131
277
|
const field = (key, max = 1000, empty = false) => text(value[key], `${path}.${key}`, max, empty);
|
|
132
278
|
switch (value.kind) {
|
|
133
|
-
case
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
279
|
+
case "paragraph":
|
|
280
|
+
return {
|
|
281
|
+
...base,
|
|
282
|
+
kind: "paragraph",
|
|
283
|
+
text: field("text", 1600),
|
|
284
|
+
...(value.heading === undefined
|
|
285
|
+
? {}
|
|
286
|
+
: {
|
|
287
|
+
heading: field("heading", 160),
|
|
288
|
+
level: value.level === 3 ? 3 : 2,
|
|
289
|
+
}),
|
|
290
|
+
};
|
|
291
|
+
case "steps":
|
|
292
|
+
case "features":
|
|
293
|
+
return {
|
|
294
|
+
...base,
|
|
295
|
+
kind: value.kind,
|
|
296
|
+
entries: list(value.entries, `${path}.entries`, 1, 12).map((raw, i) => {
|
|
297
|
+
const item = object(raw, `${path}.entries.${i}`);
|
|
298
|
+
return {
|
|
299
|
+
title: text(item.title, path, 160),
|
|
300
|
+
description: text(item.description, path, 600, true),
|
|
301
|
+
...visualDescriptor(item),
|
|
302
|
+
};
|
|
303
|
+
}),
|
|
304
|
+
};
|
|
305
|
+
case "comparison":
|
|
306
|
+
return {
|
|
307
|
+
...base,
|
|
308
|
+
kind: "comparison",
|
|
309
|
+
columns: list(value.columns, `${path}.columns`, 2, 4).map((raw, i) => comparisonColumn(raw, `${path}.columns.${i}`, base.evidence
|
|
310
|
+
.map((e) => sources.get(e.sourceId).text)
|
|
311
|
+
.join("\n"))),
|
|
312
|
+
};
|
|
313
|
+
case "code": {
|
|
314
|
+
const sourceId = field("sourceId", 80), segment = sources.get(sourceId);
|
|
315
|
+
if (segment?.kind !== "code" ||
|
|
316
|
+
!base.evidence.some((e) => e.sourceId === sourceId))
|
|
317
|
+
return fail("CONTENT_INVALID_CODE_SOURCE", path);
|
|
141
318
|
if (value.code !== undefined && value.code !== segment.text)
|
|
142
|
-
fail(
|
|
143
|
-
return {
|
|
319
|
+
fail("CONTENT_CODE_CHANGED", path);
|
|
320
|
+
return {
|
|
321
|
+
...base,
|
|
322
|
+
kind: "code",
|
|
323
|
+
sourceId,
|
|
324
|
+
language: segment.language || "text",
|
|
325
|
+
code: segment.text,
|
|
326
|
+
};
|
|
144
327
|
}
|
|
145
|
-
case
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
328
|
+
case "parameters":
|
|
329
|
+
return {
|
|
330
|
+
...base,
|
|
331
|
+
kind: "parameters",
|
|
332
|
+
rows: list(value.rows, `${path}.rows`, 1, 20).map((raw) => {
|
|
333
|
+
const row = object(raw, path);
|
|
334
|
+
return {
|
|
335
|
+
name: text(row.name, path, 160),
|
|
336
|
+
value: text(row.value, path, 1000, true),
|
|
337
|
+
description: text(row.description, path, 500, true),
|
|
338
|
+
};
|
|
339
|
+
}),
|
|
340
|
+
};
|
|
341
|
+
case "quote": {
|
|
342
|
+
const copy = field("text", 1600);
|
|
343
|
+
if (!base.evidence.some((e) => sources.get(e.sourceId).text.includes(copy)))
|
|
344
|
+
fail("CONTENT_UNSUPPORTED_QUOTE", path);
|
|
345
|
+
const attribution = field("attribution", 160, true);
|
|
346
|
+
if (attribution &&
|
|
347
|
+
!base.evidence.some((e) => sources.get(e.sourceId).text.includes(attribution)))
|
|
348
|
+
fail("CONTENT_UNSUPPORTED_ATTRIBUTION", path);
|
|
349
|
+
const role = value.role === undefined ? undefined : field("role", 160, true);
|
|
350
|
+
if (role &&
|
|
351
|
+
!base.evidence.some((e) => sources.get(e.sourceId).text.includes(role)))
|
|
352
|
+
fail("CONTENT_UNSUPPORTED_ATTRIBUTION", path);
|
|
353
|
+
return {
|
|
354
|
+
...base,
|
|
355
|
+
kind: "quote",
|
|
356
|
+
text: copy,
|
|
357
|
+
attribution,
|
|
358
|
+
...(role === undefined ? {} : { role }),
|
|
359
|
+
...(value.avatarAssetId === undefined
|
|
360
|
+
? {}
|
|
361
|
+
: {
|
|
362
|
+
avatarAssetId: identity(value.avatarAssetId, `${path}.avatarAssetId`),
|
|
363
|
+
}),
|
|
364
|
+
};
|
|
154
365
|
}
|
|
155
|
-
case
|
|
156
|
-
if (![
|
|
157
|
-
fail(
|
|
158
|
-
const proof = base.evidence
|
|
159
|
-
|
|
366
|
+
case "chart": {
|
|
367
|
+
if (!["bar", "line"].includes(String(value.chart)))
|
|
368
|
+
fail("CONTENT_INVALID_CHART", path);
|
|
369
|
+
const proof = base.evidence
|
|
370
|
+
.map((e) => sources.get(e.sourceId).text)
|
|
371
|
+
.join("\n");
|
|
372
|
+
const unit = field("unit", 60, true);
|
|
160
373
|
if (unit && !proof.includes(unit))
|
|
161
|
-
fail(
|
|
374
|
+
fail("CONTENT_UNSUPPORTED_UNIT", path);
|
|
162
375
|
const numbers = new Set(proof.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || []);
|
|
163
|
-
return {
|
|
164
|
-
|
|
165
|
-
|
|
376
|
+
return {
|
|
377
|
+
...base,
|
|
378
|
+
kind: "chart",
|
|
379
|
+
chart: value.chart,
|
|
380
|
+
unit,
|
|
381
|
+
values: list(value.values, path, 2, 12).map((raw) => {
|
|
382
|
+
const entry = object(raw, path);
|
|
383
|
+
if (typeof entry.value !== "number" ||
|
|
384
|
+
!Number.isFinite(entry.value) ||
|
|
385
|
+
!numbers.has(entry.value))
|
|
386
|
+
fail("CONTENT_UNSUPPORTED_VALUE", path);
|
|
387
|
+
const label = text(entry.label, path, 100);
|
|
388
|
+
if (!proof.includes(label))
|
|
389
|
+
fail("CONTENT_UNSUPPORTED_LABEL", path);
|
|
390
|
+
return { label, value: entry.value };
|
|
391
|
+
}),
|
|
392
|
+
};
|
|
166
393
|
}
|
|
167
|
-
case
|
|
168
|
-
const sourceId = field(
|
|
169
|
-
if (segment?.kind !==
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
394
|
+
case "image": {
|
|
395
|
+
const sourceId = field("sourceId", 80), segment = sources.get(sourceId);
|
|
396
|
+
if (segment?.kind !== "image-reference" ||
|
|
397
|
+
!base.evidence.some((e) => e.sourceId === sourceId))
|
|
398
|
+
return fail("CONTENT_INVALID_IMAGE_SOURCE", path);
|
|
399
|
+
if (!["screenshot", "photograph", "diagram"].includes(String(value.treatment)))
|
|
400
|
+
fail("CONTENT_INVALID_IMAGE_TREATMENT", path);
|
|
173
401
|
// Availability is resolved from the authenticated asset store later, never asserted by the model.
|
|
174
|
-
return {
|
|
402
|
+
return {
|
|
403
|
+
...base,
|
|
404
|
+
kind: "image",
|
|
405
|
+
sourceId,
|
|
406
|
+
alt: field("alt", 300, true),
|
|
407
|
+
treatment: value.treatment,
|
|
408
|
+
status: "missing",
|
|
409
|
+
};
|
|
175
410
|
}
|
|
176
|
-
default:
|
|
411
|
+
default:
|
|
412
|
+
return fail("CONTENT_UNKNOWN_BLOCK", path);
|
|
177
413
|
}
|
|
178
414
|
};
|
|
179
|
-
const pages = list(root.pages,
|
|
415
|
+
const pages = list(root.pages, "pages", 1, root.kind === "social" ? 8 : 24).map((raw, index) => {
|
|
180
416
|
const path = `pages.${index}`, page = object(raw, path);
|
|
181
|
-
if (![
|
|
182
|
-
|
|
183
|
-
|
|
417
|
+
if (![
|
|
418
|
+
"cover",
|
|
419
|
+
"explain",
|
|
420
|
+
"instruct",
|
|
421
|
+
"compare",
|
|
422
|
+
"evidence",
|
|
423
|
+
"conclude",
|
|
424
|
+
].includes(String(page.purpose)))
|
|
425
|
+
fail("CONTENT_INVALID_PURPOSE", path);
|
|
426
|
+
return {
|
|
427
|
+
...titleEmphasis(page.titleEmphasis, page.title, `${path}.titleEmphasis`),
|
|
428
|
+
...emojiField(page.emoji, `${path}.emoji`),
|
|
429
|
+
...publication(page.publication, `${path}.publication`, source.text),
|
|
430
|
+
...bookend(page.bookend, `${path}.bookend`, source.text),
|
|
431
|
+
...sceneField(page.scene, `${path}.scene`),
|
|
432
|
+
id: unique(page.id, `${path}.id`),
|
|
433
|
+
title: text(page.title, `${path}.title`, 160),
|
|
434
|
+
purpose: page.purpose,
|
|
435
|
+
evidence: evidence(page.evidence, `${path}.evidence`),
|
|
436
|
+
blocks: list(page.blocks, `${path}.blocks`, 1, 8).map((value, i) => block(value, `${path}.blocks.${i}`)),
|
|
437
|
+
...(page.caption !== undefined
|
|
438
|
+
? { caption: text(page.caption, `${path}.caption`, 2000, true) }
|
|
439
|
+
: {}),
|
|
440
|
+
...(page.notes !== undefined
|
|
441
|
+
? { notes: text(page.notes, `${path}.notes`, 2000, true) }
|
|
442
|
+
: {}),
|
|
443
|
+
};
|
|
184
444
|
});
|
|
185
|
-
if (root.kind ===
|
|
186
|
-
for (const segment of source.segments.filter(s => s.kind ===
|
|
187
|
-
if (!pages.some(p => p.blocks.some(b => b.kind ===
|
|
188
|
-
fail(
|
|
189
|
-
if (root.kind ===
|
|
190
|
-
fail(
|
|
191
|
-
return {
|
|
445
|
+
if (root.kind === "presentation")
|
|
446
|
+
for (const segment of source.segments.filter((s) => s.kind === "code"))
|
|
447
|
+
if (!pages.some((p) => p.blocks.some((b) => b.kind === "code" && b.sourceId === segment.id)))
|
|
448
|
+
fail("CONTENT_CODE_OMITTED", segment.id);
|
|
449
|
+
if (root.kind === "social" && pages.some((page) => !page.caption?.trim()))
|
|
450
|
+
fail("CONTENT_CAPTION_REQUIRED", "pages");
|
|
451
|
+
return {
|
|
452
|
+
version: 1,
|
|
453
|
+
kind: root.kind,
|
|
454
|
+
title: text(root.title, "title", 160),
|
|
455
|
+
pages,
|
|
456
|
+
};
|
|
192
457
|
}
|
|
193
458
|
function contentBlockText(block) {
|
|
194
459
|
switch (block.kind) {
|
|
195
|
-
case
|
|
196
|
-
|
|
197
|
-
case
|
|
198
|
-
case
|
|
199
|
-
|
|
200
|
-
case
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
460
|
+
case "paragraph":
|
|
461
|
+
return [...(block.heading ? [block.heading] : []), block.text];
|
|
462
|
+
case "steps":
|
|
463
|
+
case "features":
|
|
464
|
+
return block.entries.flatMap((e) => [e.title, e.description]);
|
|
465
|
+
case "comparison":
|
|
466
|
+
return block.columns.flatMap((c) => [
|
|
467
|
+
c.title,
|
|
468
|
+
...c.points,
|
|
469
|
+
...(c.price ? [c.price] : []),
|
|
470
|
+
...(c.values || []),
|
|
471
|
+
]);
|
|
472
|
+
case "code":
|
|
473
|
+
return [block.code];
|
|
474
|
+
case "parameters":
|
|
475
|
+
return block.rows.flatMap((r) => [r.name, r.value, r.description]);
|
|
476
|
+
case "quote":
|
|
477
|
+
return [block.text, block.attribution];
|
|
478
|
+
case "chart":
|
|
479
|
+
return [
|
|
480
|
+
block.unit,
|
|
481
|
+
...block.values.flatMap((v) => [v.label, String(v.value)]),
|
|
482
|
+
];
|
|
483
|
+
case "image":
|
|
484
|
+
return [];
|
|
204
485
|
}
|
|
205
486
|
}
|
|
206
487
|
/** Validate persisted/editor-authored content without reasserting that user edits came from AI evidence. */
|
|
207
488
|
function validateEditableContentPage(raw, { allowIncomplete = false } = {}) {
|
|
208
489
|
const draftText = (value, path, max = 1000, empty = false) => text(value, path, allowIncomplete ? Math.max(max, 20000) : max, empty || allowIncomplete);
|
|
209
|
-
const page = object(raw,
|
|
210
|
-
const id = (value, path) => {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
490
|
+
const page = object(raw, "page"), ids = new Set();
|
|
491
|
+
const id = (value, path) => {
|
|
492
|
+
const result = identity(value, path);
|
|
493
|
+
if (ids.has(result))
|
|
494
|
+
fail("CONTENT_DUPLICATE_ID", path);
|
|
495
|
+
ids.add(result);
|
|
496
|
+
return result;
|
|
497
|
+
};
|
|
498
|
+
const proof = (value, path) => list(value, path, 1, 32).map((raw, index) => {
|
|
499
|
+
const item = object(raw, `${path}.${index}`);
|
|
500
|
+
return {
|
|
501
|
+
sourceId: text(item.sourceId, path, 80),
|
|
502
|
+
...(item.quote === undefined
|
|
503
|
+
? {}
|
|
504
|
+
: { quote: text(item.quote, path, 20000) }),
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
if (![
|
|
508
|
+
"cover",
|
|
509
|
+
"explain",
|
|
510
|
+
"instruct",
|
|
511
|
+
"compare",
|
|
512
|
+
"evidence",
|
|
513
|
+
"conclude",
|
|
514
|
+
].includes(String(page.purpose)))
|
|
515
|
+
fail("CONTENT_INVALID_PURPOSE", "page.purpose");
|
|
516
|
+
const pageId = id(page.id, "page.id");
|
|
517
|
+
const blocks = list(page.blocks, "page.blocks", 1, 8).map((raw, index) => {
|
|
518
|
+
const path = `page.blocks.${index}`, value = object(raw, path), base = {
|
|
519
|
+
id: id(value.id, `${path}.id`),
|
|
520
|
+
evidence: proof(value.evidence, `${path}.evidence`),
|
|
521
|
+
};
|
|
218
522
|
const field = (name, max = 1000, empty = false) => draftText(value[name], `${path}.${name}`, max, empty);
|
|
219
523
|
switch (value.kind) {
|
|
220
|
-
case
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
524
|
+
case "paragraph":
|
|
525
|
+
return {
|
|
526
|
+
...base,
|
|
527
|
+
kind: "paragraph",
|
|
528
|
+
text: field("text", 1600),
|
|
529
|
+
...(value.heading === undefined
|
|
530
|
+
? {}
|
|
531
|
+
: {
|
|
532
|
+
heading: field("heading", 160),
|
|
533
|
+
level: value.level === 3 ? 3 : 2,
|
|
534
|
+
}),
|
|
535
|
+
};
|
|
536
|
+
case "steps":
|
|
537
|
+
case "features":
|
|
538
|
+
return {
|
|
539
|
+
...base,
|
|
540
|
+
kind: value.kind,
|
|
541
|
+
entries: list(value.entries, path, 1, 12).map((raw) => {
|
|
542
|
+
const item = object(raw, path);
|
|
543
|
+
return {
|
|
544
|
+
title: draftText(item.title, path, 160),
|
|
545
|
+
description: draftText(item.description, path, 600, true),
|
|
546
|
+
...visualDescriptor(item),
|
|
547
|
+
};
|
|
548
|
+
}),
|
|
549
|
+
};
|
|
550
|
+
case "comparison":
|
|
551
|
+
return {
|
|
552
|
+
...base,
|
|
553
|
+
kind: "comparison",
|
|
554
|
+
columns: list(value.columns, path, 2, 4).map((raw, i) => comparisonColumn(raw, `${path}.columns.${i}`, undefined, allowIncomplete)),
|
|
555
|
+
};
|
|
556
|
+
case "code":
|
|
557
|
+
return {
|
|
558
|
+
...base,
|
|
559
|
+
kind: "code",
|
|
560
|
+
sourceId: field("sourceId", 80),
|
|
561
|
+
language: field("language", 40),
|
|
562
|
+
code: field("code", 20000),
|
|
563
|
+
};
|
|
564
|
+
case "parameters":
|
|
565
|
+
return {
|
|
566
|
+
...base,
|
|
567
|
+
kind: "parameters",
|
|
568
|
+
rows: list(value.rows, path, 1, 20).map((raw) => {
|
|
569
|
+
const item = object(raw, path);
|
|
570
|
+
return {
|
|
571
|
+
name: draftText(item.name, path, 160),
|
|
572
|
+
value: draftText(item.value, path, 1000, true),
|
|
573
|
+
description: draftText(item.description, path, 500, true),
|
|
574
|
+
};
|
|
575
|
+
}),
|
|
576
|
+
};
|
|
577
|
+
case "quote":
|
|
578
|
+
return {
|
|
579
|
+
...base,
|
|
580
|
+
kind: "quote",
|
|
581
|
+
text: field("text", 1600),
|
|
582
|
+
attribution: field("attribution", 160, true),
|
|
583
|
+
...(value.role === undefined
|
|
584
|
+
? {}
|
|
585
|
+
: { role: field("role", 160, true) }),
|
|
586
|
+
...(value.avatarAssetId === undefined
|
|
587
|
+
? {}
|
|
588
|
+
: {
|
|
589
|
+
avatarAssetId: identity(value.avatarAssetId, `${path}.avatarAssetId`),
|
|
590
|
+
}),
|
|
591
|
+
};
|
|
592
|
+
case "chart": {
|
|
593
|
+
if (!["bar", "line"].includes(String(value.chart)))
|
|
594
|
+
fail("CONTENT_INVALID_CHART", path);
|
|
595
|
+
return {
|
|
596
|
+
...base,
|
|
597
|
+
kind: "chart",
|
|
598
|
+
chart: value.chart,
|
|
599
|
+
unit: field("unit", 60, true),
|
|
600
|
+
values: list(value.values, path, 2, 12).map((raw) => {
|
|
601
|
+
const item = object(raw, path);
|
|
602
|
+
if (typeof item.value !== "number" ||
|
|
603
|
+
(!(allowIncomplete && Number.isNaN(item.value)) &&
|
|
604
|
+
(!Number.isFinite(item.value) || Math.abs(item.value) > 1e15)))
|
|
605
|
+
fail("CONTENT_INVALID_VALUE", path);
|
|
606
|
+
return {
|
|
607
|
+
label: draftText(item.label, path, 100),
|
|
608
|
+
value: item.value,
|
|
609
|
+
};
|
|
610
|
+
}),
|
|
611
|
+
};
|
|
232
612
|
}
|
|
233
|
-
case
|
|
234
|
-
if (![
|
|
235
|
-
|
|
236
|
-
|
|
613
|
+
case "image": {
|
|
614
|
+
if (!["screenshot", "photograph", "diagram"].includes(String(value.treatment)) ||
|
|
615
|
+
!["missing", "available"].includes(String(value.status)))
|
|
616
|
+
fail("CONTENT_INVALID_IMAGE", "page");
|
|
617
|
+
return {
|
|
618
|
+
...base,
|
|
619
|
+
kind: "image",
|
|
620
|
+
sourceId: field("sourceId", 80),
|
|
621
|
+
alt: field("alt", 300, true),
|
|
622
|
+
treatment: value.treatment,
|
|
623
|
+
status: value.status,
|
|
624
|
+
...(value.status === "available"
|
|
625
|
+
? { assetId: field("assetId", 200) }
|
|
626
|
+
: {}),
|
|
627
|
+
};
|
|
237
628
|
}
|
|
238
|
-
default:
|
|
629
|
+
default:
|
|
630
|
+
return fail("CONTENT_UNKNOWN_BLOCK", path);
|
|
239
631
|
}
|
|
240
632
|
});
|
|
241
|
-
return {
|
|
633
|
+
return {
|
|
634
|
+
...titleEmphasis(page.titleEmphasis, page.title, "page.titleEmphasis"),
|
|
635
|
+
...emojiField(page.emoji, "page.emoji"),
|
|
636
|
+
...publication(page.publication, "page.publication"),
|
|
637
|
+
...bookend(page.bookend, "page.bookend"),
|
|
638
|
+
...sceneField(page.scene, "page.scene"),
|
|
639
|
+
id: pageId,
|
|
640
|
+
title: draftText(page.title, "page.title", 160),
|
|
641
|
+
purpose: page.purpose,
|
|
642
|
+
evidence: proof(page.evidence, "page.evidence"),
|
|
643
|
+
blocks,
|
|
644
|
+
...(page.caption === undefined
|
|
645
|
+
? {}
|
|
646
|
+
: { caption: draftText(page.caption, "page.caption", 2000, true) }),
|
|
647
|
+
...(page.notes === undefined
|
|
648
|
+
? {}
|
|
649
|
+
: { notes: draftText(page.notes, "page.notes", 2000, true) }),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function visualDescriptor(item) {
|
|
653
|
+
const icons = new Set(['BsJournalText', 'BsPersonCheck', 'BsShare', 'BsPeople', 'BsSearch', 'BsShieldCheck', 'BsBarChart', 'BsCodeSlash', 'BsSignpostSplit', 'BsCheck2Circle', 'BsFolder2Open', 'BsGrid1X2']);
|
|
654
|
+
const illustrations = new Set(['document', 'collaboration', 'review', 'feedback', 'symbol']);
|
|
655
|
+
if (item.icon != null && (typeof item.icon !== 'string' || !icons.has(item.icon)))
|
|
656
|
+
throw new Error('Invalid semantic icon');
|
|
657
|
+
if (item.illustration != null && (typeof item.illustration !== 'string' || !illustrations.has(item.illustration)))
|
|
658
|
+
throw new Error('Invalid semantic illustration');
|
|
659
|
+
return { ...(typeof item.icon === 'string' ? { icon: item.icon } : {}), ...(typeof item.illustration === 'string' ? { illustration: item.illustration } : {}) };
|
|
242
660
|
}
|