@colixsystems/widget-sdk 0.270.0 → 0.272.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 +34 -2
- package/dist/basic-style.js +37 -0
- package/dist/contract.cjs +53 -1
- package/dist/contract.js +53 -1
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +159 -41
- package/dist/linter.js +158 -46
- package/package.json +1 -1
- package/src/contract.js +53 -1
package/README.md
CHANGED
|
@@ -86,7 +86,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
86
86
|
|
|
87
87
|
## Status
|
|
88
88
|
|
|
89
|
-
`v0.
|
|
89
|
+
`v0.272.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**.
|
|
90
|
+
|
|
91
|
+
### What's new in 0.272.0 (contract 1.222.0)
|
|
92
|
+
|
|
93
|
+
**Breaking for authors: more routes to the page's global object are banned.** `self`, `top`, `parent`, `frames` and `opener` are other names for `window`, `frameElement` leads to the host document, and a function's `.constructor` is `Function()`. The linter now refuses them (`no-self`, `no-top`, `no-parent`, `no-frames`, `no-opener`, `no-frameelement`, `no-constructor-access`), refuses `eval` and `Function` as any reference, not only as a call (`const e = eval`, `(0, eval)(…)`), and refuses a `\u` escape that spells a letter, digit, `_` or `$` in code (`no-identifier-escape`), which would hide a name from the scan. A widget version that uses them fails lint at submission; versions already published keep running.
|
|
94
|
+
|
|
95
|
+
- **`self` / `top` / `parent` / `frames` / `opener`** are refused wherever they read a variable: `top[k]`, `{ ...self }`, `typeof parent`, `c ? frames : x`. That includes a local variable with one of these names, so rename it (`const [rootTop, setRootTop] = useState(null)`, `const parentId = …`). A property (`insets.top`, `node.parent`), an object key (`{ top: 0 }`, `const { top: topInset } = insets`) and a word in JSX text after another word on the same line (`Back to top`) are fine. JSX text that is only the word (`<Text>top</Text>`) or starts a line is refused; render it as `{"top"}` or through `t()`.
|
|
96
|
+
- **`.constructor`** is refused as a property read (`x.constructor`, `x?.constructor`), a destructured key (`const { constructor } = fn`) and the quoted name (`x["constructor"]`, `Reflect.get(p, "constructor")`). A class component's own `constructor(props)` method is fine.
|
|
97
|
+
- **`Function`** stays allowed after `instanceof` (`v instanceof Function`); prefer `typeof v === "function"`. To test for a plain object, compare `Object.getPrototypeOf(x) === Object.prototype`.
|
|
98
|
+
|
|
99
|
+
### What's new in 0.271.0 (contract 1.221.0)
|
|
100
|
+
|
|
101
|
+
**Basic styling in one call.** Spread `BASIC_STYLE_SCHEMA` into `manifest.styleSchema` and call `basicStyle(style)` in the component: the five basic style fields (`background`, `textColor`, `align`, `padding`, `margin`) are declared and applied without wiring each by hand. See *Basic styling* under *Design & visual polish*.
|
|
90
102
|
|
|
91
103
|
### What's new in 0.270.0 (contract 1.220.0)
|
|
92
104
|
|
|
@@ -3345,6 +3357,22 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
3345
3357
|
|
|
3346
3358
|
**Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.
|
|
3347
3359
|
|
|
3360
|
+
- **Basic styling: one schema, one call.** Every generated widget offers five whole-widget style fields: `background`, `textColor`, `align`, `padding` and `margin`. Spread `BASIC_STYLE_SCHEMA` into your manifest's `styleSchema`, then apply them with `basicStyle(style)`. It returns `{ root, text }`: spread `root` on the outermost `View` (background, align as `alignItems`, per-side padding and margin) and `text` on each `Text` in a theme text colour. A field the author left unset emits nothing, so your theme-based styles show through. `appstudio-widget lint` counts the call as reading all five. When `submit_widget_source` or the Studio's widget builder publishes a widget that calls `basicStyle()`, any of the five its manifest leaves out is declared for it.
|
|
3361
|
+
|
|
3362
|
+
```jsx
|
|
3363
|
+
import { View, Text, basicStyle, BASIC_STYLE_SCHEMA } from "@colixsystems/widget-sdk";
|
|
3364
|
+
|
|
3365
|
+
export const manifest = { /* … */ styleSchema: { ...BASIC_STYLE_SCHEMA, titleColor: { type: "color", label: "Title", ui: { group: "Title" } } } };
|
|
3366
|
+
|
|
3367
|
+
export default function Hero({ title, style = {} }) {
|
|
3368
|
+
const basic = basicStyle(style);
|
|
3369
|
+
return (
|
|
3370
|
+
<View style={[styles.root, basic.root]}>
|
|
3371
|
+
<Text style={[styles.title, basic.text, style.titleColor && { color: style.titleColor }]}>{title}</Text>
|
|
3372
|
+
</View>
|
|
3373
|
+
);
|
|
3374
|
+
}
|
|
3375
|
+
```
|
|
3348
3376
|
- **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. Don't hardcode raw pixel values.
|
|
3349
3377
|
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
3350
3378
|
- **Style text by its ROLE, not by inventing a size and weight.** `theme.typography.roles` carries the workspace's type scale as six semantic tiers — `display`, `title`, `subtitle`, `body`, `caption`, `overline` — each already resolved to a `fontSize`, `fontWeight`, `fontStyle`, `lineHeight` (a MULTIPLE of the size), `letterSpacing`, `textTransform` and a `colorToken`. Pick the tier the text IS, rather than choosing `fontSize: 16, fontWeight: "600"` yourself: your widget's section title is `title`, each card or row title is `subtitle`, running text is `body`, timestamps and helper text are `caption`, a small kicker or column label is `overline`, and a headline stat is `display`. This is what stops one widget rendering a subtitle bold while the widget beside it renders one italic, and it lets the workspace re-tune every subtitle in the app from Theme Settings in one edit. **Two tokens need converting rather than copying** — `lineHeight` is a multiple (React Native wants absolute pixels, so the raw `1.15` gives you a one-pixel line box) and `colorToken` names a colour rather than being one:
|
|
@@ -3473,6 +3501,10 @@ joined — it then reads back as `{ id, name, invited: true }`, with no `email`
|
|
|
3473
3501
|
behind it. `status: 'open'` is every invite that is neither accepted nor revoked
|
|
3474
3502
|
(`pending`, `not_sent` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
|
|
3475
3503
|
|
|
3504
|
+
Only a signed-in member viewer sees a `USER` cell's `email`. A visitor who is not
|
|
3505
|
+
signed in, and an anonymous account, read a member as `{ id, name }`, so render
|
|
3506
|
+
`name` and treat `email` as optional.
|
|
3507
|
+
|
|
3476
3508
|
**The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
|
|
3477
3509
|
someone who is not a member yet, so the backend gates the entire invite surface —
|
|
3478
3510
|
listing included — on the `users.write` capability plus a signed `users.write:*`
|
|
@@ -3756,7 +3788,7 @@ npx appstudio-widget lint path/to/widget.jsx
|
|
|
3756
3788
|
npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
|
|
3757
3789
|
```
|
|
3758
3790
|
|
|
3759
|
-
Scans for banned patterns (`eval`, `
|
|
3791
|
+
Scans for banned patterns (every `CONTRACT.bannedApis` entry — `eval`, `Function`, dynamic `import()`, `window` and its other names `self` / `top` / `parent` / `frames`, `.constructor`, … — and direct imports of host stores) and for the styling rules below. **Only error-severity findings change the exit code**; warnings print and exit 0.
|
|
3760
3792
|
|
|
3761
3793
|
```js
|
|
3762
3794
|
import {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The five style fields every generated widget declares, in one call: spread
|
|
2
|
+
// BASIC_STYLE_SCHEMA into `manifest.styleSchema`, apply `basicStyle(style)`.
|
|
3
|
+
// Platform-free, so the web and native entries export this one module.
|
|
4
|
+
|
|
5
|
+
import { CONTRACT } from "./contract.js";
|
|
6
|
+
import { spacingOverlay } from "./card-style.js";
|
|
7
|
+
|
|
8
|
+
export const BASIC_STYLE_SCHEMA = CONTRACT.basicStyleSchema;
|
|
9
|
+
|
|
10
|
+
const ALIGN_ITEMS = Object.freeze({
|
|
11
|
+
left: "flex-start",
|
|
12
|
+
center: "center",
|
|
13
|
+
right: "flex-end",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The basic style fields as React Native styles. Each is emitted only when the
|
|
18
|
+
* author set it, so the widget's own theme-based styles show through.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} [style] the widget's `props.style`
|
|
21
|
+
* @returns {{ root: object, text: object|null }} `root` for the outermost
|
|
22
|
+
* View (backgroundColor, alignItems, padding, margin); `text` (`{ color }`)
|
|
23
|
+
* for each Text coloured by a theme text token.
|
|
24
|
+
*/
|
|
25
|
+
export function basicStyle(style) {
|
|
26
|
+
const s = style || {};
|
|
27
|
+
const alignItems = ALIGN_ITEMS[s.align];
|
|
28
|
+
return {
|
|
29
|
+
root: {
|
|
30
|
+
...(s.background ? { backgroundColor: s.background } : null),
|
|
31
|
+
...(alignItems ? { alignItems } : null),
|
|
32
|
+
...spacingOverlay(s.padding, "padding"),
|
|
33
|
+
...spacingOverlay(s.margin, "margin"),
|
|
34
|
+
},
|
|
35
|
+
text: s.textColor ? { color: s.textColor } : null,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/contract.cjs
CHANGED
|
@@ -389,6 +389,38 @@ const THEME_SPACING_SCALE = Object.freeze({
|
|
|
389
389
|
default: 1,
|
|
390
390
|
});
|
|
391
391
|
|
|
392
|
+
// The style fields every generated widget declares, with the controls the
|
|
393
|
+
// Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
|
|
394
|
+
// `basicStyle()`, the linter, the publish gate and the Mason plan gate.
|
|
395
|
+
const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
|
|
396
|
+
const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
|
|
397
|
+
const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
398
|
+
background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
|
|
399
|
+
textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
|
|
400
|
+
align: Object.freeze({
|
|
401
|
+
type: "select",
|
|
402
|
+
label: "Align",
|
|
403
|
+
enum: Object.freeze([
|
|
404
|
+
Object.freeze({ value: "left", label: "Left" }),
|
|
405
|
+
Object.freeze({ value: "center", label: "Center" }),
|
|
406
|
+
Object.freeze({ value: "right", label: "Right" }),
|
|
407
|
+
]),
|
|
408
|
+
ui: BASIC_STYLE_GROUP,
|
|
409
|
+
}),
|
|
410
|
+
padding: Object.freeze({
|
|
411
|
+
type: "spacing",
|
|
412
|
+
label: "Padding",
|
|
413
|
+
validation: BASIC_SPACING_RANGE,
|
|
414
|
+
ui: BASIC_STYLE_GROUP,
|
|
415
|
+
}),
|
|
416
|
+
margin: Object.freeze({
|
|
417
|
+
type: "spacing",
|
|
418
|
+
label: "Margin",
|
|
419
|
+
validation: BASIC_SPACING_RANGE,
|
|
420
|
+
ui: BASIC_STYLE_GROUP,
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
|
|
392
424
|
// The card-surface field names shared by every widget that paints its own card
|
|
393
425
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
394
426
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -4508,6 +4540,20 @@ const BANNED_APIS = [
|
|
|
4508
4540
|
reason:
|
|
4509
4541
|
"Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
|
|
4510
4542
|
},
|
|
4543
|
+
...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
|
|
4544
|
+
identifier,
|
|
4545
|
+
reason:
|
|
4546
|
+
"Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
|
|
4547
|
+
})),
|
|
4548
|
+
{
|
|
4549
|
+
identifier: "frameElement",
|
|
4550
|
+
reason: "The element framing the page, a route to the host document.",
|
|
4551
|
+
},
|
|
4552
|
+
{
|
|
4553
|
+
identifier: ".constructor",
|
|
4554
|
+
reason:
|
|
4555
|
+
"Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
|
|
4556
|
+
},
|
|
4511
4557
|
];
|
|
4512
4558
|
|
|
4513
4559
|
// REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
|
|
@@ -6508,7 +6554,12 @@ const CONTRACT = deepFreeze({
|
|
|
6508
6554
|
// every query, record, figure and aggregate on that table, app-wide.
|
|
6509
6555
|
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6510
6556
|
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6511
|
-
|
|
6557
|
+
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6558
|
+
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6559
|
+
// 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
|
|
6560
|
+
// `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
|
|
6561
|
+
// `Function` are refused as any reference, not only as a call.
|
|
6562
|
+
version: "1.222.0",
|
|
6512
6563
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6513
6564
|
hooks: HOOKS,
|
|
6514
6565
|
primitives: PRIMITIVES,
|
|
@@ -6533,6 +6584,7 @@ const CONTRACT = deepFreeze({
|
|
|
6533
6584
|
themeSpacingScale: THEME_SPACING_SCALE,
|
|
6534
6585
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6535
6586
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6587
|
+
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6536
6588
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6537
6589
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6538
6590
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
package/dist/contract.js
CHANGED
|
@@ -386,6 +386,38 @@ const THEME_SPACING_SCALE = Object.freeze({
|
|
|
386
386
|
default: 1,
|
|
387
387
|
});
|
|
388
388
|
|
|
389
|
+
// The style fields every generated widget declares, with the controls the
|
|
390
|
+
// Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
|
|
391
|
+
// `basicStyle()`, the linter, the publish gate and the Mason plan gate.
|
|
392
|
+
const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
|
|
393
|
+
const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
|
|
394
|
+
const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
395
|
+
background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
|
|
396
|
+
textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
|
|
397
|
+
align: Object.freeze({
|
|
398
|
+
type: "select",
|
|
399
|
+
label: "Align",
|
|
400
|
+
enum: Object.freeze([
|
|
401
|
+
Object.freeze({ value: "left", label: "Left" }),
|
|
402
|
+
Object.freeze({ value: "center", label: "Center" }),
|
|
403
|
+
Object.freeze({ value: "right", label: "Right" }),
|
|
404
|
+
]),
|
|
405
|
+
ui: BASIC_STYLE_GROUP,
|
|
406
|
+
}),
|
|
407
|
+
padding: Object.freeze({
|
|
408
|
+
type: "spacing",
|
|
409
|
+
label: "Padding",
|
|
410
|
+
validation: BASIC_SPACING_RANGE,
|
|
411
|
+
ui: BASIC_STYLE_GROUP,
|
|
412
|
+
}),
|
|
413
|
+
margin: Object.freeze({
|
|
414
|
+
type: "spacing",
|
|
415
|
+
label: "Margin",
|
|
416
|
+
validation: BASIC_SPACING_RANGE,
|
|
417
|
+
ui: BASIC_STYLE_GROUP,
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
|
|
389
421
|
// The card-surface field names shared by every widget that paints its own card
|
|
390
422
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
391
423
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -4505,6 +4537,20 @@ const BANNED_APIS = [
|
|
|
4505
4537
|
reason:
|
|
4506
4538
|
"Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
|
|
4507
4539
|
},
|
|
4540
|
+
...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
|
|
4541
|
+
identifier,
|
|
4542
|
+
reason:
|
|
4543
|
+
"Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
|
|
4544
|
+
})),
|
|
4545
|
+
{
|
|
4546
|
+
identifier: "frameElement",
|
|
4547
|
+
reason: "The element framing the page, a route to the host document.",
|
|
4548
|
+
},
|
|
4549
|
+
{
|
|
4550
|
+
identifier: ".constructor",
|
|
4551
|
+
reason:
|
|
4552
|
+
"Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
|
|
4553
|
+
},
|
|
4508
4554
|
];
|
|
4509
4555
|
|
|
4510
4556
|
// REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
|
|
@@ -6505,7 +6551,12 @@ const CONTRACT = deepFreeze({
|
|
|
6505
6551
|
// every query, record, figure and aggregate on that table, app-wide.
|
|
6506
6552
|
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6507
6553
|
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6508
|
-
|
|
6554
|
+
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6555
|
+
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6556
|
+
// 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
|
|
6557
|
+
// `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
|
|
6558
|
+
// `Function` are refused as any reference, not only as a call.
|
|
6559
|
+
version: "1.222.0",
|
|
6509
6560
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6510
6561
|
hooks: HOOKS,
|
|
6511
6562
|
primitives: PRIMITIVES,
|
|
@@ -6530,6 +6581,7 @@ const CONTRACT = deepFreeze({
|
|
|
6530
6581
|
themeSpacingScale: THEME_SPACING_SCALE,
|
|
6531
6582
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6532
6583
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6584
|
+
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6533
6585
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6534
6586
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6535
6587
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
package/dist/index.d.ts
CHANGED
|
@@ -5576,6 +5576,27 @@ export function roleColor(tokens: any, colors: any): string | undefined;
|
|
|
5576
5576
|
*/
|
|
5577
5577
|
export function textRoleStyle(role: TextRole | string, typography: any, colors?: any): Record<string, any>;
|
|
5578
5578
|
|
|
5579
|
+
/**
|
|
5580
|
+
* The five style fields every generated widget declares — `background`,
|
|
5581
|
+
* `textColor`, `align`, `padding` and `margin` — as styleSchema entries.
|
|
5582
|
+
* Spread it into `manifest.styleSchema`; `basicStyle()` applies it.
|
|
5583
|
+
*/
|
|
5584
|
+
export const BASIC_STYLE_SCHEMA: Readonly<WidgetPropertySchema>;
|
|
5585
|
+
/**
|
|
5586
|
+
* The basic style fields as React Native styles, each only when set. Spread
|
|
5587
|
+
* `root` on the outermost View and `text` on each Text in a theme text colour.
|
|
5588
|
+
*
|
|
5589
|
+
* @example
|
|
5590
|
+
* const basic = basicStyle(style);
|
|
5591
|
+
* <View style={[styles.root, basic.root]}>
|
|
5592
|
+
* <Text style={[styles.title, basic.text]}>{title}</Text>
|
|
5593
|
+
* </View>
|
|
5594
|
+
*/
|
|
5595
|
+
export function basicStyle(style?: Record<string, any> | null): {
|
|
5596
|
+
root: Record<string, any>;
|
|
5597
|
+
text: { color: string } | null;
|
|
5598
|
+
};
|
|
5599
|
+
|
|
5579
5600
|
// ----------------------------------------------------- formatted content
|
|
5580
5601
|
// sc-6970 — widgets have no HTML path: they render through React Native
|
|
5581
5602
|
// primitives, which have no `dangerouslySetInnerHTML` on either host. Content
|
package/dist/index.js
CHANGED
|
@@ -268,6 +268,8 @@ export {
|
|
|
268
268
|
gradientProps,
|
|
269
269
|
cardGradientProps,
|
|
270
270
|
} from "./card-style.js";
|
|
271
|
+
// The five style fields every generated widget declares, in one call.
|
|
272
|
+
export { BASIC_STYLE_SCHEMA, basicStyle } from "./basic-style.js";
|
|
271
273
|
// The themed button; follows the theme's button, destructive and ring scopes.
|
|
272
274
|
export { Button, IconButton, BUTTON_VARIANTS } from "./button.js";
|
|
273
275
|
export {
|
package/dist/index.native.js
CHANGED
|
@@ -261,6 +261,8 @@ export {
|
|
|
261
261
|
gradientProps,
|
|
262
262
|
cardGradientProps,
|
|
263
263
|
} from "./card-style.js";
|
|
264
|
+
// The five style fields every generated widget declares, in one call.
|
|
265
|
+
export { BASIC_STYLE_SCHEMA, basicStyle } from "./basic-style.js";
|
|
264
266
|
// The themed button; follows the theme's button, destructive and ring scopes.
|
|
265
267
|
export { Button, IconButton, BUTTON_VARIANTS } from "./button.native.js";
|
|
266
268
|
export {
|
package/dist/linter.cjs
CHANGED
|
@@ -13,42 +13,151 @@
|
|
|
13
13
|
const { CONTRACT, normaliseActionTriggerTypes } = require("./contract.cjs");
|
|
14
14
|
const { LUCIDE_ICON_NAMES, LUCIDE_VERSION } = require("./lucideIconNames.cjs");
|
|
15
15
|
|
|
16
|
+
// Per-identifier match rule, run over the whole masked source. Most banned
|
|
17
|
+
// identifiers compile to a whole-word match.
|
|
16
18
|
function _ruleForIdentifier(identifier, reason) {
|
|
17
19
|
const id = identifier;
|
|
18
20
|
if (id === "Function") {
|
|
21
|
+
// Any reference, so an alias (`const F = Function`) is refused too.
|
|
19
22
|
return {
|
|
20
23
|
id: "no-function-constructor",
|
|
21
24
|
label: `${reason} (banned: Function() constructor)`,
|
|
22
|
-
pattern: /(
|
|
25
|
+
pattern: /(?<![\w$])Function(?![\w$])/g,
|
|
26
|
+
accept: (code, index) =>
|
|
27
|
+
!_isMemberName(code, index) && _wordBefore(code, index) !== "instanceof",
|
|
23
28
|
};
|
|
24
29
|
}
|
|
25
30
|
if (id === "new Function") {
|
|
26
31
|
return {
|
|
27
32
|
id: "no-new-function",
|
|
28
33
|
label: `${reason} (banned: new Function())`,
|
|
29
|
-
pattern: /\bnew\s+Function\s*\(
|
|
34
|
+
pattern: /\bnew\s+Function\s*\(/g,
|
|
30
35
|
};
|
|
31
36
|
}
|
|
32
37
|
if (id === "import(") {
|
|
33
38
|
return {
|
|
34
39
|
id: "no-dynamic-import",
|
|
35
40
|
label: `${reason} (banned: dynamic import())`,
|
|
36
|
-
pattern: /(
|
|
41
|
+
pattern: /(?<![\w$.])import\s*\(/g,
|
|
37
42
|
};
|
|
38
43
|
}
|
|
39
44
|
if (id === "eval") {
|
|
45
|
+
// Any reference, so indirect `(0, eval)(…)` is refused too.
|
|
40
46
|
return {
|
|
41
47
|
id: "no-eval",
|
|
42
48
|
label: `${reason} (banned: eval)`,
|
|
43
|
-
pattern:
|
|
49
|
+
pattern: /(?<![\w$])eval(?![\w$])/g,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (id === ".constructor") {
|
|
53
|
+
return {
|
|
54
|
+
id: "no-constructor-access",
|
|
55
|
+
label: `${reason} (banned: .constructor)`,
|
|
56
|
+
// Also `{ constructor: F } = fn`; a class's `constructor(` passes.
|
|
57
|
+
pattern: /\.\s*constructor(?![\w$])|(?<![\w$.])constructor(?![\w$])(?!\s*\()/g,
|
|
58
|
+
stringPattern: /(["'`])constructor\1/g,
|
|
44
59
|
};
|
|
45
60
|
}
|
|
46
61
|
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
const rule = { id: `no-${id.toLowerCase()}`, label: `${reason} (banned: ${id})` };
|
|
63
|
+
if (_GLOBAL_OBJECT_ALIASES.has(id)) {
|
|
64
|
+
// Also a common property name, so only a read of the variable is refused.
|
|
65
|
+
return {
|
|
66
|
+
...rule,
|
|
67
|
+
pattern: new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "g"),
|
|
68
|
+
accept: (code, index) => _readsVariable(code, index, id.length),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ...rule, pattern: new RegExp(`\\b${escaped}\\b`, "g") };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Other names for the page's global object that are also everyday property
|
|
75
|
+
// names (`insets.top`, `node.parent`).
|
|
76
|
+
const _GLOBAL_OBJECT_ALIASES = new Set(["self", "top", "parent", "frames", "opener"]);
|
|
77
|
+
|
|
78
|
+
// Read-leading keywords `_EXPRESSION_LEAD_WORDS` leaves out.
|
|
79
|
+
const _READ_LEAD_WORDS = new Set(["new", "extends", "default"]);
|
|
80
|
+
|
|
81
|
+
// Index of the last non-space character before `index`, and whether a
|
|
82
|
+
// newline lies between.
|
|
83
|
+
function _tokenBefore(code, index) {
|
|
84
|
+
let at = index - 1;
|
|
85
|
+
let newline = false;
|
|
86
|
+
for (; at >= 0 && /\s/.test(code[at]); at -= 1) {
|
|
87
|
+
if (code[at] === "\n") newline = true;
|
|
88
|
+
}
|
|
89
|
+
return { at, newline };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _wordBefore(code, index) {
|
|
93
|
+
const { at } = _tokenBefore(code, index);
|
|
94
|
+
let start = at + 1;
|
|
95
|
+
while (start > 0 && /[\w$]/.test(code[start - 1])) start -= 1;
|
|
96
|
+
return code.slice(start, at + 1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// `x.name` / `x?.name`, but not a spread (`...name`) and not the decimal point
|
|
100
|
+
// of an integer (`1.` + newline starts a new statement).
|
|
101
|
+
function _isMemberName(code, index) {
|
|
102
|
+
const { at } = _tokenBefore(code, index);
|
|
103
|
+
if (code[at] !== ".") return false;
|
|
104
|
+
if (code[at - 1] === ".") return false;
|
|
105
|
+
return !/(^|[^\w$.])\d[\d_]*$/.test(code.slice(Math.max(0, at - 64), at));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Whether the name at `index` reads its variable. A member name, an object
|
|
109
|
+
// key, a write, and a word after a declaration keyword or JSX text on the
|
|
110
|
+
// same line do not.
|
|
111
|
+
function _readsVariable(code, index, length) {
|
|
112
|
+
if (_isMemberName(code, index)) return false;
|
|
113
|
+
const { at: before, newline } = _tokenBefore(code, index);
|
|
114
|
+
let after = index + length;
|
|
115
|
+
while (after < code.length && /\s/.test(code[after])) after += 1;
|
|
116
|
+
const prev = code[before] || "";
|
|
117
|
+
if (code[after] === ":" && (prev === "{" || prev === ",")) return false;
|
|
118
|
+
if (code[after] === "=" && !/[=>]/.test(code[after + 1] || "")) return false;
|
|
119
|
+
if (newline || !/[\w$]/.test(prev)) return true;
|
|
120
|
+
const word = _wordBefore(code, index);
|
|
121
|
+
return _EXPRESSION_LEAD_WORDS.has(word) || _READ_LEAD_WORDS.has(word);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A `\u` escape spelling an ASCII identifier character (`\u0077indow`).
|
|
125
|
+
const _IDENTIFIER_ESCAPE_RE = /\\u(?:\{0*([0-9a-fA-F]{1,6})\}|([0-9a-fA-F]{4}))/g;
|
|
126
|
+
|
|
127
|
+
function _escapesAsciiIdentifier(code, index) {
|
|
128
|
+
// A fresh regex: matchAll clones the shared one with its lastIndex.
|
|
129
|
+
const at = new RegExp(_IDENTIFIER_ESCAPE_RE.source, "y");
|
|
130
|
+
at.lastIndex = index;
|
|
131
|
+
const m = at.exec(code);
|
|
132
|
+
const ch = String.fromCodePoint(parseInt(m[1] || m[2], 16));
|
|
133
|
+
return /^[\w$]$/.test(ch);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// One finding per rule per line, in line order, then rule order. Masking keeps
|
|
137
|
+
// every index, so a match's line is the source's line.
|
|
138
|
+
function _bannedApiFindings(source, lines) {
|
|
139
|
+
const masks = [_stripNonCode(source), _stripNonCode(source, { keepStrings: true })];
|
|
140
|
+
const hits = [];
|
|
141
|
+
RULES.forEach((rule, order) => {
|
|
142
|
+
const ruleLines = new Set();
|
|
143
|
+
[rule.pattern, rule.stringPattern].forEach((pattern, maskIndex) => {
|
|
144
|
+
if (!pattern) return;
|
|
145
|
+
const code = masks[maskIndex];
|
|
146
|
+
const lineAt = _lineCounter(code);
|
|
147
|
+
for (const match of code.matchAll(pattern)) {
|
|
148
|
+
if (!rule.accept || rule.accept(code, match.index)) ruleLines.add(lineAt(match.index));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
for (const line of ruleLines) hits.push({ rule, order, line });
|
|
152
|
+
});
|
|
153
|
+
hits.sort((a, b) => a.line - b.line || a.order - b.order);
|
|
154
|
+
return hits.map(({ rule, line }) => ({
|
|
155
|
+
rule: rule.id,
|
|
156
|
+
severity: "error",
|
|
157
|
+
label: rule.label,
|
|
158
|
+
line,
|
|
159
|
+
snippet: lines[line - 1].trim().slice(0, 200),
|
|
160
|
+
}));
|
|
52
161
|
}
|
|
53
162
|
|
|
54
163
|
const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
@@ -209,7 +318,14 @@ const EXTRA_RULES = [
|
|
|
209
318
|
{
|
|
210
319
|
id: "no-auth-store-import",
|
|
211
320
|
label: "widgets must not import the host's auth store",
|
|
212
|
-
pattern: /useAuthStore
|
|
321
|
+
pattern: /useAuthStore/g,
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: "no-identifier-escape",
|
|
325
|
+
label:
|
|
326
|
+
"a \\u escape spelling a letter, digit, _ or $ hides a name from the banned-API scan; write the name plainly",
|
|
327
|
+
pattern: _IDENTIFIER_ESCAPE_RE,
|
|
328
|
+
accept: _escapesAsciiIdentifier,
|
|
213
329
|
},
|
|
214
330
|
];
|
|
215
331
|
|
|
@@ -1946,11 +2062,15 @@ function lintStyleWiring(manifest, files) {
|
|
|
1946
2062
|
const code = sources
|
|
1947
2063
|
.map((s) => _stripNonCode(s, { keepStrings: true }))
|
|
1948
2064
|
.join("\n");
|
|
2065
|
+
const appliedByHelper = callsBasicStyle(sources)
|
|
2066
|
+
? _WHOLE_WIDGET_STYLE_FIELDS
|
|
2067
|
+
: new Set();
|
|
1949
2068
|
const findings = [];
|
|
1950
2069
|
for (const field of Object.keys(schema)) {
|
|
1951
2070
|
// A key that is not a bare identifier cannot be scanned by name; the
|
|
1952
2071
|
// manifest validator rejects those anyway.
|
|
1953
2072
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
|
|
2073
|
+
if (appliedByHelper.has(field)) continue;
|
|
1954
2074
|
if (new RegExp(`\\b${field}\\b`).test(code)) continue;
|
|
1955
2075
|
findings.push({
|
|
1956
2076
|
rule: "style-field-unread",
|
|
@@ -1961,7 +2081,8 @@ function lintStyleWiring(manifest, files) {
|
|
|
1961
2081
|
`— per instance, and app-wide under Design -> Widget appearance — ` +
|
|
1962
2082
|
`moves nothing. Read it from props.style and apply it ONLY when ` +
|
|
1963
2083
|
`set, so the theme still shows through while the author has chosen ` +
|
|
1964
|
-
`none. If it is not styleable, drop it from styleSchema instead
|
|
2084
|
+
`none. If it is not styleable, drop it from styleSchema instead.` +
|
|
2085
|
+
(_WHOLE_WIDGET_STYLE_FIELDS.has(field) ? _BASIC_STYLE_FIX : ""),
|
|
1965
2086
|
line: 0,
|
|
1966
2087
|
snippet: field,
|
|
1967
2088
|
});
|
|
@@ -1970,6 +2091,26 @@ function lintStyleWiring(manifest, files) {
|
|
|
1970
2091
|
return { ok: !hasErrors, findings };
|
|
1971
2092
|
}
|
|
1972
2093
|
|
|
2094
|
+
const _BASIC_STYLE_CALL = /\bbasicStyle\s*\(/;
|
|
2095
|
+
|
|
2096
|
+
const _BASIC_STYLE_FIX =
|
|
2097
|
+
` The one-line fix for every basic field: basicStyle(props.style) from ` +
|
|
2098
|
+
`@colixsystems/widget-sdk — spread its .root on the outermost View and ` +
|
|
2099
|
+
`its .text on each Text in a theme text colour.`;
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* Whether the bundle calls `basicStyle()`, which applies every basic style
|
|
2103
|
+
* field (`CONTRACT.basicStyleSchema`) in one call.
|
|
2104
|
+
*
|
|
2105
|
+
* @param {object|string[]|string} files bundle sources, as `lintStyleWiring` takes them
|
|
2106
|
+
* @returns {boolean}
|
|
2107
|
+
*/
|
|
2108
|
+
function callsBasicStyle(files) {
|
|
2109
|
+
return _bundleSources(files).some((s) =>
|
|
2110
|
+
_BASIC_STYLE_CALL.test(_stripNonCode(s)),
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
1973
2114
|
// sc-7799 — a group name that groups everything defeats the rule: one
|
|
1974
2115
|
// catch-all fieldset reads exactly like the flat list it replaced.
|
|
1975
2116
|
const _GENERIC_STYLE_GROUPS = new Set([
|
|
@@ -1982,16 +2123,9 @@ const _GENERIC_STYLE_GROUPS = new Set([
|
|
|
1982
2123
|
]);
|
|
1983
2124
|
|
|
1984
2125
|
// The five the basic-style gate mandates; they restyle the widget as a whole.
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
|
|
1989
|
-
"background",
|
|
1990
|
-
"textColor",
|
|
1991
|
-
"align",
|
|
1992
|
-
"padding",
|
|
1993
|
-
"margin",
|
|
1994
|
-
]);
|
|
2126
|
+
const _WHOLE_WIDGET_STYLE_FIELDS = new Set(
|
|
2127
|
+
Object.keys(CONTRACT.basicStyleSchema),
|
|
2128
|
+
);
|
|
1995
2129
|
|
|
1996
2130
|
// A second element an author would style apart from the text. Pressable and
|
|
1997
2131
|
// Button are deliberately absent: a touch target wrapping the only label is
|
|
@@ -2312,26 +2446,9 @@ function lintSource(source, options) {
|
|
|
2312
2446
|
}
|
|
2313
2447
|
const findings = [];
|
|
2314
2448
|
const lines = source.split(/\r?\n/);
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
|
|
2318
|
-
// (masking preserves newlines), so the reported snippet still comes from
|
|
2319
|
-
// the original source.
|
|
2320
|
-
const codeLines = _stripNonCode(source).split(/\r?\n/);
|
|
2321
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2322
|
-
const codeLine = codeLines[i];
|
|
2323
|
-
for (const rule of RULES) {
|
|
2324
|
-
if (rule.pattern.test(codeLine)) {
|
|
2325
|
-
findings.push({
|
|
2326
|
-
rule: rule.id,
|
|
2327
|
-
severity: "error",
|
|
2328
|
-
label: rule.label,
|
|
2329
|
-
line: i + 1,
|
|
2330
|
-
snippet: lines[i].trim().slice(0, 200),
|
|
2331
|
-
});
|
|
2332
|
-
}
|
|
2333
|
-
}
|
|
2334
|
-
}
|
|
2449
|
+
// Comments and string text are masked, so a banned identifier fires only on
|
|
2450
|
+
// code, never on the same word in prose or string data.
|
|
2451
|
+
findings.push(..._bannedApiFindings(source, lines));
|
|
2335
2452
|
findings.push(
|
|
2336
2453
|
..._importRules(source, options && options.manifest).map((f) => ({
|
|
2337
2454
|
...f,
|
|
@@ -2385,6 +2502,7 @@ function lintSource(source, options) {
|
|
|
2385
2502
|
module.exports = {
|
|
2386
2503
|
lintSource,
|
|
2387
2504
|
lintStyleWiring,
|
|
2505
|
+
callsBasicStyle,
|
|
2388
2506
|
lintEditability,
|
|
2389
2507
|
bannedIdentifiers,
|
|
2390
2508
|
narrowManifestForFile,
|
package/dist/linter.js
CHANGED
|
@@ -9,47 +9,151 @@
|
|
|
9
9
|
import { CONTRACT, normaliseActionTriggerTypes } from "./contract.js";
|
|
10
10
|
import { LUCIDE_ICON_NAMES, LUCIDE_VERSION } from "./lucideIconNames.js";
|
|
11
11
|
|
|
12
|
-
// Per-identifier match rule
|
|
13
|
-
// whole-word match
|
|
14
|
-
// `import(`) that needs a tighter regex.
|
|
12
|
+
// Per-identifier match rule, run over the whole masked source. Most banned
|
|
13
|
+
// identifiers compile to a whole-word match.
|
|
15
14
|
function _ruleForIdentifier(identifier, reason) {
|
|
16
15
|
const id = identifier;
|
|
17
16
|
if (id === "Function") {
|
|
17
|
+
// Any reference, so an alias (`const F = Function`) is refused too.
|
|
18
18
|
return {
|
|
19
19
|
id: "no-function-constructor",
|
|
20
20
|
label: `${reason} (banned: Function() constructor)`,
|
|
21
|
-
pattern: /(
|
|
21
|
+
pattern: /(?<![\w$])Function(?![\w$])/g,
|
|
22
|
+
accept: (code, index) =>
|
|
23
|
+
!_isMemberName(code, index) && _wordBefore(code, index) !== "instanceof",
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
if (id === "new Function") {
|
|
25
27
|
return {
|
|
26
28
|
id: "no-new-function",
|
|
27
29
|
label: `${reason} (banned: new Function())`,
|
|
28
|
-
pattern: /\bnew\s+Function\s*\(
|
|
30
|
+
pattern: /\bnew\s+Function\s*\(/g,
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
if (id === "import(") {
|
|
32
34
|
return {
|
|
33
35
|
id: "no-dynamic-import",
|
|
34
36
|
label: `${reason} (banned: dynamic import())`,
|
|
35
|
-
pattern: /(
|
|
37
|
+
pattern: /(?<![\w$.])import\s*\(/g,
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
if (id === "eval") {
|
|
41
|
+
// Any reference, so indirect `(0, eval)(…)` is refused too.
|
|
39
42
|
return {
|
|
40
43
|
id: "no-eval",
|
|
41
44
|
label: `${reason} (banned: eval)`,
|
|
42
|
-
pattern:
|
|
45
|
+
pattern: /(?<![\w$])eval(?![\w$])/g,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (id === ".constructor") {
|
|
49
|
+
return {
|
|
50
|
+
id: "no-constructor-access",
|
|
51
|
+
label: `${reason} (banned: .constructor)`,
|
|
52
|
+
// Also `{ constructor: F } = fn`; a class's `constructor(` passes.
|
|
53
|
+
pattern: /\.\s*constructor(?![\w$])|(?<![\w$.])constructor(?![\w$])(?!\s*\()/g,
|
|
54
|
+
stringPattern: /(["'`])constructor\1/g,
|
|
43
55
|
};
|
|
44
56
|
}
|
|
45
|
-
// Default: word-boundary anchored. Used for the simple identifier
|
|
46
|
-
// forms (`window`, `document`, `process`, ...).
|
|
47
57
|
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
const rule = { id: `no-${id.toLowerCase()}`, label: `${reason} (banned: ${id})` };
|
|
59
|
+
if (_GLOBAL_OBJECT_ALIASES.has(id)) {
|
|
60
|
+
// Also a common property name, so only a read of the variable is refused.
|
|
61
|
+
return {
|
|
62
|
+
...rule,
|
|
63
|
+
pattern: new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "g"),
|
|
64
|
+
accept: (code, index) => _readsVariable(code, index, id.length),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { ...rule, pattern: new RegExp(`\\b${escaped}\\b`, "g") };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Other names for the page's global object that are also everyday property
|
|
71
|
+
// names (`insets.top`, `node.parent`).
|
|
72
|
+
const _GLOBAL_OBJECT_ALIASES = new Set(["self", "top", "parent", "frames", "opener"]);
|
|
73
|
+
|
|
74
|
+
// Read-leading keywords `_EXPRESSION_LEAD_WORDS` leaves out.
|
|
75
|
+
const _READ_LEAD_WORDS = new Set(["new", "extends", "default"]);
|
|
76
|
+
|
|
77
|
+
// Index of the last non-space character before `index`, and whether a
|
|
78
|
+
// newline lies between.
|
|
79
|
+
function _tokenBefore(code, index) {
|
|
80
|
+
let at = index - 1;
|
|
81
|
+
let newline = false;
|
|
82
|
+
for (; at >= 0 && /\s/.test(code[at]); at -= 1) {
|
|
83
|
+
if (code[at] === "\n") newline = true;
|
|
84
|
+
}
|
|
85
|
+
return { at, newline };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _wordBefore(code, index) {
|
|
89
|
+
const { at } = _tokenBefore(code, index);
|
|
90
|
+
let start = at + 1;
|
|
91
|
+
while (start > 0 && /[\w$]/.test(code[start - 1])) start -= 1;
|
|
92
|
+
return code.slice(start, at + 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `x.name` / `x?.name`, but not a spread (`...name`) and not the decimal point
|
|
96
|
+
// of an integer (`1.` + newline starts a new statement).
|
|
97
|
+
function _isMemberName(code, index) {
|
|
98
|
+
const { at } = _tokenBefore(code, index);
|
|
99
|
+
if (code[at] !== ".") return false;
|
|
100
|
+
if (code[at - 1] === ".") return false;
|
|
101
|
+
return !/(^|[^\w$.])\d[\d_]*$/.test(code.slice(Math.max(0, at - 64), at));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Whether the name at `index` reads its variable. A member name, an object
|
|
105
|
+
// key, a write, and a word after a declaration keyword or JSX text on the
|
|
106
|
+
// same line do not.
|
|
107
|
+
function _readsVariable(code, index, length) {
|
|
108
|
+
if (_isMemberName(code, index)) return false;
|
|
109
|
+
const { at: before, newline } = _tokenBefore(code, index);
|
|
110
|
+
let after = index + length;
|
|
111
|
+
while (after < code.length && /\s/.test(code[after])) after += 1;
|
|
112
|
+
const prev = code[before] || "";
|
|
113
|
+
if (code[after] === ":" && (prev === "{" || prev === ",")) return false;
|
|
114
|
+
if (code[after] === "=" && !/[=>]/.test(code[after + 1] || "")) return false;
|
|
115
|
+
if (newline || !/[\w$]/.test(prev)) return true;
|
|
116
|
+
const word = _wordBefore(code, index);
|
|
117
|
+
return _EXPRESSION_LEAD_WORDS.has(word) || _READ_LEAD_WORDS.has(word);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A `\u` escape spelling an ASCII identifier character (`\u0077indow`).
|
|
121
|
+
const _IDENTIFIER_ESCAPE_RE = /\\u(?:\{0*([0-9a-fA-F]{1,6})\}|([0-9a-fA-F]{4}))/g;
|
|
122
|
+
|
|
123
|
+
function _escapesAsciiIdentifier(code, index) {
|
|
124
|
+
// A fresh regex: matchAll clones the shared one with its lastIndex.
|
|
125
|
+
const at = new RegExp(_IDENTIFIER_ESCAPE_RE.source, "y");
|
|
126
|
+
at.lastIndex = index;
|
|
127
|
+
const m = at.exec(code);
|
|
128
|
+
const ch = String.fromCodePoint(parseInt(m[1] || m[2], 16));
|
|
129
|
+
return /^[\w$]$/.test(ch);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// One finding per rule per line, in line order, then rule order. Masking keeps
|
|
133
|
+
// every index, so a match's line is the source's line.
|
|
134
|
+
function _bannedApiFindings(source, lines) {
|
|
135
|
+
const masks = [_stripNonCode(source), _stripNonCode(source, { keepStrings: true })];
|
|
136
|
+
const hits = [];
|
|
137
|
+
RULES.forEach((rule, order) => {
|
|
138
|
+
const ruleLines = new Set();
|
|
139
|
+
[rule.pattern, rule.stringPattern].forEach((pattern, maskIndex) => {
|
|
140
|
+
if (!pattern) return;
|
|
141
|
+
const code = masks[maskIndex];
|
|
142
|
+
const lineAt = _lineCounter(code);
|
|
143
|
+
for (const match of code.matchAll(pattern)) {
|
|
144
|
+
if (!rule.accept || rule.accept(code, match.index)) ruleLines.add(lineAt(match.index));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
for (const line of ruleLines) hits.push({ rule, order, line });
|
|
148
|
+
});
|
|
149
|
+
hits.sort((a, b) => a.line - b.line || a.order - b.order);
|
|
150
|
+
return hits.map(({ rule, line }) => ({
|
|
151
|
+
rule: rule.id,
|
|
152
|
+
severity: "error",
|
|
153
|
+
label: rule.label,
|
|
154
|
+
line,
|
|
155
|
+
snippet: lines[line - 1].trim().slice(0, 200),
|
|
156
|
+
}));
|
|
53
157
|
}
|
|
54
158
|
|
|
55
159
|
const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
@@ -73,7 +177,14 @@ const EXTRA_RULES = [
|
|
|
73
177
|
{
|
|
74
178
|
id: "no-auth-store-import",
|
|
75
179
|
label: "widgets must not import the host's auth store",
|
|
76
|
-
pattern: /useAuthStore
|
|
180
|
+
pattern: /useAuthStore/g,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "no-identifier-escape",
|
|
184
|
+
label:
|
|
185
|
+
"a \\u escape spelling a letter, digit, _ or $ hides a name from the banned-API scan; write the name plainly",
|
|
186
|
+
pattern: _IDENTIFIER_ESCAPE_RE,
|
|
187
|
+
accept: _escapesAsciiIdentifier,
|
|
77
188
|
},
|
|
78
189
|
];
|
|
79
190
|
|
|
@@ -2009,11 +2120,15 @@ export function lintStyleWiring(manifest, files) {
|
|
|
2009
2120
|
const code = sources
|
|
2010
2121
|
.map((s) => _stripNonCode(s, { keepStrings: true }))
|
|
2011
2122
|
.join("\n");
|
|
2123
|
+
const appliedByHelper = callsBasicStyle(sources)
|
|
2124
|
+
? _WHOLE_WIDGET_STYLE_FIELDS
|
|
2125
|
+
: new Set();
|
|
2012
2126
|
const findings = [];
|
|
2013
2127
|
for (const field of Object.keys(schema)) {
|
|
2014
2128
|
// A key that is not a bare identifier cannot be scanned by name; the
|
|
2015
2129
|
// manifest validator rejects those anyway.
|
|
2016
2130
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
|
|
2131
|
+
if (appliedByHelper.has(field)) continue;
|
|
2017
2132
|
if (new RegExp(`\\b${field}\\b`).test(code)) continue;
|
|
2018
2133
|
findings.push({
|
|
2019
2134
|
rule: "style-field-unread",
|
|
@@ -2024,7 +2139,8 @@ export function lintStyleWiring(manifest, files) {
|
|
|
2024
2139
|
`— per instance, and app-wide under Design -> Widget appearance — ` +
|
|
2025
2140
|
`moves nothing. Read it from props.style and apply it ONLY when ` +
|
|
2026
2141
|
`set, so the theme still shows through while the author has chosen ` +
|
|
2027
|
-
`none. If it is not styleable, drop it from styleSchema instead
|
|
2142
|
+
`none. If it is not styleable, drop it from styleSchema instead.` +
|
|
2143
|
+
(_WHOLE_WIDGET_STYLE_FIELDS.has(field) ? _BASIC_STYLE_FIX : ""),
|
|
2028
2144
|
line: 0,
|
|
2029
2145
|
snippet: field,
|
|
2030
2146
|
});
|
|
@@ -2033,6 +2149,26 @@ export function lintStyleWiring(manifest, files) {
|
|
|
2033
2149
|
return { ok: !hasErrors, findings };
|
|
2034
2150
|
}
|
|
2035
2151
|
|
|
2152
|
+
const _BASIC_STYLE_CALL = /\bbasicStyle\s*\(/;
|
|
2153
|
+
|
|
2154
|
+
const _BASIC_STYLE_FIX =
|
|
2155
|
+
` The one-line fix for every basic field: basicStyle(props.style) from ` +
|
|
2156
|
+
`@colixsystems/widget-sdk — spread its .root on the outermost View and ` +
|
|
2157
|
+
`its .text on each Text in a theme text colour.`;
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* Whether the bundle calls `basicStyle()`, which applies every basic style
|
|
2161
|
+
* field (`CONTRACT.basicStyleSchema`) in one call.
|
|
2162
|
+
*
|
|
2163
|
+
* @param {object|string[]|string} files bundle sources, as `lintStyleWiring` takes them
|
|
2164
|
+
* @returns {boolean}
|
|
2165
|
+
*/
|
|
2166
|
+
export function callsBasicStyle(files) {
|
|
2167
|
+
return _bundleSources(files).some((s) =>
|
|
2168
|
+
_BASIC_STYLE_CALL.test(_stripNonCode(s)),
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2036
2172
|
// sc-7799 — a group name that groups everything defeats the rule: one
|
|
2037
2173
|
// catch-all fieldset reads exactly like the flat list it replaced.
|
|
2038
2174
|
const _GENERIC_STYLE_GROUPS = new Set([
|
|
@@ -2045,16 +2181,9 @@ const _GENERIC_STYLE_GROUPS = new Set([
|
|
|
2045
2181
|
]);
|
|
2046
2182
|
|
|
2047
2183
|
// The five the basic-style gate mandates; they restyle the widget as a whole.
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
|
|
2052
|
-
"background",
|
|
2053
|
-
"textColor",
|
|
2054
|
-
"align",
|
|
2055
|
-
"padding",
|
|
2056
|
-
"margin",
|
|
2057
|
-
]);
|
|
2184
|
+
const _WHOLE_WIDGET_STYLE_FIELDS = new Set(
|
|
2185
|
+
Object.keys(CONTRACT.basicStyleSchema),
|
|
2186
|
+
);
|
|
2058
2187
|
|
|
2059
2188
|
// A second element an author would style apart from the text. Pressable and
|
|
2060
2189
|
// Button are deliberately absent: a touch target wrapping the only label is
|
|
@@ -2375,26 +2504,9 @@ export function lintSource(source, options) {
|
|
|
2375
2504
|
}
|
|
2376
2505
|
const findings = [];
|
|
2377
2506
|
const lines = source.split(/\r?\n/);
|
|
2378
|
-
//
|
|
2379
|
-
//
|
|
2380
|
-
|
|
2381
|
-
// (masking preserves newlines), so the reported snippet still comes from
|
|
2382
|
-
// the original source.
|
|
2383
|
-
const codeLines = _stripNonCode(source).split(/\r?\n/);
|
|
2384
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2385
|
-
const codeLine = codeLines[i];
|
|
2386
|
-
for (const rule of RULES) {
|
|
2387
|
-
if (rule.pattern.test(codeLine)) {
|
|
2388
|
-
findings.push({
|
|
2389
|
-
rule: rule.id,
|
|
2390
|
-
severity: "error",
|
|
2391
|
-
label: rule.label,
|
|
2392
|
-
line: i + 1,
|
|
2393
|
-
snippet: lines[i].trim().slice(0, 200),
|
|
2394
|
-
});
|
|
2395
|
-
}
|
|
2396
|
-
}
|
|
2397
|
-
}
|
|
2507
|
+
// Comments and string text are masked, so a banned identifier fires only on
|
|
2508
|
+
// code, never on the same word in prose or string data.
|
|
2509
|
+
findings.push(..._bannedApiFindings(source, lines));
|
|
2398
2510
|
// REQ-WSDK-PLATFORM §3.4: import-specifier validation.
|
|
2399
2511
|
findings.push(
|
|
2400
2512
|
..._importRules(source, options && options.manifest).map((f) => ({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.272.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
package/src/contract.js
CHANGED
|
@@ -386,6 +386,38 @@ const THEME_SPACING_SCALE = Object.freeze({
|
|
|
386
386
|
default: 1,
|
|
387
387
|
});
|
|
388
388
|
|
|
389
|
+
// The style fields every generated widget declares, with the controls the
|
|
390
|
+
// Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
|
|
391
|
+
// `basicStyle()`, the linter, the publish gate and the Mason plan gate.
|
|
392
|
+
const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
|
|
393
|
+
const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
|
|
394
|
+
const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
395
|
+
background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
|
|
396
|
+
textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
|
|
397
|
+
align: Object.freeze({
|
|
398
|
+
type: "select",
|
|
399
|
+
label: "Align",
|
|
400
|
+
enum: Object.freeze([
|
|
401
|
+
Object.freeze({ value: "left", label: "Left" }),
|
|
402
|
+
Object.freeze({ value: "center", label: "Center" }),
|
|
403
|
+
Object.freeze({ value: "right", label: "Right" }),
|
|
404
|
+
]),
|
|
405
|
+
ui: BASIC_STYLE_GROUP,
|
|
406
|
+
}),
|
|
407
|
+
padding: Object.freeze({
|
|
408
|
+
type: "spacing",
|
|
409
|
+
label: "Padding",
|
|
410
|
+
validation: BASIC_SPACING_RANGE,
|
|
411
|
+
ui: BASIC_STYLE_GROUP,
|
|
412
|
+
}),
|
|
413
|
+
margin: Object.freeze({
|
|
414
|
+
type: "spacing",
|
|
415
|
+
label: "Margin",
|
|
416
|
+
validation: BASIC_SPACING_RANGE,
|
|
417
|
+
ui: BASIC_STYLE_GROUP,
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
|
|
389
421
|
// The card-surface field names shared by every widget that paints its own card
|
|
390
422
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
391
423
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -4505,6 +4537,20 @@ const BANNED_APIS = [
|
|
|
4505
4537
|
reason:
|
|
4506
4538
|
"Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
|
|
4507
4539
|
},
|
|
4540
|
+
...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
|
|
4541
|
+
identifier,
|
|
4542
|
+
reason:
|
|
4543
|
+
"Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
|
|
4544
|
+
})),
|
|
4545
|
+
{
|
|
4546
|
+
identifier: "frameElement",
|
|
4547
|
+
reason: "The element framing the page, a route to the host document.",
|
|
4548
|
+
},
|
|
4549
|
+
{
|
|
4550
|
+
identifier: ".constructor",
|
|
4551
|
+
reason:
|
|
4552
|
+
"Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
|
|
4553
|
+
},
|
|
4508
4554
|
];
|
|
4509
4555
|
|
|
4510
4556
|
// REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
|
|
@@ -6505,7 +6551,12 @@ const CONTRACT = deepFreeze({
|
|
|
6505
6551
|
// every query, record, figure and aggregate on that table, app-wide.
|
|
6506
6552
|
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6507
6553
|
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6508
|
-
|
|
6554
|
+
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6555
|
+
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6556
|
+
// 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
|
|
6557
|
+
// `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
|
|
6558
|
+
// `Function` are refused as any reference, not only as a call.
|
|
6559
|
+
version: "1.222.0",
|
|
6509
6560
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6510
6561
|
hooks: HOOKS,
|
|
6511
6562
|
primitives: PRIMITIVES,
|
|
@@ -6530,6 +6581,7 @@ const CONTRACT = deepFreeze({
|
|
|
6530
6581
|
themeSpacingScale: THEME_SPACING_SCALE,
|
|
6531
6582
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6532
6583
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6584
|
+
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6533
6585
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6534
6586
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6535
6587
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|