@colixsystems/widget-sdk 0.122.0 → 0.123.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 +37 -3
- package/dist/contract.cjs +38 -4
- package/dist/contract.js +38 -4
- package/dist/index.d.ts +124 -0
- package/dist/index.js +17 -0
- package/dist/index.native.js +15 -0
- package/dist/linter.cjs +43 -0
- package/dist/linter.js +58 -0
- package/dist/markdown-edit.js +97 -0
- package/dist/markdown-input-view.js +171 -0
- package/dist/markdown-input.js +9 -0
- package/dist/markdown-input.native.js +12 -0
- package/dist/markdown.js +229 -0
- package/dist/richtext-tokens.js +59 -0
- package/dist/richtext-view.js +156 -0
- package/dist/richtext.js +11 -0
- package/dist/richtext.native.js +8 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
37
37
|
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` — wired by the Player and the Expo export; an authoring preview omits it and the call is a no-op — no scope |
|
|
38
38
|
| **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). **Background watch (sc-6450)** — `startBackgroundWatch({ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs })` keeps positions arriving while the app is backgrounded; `stopBackgroundWatch()` releases it. NATIVE-ONLY and opt-in per app: gate the control on `backgroundSupported` (false on web, and in an export whose workspace did not opt in). The watch outlives the widget's mount, and its positions land in the same `latitude`/`longitude`/`accuracy` slots. |
|
|
39
39
|
| **CORE** | `useSpeechToText(options?)` | `{ transcript, partial, listening, supported, error, start, stop, abort, reset }` | `ctx.device.speech` — no scope. Dictation with the device's **on-device** recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call `start()` from a user gesture (a tap), never on mount. `transcript` accumulates finalised speech, `partial` holds the uncommitted guess (needs `options.interimResults`); `stop()` keeps it, `abort()` discards it. Rejects with `SpeechToTextError` (`.code` in `PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL`). **Gate your mic button on `supported`** — Firefox ships no `SpeechRecognition`. Identical on web (`SpeechRecognition`) and the Expo export (`expo-speech-recognition`). |
|
|
40
|
-
| **CORE** | `useCamera(options?)` | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (file input) and the Expo export (`expo-image-picker`). |
|
|
40
|
+
| **CORE** | `useCamera(options?)` | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (a live `getUserMedia` preview, phones included; a file input only where getUserMedia is absent) and the Expo export (`expo-image-picker`). |
|
|
41
41
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
42
42
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
43
43
|
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
@@ -70,7 +70,35 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
70
70
|
|
|
71
71
|
## Status
|
|
72
72
|
|
|
73
|
-
`v0.
|
|
73
|
+
`v0.123.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**.
|
|
74
|
+
|
|
75
|
+
### What's new in 0.123.0 (contract unchanged at 1.95.0)
|
|
76
|
+
|
|
77
|
+
**Two new primitives, `<RichText>` and `<MarkdownInput>` — a widget can finally let an app user write FORMATTED text (sc-6970).** Widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so there was no path at all from a content string to bold, a heading or a list. A generated "manage content" widget that was asked for a formatting toolbar therefore drew a fake B/I/H2 bar over a plain `TextInput` whose buttons spliced literal `<strong>`/`<br>` tags into the value — the author read tag soup with no preview of the text a reader would get, and the reader got the tags.
|
|
78
|
+
|
|
79
|
+
Content is **markdown text** now: one primitive authors it, the other renders it, and the string that travels between them is the one you store.
|
|
80
|
+
|
|
81
|
+
```jsx
|
|
82
|
+
<MarkdownInput value={draft} onChange={setDraft} placeholder="Write the announcement…" />
|
|
83
|
+
|
|
84
|
+
<RichText value={post.body} />
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`<MarkdownInput>` is a multi-line field, a toolbar whose buttons wrap the current **selection** in markdown markers (bold, italic, code, H2, H3, bullets, numbered list — pressing one again toggles it back off), and a live `<RichText>` preview of the result underneath. `previewLabel` defaults to `Preview` and `showPreview` to `true`; `minHeight` (150) and `maxHeight` (280) give the field a floor and a ceiling, so a long draft scrolls inside its box instead of growing past its card. Reach for it instead of a bare `TextInput` plus hand-rolled formatting buttons, which can only splice literal tags into the text.
|
|
88
|
+
|
|
89
|
+
`<RichText>` parses the subset — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, and one-line `` images — and renders it with SDK primitives, so formatted content reads the same in the web Player and the exported Expo app. Colour, type scale and leading come from the workspace theme; never re-style them. Pass `renderImage` (`({ src, alt, size }) => node`) when images are filestore ids, because resolving one needs the scopes your own widget holds — without it only absolute `http(s)` URLs render. HTML is never interpreted: stored text that still holds markup is stripped to plain text rather than shown as tags.
|
|
90
|
+
|
|
91
|
+
The grammar is exported too, for content you need to inspect rather than render: `parseMarkdown(text)` returns the block list, `markdownToPlainText(text)` a marker-free projection for a list preview, a search index or an accessibility label, `parseMarkdownImage(line)` / `formatMarkdownImage(block)` read and write the one-line image form, `stripHtmlToMarkdown(text)` normalises a legacy HTML row on read, and `MARKDOWN_IMAGE_SIZES` is the closed size list (`small` | `medium` | `large` | `full`).
|
|
92
|
+
|
|
93
|
+
A widget that renders images itself through `renderImage` gets the image half of the same grammar, so it classifies a src exactly the way the parser does rather than re-deriving the rule: `isSafeMarkdownImageSrc(src)` (an `http(s)` URL or a filestore id — anything else, a `javascript:` scheme included, is refused), `isHttpImageSrc(src)` to tell a URL from a filestore id, `normaliseMarkdownImageSize(size)` and `readMarkdownAlt(raw)`.
|
|
94
|
+
|
|
95
|
+
The linter gains `no-html-in-content` to catch the old shape: an HTML tag inside a **string** (`"<strong>"`, `"<br>"`, `"<p>…</p>"`) is flagged, because no host will ever render it. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX cannot trip it. Warning-severity for a human author, blocking for the AI widget agent.
|
|
96
|
+
|
|
97
|
+
Additive — two new primitives, ten new grammar exports, one new soft linter rule; no existing export changed signature. `CONTRACT.primitives` carries both entries; `CONTRACT.version` is unchanged at `1.95.0`.
|
|
98
|
+
|
|
99
|
+
### What's new in contract 1.95.0 (package unchanged at 0.122.0)
|
|
100
|
+
|
|
101
|
+
**`useCamera().capture()` opens a real camera on the DESKTOP web Player.** The web broker's only camera surface was `<input type="file" capture="environment">`, and that attribute is honoured by phone browsers but **silently ignored on the desktop** — so on a laptop `capture()` opened an ordinary file dialog, indistinguishable from `pick()`. It now opens a live `getUserMedia` preview with a shutter, on every web host. Nothing a widget imports changed: same signature, same normalised asset, same `PERMISSION_DENIED | UNSUPPORTED | INTERNAL` vocabulary, and the Expo export is untouched. Three things a widget can observe: `options.quality` now reaches the encoder on web (it previously reached nothing); a phone gets the same in-page preview rather than its OS camera app, which trades the phone camera's optics for one behaviour everywhere; and a camera that cannot be opened (no device, or another app holding it) rejects `UNSUPPORTED` instead of quietly showing a file dialog.
|
|
74
102
|
|
|
75
103
|
### What's new in 0.122.0 (contract 1.94.0)
|
|
76
104
|
|
|
@@ -228,7 +256,7 @@ fd.append("file", asset.file);
|
|
|
228
256
|
await ctx.assets.upload(fd);
|
|
229
257
|
```
|
|
230
258
|
|
|
231
|
-
`options` (`{ allowsEditing, quality }`) are **hints**: the Expo export applies both, the web
|
|
259
|
+
`options` (`{ allowsEditing, quality }`) are **hints**: the Expo export applies both, the web camera applies `quality` only — so never depend on a cropped result. `reset()` clears the asset and releases it (on web that revokes the blob URL, which otherwise leaks for the life of the document). **Gate your camera button on `supported`** — a host that brokers no camera reports `false` rather than throwing. The web Player brokers it via `getUserMedia` — a live preview with a shutter — on every web host: it is the only camera a DESKTOP browser will open, and a phone reaches the same preview rather than its own camera app. It falls back to a file input (`capture="environment"`) only where getUserMedia is absent up front: an insecure origin or an older browser. Once the permission prompt has been shown the user gesture is spent, so a camera that then fails to open rejects (`PERMISSION_DENIED` if refused, `UNSUPPORTED` if there is no usable device) rather than falling back to a file dialog the browser would refuse to open. The Expo export uses `expo-image-picker`, whose config plugin declares the camera and photo-library permissions the runtime needs.
|
|
232
260
|
|
|
233
261
|
Additive — one new hook, one new optional context-slice member; no existing export changed signature.
|
|
234
262
|
|
|
@@ -1223,6 +1251,10 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
1223
1251
|
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
1224
1252
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
1225
1253
|
- `Overlay` — the screen-level overlay (sc-6607). `<Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">…</Overlay>` renders its children OUTSIDE the widget's layout box on both hosts (web portals it to the document root, native uses the OS modal), so no clipping card, `ScrollView` or neighbouring widget can cut off a document/media preview, lightbox or confirm dialog — which absolute positioning inside the widget cannot achieve on either platform. Children stay in your own React tree, so state and hooks work normally. `onRequestClose` carries the backdrop press, Escape on web, and the Android back button; `size` is `sm` | `md` (default) | `lg` | `full`; the scrim, surface, radius, padding and elevation come from the workspace theme. An anchored dropdown still belongs inside your own root.
|
|
1254
|
+
- `RichText` — the formatted-content renderer (sc-6970). `<RichText value={post.body} />` parses the markdown subset (`**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, one-line `` images) and renders it with SDK primitives, so formatted content reads the same in the web Player and the exported Expo app. `value` is markdown, NOT HTML — widgets have no `dangerouslySetInnerHTML` on either host, so tags in a content string render as literal tag soup; stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value`, `renderImage` (`({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving one needs the scopes your own widget holds; without it only absolute `http(s)` URLs render), `style`, `testID`. Colour, type scale and leading come from the workspace theme — never re-style them.
|
|
1255
|
+
- `MarkdownInput` — the authoring half of `<RichText>` (sc-6970). `<MarkdownInput value={draft} onChange={setDraft} />` is a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list, each toggling back off), and a live `<RichText>` preview of the result underneath — the way to let an app USER write formatted text, instead of a bare `TextInput` plus hand-rolled buttons that can only splice literal tags into the value. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default `Preview`), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (150) and `maxHeight` (280) so a long draft scrolls inside its box, `accessibilityLabel`, `style`, `testID`.
|
|
1256
|
+
- `parseMarkdown`, `markdownToPlainText`, `parseMarkdownImage`, `formatMarkdownImage`, `stripHtmlToMarkdown`, `MARKDOWN_IMAGE_SIZES` — the same grammar as pure helpers, for content you inspect rather than render: the block list, a marker-free projection (list previews, search, a11y labels), the one-line image form read and written, a legacy-HTML row normalised to markdown on read, and the closed size list (`small` | `medium` | `large` | `full`).
|
|
1257
|
+
- `isSafeMarkdownImageSrc`, `isHttpImageSrc`, `normaliseMarkdownImageSize`, `readMarkdownAlt` — the image half of that grammar, for a widget rendering images itself via `renderImage`. `isSafeMarkdownImageSrc` is the same allowlist the parser applies (an `http(s)` URL or a filestore id; a `javascript:` scheme is refused), so the widget and the parser cannot disagree about which src is renderable.
|
|
1226
1258
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
1227
1259
|
|
|
1228
1260
|
## Design & visual polish
|
|
@@ -1501,6 +1533,8 @@ Two rules keep a widget's look reachable from the Studio (sc-6455). `no-hardcode
|
|
|
1501
1533
|
|
|
1502
1534
|
Pass `--manifest` to enable `style-field-unread` — it needs the manifest, and it needs every source at once so a split-impl widget's per-host field reads are seen together.
|
|
1503
1535
|
|
|
1536
|
+
`no-html-in-content` flags an HTML tag inside a **string** (sc-6970) — `"<strong>"`, `"<br>"`, `"<p>…</p>"`. There is no HTML renderer on either host, so the tag reaches the reader as literal text; author formatted text with `<MarkdownInput>` and render it with `<RichText>` instead. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX can never trip it. A warning for a human author, blocking for the AI widget agent.
|
|
1537
|
+
|
|
1504
1538
|
## Local dev loop (`appstudio-widget dev`)
|
|
1505
1539
|
|
|
1506
1540
|
Author a marketplace widget with live reload instead of the publish → submit →
|
package/dist/contract.cjs
CHANGED
|
@@ -1664,8 +1664,10 @@ const HOOKS = [
|
|
|
1664
1664
|
"upload part for the host (a File on web, { uri, name, type } on native): append it to a FormData as `file` and pass " +
|
|
1665
1665
|
"that to ctx.assets.upload(fd) — one code path on both platforms. reset() clears the asset and releases it. Check " +
|
|
1666
1666
|
"`supported` before rendering a camera button. options: { allowsEditing, quality } are HINTS the host honours where it " +
|
|
1667
|
-
"can — the Expo export applies both, the web
|
|
1668
|
-
"
|
|
1667
|
+
"can — the Expo export applies both, the web camera applies quality only, so never depend on a cropped result. " +
|
|
1668
|
+
"Behaviour is otherwise identical on web (a getUserMedia preview on EVERY host, phones included; a file input " +
|
|
1669
|
+
"only where getUserMedia is absent) and the Expo export (expo-image-picker). A camera that cannot be opened " +
|
|
1670
|
+
"rejects UNSUPPORTED — there is no silent fall back to a file dialog.",
|
|
1669
1671
|
returnShape: {
|
|
1670
1672
|
asset:
|
|
1671
1673
|
"{ uri, name, mimeType, width, height, size, file } | null",
|
|
@@ -1835,6 +1837,25 @@ const PRIMITIVES = [
|
|
|
1835
1837
|
rnComponent: null,
|
|
1836
1838
|
docsUrl: null,
|
|
1837
1839
|
},
|
|
1840
|
+
// sc-6970 — formatted content, rendered. Widgets have no HTML path on either
|
|
1841
|
+
// host, so this is the ONE way bold, headings and lists reach a reader.
|
|
1842
|
+
{
|
|
1843
|
+
name: "RichText",
|
|
1844
|
+
description:
|
|
1845
|
+
'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, and one-line `` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them.',
|
|
1846
|
+
rnComponent: null,
|
|
1847
|
+
docsUrl: null,
|
|
1848
|
+
},
|
|
1849
|
+
// sc-6970 — formatted content, authored. The authoring half of `<RichText>`:
|
|
1850
|
+
// a real selection-aware toolbar plus a live preview, so a generated widget
|
|
1851
|
+
// never has to splice HTML tags into a plain TextInput again.
|
|
1852
|
+
{
|
|
1853
|
+
name: "MarkdownInput",
|
|
1854
|
+
description:
|
|
1855
|
+
'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), and a live `<RichText>` preview of the result underneath. Use it instead of a bare `TextInput` plus hand-rolled formatting buttons — buttons that splice `<strong>`/`<br>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`.',
|
|
1856
|
+
rnComponent: null,
|
|
1857
|
+
docsUrl: null,
|
|
1858
|
+
},
|
|
1838
1859
|
];
|
|
1839
1860
|
|
|
1840
1861
|
const CATEGORIES = [
|
|
@@ -2303,7 +2324,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2303
2324
|
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2304
2325
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
|
|
2305
2326
|
"Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
|
|
2306
|
-
"window.SpeechRecognition and a
|
|
2327
|
+
"window.SpeechRecognition and a getUserMedia camera preview; the Expo export via expo-location, expo-speech-recognition and " +
|
|
2307
2328
|
"expo-image-picker. " +
|
|
2308
2329
|
"getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
|
|
2309
2330
|
"speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
|
|
@@ -3621,7 +3642,20 @@ const CONTRACT = deepFreeze({
|
|
|
3621
3642
|
// widget that branched on 404 alone should accept both, and the actionable
|
|
3622
3643
|
// case -- a public space whose upload access is still off -- is now
|
|
3623
3644
|
// distinguishable instead of arriving as a bare "Space not found".
|
|
3624
|
-
|
|
3645
|
+
// 1.95.0: behavioural (web host only) -- `ctx.device.camera.capture()` in the
|
|
3646
|
+
// Player now opens a live `getUserMedia` preview with a shutter instead of
|
|
3647
|
+
// a file input. `<input capture="environment">` is honoured only by phone
|
|
3648
|
+
// browsers and is SILENTLY IGNORED on the desktop, so `capture()` there was
|
|
3649
|
+
// a plain file dialog -- indistinguishable from `pick()`, and no camera at
|
|
3650
|
+
// all. No signature, asset shape or error code moved, and the native export
|
|
3651
|
+
// is untouched; what a web host DOES for the same call changed. Two
|
|
3652
|
+
// consequences worth branching on: `options.quality` now reaches the JPEG
|
|
3653
|
+
// encoder on web (it previously reached nothing), and a phone now gets the
|
|
3654
|
+
// same in-page preview rather than its OS camera app. A camera that cannot
|
|
3655
|
+
// be opened rejects UNSUPPORTED rather than falling back to a file dialog:
|
|
3656
|
+
// the gesture is already spent on the permission prompt, and a file input
|
|
3657
|
+
// clicked outside it would never open.
|
|
3658
|
+
version: "1.95.0",
|
|
3625
3659
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3626
3660
|
hooks: HOOKS,
|
|
3627
3661
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -1664,8 +1664,10 @@ const HOOKS = [
|
|
|
1664
1664
|
"upload part for the host (a File on web, { uri, name, type } on native): append it to a FormData as `file` and pass " +
|
|
1665
1665
|
"that to ctx.assets.upload(fd) — one code path on both platforms. reset() clears the asset and releases it. Check " +
|
|
1666
1666
|
"`supported` before rendering a camera button. options: { allowsEditing, quality } are HINTS the host honours where it " +
|
|
1667
|
-
"can — the Expo export applies both, the web
|
|
1668
|
-
"
|
|
1667
|
+
"can — the Expo export applies both, the web camera applies quality only, so never depend on a cropped result. " +
|
|
1668
|
+
"Behaviour is otherwise identical on web (a getUserMedia preview on EVERY host, phones included; a file input " +
|
|
1669
|
+
"only where getUserMedia is absent) and the Expo export (expo-image-picker). A camera that cannot be opened " +
|
|
1670
|
+
"rejects UNSUPPORTED — there is no silent fall back to a file dialog.",
|
|
1669
1671
|
returnShape: {
|
|
1670
1672
|
asset:
|
|
1671
1673
|
"{ uri, name, mimeType, width, height, size, file } | null",
|
|
@@ -1835,6 +1837,25 @@ const PRIMITIVES = [
|
|
|
1835
1837
|
rnComponent: null,
|
|
1836
1838
|
docsUrl: null,
|
|
1837
1839
|
},
|
|
1840
|
+
// sc-6970 — formatted content, rendered. Widgets have no HTML path on either
|
|
1841
|
+
// host, so this is the ONE way bold, headings and lists reach a reader.
|
|
1842
|
+
{
|
|
1843
|
+
name: "RichText",
|
|
1844
|
+
description:
|
|
1845
|
+
'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, and one-line `` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them.',
|
|
1846
|
+
rnComponent: null,
|
|
1847
|
+
docsUrl: null,
|
|
1848
|
+
},
|
|
1849
|
+
// sc-6970 — formatted content, authored. The authoring half of `<RichText>`:
|
|
1850
|
+
// a real selection-aware toolbar plus a live preview, so a generated widget
|
|
1851
|
+
// never has to splice HTML tags into a plain TextInput again.
|
|
1852
|
+
{
|
|
1853
|
+
name: "MarkdownInput",
|
|
1854
|
+
description:
|
|
1855
|
+
'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), and a live `<RichText>` preview of the result underneath. Use it instead of a bare `TextInput` plus hand-rolled formatting buttons — buttons that splice `<strong>`/`<br>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`.',
|
|
1856
|
+
rnComponent: null,
|
|
1857
|
+
docsUrl: null,
|
|
1858
|
+
},
|
|
1838
1859
|
];
|
|
1839
1860
|
|
|
1840
1861
|
const CATEGORIES = [
|
|
@@ -2303,7 +2324,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2303
2324
|
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2304
2325
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
|
|
2305
2326
|
"Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
|
|
2306
|
-
"window.SpeechRecognition and a
|
|
2327
|
+
"window.SpeechRecognition and a getUserMedia camera preview; the Expo export via expo-location, expo-speech-recognition and " +
|
|
2307
2328
|
"expo-image-picker. " +
|
|
2308
2329
|
"getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
|
|
2309
2330
|
"speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
|
|
@@ -3621,7 +3642,20 @@ const CONTRACT = deepFreeze({
|
|
|
3621
3642
|
// widget that branched on 404 alone should accept both, and the actionable
|
|
3622
3643
|
// case -- a public space whose upload access is still off -- is now
|
|
3623
3644
|
// distinguishable instead of arriving as a bare "Space not found".
|
|
3624
|
-
|
|
3645
|
+
// 1.95.0: behavioural (web host only) -- `ctx.device.camera.capture()` in the
|
|
3646
|
+
// Player now opens a live `getUserMedia` preview with a shutter instead of
|
|
3647
|
+
// a file input. `<input capture="environment">` is honoured only by phone
|
|
3648
|
+
// browsers and is SILENTLY IGNORED on the desktop, so `capture()` there was
|
|
3649
|
+
// a plain file dialog -- indistinguishable from `pick()`, and no camera at
|
|
3650
|
+
// all. No signature, asset shape or error code moved, and the native export
|
|
3651
|
+
// is untouched; what a web host DOES for the same call changed. Two
|
|
3652
|
+
// consequences worth branching on: `options.quality` now reaches the JPEG
|
|
3653
|
+
// encoder on web (it previously reached nothing), and a phone now gets the
|
|
3654
|
+
// same in-page preview rather than its OS camera app. A camera that cannot
|
|
3655
|
+
// be opened rejects UNSUPPORTED rather than falling back to a file dialog:
|
|
3656
|
+
// the gesture is already spent on the permission prompt, and a file input
|
|
3657
|
+
// clicked outside it would never open.
|
|
3658
|
+
version: "1.95.0",
|
|
3625
3659
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3626
3660
|
hooks: HOOKS,
|
|
3627
3661
|
primitives: PRIMITIVES,
|
package/dist/index.d.ts
CHANGED
|
@@ -2236,6 +2236,130 @@ export const Overlay: (props: {
|
|
|
2236
2236
|
children?: ReactNode;
|
|
2237
2237
|
}) => any;
|
|
2238
2238
|
|
|
2239
|
+
// ----------------------------------------------------- formatted content
|
|
2240
|
+
// sc-6970 — widgets have no HTML path: they render through React Native
|
|
2241
|
+
// primitives, which have no `dangerouslySetInnerHTML` on either host. Content
|
|
2242
|
+
// with formatting is therefore markdown text, authored with `<MarkdownInput>`
|
|
2243
|
+
// and displayed with `<RichText>`.
|
|
2244
|
+
|
|
2245
|
+
/** One inline run of a parsed markdown line. */
|
|
2246
|
+
export interface MarkdownSpan {
|
|
2247
|
+
text: string;
|
|
2248
|
+
bold?: boolean;
|
|
2249
|
+
italic?: boolean;
|
|
2250
|
+
code?: boolean;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
/** One parsed markdown block. Image blocks carry no spans. */
|
|
2254
|
+
export type MarkdownBlock =
|
|
2255
|
+
| { type: "heading"; level: 1 | 2 | 3; spans: MarkdownSpan[] }
|
|
2256
|
+
| { type: "paragraph"; spans: MarkdownSpan[] }
|
|
2257
|
+
| { type: "bullet"; marker: string; spans: MarkdownSpan[] }
|
|
2258
|
+
| { type: "ordered"; marker: string; spans: MarkdownSpan[] }
|
|
2259
|
+
| { type: "image"; src: string; alt: string; size: MarkdownImageSize };
|
|
2260
|
+
|
|
2261
|
+
export type MarkdownImageSize = "small" | "medium" | "large" | "full";
|
|
2262
|
+
|
|
2263
|
+
export const MARKDOWN_IMAGE_SIZES: readonly MarkdownImageSize[];
|
|
2264
|
+
|
|
2265
|
+
/** Parses the markdown subset into blocks. Non-string input yields `[]`. */
|
|
2266
|
+
export function parseMarkdown(text: unknown): MarkdownBlock[];
|
|
2267
|
+
|
|
2268
|
+
/** Marker-free projection of `text`, for search, previews and a11y labels. */
|
|
2269
|
+
export function markdownToPlainText(text: unknown): string;
|
|
2270
|
+
|
|
2271
|
+
/** Reads one line as an image block, or `null` when it is not one. */
|
|
2272
|
+
export function parseMarkdownImage(
|
|
2273
|
+
line: unknown,
|
|
2274
|
+
): { src: string; alt: string; size: MarkdownImageSize } | null;
|
|
2275
|
+
|
|
2276
|
+
/** Writes an image block back to its one-line markdown form. */
|
|
2277
|
+
export function formatMarkdownImage(block: {
|
|
2278
|
+
src?: string;
|
|
2279
|
+
alt?: string;
|
|
2280
|
+
size?: string;
|
|
2281
|
+
}): string;
|
|
2282
|
+
|
|
2283
|
+
/**
|
|
2284
|
+
* Normalises stored content to markdown: HTML-bearing text has its tags
|
|
2285
|
+
* stripped to line breaks, anything else is returned unchanged. HTML is never
|
|
2286
|
+
* interpreted.
|
|
2287
|
+
*/
|
|
2288
|
+
export function stripHtmlToMarkdown(text: unknown): string;
|
|
2289
|
+
|
|
2290
|
+
/** Un-escapes an alt captured by the image grammar. */
|
|
2291
|
+
export function readMarkdownAlt(raw: unknown): string;
|
|
2292
|
+
|
|
2293
|
+
/** True when `src` is an http(s) URL rather than a filestore id. */
|
|
2294
|
+
export function isHttpImageSrc(src: unknown): boolean;
|
|
2295
|
+
|
|
2296
|
+
/**
|
|
2297
|
+
* True when `src` is renderable — an http(s) URL or a filestore id. Content is
|
|
2298
|
+
* author-supplied, so anything else (a `javascript:` scheme, say) is refused.
|
|
2299
|
+
*/
|
|
2300
|
+
export function isSafeMarkdownImageSrc(src: unknown): boolean;
|
|
2301
|
+
|
|
2302
|
+
/** Coerces any value to a known image size, defaulting to `"full"`. */
|
|
2303
|
+
export function normaliseMarkdownImageSize(size: unknown): MarkdownImageSize;
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* sc-6970 — renders the markdown subset with SDK primitives, so formatted
|
|
2307
|
+
* content reads the same in the web Player and the exported Expo app. `value`
|
|
2308
|
+
* is markdown, never HTML.
|
|
2309
|
+
*
|
|
2310
|
+
* @example
|
|
2311
|
+
* <RichText value={record.body} />
|
|
2312
|
+
*/
|
|
2313
|
+
export const RichText: (props: {
|
|
2314
|
+
/** Markdown text. Legacy HTML is stripped to plain text, never rendered. */
|
|
2315
|
+
value?: string;
|
|
2316
|
+
/**
|
|
2317
|
+
* Resolves an image block to a node. Supply it when images are filestore
|
|
2318
|
+
* ids — without it only absolute http(s) URLs render.
|
|
2319
|
+
*/
|
|
2320
|
+
renderImage?: (block: {
|
|
2321
|
+
src: string;
|
|
2322
|
+
alt: string;
|
|
2323
|
+
size: MarkdownImageSize;
|
|
2324
|
+
}) => ReactNode;
|
|
2325
|
+
style?: any;
|
|
2326
|
+
testID?: string;
|
|
2327
|
+
}) => any;
|
|
2328
|
+
|
|
2329
|
+
/**
|
|
2330
|
+
* sc-6970 — the authoring half of `<RichText>`: a multi-line field whose
|
|
2331
|
+
* toolbar wraps the current selection in markdown markers, with a live preview
|
|
2332
|
+
* of the rendered result. Use it instead of a bare `TextInput` plus hand-rolled
|
|
2333
|
+
* formatting buttons, which can only splice literal tags into the text.
|
|
2334
|
+
*
|
|
2335
|
+
* @example
|
|
2336
|
+
* <MarkdownInput value={draft} onChange={setDraft} placeholder="Write…" />
|
|
2337
|
+
*/
|
|
2338
|
+
export const MarkdownInput: (props: {
|
|
2339
|
+
/** Markdown text. */
|
|
2340
|
+
value?: string;
|
|
2341
|
+
/** Receives the next markdown string on every edit. */
|
|
2342
|
+
onChange?: (next: string) => void;
|
|
2343
|
+
placeholder?: string;
|
|
2344
|
+
/** Heading above the live preview. Defaults to "Preview". */
|
|
2345
|
+
previewLabel?: string;
|
|
2346
|
+
/** Set false to hide the live preview. Defaults to true. */
|
|
2347
|
+
showPreview?: boolean;
|
|
2348
|
+
/** Passed through to the preview's `<RichText>`. */
|
|
2349
|
+
renderImage?: (block: {
|
|
2350
|
+
src: string;
|
|
2351
|
+
alt: string;
|
|
2352
|
+
size: MarkdownImageSize;
|
|
2353
|
+
}) => ReactNode;
|
|
2354
|
+
/** Field floor, so it opens at a usable height. Defaults to 150. */
|
|
2355
|
+
minHeight?: number;
|
|
2356
|
+
/** Field ceiling, so it scrolls internally instead of growing. Defaults to 280. */
|
|
2357
|
+
maxHeight?: number;
|
|
2358
|
+
accessibilityLabel?: string;
|
|
2359
|
+
style?: any;
|
|
2360
|
+
testID?: string;
|
|
2361
|
+
}) => any;
|
|
2362
|
+
|
|
2239
2363
|
// ------------------------------------------------------- theme derivation
|
|
2240
2364
|
// sc-3696: the colour maths both hosts resolve `useTheme()` with. Exported so
|
|
2241
2365
|
// the Player (frontend/src/services/widgetTheme.js) and the exported app's
|
package/dist/index.js
CHANGED
|
@@ -94,6 +94,23 @@ export { useToast } from "./toast.js";
|
|
|
94
94
|
// each platform's primitives, so a widget's modal escapes its container box
|
|
95
95
|
// identically on the Player and the Expo export.
|
|
96
96
|
export { Overlay } from "./overlay.js";
|
|
97
|
+
// sc-6970 — formatted content. `<RichText>` renders the markdown subset and
|
|
98
|
+
// `<MarkdownInput>` authors it; widgets have no HTML path on either host, so
|
|
99
|
+
// these are the ONE way content with bold/headings/lists is written and shown.
|
|
100
|
+
export { RichText } from "./richtext.js";
|
|
101
|
+
export { MarkdownInput } from "./markdown-input.js";
|
|
102
|
+
export {
|
|
103
|
+
parseMarkdown,
|
|
104
|
+
markdownToPlainText,
|
|
105
|
+
parseMarkdownImage,
|
|
106
|
+
formatMarkdownImage,
|
|
107
|
+
stripHtmlToMarkdown,
|
|
108
|
+
readMarkdownAlt,
|
|
109
|
+
isHttpImageSrc,
|
|
110
|
+
isSafeMarkdownImageSrc,
|
|
111
|
+
normaliseMarkdownImageSize,
|
|
112
|
+
MARKDOWN_IMAGE_SIZES,
|
|
113
|
+
} from "./markdown.js";
|
|
97
114
|
export {
|
|
98
115
|
Text,
|
|
99
116
|
View,
|
package/dist/index.native.js
CHANGED
|
@@ -90,6 +90,21 @@ export { useClipboard, ClipboardError } from "./clipboard.native.js";
|
|
|
90
90
|
export { useToast } from "./toast.native.js";
|
|
91
91
|
// sc-6607 — see the note on the web mirror in ./index.js.
|
|
92
92
|
export { Overlay } from "./overlay.native.js";
|
|
93
|
+
// sc-6970 — see the note on the web mirror in ./index.js.
|
|
94
|
+
export { RichText } from "./richtext.native.js";
|
|
95
|
+
export { MarkdownInput } from "./markdown-input.native.js";
|
|
96
|
+
export {
|
|
97
|
+
parseMarkdown,
|
|
98
|
+
markdownToPlainText,
|
|
99
|
+
parseMarkdownImage,
|
|
100
|
+
formatMarkdownImage,
|
|
101
|
+
stripHtmlToMarkdown,
|
|
102
|
+
readMarkdownAlt,
|
|
103
|
+
isHttpImageSrc,
|
|
104
|
+
isSafeMarkdownImageSrc,
|
|
105
|
+
normaliseMarkdownImageSize,
|
|
106
|
+
MARKDOWN_IMAGE_SIZES,
|
|
107
|
+
} from "./markdown.js";
|
|
93
108
|
export {
|
|
94
109
|
Text,
|
|
95
110
|
View,
|
package/dist/linter.cjs
CHANGED
|
@@ -392,6 +392,47 @@ function _hostApiUrlRules(source) {
|
|
|
392
392
|
return findings;
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
// sc-6970 — see linter.js for the rationale comment. The two files must stay
|
|
396
|
+
// in lockstep (the contract test asserts behaviour-equivalence).
|
|
397
|
+
const HTML_IN_CONTENT_RE =
|
|
398
|
+
/<\/\s*[a-z][a-z0-9]*\s*>|<(?:br|hr|img|p|div|span|strong|em|b|i|u|s|h[1-6]|ul|ol|li|a|blockquote|code|pre|table|thead|tbody|tr|td|th)(?:\s[^<>]*)?\/?>/i;
|
|
399
|
+
|
|
400
|
+
function _stringLiteralLines(source) {
|
|
401
|
+
const withStrings = _stripNonCode(source, { keepStrings: true }).split(
|
|
402
|
+
/\r?\n/,
|
|
403
|
+
);
|
|
404
|
+
const withoutStrings = _stripNonCode(source).split(/\r?\n/);
|
|
405
|
+
return withStrings.map((line, i) => {
|
|
406
|
+
const masked = withoutStrings[i] || "";
|
|
407
|
+
let out = "";
|
|
408
|
+
for (let c = 0; c < line.length; c += 1) {
|
|
409
|
+
out += masked[c] === line[c] ? " " : line[c];
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function _htmlInContentRules(source) {
|
|
416
|
+
const findings = [];
|
|
417
|
+
const lines = source.split(/\r?\n/);
|
|
418
|
+
const stringLines = _stringLiteralLines(source);
|
|
419
|
+
for (let i = 0; i < stringLines.length; i += 1) {
|
|
420
|
+
if (!HTML_IN_CONTENT_RE.test(stringLines[i])) continue;
|
|
421
|
+
findings.push({
|
|
422
|
+
rule: "no-html-in-content",
|
|
423
|
+
severity: "warning",
|
|
424
|
+
label:
|
|
425
|
+
"HTML tag in a string — widgets have no HTML renderer on either " +
|
|
426
|
+
"host, so it shows as literal tag soup. Author formatted text with " +
|
|
427
|
+
"<MarkdownInput> and render it with <RichText> (markdown), never " +
|
|
428
|
+
"spliced tags.",
|
|
429
|
+
line: i + 1,
|
|
430
|
+
snippet: lines[i].trim().slice(0, 200),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return findings;
|
|
434
|
+
}
|
|
435
|
+
|
|
395
436
|
// sc-5619 — see linter.js for the rationale comment. The two files must stay
|
|
396
437
|
// in lockstep (the contract test asserts behaviour-equivalence).
|
|
397
438
|
const PAGE_URL_RE = /["'`][^"'`]*\/play\/|\/play\/\$\{/;
|
|
@@ -1443,6 +1484,8 @@ function lintSource(source, options) {
|
|
|
1443
1484
|
})),
|
|
1444
1485
|
);
|
|
1445
1486
|
findings.push(..._hostApiUrlRules(source));
|
|
1487
|
+
// sc-6970 — soft warning: HTML markup spliced into a content string.
|
|
1488
|
+
findings.push(..._htmlInContentRules(source));
|
|
1446
1489
|
findings.push(..._translationApiRules(source));
|
|
1447
1490
|
findings.push(..._handBuiltPageUrlRules(source));
|
|
1448
1491
|
findings.push(..._lucideIconRules(source));
|
package/dist/linter.js
CHANGED
|
@@ -294,6 +294,62 @@ function _hostApiUrlRules(source) {
|
|
|
294
294
|
return findings;
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
// sc-6970 — no-html-in-content.
|
|
298
|
+
//
|
|
299
|
+
// Widgets draw with React Native primitives, so there is no
|
|
300
|
+
// `dangerouslySetInnerHTML` on either host: an HTML tag inside a STRING stays
|
|
301
|
+
// literal text the reader sees as tag soup. This is the defect a generated
|
|
302
|
+
// "manage content" widget shipped — a hand-rolled B / I / H2 toolbar whose only
|
|
303
|
+
// possible action was splicing `<strong>` and `<br>` into a TextInput, leaving
|
|
304
|
+
// the author reading markup instead of a preview. `severity: "warning"` so a
|
|
305
|
+
// human submission still publishes and the review queue flags it, while the AI
|
|
306
|
+
// publish loop treats it as a blocking check that drives a repair turn.
|
|
307
|
+
//
|
|
308
|
+
// Only STRING content is scanned. JSX is excluded by construction (the masked
|
|
309
|
+
// and unmasked sources agree outside string literals), so a widget's own
|
|
310
|
+
// `<View>` / `<Text>` elements can never trigger it.
|
|
311
|
+
const HTML_IN_CONTENT_RE =
|
|
312
|
+
/<\/\s*[a-z][a-z0-9]*\s*>|<(?:br|hr|img|p|div|span|strong|em|b|i|u|s|h[1-6]|ul|ol|li|a|blockquote|code|pre|table|thead|tbody|tr|td|th)(?:\s[^<>]*)?\/?>/i;
|
|
313
|
+
|
|
314
|
+
// Everything the masked source hides but the string-keeping source shows is,
|
|
315
|
+
// by definition, string/template text. Column positions are preserved by both
|
|
316
|
+
// maskings, so the result still lines up 1:1 with the original lines.
|
|
317
|
+
function _stringLiteralLines(source) {
|
|
318
|
+
const withStrings = _stripNonCode(source, { keepStrings: true }).split(
|
|
319
|
+
/\r?\n/,
|
|
320
|
+
);
|
|
321
|
+
const withoutStrings = _stripNonCode(source).split(/\r?\n/);
|
|
322
|
+
return withStrings.map((line, i) => {
|
|
323
|
+
const masked = withoutStrings[i] || "";
|
|
324
|
+
let out = "";
|
|
325
|
+
for (let c = 0; c < line.length; c += 1) {
|
|
326
|
+
out += masked[c] === line[c] ? " " : line[c];
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function _htmlInContentRules(source) {
|
|
333
|
+
const findings = [];
|
|
334
|
+
const lines = source.split(/\r?\n/);
|
|
335
|
+
const stringLines = _stringLiteralLines(source);
|
|
336
|
+
for (let i = 0; i < stringLines.length; i += 1) {
|
|
337
|
+
if (!HTML_IN_CONTENT_RE.test(stringLines[i])) continue;
|
|
338
|
+
findings.push({
|
|
339
|
+
rule: "no-html-in-content",
|
|
340
|
+
severity: "warning",
|
|
341
|
+
label:
|
|
342
|
+
"HTML tag in a string — widgets have no HTML renderer on either " +
|
|
343
|
+
"host, so it shows as literal tag soup. Author formatted text with " +
|
|
344
|
+
"<MarkdownInput> and render it with <RichText> (markdown), never " +
|
|
345
|
+
"spliced tags.",
|
|
346
|
+
line: i + 1,
|
|
347
|
+
snippet: lines[i].trim().slice(0, 200),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return findings;
|
|
351
|
+
}
|
|
352
|
+
|
|
297
353
|
// sc-5619 — no-hand-built-page-url.
|
|
298
354
|
//
|
|
299
355
|
// A published app answers on two URL shapes: `/play/<tenantId>/page/<slug>` on
|
|
@@ -1477,6 +1533,8 @@ export function lintSource(source, options) {
|
|
|
1477
1533
|
);
|
|
1478
1534
|
// REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
|
|
1479
1535
|
findings.push(..._hostApiUrlRules(source));
|
|
1536
|
+
// sc-6970 — soft warning: HTML markup spliced into a content string.
|
|
1537
|
+
findings.push(..._htmlInContentRules(source));
|
|
1480
1538
|
findings.push(..._translationApiRules(source));
|
|
1481
1539
|
findings.push(..._handBuiltPageUrlRules(source));
|
|
1482
1540
|
findings.push(..._lucideIconRules(source));
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// sc-6970 — the selection edits `<MarkdownInput>`'s toolbar performs.
|
|
2
|
+
//
|
|
3
|
+
// Pure string maths, kept out of the component the way `richtext-tokens.js`
|
|
4
|
+
// keeps the theme maths out: a toolbar button is only correct if it wraps the
|
|
5
|
+
// author's SELECTION rather than appending at the end, and that is a thing
|
|
6
|
+
// tests can pin without a renderer.
|
|
7
|
+
|
|
8
|
+
const LINE_RE = /^(\s*)((?:#{1,3}\s)|(?:[-*]\s)|(?:\d+[.)]\s))?([\s\S]*)$/;
|
|
9
|
+
|
|
10
|
+
const LINE_KINDS = Object.freeze({
|
|
11
|
+
heading2: { marker: "## ", test: /^##\s/ },
|
|
12
|
+
heading3: { marker: "### ", test: /^###\s/ },
|
|
13
|
+
bullet: { marker: "- ", test: /^[-*]\s/ },
|
|
14
|
+
ordered: { marker: null, test: /^\d+[.)]\s/ },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function clamp(index, max) {
|
|
18
|
+
const n = Number.isFinite(index) ? Math.trunc(index) : 0;
|
|
19
|
+
return Math.min(Math.max(n, 0), max);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readRange(text, selection) {
|
|
23
|
+
const src = typeof text === "string" ? text : "";
|
|
24
|
+
const raw = selection && typeof selection === "object" ? selection : {};
|
|
25
|
+
const a = clamp(raw.start, src.length);
|
|
26
|
+
const b = clamp(raw.end, src.length);
|
|
27
|
+
return { src, start: Math.min(a, b), end: Math.max(a, b) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function wrap(marker) {
|
|
31
|
+
return (text, selection) => {
|
|
32
|
+
const { src, start, end } = readRange(text, selection);
|
|
33
|
+
const selected = src.slice(start, end);
|
|
34
|
+
const pair = marker.length * 2;
|
|
35
|
+
|
|
36
|
+
// Already wrapped — the button toggles the emphasis back off.
|
|
37
|
+
if (selected.length >= pair && selected.startsWith(marker) && selected.endsWith(marker)) {
|
|
38
|
+
const inner = selected.slice(marker.length, selected.length - marker.length);
|
|
39
|
+
return {
|
|
40
|
+
value: src.slice(0, start) + inner + src.slice(end),
|
|
41
|
+
selection: { start, end: start + inner.length },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
value: src.slice(0, start) + marker + selected + marker + src.slice(end),
|
|
47
|
+
// Leaves the author's own text selected (or the caret between an empty
|
|
48
|
+
// pair), so typing continues inside the emphasis they just asked for.
|
|
49
|
+
selection: { start: start + marker.length, end: end + marker.length },
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function linePrefix(kind) {
|
|
55
|
+
const spec = LINE_KINDS[kind];
|
|
56
|
+
return (text, selection) => {
|
|
57
|
+
const { src, start, end } = readRange(text, selection);
|
|
58
|
+
const blockStart = src.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
|
|
59
|
+
const newlineAfter = src.indexOf("\n", end);
|
|
60
|
+
const blockEnd = newlineAfter === -1 ? src.length : newlineAfter;
|
|
61
|
+
|
|
62
|
+
const lines = src.slice(blockStart, blockEnd).split("\n");
|
|
63
|
+
const filled = lines.filter((line) => line.trim());
|
|
64
|
+
// Toggled off only when EVERY line already carries this kind, so a mixed
|
|
65
|
+
// selection becomes uniformly marked rather than half-stripped.
|
|
66
|
+
const removing =
|
|
67
|
+
filled.length > 0 &&
|
|
68
|
+
filled.every((line) => spec.test.test(line.replace(/^\s*/, "")));
|
|
69
|
+
|
|
70
|
+
let ordinal = 0;
|
|
71
|
+
const rebuilt = lines.map((line) => {
|
|
72
|
+
const match = LINE_RE.exec(line);
|
|
73
|
+
const indent = match[1];
|
|
74
|
+
const body = match[3];
|
|
75
|
+
if (!line.trim()) return line;
|
|
76
|
+
if (removing) return indent + body;
|
|
77
|
+
ordinal += 1;
|
|
78
|
+
return indent + (spec.marker || `${ordinal}. `) + body;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const block = rebuilt.join("\n");
|
|
82
|
+
return {
|
|
83
|
+
value: src.slice(0, blockStart) + block + src.slice(blockEnd),
|
|
84
|
+
selection: { start: blockStart, end: blockStart + block.length },
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const markdownEditOps = Object.freeze({
|
|
90
|
+
bold: wrap("**"),
|
|
91
|
+
italic: wrap("*"),
|
|
92
|
+
code: wrap("`"),
|
|
93
|
+
heading2: linePrefix("heading2"),
|
|
94
|
+
heading3: linePrefix("heading3"),
|
|
95
|
+
bullet: linePrefix("bullet"),
|
|
96
|
+
ordered: linePrefix("ordered"),
|
|
97
|
+
});
|