sbuilder-mcp 0.76.4 → 0.78.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.
@@ -7,7 +7,7 @@
7
7
  * platform's own order — read off `builtinapps.Keys` rather than typed here,
8
8
  * and checked at codegen against that route's own `key` parameter description.
9
9
  */
10
- export const BUILTIN_APP_KEYS = ["mail", "multilingual", "agent", "chat", "booking", "loyalty", "payments", "courses"];
10
+ export const BUILTIN_APP_KEYS = ["mail", "multilingual", "agent", "chat", "booking", "loyalty", "payments", "courses", "shipping", "pos", "tickets"];
11
11
  /**
12
12
  * The pages an app needs that installing it does not create.
13
13
  *
@@ -1,7 +1,7 @@
1
1
  export const DEAD_KEY_SOURCE = {
2
- "files": 4599,
3
- "identifiers": 85994,
4
- "seededKeys": 442,
2
+ "files": 5543,
3
+ "identifiers": 102115,
4
+ "seededKeys": 443,
5
5
  "dead": 0
6
6
  };
7
7
  /**
@@ -1,4 +1,7 @@
1
- import { ELEMENTS, TRAIT_WRITES } from './elements.generated.js';
1
+ import { ELEMENTS, TRAIT_WRITES, WRITE_PRECONDITIONS } from './elements.generated.js';
2
+ import { FORM_TEMPLATES } from './checkout.generated.js';
3
+ import { isBaseOnlyConfig } from '../domains/site/baseonly.js';
4
+ import { bindableFields } from '../domains/site/writecheck.js';
2
5
  import { animationValues, animationVocabulary, elementVocabularies, sharedVocabularies, vocabulariesForWrites, } from '../domains/site/vocabulary.js';
3
6
  import { neverTranslatedOn, translatableSpecials } from '../domains/site/translate.js';
4
7
  /** Eight to choose from; the hints for the chosen one come with sb_traits_for. */
@@ -48,6 +51,94 @@ export function catalogBrowse() {
48
51
  export function largestCategorySize() {
49
52
  return Math.max(...Object.values(catalogBrowse()).map((g) => g.length));
50
53
  }
54
+ /**
55
+ * Lowercase, diacritics off, camelCase and snake_case split — the unit a word
56
+ * match compares. `đ` is a LETTER, not a base plus a combining mark, so NFD
57
+ * leaves it whole and "đặt" used to split into nothing; it folds to `d` first.
58
+ */
59
+ export function searchWords(s) {
60
+ return s
61
+ .replace(/đ/g, 'd')
62
+ .replace(/Đ/g, 'D')
63
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
64
+ .normalize('NFD')
65
+ .replace(/[\u0300-\u036f]/g, '')
66
+ .toLowerCase()
67
+ .split(/[^a-z0-9]+/)
68
+ .filter(Boolean);
69
+ }
70
+ /** Two spellings of one name compare equal: "Product list", "product-list", "PRÓDUCT LÍST". */
71
+ const slug = (s) => searchWords(s).join('-');
72
+ /**
73
+ * CAPABILITY is the element's own generated config/specials keys and the
74
+ * vocabulary VALUES they take — so "pagination", "load_more" and "sort" find
75
+ * the element that does them from the shared metadata, with no alias table to
76
+ * drift. The weakest word signal, because a key name is not a purpose.
77
+ */
78
+ let index = null;
79
+ function searchIndex() {
80
+ return (index ??= Object.values(ELEMENTS).map((el) => {
81
+ const cap = [
82
+ ...Object.keys(el.defaults?.config ?? {}),
83
+ ...Object.keys(el.defaults?.specials ?? {}),
84
+ ...Object.values(elementVocabularies(el.type)).flatMap((v) => [v.writeKey, ...v.values]),
85
+ ];
86
+ const hints = [...el.semantics, ...el.useWhen];
87
+ // The element's own VIETNAMESE default copy ("Giờ hẹn", "Chọn một mục") —
88
+ // the only words a Vietnamese query can meet, since every hint is English.
89
+ // Non-ASCII strings only: an ASCII default is a value ("native", "future"), not copy.
90
+ const copy = Object.values(el.defaults?.specials ?? {}).filter((v) => typeof v === 'string' && /[^\x00-\x7f]/.test(v));
91
+ return {
92
+ el,
93
+ ix: {
94
+ type: el.type,
95
+ label: slug(el.label),
96
+ typeWords: new Set(searchWords(el.type)),
97
+ labelWords: new Set(searchWords(el.label)),
98
+ hintWords: new Set(searchWords(hints.join(' '))),
99
+ descWords: new Set(searchWords(`${el.category} ${el.description}`)),
100
+ capWords: new Set(searchWords(cap.join(' '))),
101
+ copyWords: new Set(searchWords(copy.join(' '))),
102
+ hay: searchWords([el.type, el.label, el.category, el.description, ...hints].join(' ')).join(' '),
103
+ },
104
+ };
105
+ }));
106
+ }
107
+ /** A whole word, or its plural — "image" finds "images" without a stemmer. */
108
+ const has = (set, t) => set.has(t) || set.has(`${t}s`);
109
+ /**
110
+ * Tiered, so a stronger signal is never outvoted by a pile of weaker ones:
111
+ * exact type > exact label > the type's own words > the label's > semantics and
112
+ * useWhen > description > capability > bare substring. Measured before this:
113
+ * "list" returned address-book first and not `list` at all, because a flat
114
+ * substring count over prose ranks whatever mentions a word most.
115
+ */
116
+ function score(ix, q, terms) {
117
+ let s = 0;
118
+ if (ix.type === q)
119
+ s += 1e6;
120
+ if (ix.label === q)
121
+ s += 1e5;
122
+ if (ix.type.startsWith(q))
123
+ s += 1e4;
124
+ for (const t of terms) {
125
+ if (has(ix.typeWords, t))
126
+ s += 1000;
127
+ if (has(ix.labelWords, t))
128
+ s += 300;
129
+ if (has(ix.hintWords, t))
130
+ s += 30;
131
+ if (has(ix.descWords, t))
132
+ s += 10;
133
+ if (ix.copyWords.has(t))
134
+ s += 5;
135
+ if (has(ix.capWords, t))
136
+ s += 3;
137
+ if (ix.hay.includes(t))
138
+ s += 1;
139
+ }
140
+ return s;
141
+ }
51
142
  /**
52
143
  * Four fields to CHOOSE by.
53
144
  *
@@ -57,14 +148,10 @@ export function largestCategorySize() {
57
148
  * `traitsFor` now carries. `detail: true` restores the hints per match.
58
149
  */
59
150
  export function catalogMatches(query, opts = {}) {
60
- const terms = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
61
- return Object.values(ELEMENTS)
62
- .map((el) => {
63
- const hay = [el.type, el.label, el.category, el.description, ...el.semantics, ...el.useWhen]
64
- .join(' ')
65
- .toLowerCase();
66
- return { el, score: terms.filter((t) => hay.includes(t)).length };
67
- })
151
+ const terms = searchWords(query);
152
+ const q = terms.join('-');
153
+ return searchIndex()
154
+ .map(({ el, ix }) => ({ el, score: score(ix, q, terms) }))
68
155
  .filter((s) => s.score > 0)
69
156
  .sort((a, b) => b.score - a.score || a.el.type.localeCompare(b.el.type))
70
157
  .slice(0, opts.limit ?? DEFAULT_CATALOG_LIMIT)
@@ -80,10 +167,66 @@ export function catalogMatches(query, opts = {}) {
80
167
  : {}),
81
168
  }));
82
169
  }
170
+ let templateIndex = null;
171
+ /**
172
+ * THE PLATFORM'S FORM TEMPLATES, found the way an agent asks for them: "đặt
173
+ * lịch", "ngày nhận phòng". A booking form is not an element — it is a form
174
+ * document `sb_store action:"form"` seeds — so an element search alone sends
175
+ * the caller to hand-assemble one field at a time. Indexed on the key, the
176
+ * title, the submit text and every field label.
177
+ *
178
+ * Strict on purpose, because each one costs the caller tokens: a single word
179
+ * must BE the key ("booking"), and several words must ALL be in one template.
180
+ * "email" is on half of them and asks for none.
181
+ */
182
+ export function formTemplateMatches(query, limit = 3) {
183
+ templateIndex ??= Object.values(FORM_TEMPLATES).map((t) => {
184
+ const nodes = Object.values(t.document.nodes);
185
+ const str = (v) => (typeof v === 'string' ? v : '');
186
+ const title = str(nodes.find((n) => n.data.type === 'form-title')?.specials?.text);
187
+ const copy = nodes.flatMap((n) => [str(n.specials?.label), str(n.specials?.text)]);
188
+ return {
189
+ key: t.key,
190
+ type: t.type,
191
+ title,
192
+ titleWords: new Set(searchWords(title)),
193
+ all: new Set(searchWords([t.key, ...copy].join(' '))),
194
+ };
195
+ });
196
+ const terms = searchWords(query);
197
+ if (terms.length === 0)
198
+ return [];
199
+ return templateIndex
200
+ .filter((t) => (terms.length === 1 ? t.key === terms[0] : terms.every((w) => t.all.has(w))))
201
+ .map((t) => ({ t, score: terms.filter((w) => t.titleWords.has(w)).length }))
202
+ .sort((a, b) => b.score - a.score || a.t.key.localeCompare(b.t.key))
203
+ .slice(0, limit)
204
+ .map(({ t }) => ({
205
+ template: t.key,
206
+ form_type: t.type,
207
+ title: t.title,
208
+ use: `sb_store action:"form" template:"${t.key}"`,
209
+ }));
210
+ }
211
+ /**
212
+ * Elements and templates in ONE ranking. A template whose TITLE holds every
213
+ * word of the query ("đặt lịch" → "Đặt lịch hẹn") is the answer the caller
214
+ * meant and goes first — unless an element's type IS the query, which always
215
+ * wins. Otherwise templates trail the elements, as before.
216
+ */
217
+ export function searchWithTemplates(query, elements) {
218
+ const templates = formTemplateMatches(query);
219
+ const terms = searchWords(query);
220
+ const exactType = elements[0]?.type === terms.join('-');
221
+ const strong = templates.filter((t) => terms.every((w) => searchWords(`${t.template} ${t.title}`).includes(w)));
222
+ if (exactType || strong.length === 0)
223
+ return [...elements, ...templates];
224
+ return [...strong, ...elements, ...templates.filter((t) => !strong.includes(t))];
225
+ }
83
226
  /**
84
227
  * What one inspector control writes.
85
228
  *
86
- * 118 of the 435 controls declare it in the platform's trait registry. The rest
229
+ * 165 of the 577 controls declare it in the platform's trait registry. The rest
87
230
  * live inside a Vue widget's prop closure, which is not machine-readable — so
88
231
  * they come back named but undescribed, with the honest reason. Saying nothing
89
232
  * would read as "this control writes nothing".
@@ -125,10 +268,23 @@ export function traitsFor(type, control) {
125
268
  }
126
269
  return { type, control, ...describeControl(control) };
127
270
  }
271
+ // Read from base only whatever breakpoint is asked for — sb_set routes them
272
+ // there, so a "tablet-only" value of one of these is the desktop value too.
273
+ const baseOnly = [
274
+ ...new Set([
275
+ ...Object.keys(el.defaults?.config ?? {}),
276
+ ...el.controls.flatMap((c) => (TRAIT_WRITES[c]?.writes ?? []).filter((w) => w.target === 'config').map((w) => w.writeKey)),
277
+ ]),
278
+ ].filter((k) => isBaseOnlyConfig(type, k));
128
279
  const declared = {};
129
- for (const c of el.controls)
130
- if (TRAIT_WRITES[c])
131
- declared[c] = describeControl(c);
280
+ for (const c of el.controls) {
281
+ if (!TRAIT_WRITES[c])
282
+ continue;
283
+ const base = TRAIT_WRITES[c].writes.some((w) => w.target === 'config' && isBaseOnlyConfig(type, w.writeKey));
284
+ declared[c] = { ...describeControl(c), ...(base ? { responsive: false } : {}) };
285
+ }
286
+ const preconditions = WRITE_PRECONDITIONS.filter((p) => p.types.includes(type)).map(({ types: _, ...p }) => p);
287
+ const bindable = bindableFields(type);
132
288
  return {
133
289
  type: el.type,
134
290
  hints: { useWhen: el.useWhen, avoidWhen: el.avoidWhen, contentTips: el.contentTips },
@@ -137,6 +293,15 @@ export function traitsFor(type, control) {
137
293
  groups: t.groups.map((g) => ({ group: g.label, controls: g.controls })),
138
294
  })),
139
295
  declared,
296
+ ...(baseOnly.length ? { base_only: baseOnly } : {}),
297
+ // Legal apart, meaningless together — the platform's own declaration.
298
+ ...(preconditions.length ? { preconditions } : {}),
299
+ // The click actions per trigger; binding_events replaces them once the node
300
+ // carries a purchase binding (add_to_cart is the BINDING, never an event).
301
+ ...(el.events ? { events: el.events } : {}),
302
+ ...(el.bindingEvents ? { binding_events: el.bindingEvents } : {}),
303
+ // The specials a record binding may target on this element.
304
+ ...(bindable.length ? { bindable } : {}),
140
305
  // The keys this element actually seeds. For `config` and `specials` —
141
306
  // which, unlike `style`, are NOT open — this is the machine-readable
142
307
  // answer to "what does this element store", and often the only one.