@protoyard/blueprint 0.0.0-stage → 1.1.0

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 (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1149 -2
  3. package/dist/aggregate.d.ts +83 -0
  4. package/dist/aggregate.js +268 -0
  5. package/dist/app-model.d.ts +76 -0
  6. package/dist/app-model.js +287 -0
  7. package/dist/archetypes/index.d.ts +1 -0
  8. package/dist/archetypes/index.js +1 -0
  9. package/dist/archetypes/schema.d.ts +126 -0
  10. package/dist/archetypes/schema.js +77 -0
  11. package/dist/audit.d.ts +52 -0
  12. package/dist/audit.js +149 -0
  13. package/dist/authoring/deck-lint.d.ts +32 -0
  14. package/dist/authoring/deck-lint.js +567 -0
  15. package/dist/authoring/index.d.ts +1 -0
  16. package/dist/authoring/index.js +1 -0
  17. package/dist/authoring/select-deck-layouts.d.ts +50 -0
  18. package/dist/authoring/select-deck-layouts.js +213 -0
  19. package/dist/authoring-apis.d.ts +13 -0
  20. package/dist/authoring-apis.js +13 -0
  21. package/dist/authoring-validation.d.ts +65 -0
  22. package/dist/authoring-validation.js +267 -0
  23. package/dist/avatar.d.ts +25 -0
  24. package/dist/avatar.js +30 -0
  25. package/dist/brand.d.ts +70 -0
  26. package/dist/brand.js +294 -0
  27. package/dist/brief/archetypes.d.ts +28 -0
  28. package/dist/brief/archetypes.js +798 -0
  29. package/dist/brief/content-synthesize.d.ts +31 -0
  30. package/dist/brief/content-synthesize.js +210 -0
  31. package/dist/brief/deck-outlines.d.ts +96 -0
  32. package/dist/brief/deck-outlines.js +561 -0
  33. package/dist/brief/draft-bodies.d.ts +29 -0
  34. package/dist/brief/draft-bodies.js +224 -0
  35. package/dist/brief/fields.d.ts +19 -0
  36. package/dist/brief/fields.js +229 -0
  37. package/dist/brief/index.d.ts +8 -0
  38. package/dist/brief/index.js +8 -0
  39. package/dist/brief/kind-detection.d.ts +69 -0
  40. package/dist/brief/kind-detection.js +353 -0
  41. package/dist/brief/limits.d.ts +117 -0
  42. package/dist/brief/limits.js +217 -0
  43. package/dist/brief/page-doc-outlines.d.ts +80 -0
  44. package/dist/brief/page-doc-outlines.js +485 -0
  45. package/dist/brief/synthesize.d.ts +59 -0
  46. package/dist/brief/synthesize.js +511 -0
  47. package/dist/brief/text.d.ts +13 -0
  48. package/dist/brief/text.js +59 -0
  49. package/dist/canvas-screen.d.ts +31 -0
  50. package/dist/canvas-screen.js +33 -0
  51. package/dist/canvas-seed.d.ts +58 -0
  52. package/dist/canvas-seed.js +82 -0
  53. package/dist/computed/evaluate.d.ts +11 -0
  54. package/dist/computed/evaluate.js +101 -0
  55. package/dist/computed/index.d.ts +4 -0
  56. package/dist/computed/index.js +3 -0
  57. package/dist/computed/public.d.ts +14 -0
  58. package/dist/computed/public.js +19 -0
  59. package/dist/computed/schema.d.ts +10 -0
  60. package/dist/computed/schema.js +140 -0
  61. package/dist/computed/types.d.ts +70 -0
  62. package/dist/computed/types.js +1 -0
  63. package/dist/computed/validate.d.ts +6 -0
  64. package/dist/computed/validate.js +146 -0
  65. package/dist/csv-guard.d.ts +15 -0
  66. package/dist/csv-guard.js +29 -0
  67. package/dist/csv.d.ts +85 -0
  68. package/dist/csv.js +288 -0
  69. package/dist/files.d.ts +35 -0
  70. package/dist/files.js +246 -0
  71. package/dist/form-schema.d.ts +124 -0
  72. package/dist/form-schema.js +300 -0
  73. package/dist/generator-version.d.ts +11 -0
  74. package/dist/generator-version.js +51 -0
  75. package/dist/generator.d.ts +34 -0
  76. package/dist/generator.js +445 -0
  77. package/dist/identity.d.ts +34 -0
  78. package/dist/identity.js +91 -0
  79. package/dist/import/csv-shape.d.ts +40 -0
  80. package/dist/import/csv-shape.js +265 -0
  81. package/dist/import/json-shape.d.ts +55 -0
  82. package/dist/import/json-shape.js +491 -0
  83. package/dist/import/limits.d.ts +13 -0
  84. package/dist/import/limits.js +14 -0
  85. package/dist/import/sqlite-schema.d.ts +36 -0
  86. package/dist/import/sqlite-schema.js +729 -0
  87. package/dist/index.d.ts +60 -0
  88. package/dist/index.js +58 -0
  89. package/dist/kinds/anchors.d.ts +18 -0
  90. package/dist/kinds/anchors.js +46 -0
  91. package/dist/kinds/bodies.d.ts +6 -0
  92. package/dist/kinds/bodies.js +32 -0
  93. package/dist/kinds/brand-runtime.d.ts +19 -0
  94. package/dist/kinds/brand-runtime.js +35 -0
  95. package/dist/kinds/deck.d.ts +8 -0
  96. package/dist/kinds/deck.js +34 -0
  97. package/dist/kinds/doc.d.ts +19 -0
  98. package/dist/kinds/doc.js +41 -0
  99. package/dist/kinds/embed/index.d.ts +39 -0
  100. package/dist/kinds/embed/index.js +91 -0
  101. package/dist/kinds/envelope.d.ts +159 -0
  102. package/dist/kinds/envelope.js +78 -0
  103. package/dist/kinds/index.d.ts +11 -0
  104. package/dist/kinds/index.js +11 -0
  105. package/dist/kinds/kind.d.ts +36 -0
  106. package/dist/kinds/kind.js +55 -0
  107. package/dist/kinds/limits.d.ts +64 -0
  108. package/dist/kinds/limits.js +190 -0
  109. package/dist/kinds/page-doc-runtime.d.ts +37 -0
  110. package/dist/kinds/page-doc-runtime.js +55 -0
  111. package/dist/kinds/page.d.ts +16 -0
  112. package/dist/kinds/page.js +67 -0
  113. package/dist/kinds/parse.d.ts +18 -0
  114. package/dist/kinds/parse.js +27 -0
  115. package/dist/kinds/runtime.d.ts +128 -0
  116. package/dist/kinds/runtime.js +206 -0
  117. package/dist/kinds/services.d.ts +49 -0
  118. package/dist/kinds/services.js +249 -0
  119. package/dist/kinds/testing.d.ts +9 -0
  120. package/dist/kinds/testing.js +16 -0
  121. package/dist/migrations.d.ts +97 -0
  122. package/dist/migrations.js +351 -0
  123. package/dist/motion.d.ts +50 -0
  124. package/dist/motion.js +52 -0
  125. package/dist/notifications.d.ts +19 -0
  126. package/dist/notifications.js +177 -0
  127. package/dist/patch.d.ts +122 -0
  128. package/dist/patch.js +322 -0
  129. package/dist/policy.d.ts +23 -0
  130. package/dist/policy.js +105 -0
  131. package/dist/runtime-definition.d.ts +39 -0
  132. package/dist/runtime-definition.js +74 -0
  133. package/dist/scenarios.d.ts +9 -0
  134. package/dist/scenarios.js +49 -0
  135. package/dist/schema/blueprint.schema.json +10191 -0
  136. package/dist/schema-json.d.ts +6 -0
  137. package/dist/schema-json.js +36 -0
  138. package/dist/schema.d.ts +6348 -0
  139. package/dist/schema.js +1269 -0
  140. package/dist/search.d.ts +16 -0
  141. package/dist/search.js +96 -0
  142. package/dist/services.d.ts +89 -0
  143. package/dist/services.js +898 -0
  144. package/dist/share.d.ts +78 -0
  145. package/dist/share.js +234 -0
  146. package/dist/sheet-screen.d.ts +49 -0
  147. package/dist/sheet-screen.js +91 -0
  148. package/dist/storage.d.ts +43 -0
  149. package/dist/storage.js +179 -0
  150. package/dist/stream.d.ts +26 -0
  151. package/dist/stream.js +173 -0
  152. package/dist/untrusted.d.ts +63 -0
  153. package/dist/untrusted.js +502 -0
  154. package/dist/validation-tasks.d.ts +178 -0
  155. package/dist/validation-tasks.js +199 -0
  156. package/dist/validation.d.ts +35 -0
  157. package/dist/validation.js +312 -0
  158. package/dist/variants.d.ts +112 -0
  159. package/dist/variants.js +283 -0
  160. package/dist/workflows/index.d.ts +3 -0
  161. package/dist/workflows/index.js +3 -0
  162. package/dist/workflows/schema.d.ts +50 -0
  163. package/dist/workflows/schema.js +34 -0
  164. package/dist/workflows/transition.d.ts +15 -0
  165. package/dist/workflows/transition.js +25 -0
  166. package/dist/workflows/validate.d.ts +17 -0
  167. package/dist/workflows/validate.js +140 -0
  168. package/package.json +120 -3
@@ -0,0 +1,199 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Validation tasks (ADR-031): an optional `validation.tasks` block that turns
4
+ * an exploration into a small usability session ("book a class for Friday").
5
+ * A task succeeds on a route match and/or one restricted data predicate:
6
+ * an entity, a field, an operator from a closed list and a literal value.
7
+ * There is no expression language and nothing is ever evaluated as code, so
8
+ * a blueprint from a link stays data.
9
+ */
10
+ export const VALIDATION_DATA_OPS = ['eq', 'contains', 'count>='];
11
+ export const VALIDATION_MAX_TASKS = 20;
12
+ const NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
13
+ const FIELD_PATTERN = /^[a-z][A-Za-z0-9]*$/;
14
+ /** An app route as the hash router sees it: no query, fragment, spaces or `..`. */
15
+ const ROUTE_PATTERN = /^\/(?:[A-Za-z0-9._~:@-]+(?:\/[A-Za-z0-9._~:@-]+)*\/?)?$/;
16
+ const entityName = z
17
+ .string()
18
+ .regex(NAME_PATTERN, { message: 'entity must be a kebab-case entity name' });
19
+ const fieldName = z
20
+ .string()
21
+ .regex(FIELD_PATTERN, { message: 'field must be a lowerCamelCase field name' });
22
+ const Scalar = z.union([z.string().max(200), z.number().finite(), z.boolean(), z.null()]);
23
+ export const ValidationRouteSchema = z.strictObject({
24
+ path: z
25
+ .string()
26
+ .max(200)
27
+ .regex(ROUTE_PATTERN, {
28
+ message: 'path must be an app route such as /bookings or /bookings/new',
29
+ })
30
+ .refine((path) => !path.split('/').some((segment) => segment === '.' || segment === '..'), {
31
+ message: 'path must not contain . or .. segments',
32
+ }),
33
+ /** `exact` (default) or `prefix`: `/bookings` also matches `/bookings/b-1`. */
34
+ match: z.enum(['exact', 'prefix']).default('exact'),
35
+ });
36
+ export const ValidationDataSchema = z.discriminatedUnion('op', [
37
+ /** Some row of `entity` has `field` equal to `value` (an array field: contains it). */
38
+ z.strictObject({ op: z.literal('eq'), entity: entityName, field: fieldName, value: Scalar }),
39
+ /** Some row of `entity` has a text `field` containing `value`, ignoring case. */
40
+ z.strictObject({
41
+ op: z.literal('contains'),
42
+ entity: entityName,
43
+ field: fieldName,
44
+ value: z.string().min(1).max(200),
45
+ }),
46
+ /**
47
+ * `entity` has at least `value` rows: more than when the task started
48
+ * (`from: "task-start"`, the default: "create a booking" means +1), or in
49
+ * total (`from: "zero"`).
50
+ */
51
+ z.strictObject({
52
+ op: z.literal('count>='),
53
+ entity: entityName,
54
+ value: z.number().int().min(0).max(100_000),
55
+ from: z.enum(['task-start', 'zero']).default('task-start'),
56
+ }),
57
+ ]);
58
+ export const ValidationSuccessSchema = z
59
+ .strictObject({
60
+ route: ValidationRouteSchema.optional(),
61
+ data: ValidationDataSchema.optional(),
62
+ })
63
+ .refine((success) => success.route !== undefined || success.data !== undefined, {
64
+ message: 'success needs a route, a data predicate, or both',
65
+ });
66
+ export const ValidationTaskSchema = z.strictObject({
67
+ id: z.string().regex(NAME_PATTERN, { message: 'task id must be lowercase kebab-case' }).max(48),
68
+ /** What the participant is asked to do, shown verbatim (plain text, never HTML). */
69
+ prompt: z.string().trim().min(1).max(280),
70
+ /** Seconds before the task counts as timed out (10 s to 1 h). */
71
+ timeLimitSeconds: z.number().int().min(10).max(3600).optional(),
72
+ /** Both parts must hold when both are given. */
73
+ success: ValidationSuccessSchema,
74
+ });
75
+ export const ValidationSchema = z.strictObject({
76
+ tasks: z
77
+ .array(ValidationTaskSchema)
78
+ .min(1, { message: 'validation needs at least one task' })
79
+ .max(VALIDATION_MAX_TASKS),
80
+ });
81
+ /** Field kinds `contains` can read: text values, or a string array for `tags`. */
82
+ export const CONTAINS_FIELD_KINDS = new Set([
83
+ 'text',
84
+ 'longText',
85
+ 'email',
86
+ 'url',
87
+ 'phone',
88
+ 'enum',
89
+ 'tags',
90
+ ]);
91
+ /** Field kinds `eq` compares to a number, a boolean, or a string. */
92
+ const NUMBER_KINDS = new Set(['number', 'integer', 'money']);
93
+ const BOOLEAN_KINDS = new Set(['boolean']);
94
+ const UNCOMPARABLE_KINDS = new Set(['json', 'file', 'geo']);
95
+ /**
96
+ * Cross-reference checks the zod shape cannot express: unique task ids,
97
+ * entities and fields that exist, and values whose type fits the field.
98
+ */
99
+ export function validationIssues(validation, entities, add) {
100
+ const seen = new Map();
101
+ validation.tasks.forEach((task, i) => {
102
+ const at = ['validation', 'tasks', i];
103
+ const first = seen.get(task.id);
104
+ if (first !== undefined)
105
+ add([...at, 'id'], `duplicate task id "${task.id}" (first at tasks.${first})`);
106
+ else
107
+ seen.set(task.id, i);
108
+ const data = task.success.data;
109
+ if (!data)
110
+ return;
111
+ const dataAt = [...at, 'success', 'data'];
112
+ const entity = entities.get(data.entity);
113
+ if (!entity) {
114
+ add([...dataAt, 'entity'], `unknown entity "${data.entity}"`);
115
+ return;
116
+ }
117
+ if (data.op === 'count>=')
118
+ return;
119
+ const field = entity.fields.find((candidate) => candidate.name === data.field);
120
+ if (!field) {
121
+ add([...dataAt, 'field'], `entity "${data.entity}" has no field "${data.field}"`);
122
+ return;
123
+ }
124
+ if (data.op === 'contains') {
125
+ if (!CONTAINS_FIELD_KINDS.has(field.kind))
126
+ add([...dataAt, 'field'], `contains needs a text, enum or tags field; "${data.field}" is ${field.kind}`);
127
+ return;
128
+ }
129
+ if (UNCOMPARABLE_KINDS.has(field.kind)) {
130
+ add([...dataAt, 'field'], `eq cannot compare a ${field.kind} field`);
131
+ }
132
+ else if (data.value !== null) {
133
+ const expected = NUMBER_KINDS.has(field.kind)
134
+ ? 'number'
135
+ : BOOLEAN_KINDS.has(field.kind)
136
+ ? 'boolean'
137
+ : 'string';
138
+ if (typeof data.value !== expected)
139
+ add([...dataAt, 'value'], `"${data.field}" is ${field.kind}; value must be a ${expected}`);
140
+ }
141
+ });
142
+ }
143
+ function normalizeRoute(route) {
144
+ const path = route.replace(/^#/, '').split(/[?#]/)[0] ?? '';
145
+ const withSlash = path.startsWith('/') ? path : `/${path}`;
146
+ return withSlash.length > 1 && withSlash.endsWith('/') ? withSlash.slice(0, -1) : withSlash;
147
+ }
148
+ export function routeMatches(condition, route) {
149
+ const current = normalizeRoute(route);
150
+ const wanted = normalizeRoute(condition.path);
151
+ if (condition.match === 'exact')
152
+ return current === wanted;
153
+ return wanted === '/' || current === wanted || current.startsWith(`${wanted}/`);
154
+ }
155
+ function valueContains(value, needle) {
156
+ const wanted = needle.toLowerCase();
157
+ if (typeof value === 'string')
158
+ return value.toLowerCase().includes(wanted);
159
+ if (Array.isArray(value))
160
+ return value.some((item) => typeof item === 'string' && item.toLowerCase().includes(wanted));
161
+ return false;
162
+ }
163
+ function valueEquals(value, expected) {
164
+ if (Array.isArray(value))
165
+ return value.some((item) => item === expected);
166
+ if (expected === null)
167
+ return value === null || value === undefined;
168
+ return value === expected;
169
+ }
170
+ export function dataHolds(condition, observation) {
171
+ const rows = observation.rows[condition.entity] ?? [];
172
+ switch (condition.op) {
173
+ case 'eq':
174
+ return rows.some((row) => valueEquals(row[condition.field], condition.value));
175
+ case 'contains':
176
+ return rows.some((row) => valueContains(row[condition.field], condition.value));
177
+ case 'count>=': {
178
+ const base = condition.from === 'zero' ? 0 : (observation.baseline?.[condition.entity] ?? 0);
179
+ return rows.length >= base + condition.value;
180
+ }
181
+ }
182
+ }
183
+ /**
184
+ * Pure and deterministic: the same success condition and observation always
185
+ * give the same outcome. No clock, no randomness, no code from the blueprint.
186
+ */
187
+ export function evaluateValidationSuccess(success, observation) {
188
+ const route = success.route ? routeMatches(success.route, observation.route) : null;
189
+ const data = success.data ? dataHolds(success.data, observation) : null;
190
+ return {
191
+ met: route !== false && data !== false && (route !== null || data !== null),
192
+ route,
193
+ data,
194
+ };
195
+ }
196
+ /** Row counts per entity, to record as the `baseline` when a task starts. */
197
+ export function validationBaseline(rows) {
198
+ return Object.fromEntries(Object.entries(rows).map(([entity, list]) => [entity, list.length]));
199
+ }
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import type { BlueprintEntity, BlueprintField } from './schema.ts';
3
+ /**
4
+ * Create and update input schemas derived from an entity's fields, for the
5
+ * zod step of every entity service (use them with `parseInput` from
6
+ * `@protoyard/core`, which keys errors by field so forms show them inline).
7
+ *
8
+ * Inputs are keyed by row property: relation, persona and file fields use
9
+ * `customerId` / `relatedIds` (see `relationProperty`), everything else its
10
+ * name. Form strings are coerced: numbers, money (`'12.34'` → 1234 cents;
11
+ * plain numbers are already cents), booleans (`'on'`, `'true'`, `'1'`),
12
+ * comma-separated tags, JSON text and geo points (`{ lat, lng }` or `"lat, lng"`,
13
+ * kept to 5 decimals). Avatar fields accept library ids only (`av-001` …
14
+ * `av-175`). Blank optional values become `null` (or the field default on
15
+ * create); blank required values fail with "<Label> is required". Unknown
16
+ * keys are dropped.
17
+ */
18
+ export type EntityInput = Record<string, unknown>;
19
+ export interface EntityInputSchemas {
20
+ /** Required fields must be present and non-blank; absent optional fields get their default or `null`. */
21
+ create: z.ZodType<EntityInput, unknown>;
22
+ /** Partial: only keys present in the input are validated and returned. */
23
+ update: z.ZodType<EntityInput, unknown>;
24
+ }
25
+ /** `dueOn` → `Due on`, unless the field has a `label`. */
26
+ export declare function fieldLabel(field: Pick<BlueprintField, 'name' | 'label'>): string;
27
+ /** Row property that holds a field's value in inputs and rows. */
28
+ export declare function inputProperty(field: BlueprintField): string;
29
+ /**
30
+ * Major-unit money text to integer cents without floating point:
31
+ * `'1,234.5'` → 123450, `'-$3'` → -300. Returns null for anything else.
32
+ */
33
+ export declare function parseMoneyText(text: string): number | null;
34
+ /** Create/update schemas for an entity, built once on first use and cached. */
35
+ export declare function deriveInputSchemas(entity: BlueprintEntity): EntityInputSchemas;
@@ -0,0 +1,312 @@
1
+ import { isAvatarId } from '@protoyard/scenarios';
2
+ import { z } from 'zod';
3
+ import { relationProperty } from "./storage.js";
4
+ const ok = (value) => ({ ok: true, value });
5
+ const fail = (message) => ({ ok: false, message });
6
+ /** `dueOn` → `Due on`, unless the field has a `label`. */
7
+ export function fieldLabel(field) {
8
+ if (field.label)
9
+ return field.label;
10
+ const words = field.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
11
+ return words.charAt(0).toUpperCase() + words.slice(1);
12
+ }
13
+ /** Row property that holds a field's value in inputs and rows. */
14
+ export function inputProperty(field) {
15
+ return relationProperty(field) ?? field.name;
16
+ }
17
+ const isBlank = (value) => value === undefined ||
18
+ value === null ||
19
+ (typeof value === 'string' && value.trim() === '') ||
20
+ (Array.isArray(value) && value.length === 0);
21
+ /**
22
+ * Major-unit money text to integer cents without floating point:
23
+ * `'1,234.5'` → 123450, `'-$3'` → -300. Returns null for anything else.
24
+ */
25
+ export function parseMoneyText(text) {
26
+ // Bound work before parsing. This is deliberately linear and avoids the
27
+ // backtracking behavior of the former grouped-number regular expression.
28
+ if (text.length > 128)
29
+ return null;
30
+ let source = text.trim();
31
+ let sign = 1;
32
+ if (source.startsWith('-')) {
33
+ sign = -1;
34
+ source = source.slice(1).trimStart();
35
+ }
36
+ if (source.length > 0 && !/[\d.,\s]/.test(source[0])) {
37
+ source = source.slice(1).trimStart();
38
+ }
39
+ if (!source)
40
+ return null;
41
+ const parts = source.split('.');
42
+ if (parts.length > 2)
43
+ return null;
44
+ const whole = parts[0];
45
+ const fraction = parts[1] ?? '';
46
+ if (!/^\d+(?:,\d{3})*$/.test(whole) || (parts.length === 2 && !/^\d{1,2}$/.test(fraction))) {
47
+ return null;
48
+ }
49
+ const units = Number(whole.replace(/,/g, ''));
50
+ const cents = units * 100 + Number(fraction.padEnd(2, '0') || '0');
51
+ if (!Number.isSafeInteger(cents))
52
+ return null;
53
+ return sign * cents;
54
+ }
55
+ const PHONE = /^\+?[\d\s().-]{4,32}$/;
56
+ const ID = /^\S{1,128}$/;
57
+ function toNumber(value) {
58
+ if (typeof value === 'number')
59
+ return Number.isFinite(value) ? value : null;
60
+ if (typeof value === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(value.trim())) {
61
+ return Number(value.trim());
62
+ }
63
+ return null;
64
+ }
65
+ function bounds(label, value, min, max, unit = '') {
66
+ if (min !== undefined && value < min)
67
+ return fail(`${label} must be at least ${min}${unit}`);
68
+ if (max !== undefined && value > max)
69
+ return fail(`${label} must be at most ${max}${unit}`);
70
+ return ok(value);
71
+ }
72
+ function ids(label, value, many) {
73
+ const list = many ? value : [value];
74
+ if (!Array.isArray(list) || !list.every((id) => typeof id === 'string' && ID.test(id))) {
75
+ return fail(`Pick ${many ? 'valid' : 'a valid'} ${label.toLowerCase()}`);
76
+ }
77
+ return ok(many ? [...new Set(list)] : list[0]);
78
+ }
79
+ /** Validates and coerces one non-blank value for a field. */
80
+ function checkValue(field, value) {
81
+ const label = fieldLabel(field);
82
+ switch (field.kind) {
83
+ case 'text':
84
+ case 'longText': {
85
+ if (typeof value !== 'string')
86
+ return fail(`${label} must be text`);
87
+ const text = field.kind === 'text' ? value.trim() : value.replace(/\s+$/, '');
88
+ if (field.minLength !== undefined && text.length < field.minLength) {
89
+ return fail(`${label} must have at least ${field.minLength} characters`);
90
+ }
91
+ if (text.length > field.maxLength) {
92
+ return fail(`${label} must have at most ${field.maxLength} characters`);
93
+ }
94
+ return ok(text);
95
+ }
96
+ case 'number':
97
+ case 'integer': {
98
+ const n = toNumber(value);
99
+ if (n === null)
100
+ return fail(`${label} must be a number`);
101
+ if (field.kind === 'integer' && !Number.isInteger(n)) {
102
+ return fail(`${label} must be a whole number`);
103
+ }
104
+ return bounds(label, n, field.min, field.max);
105
+ }
106
+ case 'money': {
107
+ const cents = typeof value === 'number' ? (Number.isSafeInteger(value) ? value : null) : null;
108
+ const parsed = typeof value === 'string' ? parseMoneyText(value) : cents;
109
+ if (parsed === null)
110
+ return fail(`${label} must be an amount like 12.34`);
111
+ const show = (c) => (c / 100).toFixed(2);
112
+ if (field.min !== undefined && parsed < field.min) {
113
+ return fail(`${label} must be at least ${show(field.min)} ${field.currency}`);
114
+ }
115
+ if (field.max !== undefined && parsed > field.max) {
116
+ return fail(`${label} must be at most ${show(field.max)} ${field.currency}`);
117
+ }
118
+ return ok(parsed);
119
+ }
120
+ case 'boolean': {
121
+ if (typeof value === 'boolean')
122
+ return ok(value);
123
+ const text = String(value).trim().toLowerCase();
124
+ if (['true', 'on', '1', 'yes'].includes(text))
125
+ return ok(true);
126
+ if (['false', 'off', '0', 'no'].includes(text))
127
+ return ok(false);
128
+ return fail(`${label} must be yes or no`);
129
+ }
130
+ case 'date': {
131
+ const text = typeof value === 'string' ? value.trim() : '';
132
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.test(text);
133
+ const date = dateOnly
134
+ ? new Date(`${text}T00:00:00.000Z`)
135
+ : /^\d{4}-\d{2}-\d{2}T/.test(text)
136
+ ? new Date(text)
137
+ : null;
138
+ if (!date ||
139
+ Number.isNaN(date.getTime()) ||
140
+ (dateOnly && date.toISOString().slice(0, 10) !== text)) {
141
+ return fail(`${label} must be a date like 2026-03-02`);
142
+ }
143
+ return ok(dateOnly ? text : date.toISOString());
144
+ }
145
+ case 'datetime': {
146
+ const date = value instanceof Date
147
+ ? value
148
+ : typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value.trim())
149
+ ? new Date(value.trim())
150
+ : null;
151
+ if (!date || Number.isNaN(date.getTime())) {
152
+ return fail(`${label} must be a date and time`);
153
+ }
154
+ return ok(date.toISOString());
155
+ }
156
+ case 'enum': {
157
+ const text = typeof value === 'string' ? value.trim() : '';
158
+ if (!field.values.includes(text)) {
159
+ const options = field.values.map((v) => field.labels?.[v] ?? v).join(', ');
160
+ return fail(`${label} must be one of: ${options}`);
161
+ }
162
+ return ok(text);
163
+ }
164
+ case 'email': {
165
+ const text = typeof value === 'string' ? value.trim().toLowerCase() : '';
166
+ return z.email().safeParse(text).success ? ok(text) : fail('Enter a valid email address');
167
+ }
168
+ case 'url': {
169
+ const text = typeof value === 'string' ? value.trim() : '';
170
+ return z.url({ protocol: /^https?$/ }).safeParse(text).success
171
+ ? ok(text)
172
+ : fail('Enter a valid URL starting with https://');
173
+ }
174
+ case 'phone': {
175
+ const text = typeof value === 'string' ? value.trim() : '';
176
+ const digits = text.replace(/\D/g, '').length;
177
+ return PHONE.test(text) && digits >= 4 && digits <= 20
178
+ ? ok(text)
179
+ : fail('Enter a valid phone number');
180
+ }
181
+ case 'tags': {
182
+ const list = typeof value === 'string' ? value.split(',') : value;
183
+ if (!Array.isArray(list) || !list.every((tag) => typeof tag === 'string')) {
184
+ return fail(`${label} must be a list of tags`);
185
+ }
186
+ const tags = [...new Set(list.map((tag) => tag.trim()).filter(Boolean))];
187
+ if (tags.some((tag) => tag.length > 32))
188
+ return fail('Tags must have at most 32 characters');
189
+ if (tags.length > field.maxItems) {
190
+ return fail(`${label} can have at most ${field.maxItems} tags`);
191
+ }
192
+ return ok(tags);
193
+ }
194
+ case 'relation':
195
+ return ids(label, value, field.cardinality === 'many');
196
+ case 'persona':
197
+ return ids(label, value, false);
198
+ case 'file':
199
+ return ids(label, value, field.multiple);
200
+ case 'json': {
201
+ if (typeof value !== 'string')
202
+ return ok(value);
203
+ try {
204
+ return ok(JSON.parse(value));
205
+ }
206
+ catch {
207
+ return fail(`${label} must be valid JSON`);
208
+ }
209
+ }
210
+ case 'geo':
211
+ return checkGeo(label, value);
212
+ case 'avatar': {
213
+ const text = typeof value === 'string' ? value.trim() : '';
214
+ return isAvatarId(text)
215
+ ? ok(text)
216
+ : fail(`${label} must be a library face from av-001 to av-175`);
217
+ }
218
+ }
219
+ }
220
+ const round5 = (n) => Math.round(n * 1e5) / 1e5;
221
+ /** `{ lat, lng }` (numbers or numeric strings) or `"lat, lng"` → `{ lat, lng }` rounded to 5 decimals. */
222
+ function checkGeo(label, value) {
223
+ let lat = null;
224
+ let lng = null;
225
+ if (typeof value === 'string') {
226
+ const parts = value.split(',');
227
+ if (parts.length === 2) {
228
+ lat = toNumber(parts[0]);
229
+ lng = toNumber(parts[1]);
230
+ }
231
+ }
232
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
233
+ lat = toNumber(value.lat);
234
+ lng = toNumber(value.lng);
235
+ }
236
+ if (lat === null || lng === null)
237
+ return fail(`${label} needs a latitude and a longitude`);
238
+ if (lat < -90 || lat > 90)
239
+ return fail(`${label} latitude must be between -90 and 90`);
240
+ if (lng < -180 || lng > 180)
241
+ return fail(`${label} longitude must be between -180 and 180`);
242
+ return ok({ lat: round5(lat), lng: round5(lng) });
243
+ }
244
+ /** A geo input whose latitude and longitude are both empty counts as blank. */
245
+ function geoBlank(value) {
246
+ if (!value || typeof value !== 'object' || Array.isArray(value))
247
+ return false;
248
+ const { lat, lng } = value;
249
+ return isBlank(lat) && isBlank(lng);
250
+ }
251
+ /** Value used on create when an optional field is blank or absent. */
252
+ function blankValue(field) {
253
+ if ('default' in field && field.default !== undefined)
254
+ return field.default;
255
+ if (field.kind === 'boolean')
256
+ return false;
257
+ if (field.kind === 'tags' ||
258
+ (field.kind === 'relation' && field.cardinality === 'many') ||
259
+ (field.kind === 'file' && field.multiple)) {
260
+ return [];
261
+ }
262
+ return null;
263
+ }
264
+ function check(field, input, mode) {
265
+ const value = field.kind === 'geo' && geoBlank(input) ? null : input;
266
+ if (!isBlank(value)) {
267
+ const result = checkValue(field, value);
268
+ if (result.ok && field.required && isBlank(result.value)) {
269
+ return fail(`${fieldLabel(field)} is required`);
270
+ }
271
+ return result;
272
+ }
273
+ // An unchecked checkbox is `false`, never missing.
274
+ if (field.kind === 'boolean')
275
+ return ok(mode === 'create' ? blankValue(field) : false);
276
+ const fallback = blankValue(field);
277
+ const empty = Array.isArray(fallback) ? [] : null;
278
+ if (field.required) {
279
+ if (mode === 'create' && fallback !== null && !Array.isArray(fallback))
280
+ return ok(fallback);
281
+ return fail(`${fieldLabel(field)} is required`);
282
+ }
283
+ return ok(mode === 'create' ? fallback : empty);
284
+ }
285
+ function build(entity, mode) {
286
+ const props = entity.fields.map((field) => [inputProperty(field), field]);
287
+ const shape = Object.fromEntries(props.map(([prop]) => [prop, z.unknown().optional()]));
288
+ return z.object(shape).transform((input, ctx) => {
289
+ const output = {};
290
+ for (const [prop, field] of props) {
291
+ const value = input[prop];
292
+ if (mode === 'update' && value === undefined)
293
+ continue;
294
+ const result = check(field, value, mode);
295
+ if (result.ok)
296
+ output[prop] = result.value;
297
+ else
298
+ ctx.issues.push({ code: 'custom', message: result.message, path: [prop], input: value });
299
+ }
300
+ return output;
301
+ });
302
+ }
303
+ const cache = new WeakMap();
304
+ /** Create/update schemas for an entity, built once on first use and cached. */
305
+ export function deriveInputSchemas(entity) {
306
+ let schemas = cache.get(entity);
307
+ if (!schemas) {
308
+ schemas = { create: build(entity, 'create'), update: build(entity, 'update') };
309
+ cache.set(entity, schemas);
310
+ }
311
+ return schemas;
312
+ }
@@ -0,0 +1,112 @@
1
+ import { type LayoutId } from '@protoyard/core';
2
+ import type { DeckBlueprint, DocBlueprint, PageBlueprint } from './kinds/envelope.ts';
3
+ import { type Blueprint } from './schema.ts';
4
+ /**
5
+ * Deterministic blueprint variants for `protoyard quick --variants` (ADR-031).
6
+ * Each variant changes the shell, not the domain: its layout, theme, spacing
7
+ * density and which entity comes first. Entities, fields, roles and data stay
8
+ * the same, so variants of one idea compare like for like.
9
+ *
10
+ * The theme and layout catalogs come from the caller (the CLI reads them from
11
+ * @protoyard/themes and @protoyard/core), so this module needs no theme code.
12
+ * Randomness is a core seeded RNG over the caller's seed plus the blueprint's
13
+ * canonical JSON: the same input always yields byte-identical variants.
14
+ */
15
+ /** Built-in theme presets a brand can name (`brand.preset`); other theme ids are library themes. */
16
+ export declare const VARIANT_THEME_PRESETS: readonly ["violet", "ocean", "emerald", "rose", "amber", "monochrome"];
17
+ export declare const VARIANT_DENSITIES: readonly ["compact", "comfortable", "spacious"];
18
+ export type VariantDensity = (typeof VARIANT_DENSITIES)[number];
19
+ /** `brand.density` multipliers (the schema allows 0.75–1.25). */
20
+ export declare const VARIANT_DENSITY_SCALE: Readonly<Record<VariantDensity, number>>;
21
+ export declare const VARIANT_MAX_COUNT = 8;
22
+ export interface VariantAxes {
23
+ /** Layout ids to choose from; omitted or empty keeps the blueprint's layout. */
24
+ layouts?: readonly LayoutId[];
25
+ /** Theme ids: a built-in preset (`ocean`) or a library theme id; omitted keeps the brand. */
26
+ themes?: readonly string[];
27
+ /**
28
+ * Densities to choose from (default: all three). Density needs a preset or
29
+ * seed brand: a library theme carries its own, so it is not varied there.
30
+ */
31
+ densities?: readonly VariantDensity[];
32
+ /** Vary which entity comes first in navigation and screens (default true). */
33
+ emphasis?: boolean;
34
+ }
35
+ export interface DeriveVariantsOptions {
36
+ /** 1 to 8. */
37
+ count: number;
38
+ /** Any string; the same seed and blueprint give the same variants. */
39
+ seed?: string;
40
+ axes?: VariantAxes;
41
+ }
42
+ export interface VariantChanges {
43
+ layout?: LayoutId;
44
+ theme?: string;
45
+ density?: VariantDensity;
46
+ /** Entity name moved first. */
47
+ primaryEntity?: string;
48
+ }
49
+ export interface BlueprintVariant {
50
+ /** `v1` … `v8`. */
51
+ id: string;
52
+ /** What changed, for people: `Rail layout · Ocean theme · Compact · Bookings first`. */
53
+ label: string;
54
+ changes: VariantChanges;
55
+ blueprint: Blueprint;
56
+ }
57
+ /**
58
+ * Returns `count` distinct, valid variants of `blueprint`, none identical to
59
+ * the blueprint itself. Throws a RangeError when the axes cannot produce that
60
+ * many, and the schema's error when a catalog id is invalid (for example an
61
+ * unknown layout).
62
+ */
63
+ export declare function deriveVariants(blueprint: Blueprint, options: DeriveVariantsOptions): BlueprintVariant[];
64
+ /** Deck transitions a variant may set (`deck.style.transition`). */
65
+ export declare const VARIANT_DECK_TRANSITIONS: readonly ["none", "fade", "slide", "zoom", "morph"];
66
+ export type VariantDeckTransition = (typeof VARIANT_DECK_TRANSITIONS)[number];
67
+ /**
68
+ * What a content-kind variant may change. A deck varies its own style
69
+ * (`deck.style`: theme, transition, density, font pair); a page or doc has no
70
+ * renderer style yet, so it varies its brand (theme and density), like an
71
+ * app's shell. Slides, sections, blocks and text never change.
72
+ */
73
+ export interface KindVariantAxes {
74
+ /** Theme ids: a built-in preset or a library theme id. */
75
+ themes?: readonly string[];
76
+ /** Deck only (default: every transition). */
77
+ transitions?: readonly VariantDeckTransition[];
78
+ /** Default: all three. */
79
+ densities?: readonly VariantDensity[];
80
+ /** Deck only: font pairing ids (`@protoyard/themes` FONT_CATALOG). */
81
+ fontPairs?: readonly string[];
82
+ }
83
+ export interface KindVariantChanges {
84
+ theme?: string;
85
+ transition?: VariantDeckTransition;
86
+ density?: VariantDensity;
87
+ fontPair?: string;
88
+ }
89
+ export interface KindBlueprintVariant<B extends DeckBlueprint | PageBlueprint | DocBlueprint> {
90
+ /** `v1` … `v8`. */
91
+ id: string;
92
+ /** What changed: `Ocean theme · Fade transition · Compact · Humanist fonts`. */
93
+ label: string;
94
+ changes: KindVariantChanges;
95
+ blueprint: B;
96
+ }
97
+ export interface DeriveKindVariantsOptions {
98
+ /** 1 to 8. */
99
+ count: number;
100
+ /** Any string; the same seed and blueprint give the same variants. */
101
+ seed?: string;
102
+ axes?: KindVariantAxes;
103
+ }
104
+ /**
105
+ * `count` distinct variants of a deck, page or doc, none identical to it:
106
+ * only the style axes change (see `KindVariantAxes`). Deterministic like
107
+ * `deriveVariants`: the same blueprint, seed and axes give byte-identical
108
+ * variants. The caller validates the result (`safeParseAnyBlueprint`) when
109
+ * an axis value may be unknown. Throws a RangeError when the axes cannot
110
+ * produce that many.
111
+ */
112
+ export declare function deriveKindVariants<B extends DeckBlueprint | PageBlueprint | DocBlueprint>(blueprint: B, options: DeriveKindVariantsOptions): KindBlueprintVariant<B>[];