@neatico/visual-core 0.14.3 → 0.14.64

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