@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.
@@ -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) => { throw new ContentContractError(code, path); };
20
- const sceneField = (value, path) => { if (value === undefined)
21
- return {}; if (typeof value !== 'string' || !compositionScenes_js_1.COMPOSITION_SCENES.some(scene => scene.id === value))
22
- return fail('CONTENT_INVALID_SCENE', path); return { scene: value }; };
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 !== 'object' || Array.isArray(raw))
25
- return fail('CONTENT_INVALID_OBJECT', path);
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 !== 'string' || raw.length > max || (!empty && !raw.trim()))
30
- return fail('CONTENT_INVALID_TEXT', path);
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('CONTENT_INVALID_LIST', path);
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('CONTENT_INVALID_ID', path);
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 !== 'string' || !input.trim() || input.length > 20000)
47
- fail('SOURCE_INVALID_LENGTH', 'source');
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$/, '').length, kind = 'text', language;
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 = 'code';
58
- language = fence[2] || 'text';
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('\n'))
192
+ if (input.slice(start, end).endsWith("\n"))
66
193
  end--;
67
- if (input.slice(start, end).endsWith('\r'))
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 = 'code';
73
- language = 'bash';
199
+ kind = "code";
200
+ language = "bash";
74
201
  // Track multiline quoted arguments and escaped physical newlines without executing code.
75
- let quote = '', escaped = false, j = i;
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 === '\\' && quote !== "'") {
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$/, '').length;
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 = 'image-reference';
229
+ kind = "image-reference";
103
230
  if (end > start)
104
- segments.push({ id: `source-${segments.length + 1}`, kind, text: input.slice(start, end), start, end, ...(language ? { language } : {}) });
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('SOURCE_TOO_MANY_SEGMENTS', 'source');
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, 'plan');
113
- if (root.version !== 1 || !['presentation', 'social'].includes(String(root.kind)) || (expectedKind && root.kind !== expectedKind))
114
- fail('CONTENT_INVALID_VERSION', 'plan');
115
- const sources = new Map(source.segments.map(segment => [segment.id, segment]));
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('CONTENT_UNKNOWN_SOURCE', path);
121
- const quote = item.quote === undefined ? undefined : text(item.quote, `${path}.${index}.quote`, 20000);
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('CONTENT_UNSUPPORTED_QUOTE', path);
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) => { const id = identity(raw, path); if (ids.has(id))
128
- fail('CONTENT_DUPLICATE_ID', path); ids.add(id); return id; };
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 = { id: unique(value.id, `${path}.id`), evidence: evidence(value.evidence, `${path}.evidence`) };
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 'paragraph': return { ...base, kind: 'paragraph', text: field('text', 1600), ...(value.heading === undefined ? {} : { heading: field('heading', 160), level: value.level === 3 ? 3 : 2 }) };
134
- case 'steps':
135
- case 'features': return { ...base, kind: value.kind, entries: list(value.entries, `${path}.entries`, 1, 12).map((raw, i) => { const item = object(raw, `${path}.entries.${i}`); return { title: text(item.title, path, 160), description: text(item.description, path, 600, true) }; }) };
136
- case 'comparison': return { ...base, kind: 'comparison', columns: list(value.columns, `${path}.columns`, 2, 4).map(raw => { const col = object(raw, path); return { title: text(col.title, path, 160), points: list(col.points, path, 1, 8).map(raw => text(raw, path, 500)) }; }) };
137
- case 'code': {
138
- const sourceId = field('sourceId', 80), segment = sources.get(sourceId);
139
- if (segment?.kind !== 'code' || !base.evidence.some(e => e.sourceId === sourceId))
140
- return fail('CONTENT_INVALID_CODE_SOURCE', path);
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('CONTENT_CODE_CHANGED', path);
143
- return { ...base, kind: 'code', sourceId, language: segment.language || 'text', code: segment.text };
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 'parameters': return { ...base, kind: 'parameters', rows: list(value.rows, `${path}.rows`, 1, 20).map(raw => { const row = object(raw, path); return { name: text(row.name, path, 160), value: text(row.value, path, 1000, true), description: text(row.description, path, 500, true) }; }) };
146
- case 'quote': {
147
- const copy = field('text', 1600);
148
- if (!base.evidence.some(e => sources.get(e.sourceId).text.includes(copy)))
149
- fail('CONTENT_UNSUPPORTED_QUOTE', path);
150
- const attribution = field('attribution', 160, true);
151
- if (attribution && !base.evidence.some(e => sources.get(e.sourceId).text.includes(attribution)))
152
- fail('CONTENT_UNSUPPORTED_ATTRIBUTION', path);
153
- return { ...base, kind: 'quote', text: copy, attribution };
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 'chart': {
156
- if (!['bar', 'line'].includes(String(value.chart)))
157
- fail('CONTENT_INVALID_CHART', path);
158
- const proof = base.evidence.map(e => sources.get(e.sourceId).text).join('\n');
159
- const unit = field('unit', 60, true);
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('CONTENT_UNSUPPORTED_UNIT', path);
374
+ fail("CONTENT_UNSUPPORTED_UNIT", path);
162
375
  const numbers = new Set(proof.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || []);
163
- return { ...base, kind: 'chart', chart: value.chart, unit, values: list(value.values, path, 2, 12).map(raw => { const entry = object(raw, path); if (typeof entry.value !== 'number' || !Number.isFinite(entry.value) || !numbers.has(entry.value))
164
- fail('CONTENT_UNSUPPORTED_VALUE', path); const label = text(entry.label, path, 100); if (!proof.includes(label))
165
- fail('CONTENT_UNSUPPORTED_LABEL', path); return { label, value: entry.value }; }) };
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 'image': {
168
- const sourceId = field('sourceId', 80), segment = sources.get(sourceId);
169
- if (segment?.kind !== 'image-reference' || !base.evidence.some(e => e.sourceId === sourceId))
170
- return fail('CONTENT_INVALID_IMAGE_SOURCE', path);
171
- if (!['screenshot', 'photograph', 'diagram'].includes(String(value.treatment)))
172
- fail('CONTENT_INVALID_IMAGE_TREATMENT', path);
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 { ...base, kind: 'image', sourceId, alt: field('alt', 300, true), treatment: value.treatment, status: 'missing' };
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: return fail('CONTENT_UNKNOWN_BLOCK', path);
411
+ default:
412
+ return fail("CONTENT_UNKNOWN_BLOCK", path);
177
413
  }
178
414
  };
179
- const pages = list(root.pages, 'pages', 1, root.kind === 'social' ? 8 : 24).map((raw, index) => {
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 (!['cover', 'explain', 'instruct', 'compare', 'evidence', 'conclude'].includes(String(page.purpose)))
182
- fail('CONTENT_INVALID_PURPOSE', path);
183
- return { ...sceneField(page.scene, `${path}.scene`), id: unique(page.id, `${path}.id`), title: text(page.title, `${path}.title`, 160), purpose: page.purpose, evidence: evidence(page.evidence, `${path}.evidence`), blocks: list(page.blocks, `${path}.blocks`, 1, 8).map((value, i) => block(value, `${path}.blocks.${i}`)), ...(page.caption !== undefined ? { caption: text(page.caption, `${path}.caption`, 2000, true) } : {}), ...(page.notes !== undefined ? { notes: text(page.notes, `${path}.notes`, 2000, true) } : {}) };
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 === 'presentation')
186
- for (const segment of source.segments.filter(s => s.kind === 'code'))
187
- if (!pages.some(p => p.blocks.some(b => b.kind === 'code' && b.sourceId === segment.id)))
188
- fail('CONTENT_CODE_OMITTED', segment.id);
189
- if (root.kind === 'social' && pages.some(page => !page.caption?.trim()))
190
- fail('CONTENT_CAPTION_REQUIRED', 'pages');
191
- return { version: 1, kind: root.kind, title: text(root.title, 'title', 160), pages };
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 'paragraph': return [...(block.heading ? [block.heading] : []), block.text];
196
- case 'steps':
197
- case 'features': return block.entries.flatMap(e => [e.title, e.description]);
198
- case 'comparison': return block.columns.flatMap(c => [c.title, ...c.points]);
199
- case 'code': return [block.code];
200
- case 'parameters': return block.rows.flatMap(r => [r.name, r.value, r.description]);
201
- case 'quote': return [block.text, block.attribution];
202
- case 'chart': return [block.unit, ...block.values.flatMap(v => [v.label, String(v.value)])];
203
- case 'image': return [];
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, 'page'), ids = new Set();
210
- const id = (value, path) => { const result = identity(value, path); if (ids.has(result))
211
- fail('CONTENT_DUPLICATE_ID', path); ids.add(result); return result; };
212
- const proof = (value, path) => list(value, path, 1, 32).map((raw, index) => { const item = object(raw, `${path}.${index}`); return { sourceId: text(item.sourceId, path, 80), ...(item.quote === undefined ? {} : { quote: text(item.quote, path, 20000) }) }; });
213
- if (!['cover', 'explain', 'instruct', 'compare', 'evidence', 'conclude'].includes(String(page.purpose)))
214
- fail('CONTENT_INVALID_PURPOSE', 'page.purpose');
215
- const pageId = id(page.id, 'page.id');
216
- const blocks = list(page.blocks, 'page.blocks', 1, 8).map((raw, index) => {
217
- const path = `page.blocks.${index}`, value = object(raw, path), base = { id: id(value.id, `${path}.id`), evidence: proof(value.evidence, `${path}.evidence`) };
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 'paragraph': return { ...base, kind: 'paragraph', text: field('text', 1600), ...(value.heading === undefined ? {} : { heading: field('heading', 160), level: value.level === 3 ? 3 : 2 }) };
221
- case 'steps':
222
- case 'features': return { ...base, kind: value.kind, entries: list(value.entries, path, 1, 12).map(raw => { const item = object(raw, path); return { title: draftText(item.title, path, 160), description: draftText(item.description, path, 600, true) }; }) };
223
- case 'comparison': return { ...base, kind: 'comparison', columns: list(value.columns, path, 2, 4).map(raw => { const item = object(raw, path); return { title: draftText(item.title, path, 160), points: list(item.points, path, 1, 8).map(raw => draftText(raw, path, 500)) }; }) };
224
- case 'code': return { ...base, kind: 'code', sourceId: field('sourceId', 80), language: field('language', 40), code: field('code', 20000) };
225
- case 'parameters': return { ...base, kind: 'parameters', rows: list(value.rows, path, 1, 20).map(raw => { const item = object(raw, path); return { name: draftText(item.name, path, 160), value: draftText(item.value, path, 1000, true), description: draftText(item.description, path, 500, true) }; }) };
226
- case 'quote': return { ...base, kind: 'quote', text: field('text', 1600), attribution: field('attribution', 160, true) };
227
- case 'chart': {
228
- if (!['bar', 'line'].includes(String(value.chart)))
229
- fail('CONTENT_INVALID_CHART', path);
230
- return { ...base, kind: 'chart', chart: value.chart, unit: field('unit', 60, true), values: list(value.values, path, 2, 12).map(raw => { const item = object(raw, path); if (typeof item.value !== 'number' || (!(allowIncomplete && Number.isNaN(item.value)) && (!Number.isFinite(item.value) || Math.abs(item.value) > 1e15)))
231
- fail('CONTENT_INVALID_VALUE', path); return { label: draftText(item.label, path, 100), value: item.value }; }) };
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 'image': {
234
- if (!['screenshot', 'photograph', 'diagram'].includes(String(value.treatment)) || !['missing', 'available'].includes(String(value.status)))
235
- fail('CONTENT_INVALID_IMAGE', 'page');
236
- return { ...base, kind: 'image', sourceId: field('sourceId', 80), alt: field('alt', 300, true), treatment: value.treatment, status: value.status, ...(value.status === 'available' ? { assetId: field('assetId', 200) } : {}) };
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: return fail('CONTENT_UNKNOWN_BLOCK', path);
629
+ default:
630
+ return fail("CONTENT_UNKNOWN_BLOCK", path);
239
631
  }
240
632
  });
241
- return { ...sceneField(page.scene, 'page.scene'), id: pageId, title: draftText(page.title, 'page.title', 160), purpose: page.purpose, evidence: proof(page.evidence, 'page.evidence'), blocks, ...(page.caption === undefined ? {} : { caption: draftText(page.caption, 'page.caption', 2000, true) }), ...(page.notes === undefined ? {} : { notes: draftText(page.notes, 'page.notes', 2000, true) }) };
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
  }