@colixsystems/widget-sdk 0.102.0 → 0.103.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.
- package/README.md +10 -2
- package/dist/contract.cjs +31 -4
- package/dist/contract.js +31 -4
- package/dist/host.d.ts +11 -0
- package/dist/host.js +1 -0
- package/dist/theme-components.cjs +101 -13
- package/dist/theme-components.js +100 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
21
21
|
| **CORE** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
|
|
22
22
|
| **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
|
|
23
23
|
| **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
|
|
24
|
-
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
|
|
24
|
+
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope. `goTo(pageIdOrSlug, params?)` accepts a page UUID (a `pageRef` prop) **or its slug** (a row's page-key field) — both hosts resolve either (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
|
|
25
25
|
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
|
|
26
26
|
| **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
|
|
27
27
|
| **CORE** | `useWidgetRoute(initial)` | `[state, setState]` | `ctx.widgetRoute` — no scope. Where YOUR WIDGET is, persisted by the host so it survives a reload and travels in a shared link (the `w_<instanceId>` query key on web, the screen's route params natively): the folder a browser has opened, a wizard step, a selected tab, a list's sort and search. `useState` semantics over an object — writes MERGE, `null` clears a key back to its `initial`, and `initial` is read once. Values are scalars or flat arrays of scalars, size- and length-capped; anything else is not stored. NOT history (Back still leaves the page, on both platforms). Degrades to component state on the Studio canvas. |
|
|
@@ -68,7 +68,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
68
68
|
|
|
69
69
|
## Status
|
|
70
70
|
|
|
71
|
-
`v0.
|
|
71
|
+
`v0.103.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
72
|
+
|
|
73
|
+
### What's new in 0.103.0 (contract 1.78.0)
|
|
74
|
+
|
|
75
|
+
**An app-wide style value may have SHAPE — `widgetStyles` is no longer scalars-only.** Your `styleSchema` is offered in two places: the widget editor (per placed instance) and Theme Settings (app-wide, under your widget's own name). A field holding a structured value — an overlay object, a list of ids — persisted in the first and was silently dropped by the second, so the same edit behaved two ways depending on where the author made it.
|
|
76
|
+
|
|
77
|
+
`normaliseWidgetStyles` now carries objects and arrays, bounded by `CONTRACT.themeWidgetStyles`: `maxValueDepth` (3), `maxValueEntries` (24 per level), `maxValueBytes` (512 per field), and a new `maxBytes` (64000) over the whole map — a ceiling the per-scalar limits never stated, so the worst-case payload of that unauthenticated cold-start read is now *smaller* than before. `__proto__`-style keys are refused at every level.
|
|
78
|
+
|
|
79
|
+
Host-integration surface only — nothing a widget imports changed, and your widget still reads `props.style` without learning which layer supplied a value. `CONTRACT.version` → `1.78.0`. Additive: every value accepted before is accepted now.
|
|
72
80
|
|
|
73
81
|
### What's new in 0.95.0 (contract 1.68.0)
|
|
74
82
|
|
package/dist/contract.cjs
CHANGED
|
@@ -158,6 +158,15 @@ const THEME_WIDGET_STYLES = Object.freeze({
|
|
|
158
158
|
maxWidgets: 200,
|
|
159
159
|
// Matches the styleSchema field cap the widget agent is held to.
|
|
160
160
|
maxFieldsPerWidget: 12,
|
|
161
|
+
// sc-5646: a styleSchema field may hold a STRUCTURED value (a container's
|
|
162
|
+
// overlay, a list of ids), so the ceiling is stated in bytes rather than
|
|
163
|
+
// assumed to be one short scalar.
|
|
164
|
+
maxValueBytes: 512,
|
|
165
|
+
// The whole map, because it rides that unauthenticated cold-start read.
|
|
166
|
+
maxBytes: 64000,
|
|
167
|
+
// Nesting/width a structured value may reach before it is dropped.
|
|
168
|
+
maxValueDepth: 3,
|
|
169
|
+
maxValueEntries: 24,
|
|
161
170
|
});
|
|
162
171
|
// REQ-NAV-STRUCTURE: the SHAPE an app's navigation takes. One catalogue, four
|
|
163
172
|
// consumers -- the Studio's Navigation page, Mason's set_theme coercion, the web
|
|
@@ -655,11 +664,12 @@ const HOOKS = [
|
|
|
655
664
|
name: "useNavigation",
|
|
656
665
|
signature: "useNavigation()",
|
|
657
666
|
returnShape: {
|
|
658
|
-
goTo:
|
|
667
|
+
goTo:
|
|
668
|
+
"(pageIdOrSlug: string, params?: object) => void — a page's UUID (a pageRef prop) OR its SLUG (e.g. a row's page-key field); both hosts resolve either. There is NO page-name lookup: a slug is the only data-carried navigation key.",
|
|
659
669
|
openLink: "(link: string) => boolean",
|
|
660
670
|
goBack: "() => void",
|
|
661
|
-
push: "(
|
|
662
|
-
replace: "(
|
|
671
|
+
push: "(pageIdOrSlug: string, params?: object) => void",
|
|
672
|
+
replace: "(pageIdOrSlug: string, params?: object) => void",
|
|
663
673
|
back: "() => void",
|
|
664
674
|
currentRoute: "{ pageId: string, params: object }",
|
|
665
675
|
},
|
|
@@ -3259,7 +3269,24 @@ const CONTRACT = deepFreeze({
|
|
|
3259
3269
|
// are compressed to WebP by default; `compress: false` stores the file
|
|
3260
3270
|
// byte-for-byte. Existing callers are unaffected — the field is only sent
|
|
3261
3271
|
// when the opt-out is chosen.
|
|
3262
|
-
|
|
3272
|
+
// 1.77.0: documentation (sc-5713) — `goTo`/`push`/`replace` document that
|
|
3273
|
+
// they accept a page SLUG as well as a page UUID. Both hosts have always
|
|
3274
|
+
// resolved either (the web route reads id-or-slug; the export's
|
|
3275
|
+
// PAGE_ROUTES maps both to the screen name), but the contract said
|
|
3276
|
+
// `pageId` only, so data-driven widgets — whose rows carry slugs —
|
|
3277
|
+
// invented params patterns that navigate nowhere. No code changed shape.
|
|
3278
|
+
// 1.78.0: additive (sc-5646) — `widgetStyles` carries every value shape a
|
|
3279
|
+
// `styleSchema` field can produce, not just scalars and gradients. The
|
|
3280
|
+
// Studio offers the SAME field in the widget editor and in Theme
|
|
3281
|
+
// Settings, so a structured value (a container-style overlay, a list of
|
|
3282
|
+
// ids) that persisted per instance but was dropped app-wide made one
|
|
3283
|
+
// edit behave two ways. Objects and arrays are now carried, bounded by
|
|
3284
|
+
// `themeWidgetStyles.maxValueDepth` / `maxValueEntries` /
|
|
3285
|
+
// `maxValueBytes`, with `maxBytes` capping the whole map — a ceiling the
|
|
3286
|
+
// per-scalar limits never stated, so the worst-case unauthenticated
|
|
3287
|
+
// payload is SMALLER than before. `__proto__`-style keys are refused at
|
|
3288
|
+
// every level. Additive: every value accepted before is accepted now.
|
|
3289
|
+
version: "1.78.0",
|
|
3263
3290
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3264
3291
|
hooks: HOOKS,
|
|
3265
3292
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -158,6 +158,15 @@ const THEME_WIDGET_STYLES = Object.freeze({
|
|
|
158
158
|
maxWidgets: 200,
|
|
159
159
|
// Matches the styleSchema field cap the widget agent is held to.
|
|
160
160
|
maxFieldsPerWidget: 12,
|
|
161
|
+
// sc-5646: a styleSchema field may hold a STRUCTURED value (a container's
|
|
162
|
+
// overlay, a list of ids), so the ceiling is stated in bytes rather than
|
|
163
|
+
// assumed to be one short scalar.
|
|
164
|
+
maxValueBytes: 512,
|
|
165
|
+
// The whole map, because it rides that unauthenticated cold-start read.
|
|
166
|
+
maxBytes: 64000,
|
|
167
|
+
// Nesting/width a structured value may reach before it is dropped.
|
|
168
|
+
maxValueDepth: 3,
|
|
169
|
+
maxValueEntries: 24,
|
|
161
170
|
});
|
|
162
171
|
// REQ-NAV-STRUCTURE: the SHAPE an app's navigation takes. One catalogue, four
|
|
163
172
|
// consumers -- the Studio's Navigation page, Mason's set_theme coercion, the web
|
|
@@ -655,11 +664,12 @@ const HOOKS = [
|
|
|
655
664
|
name: "useNavigation",
|
|
656
665
|
signature: "useNavigation()",
|
|
657
666
|
returnShape: {
|
|
658
|
-
goTo:
|
|
667
|
+
goTo:
|
|
668
|
+
"(pageIdOrSlug: string, params?: object) => void — a page's UUID (a pageRef prop) OR its SLUG (e.g. a row's page-key field); both hosts resolve either. There is NO page-name lookup: a slug is the only data-carried navigation key.",
|
|
659
669
|
openLink: "(link: string) => boolean",
|
|
660
670
|
goBack: "() => void",
|
|
661
|
-
push: "(
|
|
662
|
-
replace: "(
|
|
671
|
+
push: "(pageIdOrSlug: string, params?: object) => void",
|
|
672
|
+
replace: "(pageIdOrSlug: string, params?: object) => void",
|
|
663
673
|
back: "() => void",
|
|
664
674
|
currentRoute: "{ pageId: string, params: object }",
|
|
665
675
|
},
|
|
@@ -3259,7 +3269,24 @@ const CONTRACT = deepFreeze({
|
|
|
3259
3269
|
// are compressed to WebP by default; `compress: false` stores the file
|
|
3260
3270
|
// byte-for-byte. Existing callers are unaffected — the field is only sent
|
|
3261
3271
|
// when the opt-out is chosen.
|
|
3262
|
-
|
|
3272
|
+
// 1.77.0: documentation (sc-5713) — `goTo`/`push`/`replace` document that
|
|
3273
|
+
// they accept a page SLUG as well as a page UUID. Both hosts have always
|
|
3274
|
+
// resolved either (the web route reads id-or-slug; the export's
|
|
3275
|
+
// PAGE_ROUTES maps both to the screen name), but the contract said
|
|
3276
|
+
// `pageId` only, so data-driven widgets — whose rows carry slugs —
|
|
3277
|
+
// invented params patterns that navigate nowhere. No code changed shape.
|
|
3278
|
+
// 1.78.0: additive (sc-5646) — `widgetStyles` carries every value shape a
|
|
3279
|
+
// `styleSchema` field can produce, not just scalars and gradients. The
|
|
3280
|
+
// Studio offers the SAME field in the widget editor and in Theme
|
|
3281
|
+
// Settings, so a structured value (a container-style overlay, a list of
|
|
3282
|
+
// ids) that persisted per instance but was dropped app-wide made one
|
|
3283
|
+
// edit behave two ways. Objects and arrays are now carried, bounded by
|
|
3284
|
+
// `themeWidgetStyles.maxValueDepth` / `maxValueEntries` /
|
|
3285
|
+
// `maxValueBytes`, with `maxBytes` capping the whole map — a ceiling the
|
|
3286
|
+
// per-scalar limits never stated, so the worst-case unauthenticated
|
|
3287
|
+
// payload is SMALLER than before. `__proto__`-style keys are refused at
|
|
3288
|
+
// every level. Additive: every value accepted before is accepted now.
|
|
3289
|
+
version: "1.78.0",
|
|
3263
3290
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3264
3291
|
hooks: HOOKS,
|
|
3265
3292
|
primitives: PRIMITIVES,
|
package/dist/host.d.ts
CHANGED
|
@@ -45,6 +45,17 @@ export function normaliseThemeComponents(raw: unknown): ThemeComponents;
|
|
|
45
45
|
* contract, so the authoritative field type is the widget's own styleSchema.
|
|
46
46
|
* Bounded by `CONTRACT.themeWidgetStyles`.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* sc-5646 host helper: validates ONE widget style object — a placed node's
|
|
50
|
+
* `props.style` or an app-wide `widgetStyles[id]` entry. Both scopes hold the
|
|
51
|
+
* same vocabulary, so both are validated the same way; `maxFields` applies the
|
|
52
|
+
* per-widget cap only the app-wide map needs.
|
|
53
|
+
*/
|
|
54
|
+
export function normaliseWidgetStyleFields(
|
|
55
|
+
raw: unknown,
|
|
56
|
+
options?: { maxFields?: number },
|
|
57
|
+
): Record<string, unknown>;
|
|
58
|
+
|
|
48
59
|
export function normaliseWidgetStyles(raw: unknown): ThemeWidgetStyles;
|
|
49
60
|
|
|
50
61
|
/**
|
package/dist/host.js
CHANGED
|
@@ -18,6 +18,7 @@ export { resolveProps } from "./property-schema.js";
|
|
|
18
18
|
// hosts, so the Player and the Expo export cannot diverge.
|
|
19
19
|
export {
|
|
20
20
|
normaliseThemeComponents,
|
|
21
|
+
normaliseWidgetStyleFields,
|
|
21
22
|
normaliseWidgetStyles,
|
|
22
23
|
applyThemeComponentStyle,
|
|
23
24
|
} from "./theme-components.js";
|
|
@@ -99,12 +99,30 @@ function normaliseThemeComponents(raw) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
// Values are assigned onto plain object literals, so `__proto__` would set a
|
|
103
|
+
// PROTOTYPE rather than a style field. Refused at every level.
|
|
104
|
+
const UNSAFE_STYLE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
105
|
+
|
|
106
|
+
function isUsableStyleKey(key) {
|
|
107
|
+
return (
|
|
108
|
+
typeof key === "string" &&
|
|
109
|
+
key.length > 0 &&
|
|
110
|
+
key.length <= 64 &&
|
|
111
|
+
!UNSAFE_STYLE_KEYS.has(key)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
// REQ-THEME-ELEMENT: one value out of the per-widget map. Unlike a component
|
|
103
116
|
// token, there is no declared `type` to coerce against -- the key space is the
|
|
104
117
|
// workspace's widget catalog, not the contract -- so validation here is
|
|
105
118
|
// STRUCTURAL. The authoritative type is the widget's own styleSchema, which the
|
|
106
119
|
// Studio honours by only ever offering fields that widget declares.
|
|
107
|
-
|
|
120
|
+
//
|
|
121
|
+
// sc-5646: that same field is offered per instance AND app-wide, so carrying
|
|
122
|
+
// only scalars here made one edit behave two ways. A structured value rides
|
|
123
|
+
// along too -- bounded by depth, width and bytes, never by trust.
|
|
124
|
+
function coerceWidgetStyleValue(value, depth = 0) {
|
|
125
|
+
const { maxValueDepth, maxValueEntries } = CONTRACT.themeWidgetStyles;
|
|
108
126
|
if (typeof value === "string") {
|
|
109
127
|
const trimmed = value.trim();
|
|
110
128
|
// Long enough for a hex, an enum value or a font name; short enough that a
|
|
@@ -113,10 +131,75 @@ function coerceWidgetStyleValue(value) {
|
|
|
113
131
|
}
|
|
114
132
|
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
115
133
|
if (typeof value === "boolean") return value;
|
|
116
|
-
if (
|
|
134
|
+
if (depth >= maxValueDepth) return undefined;
|
|
135
|
+
if (Array.isArray(value)) {
|
|
136
|
+
const items = [];
|
|
137
|
+
for (const item of value) {
|
|
138
|
+
if (items.length >= maxValueEntries) break;
|
|
139
|
+
const coerced = coerceWidgetStyleValue(item, depth + 1);
|
|
140
|
+
if (coerced !== undefined) items.push(coerced);
|
|
141
|
+
}
|
|
142
|
+
return items.length > 0 ? items : undefined;
|
|
143
|
+
}
|
|
144
|
+
if (isPlainObject(value)) {
|
|
145
|
+
// A gradient keeps its own normaliser -- its stops are interpolated into
|
|
146
|
+
// CSS and into generated export source, so they never ride the generic
|
|
147
|
+
// path. Claiming the grammar is enough to be held to it: an invalid one is
|
|
148
|
+
// dropped rather than carried through as a plain object.
|
|
149
|
+
if ("from" in value || "to" in value) {
|
|
150
|
+
return normaliseComponentGradient(value) || undefined;
|
|
151
|
+
}
|
|
152
|
+
const out = {};
|
|
153
|
+
let kept = 0;
|
|
154
|
+
for (const [key, item] of Object.entries(value)) {
|
|
155
|
+
if (kept >= maxValueEntries) break;
|
|
156
|
+
if (!isUsableStyleKey(key)) continue;
|
|
157
|
+
const coerced = coerceWidgetStyleValue(item, depth + 1);
|
|
158
|
+
if (coerced === undefined) continue;
|
|
159
|
+
out[key] = coerced;
|
|
160
|
+
kept += 1;
|
|
161
|
+
}
|
|
162
|
+
return kept > 0 ? out : undefined;
|
|
163
|
+
}
|
|
117
164
|
return undefined;
|
|
118
165
|
}
|
|
119
166
|
|
|
167
|
+
// What the value actually costs the cold-start read and the baked export.
|
|
168
|
+
function styleValueBytes(value) {
|
|
169
|
+
const json = JSON.stringify(value);
|
|
170
|
+
return typeof json === "string" ? json.length : Infinity;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* sc-5646: ONE widget's style fields, validated. The app-wide layer and a placed
|
|
175
|
+
* node's `props.style` hold the SAME vocabulary at different scopes, so they must
|
|
176
|
+
* accept the same values — a field an author can set on a node and not app-wide
|
|
177
|
+
* (or the reverse) is exactly the divergence the two surfaces are meant not to
|
|
178
|
+
* have. The per-widget FIELD cap is deliberately not applied here: it bounds the
|
|
179
|
+
* app-wide map that rides the unauthenticated cold-start read, not one node in a
|
|
180
|
+
* page layout the builder already writes unbounded.
|
|
181
|
+
*
|
|
182
|
+
* @param {unknown} raw — a `props.style` / `widgetStyles[id]` object.
|
|
183
|
+
* @param {{ maxFields?: number }} [options]
|
|
184
|
+
* @returns {Record<string, unknown>} the fields that survived; `{}` for junk.
|
|
185
|
+
*/
|
|
186
|
+
function normaliseWidgetStyleFields(raw, { maxFields } = {}) {
|
|
187
|
+
if (!isPlainObject(raw)) return {};
|
|
188
|
+
const { maxValueBytes } = CONTRACT.themeWidgetStyles;
|
|
189
|
+
const out = {};
|
|
190
|
+
let count = 0;
|
|
191
|
+
for (const [field, value] of Object.entries(raw)) {
|
|
192
|
+
if (maxFields !== undefined && count >= maxFields) break;
|
|
193
|
+
if (!isUsableStyleKey(field)) continue;
|
|
194
|
+
const coerced = coerceWidgetStyleValue(value);
|
|
195
|
+
if (coerced === undefined) continue;
|
|
196
|
+
if (styleValueBytes(coerced) > maxValueBytes) continue;
|
|
197
|
+
out[field] = coerced;
|
|
198
|
+
count += 1;
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
120
203
|
/**
|
|
121
204
|
* REQ-THEME-ELEMENT: validate `themeConfig.widgetStyles` -- app-wide style values
|
|
122
205
|
* keyed by WIDGET MANIFEST ID, then by that widget's own styleSchema field name.
|
|
@@ -132,26 +215,31 @@ function coerceWidgetStyleValue(value) {
|
|
|
132
215
|
*/
|
|
133
216
|
function normaliseWidgetStyles(raw) {
|
|
134
217
|
if (!isPlainObject(raw)) return {};
|
|
135
|
-
const { maxWidgets, maxFieldsPerWidget } =
|
|
218
|
+
const { maxWidgets, maxFieldsPerWidget, maxBytes } =
|
|
219
|
+
CONTRACT.themeWidgetStyles;
|
|
136
220
|
const idPattern = CONTRACT.manifestSchema.id.pattern;
|
|
137
221
|
const out = {};
|
|
138
222
|
let widgets = 0;
|
|
223
|
+
let bytes = 0;
|
|
139
224
|
for (const [manifestId, fields] of Object.entries(raw)) {
|
|
140
225
|
if (widgets >= maxWidgets) break;
|
|
141
226
|
if (!idPattern.test(manifestId) || !isPlainObject(fields)) continue;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
kept[field] = coerced;
|
|
149
|
-
count += 1;
|
|
150
|
-
}
|
|
227
|
+
// The SAME per-field validation a placed node's style gets, plus the cap
|
|
228
|
+
// that only this map needs.
|
|
229
|
+
const kept = normaliseWidgetStyleFields(fields, {
|
|
230
|
+
maxFields: maxFieldsPerWidget,
|
|
231
|
+
});
|
|
232
|
+
const count = Object.keys(kept).length;
|
|
151
233
|
// An emptied entry is dropped rather than persisted as `{}`, mirroring
|
|
152
234
|
// normaliseThemeComponents.
|
|
153
235
|
if (count === 0) continue;
|
|
236
|
+
// Whole entries, never half of one: a widget rendering with SOME of its
|
|
237
|
+
// app-wide fields reads as a bug, where one rendering with none reads as
|
|
238
|
+
// "not styled yet".
|
|
239
|
+
const entryBytes = styleValueBytes(kept) + manifestId.length;
|
|
240
|
+
if (bytes + entryBytes > maxBytes) break;
|
|
154
241
|
out[manifestId] = kept;
|
|
242
|
+
bytes += entryBytes;
|
|
155
243
|
widgets += 1;
|
|
156
244
|
}
|
|
157
245
|
return out;
|
|
@@ -245,4 +333,4 @@ function applyThemeComponentStyle(manifestId, theme, props, styleSchema) {
|
|
|
245
333
|
return { ...base, style: { ...themed, ...authored } };
|
|
246
334
|
}
|
|
247
335
|
|
|
248
|
-
module.exports = { normaliseThemeComponents, normaliseWidgetStyles, applyThemeComponentStyle };
|
|
336
|
+
module.exports = { normaliseThemeComponents, normaliseWidgetStyleFields, normaliseWidgetStyles, applyThemeComponentStyle };
|
package/dist/theme-components.js
CHANGED
|
@@ -91,12 +91,30 @@ export function normaliseThemeComponents(raw) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
// Values are assigned onto plain object literals, so `__proto__` would set a
|
|
95
|
+
// PROTOTYPE rather than a style field. Refused at every level.
|
|
96
|
+
const UNSAFE_STYLE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
97
|
+
|
|
98
|
+
function isUsableStyleKey(key) {
|
|
99
|
+
return (
|
|
100
|
+
typeof key === "string" &&
|
|
101
|
+
key.length > 0 &&
|
|
102
|
+
key.length <= 64 &&
|
|
103
|
+
!UNSAFE_STYLE_KEYS.has(key)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
// REQ-THEME-ELEMENT: one value out of the per-widget map. Unlike a component
|
|
95
108
|
// token, there is no declared `type` to coerce against -- the key space is the
|
|
96
109
|
// workspace's widget catalog, not the contract -- so validation here is
|
|
97
110
|
// STRUCTURAL. The authoritative type is the widget's own styleSchema, which the
|
|
98
111
|
// Studio honours by only ever offering fields that widget declares.
|
|
99
|
-
|
|
112
|
+
//
|
|
113
|
+
// sc-5646: that same field is offered per instance AND app-wide, so carrying
|
|
114
|
+
// only scalars here made one edit behave two ways. A structured value rides
|
|
115
|
+
// along too -- bounded by depth, width and bytes, never by trust.
|
|
116
|
+
function coerceWidgetStyleValue(value, depth = 0) {
|
|
117
|
+
const { maxValueDepth, maxValueEntries } = CONTRACT.themeWidgetStyles;
|
|
100
118
|
if (typeof value === "string") {
|
|
101
119
|
const trimmed = value.trim();
|
|
102
120
|
// Long enough for a hex, an enum value or a font name; short enough that a
|
|
@@ -105,10 +123,75 @@ function coerceWidgetStyleValue(value) {
|
|
|
105
123
|
}
|
|
106
124
|
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
107
125
|
if (typeof value === "boolean") return value;
|
|
108
|
-
if (
|
|
126
|
+
if (depth >= maxValueDepth) return undefined;
|
|
127
|
+
if (Array.isArray(value)) {
|
|
128
|
+
const items = [];
|
|
129
|
+
for (const item of value) {
|
|
130
|
+
if (items.length >= maxValueEntries) break;
|
|
131
|
+
const coerced = coerceWidgetStyleValue(item, depth + 1);
|
|
132
|
+
if (coerced !== undefined) items.push(coerced);
|
|
133
|
+
}
|
|
134
|
+
return items.length > 0 ? items : undefined;
|
|
135
|
+
}
|
|
136
|
+
if (isPlainObject(value)) {
|
|
137
|
+
// A gradient keeps its own normaliser -- its stops are interpolated into
|
|
138
|
+
// CSS and into generated export source, so they never ride the generic
|
|
139
|
+
// path. Claiming the grammar is enough to be held to it: an invalid one is
|
|
140
|
+
// dropped rather than carried through as a plain object.
|
|
141
|
+
if ("from" in value || "to" in value) {
|
|
142
|
+
return normaliseComponentGradient(value) || undefined;
|
|
143
|
+
}
|
|
144
|
+
const out = {};
|
|
145
|
+
let kept = 0;
|
|
146
|
+
for (const [key, item] of Object.entries(value)) {
|
|
147
|
+
if (kept >= maxValueEntries) break;
|
|
148
|
+
if (!isUsableStyleKey(key)) continue;
|
|
149
|
+
const coerced = coerceWidgetStyleValue(item, depth + 1);
|
|
150
|
+
if (coerced === undefined) continue;
|
|
151
|
+
out[key] = coerced;
|
|
152
|
+
kept += 1;
|
|
153
|
+
}
|
|
154
|
+
return kept > 0 ? out : undefined;
|
|
155
|
+
}
|
|
109
156
|
return undefined;
|
|
110
157
|
}
|
|
111
158
|
|
|
159
|
+
// What the value actually costs the cold-start read and the baked export.
|
|
160
|
+
function styleValueBytes(value) {
|
|
161
|
+
const json = JSON.stringify(value);
|
|
162
|
+
return typeof json === "string" ? json.length : Infinity;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* sc-5646: ONE widget's style fields, validated. The app-wide layer and a placed
|
|
167
|
+
* node's `props.style` hold the SAME vocabulary at different scopes, so they must
|
|
168
|
+
* accept the same values — a field an author can set on a node and not app-wide
|
|
169
|
+
* (or the reverse) is exactly the divergence the two surfaces are meant not to
|
|
170
|
+
* have. The per-widget FIELD cap is deliberately not applied here: it bounds the
|
|
171
|
+
* app-wide map that rides the unauthenticated cold-start read, not one node in a
|
|
172
|
+
* page layout the builder already writes unbounded.
|
|
173
|
+
*
|
|
174
|
+
* @param {unknown} raw — a `props.style` / `widgetStyles[id]` object.
|
|
175
|
+
* @param {{ maxFields?: number }} [options]
|
|
176
|
+
* @returns {Record<string, unknown>} the fields that survived; `{}` for junk.
|
|
177
|
+
*/
|
|
178
|
+
export function normaliseWidgetStyleFields(raw, { maxFields } = {}) {
|
|
179
|
+
if (!isPlainObject(raw)) return {};
|
|
180
|
+
const { maxValueBytes } = CONTRACT.themeWidgetStyles;
|
|
181
|
+
const out = {};
|
|
182
|
+
let count = 0;
|
|
183
|
+
for (const [field, value] of Object.entries(raw)) {
|
|
184
|
+
if (maxFields !== undefined && count >= maxFields) break;
|
|
185
|
+
if (!isUsableStyleKey(field)) continue;
|
|
186
|
+
const coerced = coerceWidgetStyleValue(value);
|
|
187
|
+
if (coerced === undefined) continue;
|
|
188
|
+
if (styleValueBytes(coerced) > maxValueBytes) continue;
|
|
189
|
+
out[field] = coerced;
|
|
190
|
+
count += 1;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
112
195
|
/**
|
|
113
196
|
* REQ-THEME-ELEMENT: validate `themeConfig.widgetStyles` -- app-wide style values
|
|
114
197
|
* keyed by WIDGET MANIFEST ID, then by that widget's own styleSchema field name.
|
|
@@ -124,26 +207,31 @@ function coerceWidgetStyleValue(value) {
|
|
|
124
207
|
*/
|
|
125
208
|
export function normaliseWidgetStyles(raw) {
|
|
126
209
|
if (!isPlainObject(raw)) return {};
|
|
127
|
-
const { maxWidgets, maxFieldsPerWidget } =
|
|
210
|
+
const { maxWidgets, maxFieldsPerWidget, maxBytes } =
|
|
211
|
+
CONTRACT.themeWidgetStyles;
|
|
128
212
|
const idPattern = CONTRACT.manifestSchema.id.pattern;
|
|
129
213
|
const out = {};
|
|
130
214
|
let widgets = 0;
|
|
215
|
+
let bytes = 0;
|
|
131
216
|
for (const [manifestId, fields] of Object.entries(raw)) {
|
|
132
217
|
if (widgets >= maxWidgets) break;
|
|
133
218
|
if (!idPattern.test(manifestId) || !isPlainObject(fields)) continue;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
kept[field] = coerced;
|
|
141
|
-
count += 1;
|
|
142
|
-
}
|
|
219
|
+
// The SAME per-field validation a placed node's style gets, plus the cap
|
|
220
|
+
// that only this map needs.
|
|
221
|
+
const kept = normaliseWidgetStyleFields(fields, {
|
|
222
|
+
maxFields: maxFieldsPerWidget,
|
|
223
|
+
});
|
|
224
|
+
const count = Object.keys(kept).length;
|
|
143
225
|
// An emptied entry is dropped rather than persisted as `{}`, mirroring
|
|
144
226
|
// normaliseThemeComponents.
|
|
145
227
|
if (count === 0) continue;
|
|
228
|
+
// Whole entries, never half of one: a widget rendering with SOME of its
|
|
229
|
+
// app-wide fields reads as a bug, where one rendering with none reads as
|
|
230
|
+
// "not styled yet".
|
|
231
|
+
const entryBytes = styleValueBytes(kept) + manifestId.length;
|
|
232
|
+
if (bytes + entryBytes > maxBytes) break;
|
|
146
233
|
out[manifestId] = kept;
|
|
234
|
+
bytes += entryBytes;
|
|
147
235
|
widgets += 1;
|
|
148
236
|
}
|
|
149
237
|
return out;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.103.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|