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