@protoyard/blueprint 0.0.0-stage → 1.1.1
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/LICENSE +21 -0
- package/README.md +1145 -2
- package/dist/aggregate.d.ts +83 -0
- package/dist/aggregate.js +268 -0
- package/dist/app-model.d.ts +76 -0
- package/dist/app-model.js +287 -0
- package/dist/archetypes/index.d.ts +1 -0
- package/dist/archetypes/index.js +1 -0
- package/dist/archetypes/schema.d.ts +126 -0
- package/dist/archetypes/schema.js +77 -0
- package/dist/audit.d.ts +52 -0
- package/dist/audit.js +149 -0
- package/dist/authoring/deck-lint.d.ts +32 -0
- package/dist/authoring/deck-lint.js +567 -0
- package/dist/authoring/index.d.ts +1 -0
- package/dist/authoring/index.js +1 -0
- package/dist/authoring/select-deck-layouts.d.ts +50 -0
- package/dist/authoring/select-deck-layouts.js +213 -0
- package/dist/authoring-apis.d.ts +13 -0
- package/dist/authoring-apis.js +13 -0
- package/dist/authoring-validation.d.ts +65 -0
- package/dist/authoring-validation.js +267 -0
- package/dist/avatar.d.ts +25 -0
- package/dist/avatar.js +30 -0
- package/dist/brand.d.ts +70 -0
- package/dist/brand.js +294 -0
- package/dist/brief/archetypes.d.ts +28 -0
- package/dist/brief/archetypes.js +798 -0
- package/dist/brief/content-synthesize.d.ts +31 -0
- package/dist/brief/content-synthesize.js +210 -0
- package/dist/brief/deck-outlines.d.ts +96 -0
- package/dist/brief/deck-outlines.js +561 -0
- package/dist/brief/draft-bodies.d.ts +29 -0
- package/dist/brief/draft-bodies.js +224 -0
- package/dist/brief/fields.d.ts +19 -0
- package/dist/brief/fields.js +229 -0
- package/dist/brief/index.d.ts +8 -0
- package/dist/brief/index.js +8 -0
- package/dist/brief/kind-detection.d.ts +69 -0
- package/dist/brief/kind-detection.js +353 -0
- package/dist/brief/limits.d.ts +117 -0
- package/dist/brief/limits.js +217 -0
- package/dist/brief/page-doc-outlines.d.ts +80 -0
- package/dist/brief/page-doc-outlines.js +485 -0
- package/dist/brief/synthesize.d.ts +59 -0
- package/dist/brief/synthesize.js +511 -0
- package/dist/brief/text.d.ts +13 -0
- package/dist/brief/text.js +59 -0
- package/dist/canvas-screen.d.ts +31 -0
- package/dist/canvas-screen.js +33 -0
- package/dist/canvas-seed.d.ts +58 -0
- package/dist/canvas-seed.js +82 -0
- package/dist/computed/evaluate.d.ts +11 -0
- package/dist/computed/evaluate.js +101 -0
- package/dist/computed/index.d.ts +4 -0
- package/dist/computed/index.js +3 -0
- package/dist/computed/public.d.ts +14 -0
- package/dist/computed/public.js +19 -0
- package/dist/computed/schema.d.ts +10 -0
- package/dist/computed/schema.js +140 -0
- package/dist/computed/types.d.ts +70 -0
- package/dist/computed/types.js +1 -0
- package/dist/computed/validate.d.ts +6 -0
- package/dist/computed/validate.js +146 -0
- package/dist/csv-guard.d.ts +15 -0
- package/dist/csv-guard.js +29 -0
- package/dist/csv.d.ts +85 -0
- package/dist/csv.js +288 -0
- package/dist/files.d.ts +35 -0
- package/dist/files.js +246 -0
- package/dist/form-schema.d.ts +124 -0
- package/dist/form-schema.js +300 -0
- package/dist/generator-version.d.ts +11 -0
- package/dist/generator-version.js +51 -0
- package/dist/generator.d.ts +34 -0
- package/dist/generator.js +445 -0
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +91 -0
- package/dist/import/csv-shape.d.ts +40 -0
- package/dist/import/csv-shape.js +265 -0
- package/dist/import/json-shape.d.ts +55 -0
- package/dist/import/json-shape.js +491 -0
- package/dist/import/limits.d.ts +13 -0
- package/dist/import/limits.js +14 -0
- package/dist/import/sqlite-schema.d.ts +36 -0
- package/dist/import/sqlite-schema.js +729 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +58 -0
- package/dist/kinds/anchors.d.ts +18 -0
- package/dist/kinds/anchors.js +46 -0
- package/dist/kinds/bodies.d.ts +6 -0
- package/dist/kinds/bodies.js +32 -0
- package/dist/kinds/brand-runtime.d.ts +19 -0
- package/dist/kinds/brand-runtime.js +35 -0
- package/dist/kinds/deck.d.ts +8 -0
- package/dist/kinds/deck.js +34 -0
- package/dist/kinds/doc.d.ts +19 -0
- package/dist/kinds/doc.js +41 -0
- package/dist/kinds/embed/index.d.ts +39 -0
- package/dist/kinds/embed/index.js +91 -0
- package/dist/kinds/envelope.d.ts +159 -0
- package/dist/kinds/envelope.js +78 -0
- package/dist/kinds/index.d.ts +11 -0
- package/dist/kinds/index.js +11 -0
- package/dist/kinds/kind.d.ts +36 -0
- package/dist/kinds/kind.js +55 -0
- package/dist/kinds/limits.d.ts +64 -0
- package/dist/kinds/limits.js +190 -0
- package/dist/kinds/page-doc-runtime.d.ts +37 -0
- package/dist/kinds/page-doc-runtime.js +55 -0
- package/dist/kinds/page.d.ts +16 -0
- package/dist/kinds/page.js +67 -0
- package/dist/kinds/parse.d.ts +18 -0
- package/dist/kinds/parse.js +27 -0
- package/dist/kinds/runtime.d.ts +128 -0
- package/dist/kinds/runtime.js +206 -0
- package/dist/kinds/services.d.ts +49 -0
- package/dist/kinds/services.js +249 -0
- package/dist/kinds/testing.d.ts +9 -0
- package/dist/kinds/testing.js +16 -0
- package/dist/migrations.d.ts +97 -0
- package/dist/migrations.js +351 -0
- package/dist/motion.d.ts +50 -0
- package/dist/motion.js +52 -0
- package/dist/notifications.d.ts +19 -0
- package/dist/notifications.js +177 -0
- package/dist/patch.d.ts +122 -0
- package/dist/patch.js +322 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +105 -0
- package/dist/runtime-definition.d.ts +39 -0
- package/dist/runtime-definition.js +74 -0
- package/dist/scenarios.d.ts +9 -0
- package/dist/scenarios.js +49 -0
- package/dist/schema/blueprint.schema.json +10191 -0
- package/dist/schema-json.d.ts +6 -0
- package/dist/schema-json.js +36 -0
- package/dist/schema.d.ts +6348 -0
- package/dist/schema.js +1269 -0
- package/dist/search.d.ts +16 -0
- package/dist/search.js +96 -0
- package/dist/services.d.ts +89 -0
- package/dist/services.js +898 -0
- package/dist/share.d.ts +78 -0
- package/dist/share.js +234 -0
- package/dist/sheet-screen.d.ts +49 -0
- package/dist/sheet-screen.js +91 -0
- package/dist/storage.d.ts +43 -0
- package/dist/storage.js +179 -0
- package/dist/stream.d.ts +26 -0
- package/dist/stream.js +173 -0
- package/dist/untrusted.d.ts +63 -0
- package/dist/untrusted.js +502 -0
- package/dist/validation-tasks.d.ts +178 -0
- package/dist/validation-tasks.js +199 -0
- package/dist/validation.d.ts +35 -0
- package/dist/validation.js +312 -0
- package/dist/variants.d.ts +112 -0
- package/dist/variants.js +283 -0
- package/dist/workflows/index.d.ts +3 -0
- package/dist/workflows/index.js +3 -0
- package/dist/workflows/schema.d.ts +50 -0
- package/dist/workflows/schema.js +34 -0
- package/dist/workflows/transition.d.ts +15 -0
- package/dist/workflows/transition.js +25 -0
- package/dist/workflows/validate.d.ts +17 -0
- package/dist/workflows/validate.js +140 -0
- package/package.json +120 -3
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { hashString } from '@protoyard/core';
|
|
2
|
+
import { isBlueprintKind, isContentKind, unknownKindMessage } from "../kinds/kind.js";
|
|
3
|
+
import { parseBlueprint, RESERVED_SCREEN_PATHS, } from "../schema.js";
|
|
4
|
+
import { ARCHETYPES, GENERIC_ARCHETYPE, } from "./archetypes.js";
|
|
5
|
+
import { synthesizeContentFromBrief } from "./content-synthesize.js";
|
|
6
|
+
import { avatarFieldFor, fieldFromPhrase, GLOBAL_CUES, PEOPLE_ENTITIES } from "./fields.js";
|
|
7
|
+
import { detectBriefKind } from "./kind-detection.js";
|
|
8
|
+
import { MAX_BRIEF_LENGTH as BRIEF_CAP, boundBriefInput } from "./limits.js";
|
|
9
|
+
import { camel, hasPhrase, normalize, plural, singular, titleCase, words } from "./text.js";
|
|
10
|
+
export function isAppBriefResult(result) {
|
|
11
|
+
return result.kind === 'app';
|
|
12
|
+
}
|
|
13
|
+
export function isContentBriefResult(result) {
|
|
14
|
+
return isContentKind(result.kind);
|
|
15
|
+
}
|
|
16
|
+
/** Longer briefs are cut here; the synthesizer reads keywords, not essays. */
|
|
17
|
+
export const MAX_BRIEF_LENGTH = BRIEF_CAP;
|
|
18
|
+
const MAX_EXTRA_ENTITIES = 4;
|
|
19
|
+
const MAX_CLAUSE_FIELDS = 8;
|
|
20
|
+
const CLAUSE = /\b(?:track|tracks|tracking|manage|manages|managing|log|logs|logging|record|records|organize|organizes|monitor|monitors|catalog|catalogs|list|lists|sell|sells|store|stores|handle|handles|follow|follows|keep|keeps|collect|collects|schedule|schedules|share|shares|review|reviews)\s+([^.;:!?\n]+)/g;
|
|
21
|
+
const FIELD_SPLIT = /\b(?:with|including|having)\b/;
|
|
22
|
+
const NOUN_END = /\b(?:for|from|in|on|by|so|to|across|at|via|using|where|when|who|which|that|while|per|into)\b/;
|
|
23
|
+
const ITEM_SPLIT = /,|\band\b|\bor\b|&|\//;
|
|
24
|
+
/** Nouns that never become entities. */
|
|
25
|
+
const STOP_NOUNS = new Set([
|
|
26
|
+
'track',
|
|
27
|
+
'time',
|
|
28
|
+
'data',
|
|
29
|
+
'thing',
|
|
30
|
+
'stuff',
|
|
31
|
+
'everything',
|
|
32
|
+
'anything',
|
|
33
|
+
'them',
|
|
34
|
+
'it',
|
|
35
|
+
'work',
|
|
36
|
+
'team',
|
|
37
|
+
'progress',
|
|
38
|
+
'status',
|
|
39
|
+
'info',
|
|
40
|
+
'information',
|
|
41
|
+
'detail',
|
|
42
|
+
'money',
|
|
43
|
+
'way',
|
|
44
|
+
'lot',
|
|
45
|
+
'day',
|
|
46
|
+
'week',
|
|
47
|
+
'month',
|
|
48
|
+
'year',
|
|
49
|
+
'one',
|
|
50
|
+
'all',
|
|
51
|
+
'more',
|
|
52
|
+
'app',
|
|
53
|
+
'tool',
|
|
54
|
+
'system',
|
|
55
|
+
'platform',
|
|
56
|
+
'business',
|
|
57
|
+
'company',
|
|
58
|
+
'people',
|
|
59
|
+
'person',
|
|
60
|
+
'user',
|
|
61
|
+
'customer',
|
|
62
|
+
'their',
|
|
63
|
+
'our',
|
|
64
|
+
'your',
|
|
65
|
+
'who',
|
|
66
|
+
'what',
|
|
67
|
+
]);
|
|
68
|
+
const DETERMINERS = new Set([
|
|
69
|
+
'the',
|
|
70
|
+
'a',
|
|
71
|
+
'an',
|
|
72
|
+
'their',
|
|
73
|
+
'our',
|
|
74
|
+
'your',
|
|
75
|
+
'all',
|
|
76
|
+
'each',
|
|
77
|
+
'every',
|
|
78
|
+
'new',
|
|
79
|
+
'of',
|
|
80
|
+
'its',
|
|
81
|
+
'any',
|
|
82
|
+
'some',
|
|
83
|
+
'many',
|
|
84
|
+
]);
|
|
85
|
+
/** Fictional product words for generated names. */
|
|
86
|
+
const NAME_WORDS = [
|
|
87
|
+
'Harbor',
|
|
88
|
+
'Lumen',
|
|
89
|
+
'Cedar',
|
|
90
|
+
'Beacon',
|
|
91
|
+
'Juniper',
|
|
92
|
+
'Copper',
|
|
93
|
+
'Willow',
|
|
94
|
+
'Lantern',
|
|
95
|
+
'Meadow',
|
|
96
|
+
'Summit',
|
|
97
|
+
'Quarry',
|
|
98
|
+
'Tidal',
|
|
99
|
+
'Ember',
|
|
100
|
+
'Orchard',
|
|
101
|
+
'Kestrel',
|
|
102
|
+
'Maple',
|
|
103
|
+
];
|
|
104
|
+
/** Brand seeds already shipped (and AA-checked) by the blueprint catalog. */
|
|
105
|
+
const BRANDS = [
|
|
106
|
+
{ seed: '#0284c7', styleProfile: 'soft', font: 'humanist' },
|
|
107
|
+
{ seed: '#059669', styleProfile: 'soft', font: 'humanist' },
|
|
108
|
+
{ seed: '#0f766e', styleProfile: 'flat', font: 'system' },
|
|
109
|
+
{ seed: '#15803d', styleProfile: 'crisp', font: 'grotesk' },
|
|
110
|
+
{ seed: '#1d4ed8', styleProfile: 'rounded', font: 'humanist' },
|
|
111
|
+
{ seed: '#1e3a8a', styleProfile: 'crisp', font: 'grotesk' },
|
|
112
|
+
{ seed: '#334155', styleProfile: 'flat', font: 'system' },
|
|
113
|
+
{ seed: '#4338ca', styleProfile: 'mono', font: 'mono' },
|
|
114
|
+
{ seed: '#4f46e5', styleProfile: 'crisp', font: 'mono' },
|
|
115
|
+
{ seed: '#6b21a8', styleProfile: 'editorial', font: 'editorial' },
|
|
116
|
+
{ seed: '#7c3aed', styleProfile: 'crisp', font: 'humanist' },
|
|
117
|
+
{ seed: '#b45309', styleProfile: 'flat', font: 'system' },
|
|
118
|
+
{ seed: '#be123c', styleProfile: 'soft', font: 'humanist' },
|
|
119
|
+
{ seed: '#c2410c', styleProfile: 'rounded', font: 'rounded' },
|
|
120
|
+
{ seed: '#db2777', styleProfile: 'rounded', font: 'geometric' },
|
|
121
|
+
{ seed: '#0891b2', styleProfile: 'soft', font: 'system' },
|
|
122
|
+
];
|
|
123
|
+
const MOBILE_CUES = ['mobile', 'phone app', 'ios', 'android', 'on the go', 'smartphone'];
|
|
124
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
125
|
+
function chooseArchetype(normalized, forced) {
|
|
126
|
+
if (forced !== undefined) {
|
|
127
|
+
const found = [...ARCHETYPES, GENERIC_ARCHETYPE].find((a) => a.id === forced);
|
|
128
|
+
if (!found)
|
|
129
|
+
throw new RangeError(`blueprintFromBrief(): unknown archetype "${forced}"`);
|
|
130
|
+
return { archetype: found, matched: [] };
|
|
131
|
+
}
|
|
132
|
+
let best = {
|
|
133
|
+
archetype: GENERIC_ARCHETYPE,
|
|
134
|
+
matched: [],
|
|
135
|
+
score: 0,
|
|
136
|
+
};
|
|
137
|
+
const candidates = [];
|
|
138
|
+
for (const archetype of ARCHETYPES) {
|
|
139
|
+
const matched = archetype.keywords.filter((k) => hasPhrase(normalized, k));
|
|
140
|
+
const specificMatches = (archetype.strongCues ?? []).filter((cue) => hasPhrase(normalized, cue));
|
|
141
|
+
// One stray word ("health", "book") is not enough, unless it names one of
|
|
142
|
+
// the archetype's own entities ("tickets", "shipments").
|
|
143
|
+
const names = archetype.entities.flatMap((e) => [e.name, e.plural]);
|
|
144
|
+
const entityCue = matched.some((cue) => {
|
|
145
|
+
if (!names.includes(cue))
|
|
146
|
+
return false;
|
|
147
|
+
if (archetype.strongCues === undefined)
|
|
148
|
+
return true;
|
|
149
|
+
return !ARCHETYPES.some((other) => other !== archetype &&
|
|
150
|
+
other.entities.some((entity) => entity.name === cue || entity.plural === cue));
|
|
151
|
+
});
|
|
152
|
+
const strong = matched.length >= 2 || entityCue || specificMatches.length > 0;
|
|
153
|
+
const score = matched.length + (specificMatches.length > 0 ? 2 : 0);
|
|
154
|
+
if (strong) {
|
|
155
|
+
const candidate = { archetype, matched, score };
|
|
156
|
+
candidates.push(candidate);
|
|
157
|
+
if (score > best.score)
|
|
158
|
+
best = candidate;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const tied = candidates.filter((candidate) => candidate.score === best.score);
|
|
162
|
+
if (tied.length > 1 && tied.some(({ archetype }) => archetype.strongCues !== undefined)) {
|
|
163
|
+
return { archetype: GENERIC_ARCHETYPE, matched: [] };
|
|
164
|
+
}
|
|
165
|
+
return { archetype: best.archetype, matched: best.matched };
|
|
166
|
+
}
|
|
167
|
+
/** `"track invoices and vendors with amount and due date"` → nouns and field phrases. */
|
|
168
|
+
function clauses(brief) {
|
|
169
|
+
const found = [];
|
|
170
|
+
for (const match of brief.toLowerCase().matchAll(CLAUSE)) {
|
|
171
|
+
let body = (match[1] ?? '').replace(/^(?:track|note)\s+of\s+/, '').replace(/^of\s+/, '');
|
|
172
|
+
const [nounPart = '', fieldPart = ''] = body.split(FIELD_SPLIT, 2);
|
|
173
|
+
body = nounPart.split(NOUN_END)[0] ?? '';
|
|
174
|
+
const nouns = body
|
|
175
|
+
.split(ITEM_SPLIT)
|
|
176
|
+
// "inspections of building sites" → "inspections": the head noun comes before "of".
|
|
177
|
+
.map((item) => words(item.split(/\bof\b/)[0] ?? '').filter((w) => !DETERMINERS.has(w)))
|
|
178
|
+
.filter((ws) => ws.length > 0)
|
|
179
|
+
.map((ws) => singular(ws.at(-1)))
|
|
180
|
+
.filter((noun) => noun.length >= 3 && !STOP_NOUNS.has(noun))
|
|
181
|
+
// Attribute words ("locations", "prices", "deadlines") are fields, not entities.
|
|
182
|
+
.filter((noun) => fieldFromPhrase(noun)?.field.kind === 'text');
|
|
183
|
+
const fields = fieldPart
|
|
184
|
+
.split(NOUN_END)[0]
|
|
185
|
+
?.split(ITEM_SPLIT)
|
|
186
|
+
.map((item) => item.trim())
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.slice(0, MAX_CLAUSE_FIELDS);
|
|
189
|
+
if (nouns.length > 0)
|
|
190
|
+
found.push({ nouns, fields: fields ?? [] });
|
|
191
|
+
}
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
function addField(entity, field) {
|
|
195
|
+
if (entity.fields.some((f) => f.name === field.name))
|
|
196
|
+
return false;
|
|
197
|
+
entity.fields.push(field);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
const kindOf = (field) => field.kind;
|
|
201
|
+
const isEnum = (f) => f.kind === 'enum';
|
|
202
|
+
const isDate = (f) => f.kind === 'date' || f.kind === 'datetime';
|
|
203
|
+
const windowOf = (f) => f.window;
|
|
204
|
+
function boardField(entity) {
|
|
205
|
+
const enums = entity.fields.filter(isEnum);
|
|
206
|
+
return (enums.find((f) => ['status', 'stage', 'state'].includes(f.name))?.name ?? enums[0]?.name ?? null);
|
|
207
|
+
}
|
|
208
|
+
function screensFor(entities, focus) {
|
|
209
|
+
const screens = [];
|
|
210
|
+
const label = (e) => titleCase(e.plural.replace(/-/g, ' '));
|
|
211
|
+
const dashboard = {
|
|
212
|
+
type: 'dashboard',
|
|
213
|
+
path: '/',
|
|
214
|
+
title: 'Overview',
|
|
215
|
+
widgets: entities.slice(0, 3).map((e) => ({ kind: 'kpi', label: label(e), entity: e.name })),
|
|
216
|
+
};
|
|
217
|
+
const money = entities
|
|
218
|
+
.flatMap((e) => e.fields.map((f) => ({ e, f })))
|
|
219
|
+
.find(({ f }) => f.kind === 'money');
|
|
220
|
+
if (money) {
|
|
221
|
+
dashboard.widgets?.push({
|
|
222
|
+
kind: 'kpi',
|
|
223
|
+
label: `Total ${titleCase(money.f.name.replace(/([A-Z])/g, ' $1').toLowerCase())}`.slice(0, 60),
|
|
224
|
+
entity: money.e.name,
|
|
225
|
+
aggregate: 'sum',
|
|
226
|
+
field: money.f.name,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const focusBoard = boardField(focus);
|
|
230
|
+
if (focusBoard) {
|
|
231
|
+
dashboard.widgets?.push({
|
|
232
|
+
kind: 'chart',
|
|
233
|
+
label: `${label(focus)} by ${focusBoard}`.slice(0, 60),
|
|
234
|
+
entity: focus.name,
|
|
235
|
+
chart: 'donut',
|
|
236
|
+
groupBy: focusBoard,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
dashboard.widgets?.push({
|
|
240
|
+
kind: 'chart',
|
|
241
|
+
label: `New ${label(focus).toLowerCase()} per week`.slice(0, 60),
|
|
242
|
+
entity: focus.name,
|
|
243
|
+
chart: 'line',
|
|
244
|
+
bucket: { field: 'createdAt', unit: 'week' },
|
|
245
|
+
span: 2,
|
|
246
|
+
});
|
|
247
|
+
screens.push(dashboard);
|
|
248
|
+
for (const entity of entities) {
|
|
249
|
+
const isFocus = entity === focus;
|
|
250
|
+
screens.push({ type: 'list', entity: entity.name });
|
|
251
|
+
const group = boardField(entity);
|
|
252
|
+
const statusLike = entity.fields.some((f) => isEnum(f) && ['status', 'stage', 'state'].includes(f.name));
|
|
253
|
+
if (group && (isFocus || statusLike)) {
|
|
254
|
+
screens.push({ type: 'board', entity: entity.name, groupBy: group });
|
|
255
|
+
}
|
|
256
|
+
const dates = entity.fields.filter(isDate);
|
|
257
|
+
const start = dates.find((f) => /^(start|begin|depart)/.test(f.name));
|
|
258
|
+
const end = dates.find((f) => f !== start && /^(end|finish|arrive|due)/.test(f.name));
|
|
259
|
+
if (start && end && kindOf(start) === kindOf(end)) {
|
|
260
|
+
screens.push({
|
|
261
|
+
type: 'timeline',
|
|
262
|
+
entity: entity.name,
|
|
263
|
+
startField: start.name,
|
|
264
|
+
endField: end.name,
|
|
265
|
+
...(group ? { groupBy: group } : {}),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
const upcoming = dates.find((f) => windowOf(f) !== undefined && windowOf(f) !== 'past');
|
|
269
|
+
if (upcoming) {
|
|
270
|
+
screens.push({
|
|
271
|
+
type: 'calendar',
|
|
272
|
+
entity: entity.name,
|
|
273
|
+
dateField: upcoming.name,
|
|
274
|
+
...(start && end && upcoming === start && kindOf(start) === kindOf(end)
|
|
275
|
+
? { endField: end.name }
|
|
276
|
+
: {}),
|
|
277
|
+
...(group ? { colorBy: group } : {}),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const geo = entity.fields.find((f) => f.kind === 'geo');
|
|
281
|
+
if (geo) {
|
|
282
|
+
screens.push({
|
|
283
|
+
type: 'map',
|
|
284
|
+
entity: entity.name,
|
|
285
|
+
geoField: geo.name,
|
|
286
|
+
...(group ? { colorBy: group } : {}),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const measured = entities.find((e) => e.fields.some((f) => ['money', 'number', 'integer'].includes(f.kind)));
|
|
291
|
+
if (measured) {
|
|
292
|
+
const numeric = measured.fields.find((f) => ['money', 'number', 'integer'].includes(f.kind));
|
|
293
|
+
screens.push({
|
|
294
|
+
type: 'analytics',
|
|
295
|
+
entity: measured.name,
|
|
296
|
+
dateField: 'createdAt',
|
|
297
|
+
measures: [
|
|
298
|
+
{ label: 'Count' },
|
|
299
|
+
{
|
|
300
|
+
label: `${numeric?.kind === 'money' ? 'Total' : 'Average'} ${numeric?.name}`.slice(0, 60),
|
|
301
|
+
aggregate: numeric?.kind === 'money' ? 'sum' : 'avg',
|
|
302
|
+
field: numeric?.name,
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
dimensions: measured.fields
|
|
306
|
+
.filter((f) => ['enum', 'persona', 'relation', 'boolean'].includes(f.kind))
|
|
307
|
+
.filter((f) => f.kind !== 'relation' || f.cardinality !== 'many')
|
|
308
|
+
.slice(0, 3)
|
|
309
|
+
.map((f) => f.name),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const related = entities.filter((e) => e !== focus &&
|
|
313
|
+
e.fields.some((f) => f.kind === 'relation' && f.to === focus.name));
|
|
314
|
+
screens.push({
|
|
315
|
+
type: 'detail',
|
|
316
|
+
entity: focus.name,
|
|
317
|
+
tabs: ['overview', 'activity', ...related.map((e) => `related:${e.name}`)],
|
|
318
|
+
});
|
|
319
|
+
screens.push({ type: 'settings', sections: ['profile', 'workspace', 'appearance', 'danger'] });
|
|
320
|
+
return screens;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A noun whose list route is reserved by the generated app (`/members` is the
|
|
324
|
+
* built-in members screen) becomes `<noun>-profile`, so a brief that tracks
|
|
325
|
+
* "members" still yields a valid blueprint.
|
|
326
|
+
*/
|
|
327
|
+
function entityNoun(noun) {
|
|
328
|
+
return RESERVED_SCREEN_PATHS.includes(`/${plural(noun)}`)
|
|
329
|
+
? `${noun}-profile`
|
|
330
|
+
: noun;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* App-only synthesis (archetype scoring, entities, screens). Extracted so
|
|
334
|
+
* `blueprintFromBrief` can dispatch content kinds without scoring apps.
|
|
335
|
+
* Exported for tests that need the app path in isolation.
|
|
336
|
+
*/
|
|
337
|
+
export function synthesizeAppFromBrief(brief, options = {}, kindDetection) {
|
|
338
|
+
const bound = boundBriefInput(brief, { maxLength: MAX_BRIEF_LENGTH });
|
|
339
|
+
const source = bound.brief;
|
|
340
|
+
const normalized = normalize(source);
|
|
341
|
+
const rationale = [...bound.notices];
|
|
342
|
+
const detection = kindDetection ?? detectBriefKind(source, options.kind !== undefined ? { kind: 'app' } : {});
|
|
343
|
+
const { archetype, matched } = chooseArchetype(normalized, options.archetype);
|
|
344
|
+
rationale.push(options.archetype !== undefined
|
|
345
|
+
? `Archetype ${archetype.id} (chosen explicitly).`
|
|
346
|
+
: matched.length > 0
|
|
347
|
+
? `Archetype ${archetype.id}: matched ${matched.join(', ')}.`
|
|
348
|
+
: `Archetype ${archetype.id}: no archetype keyword matched, so a plain tracker.`);
|
|
349
|
+
const entities = clone([...archetype.entities]);
|
|
350
|
+
if (archetype.kitArchetype) {
|
|
351
|
+
rationale.push(`Kit archetype ${archetype.kitArchetype.id}: ${Object.entries(archetype.kitArchetype.entities)
|
|
352
|
+
.map(([slot, entity]) => `${slot}=${entity}`)
|
|
353
|
+
.join(', ')}.`);
|
|
354
|
+
}
|
|
355
|
+
let focus = entities[0];
|
|
356
|
+
const byNoun = (noun) => entities.find((e) => e.name === noun || e.plural === plural(noun) || e.name === singular(noun));
|
|
357
|
+
// Nouns the brief asks to track become entities (or extend existing ones).
|
|
358
|
+
let added = 0;
|
|
359
|
+
for (const clause of clauses(source)) {
|
|
360
|
+
let target;
|
|
361
|
+
for (const word of clause.nouns) {
|
|
362
|
+
const noun = entityNoun(word);
|
|
363
|
+
let entity = byNoun(noun);
|
|
364
|
+
if (!entity && archetype === GENERIC_ARCHETYPE && focus.name === 'item' && added === 0) {
|
|
365
|
+
focus.name = noun;
|
|
366
|
+
focus.plural = plural(noun);
|
|
367
|
+
entity = focus;
|
|
368
|
+
rationale.push(`Main entity ${noun} from "${clause.nouns.join(', ')}".`);
|
|
369
|
+
added++;
|
|
370
|
+
}
|
|
371
|
+
else if (!entity && added < MAX_EXTRA_ENTITIES && /^[a-z]{3,30}$/.test(word)) {
|
|
372
|
+
entity = {
|
|
373
|
+
name: noun,
|
|
374
|
+
plural: plural(noun),
|
|
375
|
+
fields: [
|
|
376
|
+
{
|
|
377
|
+
name: 'name',
|
|
378
|
+
kind: 'text',
|
|
379
|
+
required: true,
|
|
380
|
+
fake: 'commerce.productName',
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: camel(focus.name.split('-')),
|
|
384
|
+
kind: 'relation',
|
|
385
|
+
to: focus.name,
|
|
386
|
+
onDelete: 'nullify',
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
titleField: 'name',
|
|
390
|
+
};
|
|
391
|
+
entities.push(entity);
|
|
392
|
+
added++;
|
|
393
|
+
rationale.push(`Entity ${noun} from "${clause.nouns.join(', ')}", linked to ${focus.name}.`);
|
|
394
|
+
}
|
|
395
|
+
if (entity)
|
|
396
|
+
target = entity;
|
|
397
|
+
}
|
|
398
|
+
if (!target)
|
|
399
|
+
continue;
|
|
400
|
+
for (const phrase of clause.fields) {
|
|
401
|
+
const inferred = fieldFromPhrase(phrase);
|
|
402
|
+
if (!inferred)
|
|
403
|
+
continue;
|
|
404
|
+
if (addField(target, inferred.field)) {
|
|
405
|
+
rationale.push(`Field ${target.name}.${inferred.field.name} (${inferred.field.kind}) from "${phrase}"${inferred.cue ? ` (cue "${inferred.cue}")` : ''}.`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Brief-wide cues land on the focus entity when it lacks that kind of field.
|
|
410
|
+
for (const cue of GLOBAL_CUES) {
|
|
411
|
+
const hit = cue.cues.find((c) => hasPhrase(normalized, c));
|
|
412
|
+
const same = (f) => kindOf(f) === cue.kind || (cue.kind === 'date' && isDate(f));
|
|
413
|
+
if (!hit || focus.fields.some(same))
|
|
414
|
+
continue;
|
|
415
|
+
if (addField(focus, clone(cue.field))) {
|
|
416
|
+
rationale.push(`Field ${focus.name}.${cue.field.name} (${cue.kind}) from "${hit}".`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// People-like entities get a face from the avatar library.
|
|
420
|
+
for (const entity of entities) {
|
|
421
|
+
if (!PEOPLE_ENTITIES.has(entity.name) || entity.fields.some((f) => kindOf(f) === 'avatar')) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const avatar = avatarFieldFor(entity.fields.map((f) => f.name));
|
|
425
|
+
if (avatar && addField(entity, avatar)) {
|
|
426
|
+
rationale.push(`Field ${entity.name}.${avatar.name} (avatar): ${entity.name} is a person.`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
focus = entities[0];
|
|
430
|
+
focus.features = { softDelete: true, audit: true, csv: true };
|
|
431
|
+
for (const entity of entities.slice(1))
|
|
432
|
+
entity.features = { csv: true };
|
|
433
|
+
const screens = screensFor(entities, focus);
|
|
434
|
+
rationale.push(`Screens: ${[...new Set(screens.map((s) => s.type))].join(', ')} (from enums, dates, locations and amounts).`);
|
|
435
|
+
const name = (options.name?.trim() ||
|
|
436
|
+
`${NAME_WORDS[hashString(normalized || archetype.id) % NAME_WORDS.length]} ${archetype.label}`).slice(0, 80);
|
|
437
|
+
const brand = BRANDS[hashString(name) % BRANDS.length];
|
|
438
|
+
const sentence = source
|
|
439
|
+
.replace(/\s+/g, ' ')
|
|
440
|
+
.trim()
|
|
441
|
+
.split(/(?<=[.!?])\s/)[0] ?? '';
|
|
442
|
+
const description = (sentence || `${archetype.label} prototype.`).slice(0, 280);
|
|
443
|
+
const layout = MOBILE_CUES.some((c) => hasPhrase(normalized, c))
|
|
444
|
+
? 'mobile-tabs'
|
|
445
|
+
: 'sidebar';
|
|
446
|
+
rationale.push(`Layout ${layout}; brand seed ${brand.seed} (${brand.styleProfile}).`);
|
|
447
|
+
const member = {};
|
|
448
|
+
for (const entity of entities) {
|
|
449
|
+
member[entity.name] = [
|
|
450
|
+
'read',
|
|
451
|
+
'create',
|
|
452
|
+
'update',
|
|
453
|
+
...(entity.ownerField ? ['delete:own'] : []),
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
const json = {
|
|
457
|
+
formatVersion: 1,
|
|
458
|
+
name,
|
|
459
|
+
description,
|
|
460
|
+
tags: [archetype.id],
|
|
461
|
+
brand: {
|
|
462
|
+
name: name.slice(0, 40),
|
|
463
|
+
seed: brand.seed,
|
|
464
|
+
styleProfile: brand.styleProfile,
|
|
465
|
+
font: brand.font,
|
|
466
|
+
},
|
|
467
|
+
entities,
|
|
468
|
+
roles: [
|
|
469
|
+
{ name: 'owner', label: 'Owner', permissions: { '*': ['*'] } },
|
|
470
|
+
{ name: 'admin', label: 'Admin', permissions: { '*': ['*'] } },
|
|
471
|
+
{ name: 'member', label: 'Member', permissions: member },
|
|
472
|
+
{ name: 'viewer', label: 'Viewer', permissions: { '*': ['read'] } },
|
|
473
|
+
],
|
|
474
|
+
screens,
|
|
475
|
+
...(archetype.kitArchetype ? { archetype: clone(archetype.kitArchetype) } : {}),
|
|
476
|
+
};
|
|
477
|
+
return {
|
|
478
|
+
kind: 'app',
|
|
479
|
+
kindDetection: detection,
|
|
480
|
+
blueprint: parseBlueprint(json),
|
|
481
|
+
json,
|
|
482
|
+
archetype: archetype.id,
|
|
483
|
+
layout,
|
|
484
|
+
rationale,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* A blueprint (app or content draft) from a short product brief, plus the
|
|
489
|
+
* reasons for each choice. Same brief and options → same result.
|
|
490
|
+
*
|
|
491
|
+
* 1. Resolve kind (`options.kind` or `detectBriefKind`).
|
|
492
|
+
* 2. Content kinds → kind-specific synthesizers (no app archetype scoring).
|
|
493
|
+
* 3. App → existing archetype / entity / screen path (byte-compatible JSON).
|
|
494
|
+
*/
|
|
495
|
+
export function blueprintFromBrief(brief, options = {}) {
|
|
496
|
+
if (options.kind !== undefined && !isBlueprintKind(options.kind)) {
|
|
497
|
+
throw new RangeError(unknownKindMessage(String(options.kind)));
|
|
498
|
+
}
|
|
499
|
+
// Bound once for kind detection; synthesizers bound again and record notices.
|
|
500
|
+
const source = boundBriefInput(brief, { maxLength: MAX_BRIEF_LENGTH }).brief;
|
|
501
|
+
const explicitKind = options.kind !== undefined ? options.kind : undefined;
|
|
502
|
+
const detection = detectBriefKind(source, explicitKind !== undefined ? { kind: explicitKind } : {});
|
|
503
|
+
if (detection.kind !== 'app') {
|
|
504
|
+
// Never score app archetypes for content kinds (even if `archetype` was passed).
|
|
505
|
+
// Pass the original brief so content synthesizers can attach truncation notices.
|
|
506
|
+
return synthesizeContentFromBrief(detection.kind, brief, detection, {
|
|
507
|
+
name: options.name,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return synthesizeAppFromBrief(brief, options, detection);
|
|
511
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Text helpers for the brief synthesizer: tokens, singular/plural and identifier casing. */
|
|
2
|
+
/** Lowercase words of a brief, letters only (`"Track SLA's!"` → `['track', 'sla', 's']`). */
|
|
3
|
+
export declare function words(text: string): string[];
|
|
4
|
+
/** True when `phrase` (one or more words) occurs in `text` as whole words. */
|
|
5
|
+
export declare function hasPhrase(normalized: string, phrase: string): boolean;
|
|
6
|
+
/** `"Track invoices, fast!"` → `"track invoices fast"`: words joined by single spaces. */
|
|
7
|
+
export declare function normalize(text: string): string;
|
|
8
|
+
export declare function singular(word: string): string;
|
|
9
|
+
export declare function plural(word: string): string;
|
|
10
|
+
/** `['due', 'date']` → `dueDate`. */
|
|
11
|
+
export declare function camel(parts: readonly string[]): string;
|
|
12
|
+
/** `support-ticket` → `Support tickets` style labels are left to the UI; this title-cases words. */
|
|
13
|
+
export declare function titleCase(text: string): string;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Text helpers for the brief synthesizer: tokens, singular/plural and identifier casing. */
|
|
2
|
+
/** Lowercase words of a brief, letters only (`"Track SLA's!"` → `['track', 'sla', 's']`). */
|
|
3
|
+
export function words(text) {
|
|
4
|
+
return text.toLowerCase().match(/[a-z]+/g) ?? [];
|
|
5
|
+
}
|
|
6
|
+
/** True when `phrase` (one or more words) occurs in `text` as whole words. */
|
|
7
|
+
export function hasPhrase(normalized, phrase) {
|
|
8
|
+
return ` ${normalized} `.includes(` ${phrase} `);
|
|
9
|
+
}
|
|
10
|
+
/** `"Track invoices, fast!"` → `"track invoices fast"`: words joined by single spaces. */
|
|
11
|
+
export function normalize(text) {
|
|
12
|
+
return words(text).join(' ');
|
|
13
|
+
}
|
|
14
|
+
const IRREGULAR = {
|
|
15
|
+
people: 'person',
|
|
16
|
+
children: 'child',
|
|
17
|
+
men: 'man',
|
|
18
|
+
women: 'woman',
|
|
19
|
+
data: 'data',
|
|
20
|
+
news: 'news',
|
|
21
|
+
analyses: 'analysis',
|
|
22
|
+
statuses: 'status',
|
|
23
|
+
classes: 'class',
|
|
24
|
+
businesses: 'business',
|
|
25
|
+
addresses: 'address',
|
|
26
|
+
quizzes: 'quiz',
|
|
27
|
+
};
|
|
28
|
+
export function singular(word) {
|
|
29
|
+
if (IRREGULAR[word])
|
|
30
|
+
return IRREGULAR[word];
|
|
31
|
+
if (word.endsWith('ies') && word.length > 4)
|
|
32
|
+
return `${word.slice(0, -3)}y`;
|
|
33
|
+
if (/(ches|shes|xes|sses|zes)$/.test(word))
|
|
34
|
+
return word.slice(0, -2);
|
|
35
|
+
if (word.endsWith('s') && !word.endsWith('ss') && !word.endsWith('us') && word.length > 3) {
|
|
36
|
+
return word.slice(0, -1);
|
|
37
|
+
}
|
|
38
|
+
return word;
|
|
39
|
+
}
|
|
40
|
+
export function plural(word) {
|
|
41
|
+
const back = Object.entries(IRREGULAR).find(([, one]) => one === word);
|
|
42
|
+
if (back && back[0] !== word)
|
|
43
|
+
return back[0];
|
|
44
|
+
if (/[^aeiou]y$/.test(word))
|
|
45
|
+
return `${word.slice(0, -1)}ies`;
|
|
46
|
+
if (/(s|x|z|ch|sh)$/.test(word))
|
|
47
|
+
return `${word}es`;
|
|
48
|
+
return `${word}s`;
|
|
49
|
+
}
|
|
50
|
+
/** `['due', 'date']` → `dueDate`. */
|
|
51
|
+
export function camel(parts) {
|
|
52
|
+
return parts
|
|
53
|
+
.map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
|
|
54
|
+
.join('');
|
|
55
|
+
}
|
|
56
|
+
/** `support-ticket` → `Support tickets` style labels are left to the UI; this title-cases words. */
|
|
57
|
+
export function titleCase(text) {
|
|
58
|
+
return text.replace(/(^|[\s-])([a-z])/g, (_, sep, c) => `${sep}${c.toUpperCase()}`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The `canvas` screen: an infinite canvas (shapes, sticky notes, frames,
|
|
4
|
+
* connectors) stored in one `json` field of an entity, one canvas per
|
|
5
|
+
* record. The screen picks a record; every edit saves the field through the
|
|
6
|
+
* entity's update service. Rendered by `@protoyard/crud/screens/canvas` over
|
|
7
|
+
* `@protoyard/views-ui/canvas`, which validates the stored value as an
|
|
8
|
+
* untrusted Texuli canvas before it paints.
|
|
9
|
+
*
|
|
10
|
+
* Kept in its own module so the screen union in `schema.ts` only names it.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The screen's schema, built from `schema.ts`'s shared screen base and field
|
|
14
|
+
* name rule (passed in, so this module imports nothing from `schema.ts`).
|
|
15
|
+
*/
|
|
16
|
+
export declare function canvasScreenSchema<Base extends z.ZodRawShape>(base: Base, fieldName: z.ZodString): z.ZodObject<Base & {
|
|
17
|
+
type: z.ZodLiteral<"canvas">;
|
|
18
|
+
entity: z.ZodString;
|
|
19
|
+
/** A `json` field holding each record's canvas. */
|
|
20
|
+
field: z.ZodString;
|
|
21
|
+
} extends infer T ? { -readonly [P in keyof T]: T[P]; } : never, z.core.$strict>;
|
|
22
|
+
/** Cross-reference issues for one canvas screen of `entity` (`add(path, message)`). */
|
|
23
|
+
export declare function canvasScreenIssues(entity: {
|
|
24
|
+
name: string;
|
|
25
|
+
fields: readonly {
|
|
26
|
+
name: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
}[];
|
|
29
|
+
}, screen: {
|
|
30
|
+
field: string;
|
|
31
|
+
}, path: readonly (string | number)[], add: (path: (string | number)[], message: string) => void): void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The `canvas` screen: an infinite canvas (shapes, sticky notes, frames,
|
|
4
|
+
* connectors) stored in one `json` field of an entity, one canvas per
|
|
5
|
+
* record. The screen picks a record; every edit saves the field through the
|
|
6
|
+
* entity's update service. Rendered by `@protoyard/crud/screens/canvas` over
|
|
7
|
+
* `@protoyard/views-ui/canvas`, which validates the stored value as an
|
|
8
|
+
* untrusted Texuli canvas before it paints.
|
|
9
|
+
*
|
|
10
|
+
* Kept in its own module so the screen union in `schema.ts` only names it.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The screen's schema, built from `schema.ts`'s shared screen base and field
|
|
14
|
+
* name rule (passed in, so this module imports nothing from `schema.ts`).
|
|
15
|
+
*/
|
|
16
|
+
export function canvasScreenSchema(base, fieldName) {
|
|
17
|
+
return z.strictObject({
|
|
18
|
+
...base,
|
|
19
|
+
type: z.literal('canvas'),
|
|
20
|
+
entity: z.string().min(1),
|
|
21
|
+
/** A `json` field holding each record's canvas. */
|
|
22
|
+
field: fieldName,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Cross-reference issues for one canvas screen of `entity` (`add(path, message)`). */
|
|
26
|
+
export function canvasScreenIssues(entity, screen, path, add) {
|
|
27
|
+
const field = entity.fields.find((candidate) => candidate.name === screen.field);
|
|
28
|
+
if (!field)
|
|
29
|
+
add([...path, 'field'], `unknown field "${screen.field}" on "${entity.name}"`);
|
|
30
|
+
else if (field.kind !== 'json') {
|
|
31
|
+
add([...path, 'field'], `field "${screen.field}" must be a json field of "${entity.name}"`);
|
|
32
|
+
}
|
|
33
|
+
}
|