@colixsystems/widget-sdk 0.275.2 → 0.277.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.
@@ -1,8 +1,16 @@
1
- // CommonJS twin of lucideIconName.js for the backend (the page service +
2
- // any other Node consumer require this). Keep the two in sync — same
3
- // algorithm, same behaviour. See lucideIconName.js for the rationale.
1
+ // GENERATED from lucideIconName.js by scripts/generate-cjs.js — DO NOT EDIT.
2
+ // Change lucideIconName.js, then run `npm run generate:cjs` in packages/widget-sdk.
4
3
  "use strict";
5
4
 
5
+ // Normalize a human-typed Lucide icon name to the PascalCase form Lucide
6
+ // exports its components under. The lucide.dev gallery shows kebab-case
7
+ // (`arrow-right`) and its copy button hands you kebab-case, but
8
+ // `lucide-react` / `lucide-react-native` key their components in PascalCase
9
+ // (`ArrowRight`). This bridges the two so any of kebab / snake / spaced /
10
+ // camel / PascalCase resolves: `arrow-right`, `arrow_right`, `arrow right`,
11
+ // `arrowRight`, and `ArrowRight` all become `ArrowRight`; `building-2` becomes
12
+ // `Building2`. Non-strings and blanks return "" so callers fall back to a
13
+ // placeholder. Already-PascalCase names pass through unchanged.
6
14
  function normalizeLucideIconName(name) {
7
15
  if (typeof name !== "string") return "";
8
16
  const trimmed = name.trim();
@@ -13,5 +21,6 @@ function normalizeLucideIconName(name) {
13
21
  .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
14
22
  .join("");
15
23
  }
16
-
17
- module.exports = { normalizeLucideIconName };
24
+ module.exports = {
25
+ normalizeLucideIconName,
26
+ };
package/dist/manifest.cjs CHANGED
@@ -1,11 +1,12 @@
1
- // CommonJS mirror of manifest.js. Lets backend services (CJS) call
2
- // `validateManifest(...)` without a dynamic-import detour. The two files
3
- // share their constants by mutual import via createRequire — manifest.js
4
- // re-exports the same functions defined here so there is exactly one
5
- // implementation.
1
+ // GENERATED from manifest.js by scripts/generate-cjs.js — DO NOT EDIT.
2
+ // Change manifest.js, then run `npm run generate:cjs` in packages/widget-sdk.
3
+ "use strict";
6
4
 
7
5
  const { CONTRACT } = require("./contract.cjs");
8
6
 
7
+ // Structural validation of a widget manifest, shared by the CLI, hosts and backend.
8
+
9
+
9
10
  const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
10
11
 
11
12
  // sc-4505 — a payload / input field map: { <field>: { type, label? } }. This is
@@ -120,16 +121,7 @@ function validateManifestTranslations(translations, manifestId, errors) {
120
121
  }
121
122
  }
122
123
 
123
- // REQ-MKT-ACTION (sc-4879): a widget no longer declares server-side actions.
124
- //
125
- // `manifest.actions` was how an automation shipped before Actions existed as
126
- // their own marketplace deliverable — a widget was two products in one package,
127
- // a component that renders and a script that never renders at all. It is now
128
- // REFUSED rather than ignored: an author who declares it believes the
129
- // automation will ship, and silence would publish that misunderstanding.
130
- //
131
- // The replacement is a marketplace Action (`@colixsystems/action-sdk`), which
132
- // the workspace installs and configures on its own.
124
+ // REQ-MKT-ACTION (sc-4879): refused, not ignored — the author expects it to ship.
133
125
  function validateManifestActions(_actions, errors) {
134
126
  errors.push(
135
127
  "manifest.actions is no longer supported — ship the automation as a " +
package/dist/manifest.js CHANGED
@@ -1,10 +1,6 @@
1
- // CommonJS mirror of manifest.js. Lets backend services (CJS) call
2
- // `validateManifest(...)` without a dynamic-import detour. The two files
3
- // share their constants by mutual import via createRequire — manifest.js
4
- // re-exports the same functions defined here so there is exactly one
5
- // implementation.
1
+ // Structural validation of a widget manifest, shared by the CLI, hosts and backend.
6
2
 
7
- import { CONTRACT, normaliseActionTriggerTypes } from "./contract.js";
3
+ import { CONTRACT } from "./contract.js";
8
4
 
9
5
  const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
10
6
 
@@ -39,20 +35,6 @@ const VALID_CATEGORIES = new Set([
39
35
  ]);
40
36
  const VALID_PLATFORMS = new Set(["web", "native"]);
41
37
 
42
- // REQ-WIDGET-ACTION — structural validation for manifest-declared server
43
- // actions. Mirrors the backend action.service caps; the runner re-validates
44
- // the cron expression (node-cron) and binds the tenant-local API key + table
45
- // at enable time, so we only check shape + size here (cross-env: no Buffer).
46
- const VALID_ACTION_TRIGGERS = new Set([
47
- "schedule",
48
- "record_created",
49
- "record_updated",
50
- "record_deleted",
51
- ]);
52
- const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
53
- const ACTION_TIMEOUT_MIN_MS = 100;
54
- const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
55
-
56
38
  // REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
57
39
  // The relative key pattern is deliberately tighter than the dictionary's
58
40
  // own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
@@ -134,54 +116,13 @@ function validateManifestTranslations(translations, manifestId, errors) {
134
116
  }
135
117
  }
136
118
 
137
- function validateManifestActions(actions, errors) {
138
- if (!Array.isArray(actions)) {
139
- errors.push("manifest.actions must be an array (omit it or use [] for none)");
140
- return;
141
- }
142
- const seenKeys = new Set();
143
- for (const a of actions) {
144
- if (a === null || typeof a !== "object") {
145
- errors.push("manifest.actions entries must be objects");
146
- break;
147
- }
148
- if (!isNonEmptyString(a.key)) {
149
- errors.push("manifest.actions[].key must be a non-empty string");
150
- } else if (seenKeys.has(a.key)) {
151
- errors.push(`manifest.actions[].key "${a.key}" is duplicated`);
152
- } else {
153
- seenKeys.add(a.key);
154
- }
155
- pushIf(errors, isNonEmptyString(a.name), "manifest.actions[].name must be a non-empty string");
156
- const triggerTypes = normaliseActionTriggerTypes(a);
157
- if (triggerTypes === null) {
158
- errors.push(
159
- `manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...VALID_ACTION_TRIGGERS].join(", ")}`,
160
- );
161
- } else if (triggerTypes.includes("schedule")) {
162
- pushIf(
163
- errors,
164
- isNonEmptyString(a.scheduleCron),
165
- "manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'",
166
- );
167
- }
168
- if (!isNonEmptyString(a.scriptSource)) {
169
- errors.push("manifest.actions[].scriptSource must be a non-empty string");
170
- } else if (utf8ByteLength(a.scriptSource) > ACTION_SCRIPT_MAX_BYTES) {
171
- errors.push("manifest.actions[].scriptSource exceeds 200 KiB");
172
- }
173
- if (a.timeoutMs !== undefined) {
174
- const t = Number(a.timeoutMs);
175
- if (!Number.isFinite(t) || t < ACTION_TIMEOUT_MIN_MS || t > ACTION_TIMEOUT_MAX_MS) {
176
- errors.push("manifest.actions[].timeoutMs must be between 100 and 300000");
177
- }
178
- }
179
- if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
180
- errors.push(
181
- "manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install",
182
- );
183
- }
184
- }
119
+ // REQ-MKT-ACTION (sc-4879): refused, not ignored — the author expects it to ship.
120
+ function validateManifestActions(_actions, errors) {
121
+ errors.push(
122
+ "manifest.actions is no longer supported — ship the automation as a " +
123
+ "marketplace Action (@colixsystems/action-sdk) instead of declaring it " +
124
+ "on a widget",
125
+ );
185
126
  }
186
127
 
187
128
  function canonicalCategory(c) {
@@ -361,7 +302,7 @@ function validateManifest(m) {
361
302
  }
362
303
  }
363
304
 
364
- // `actions` is optional (additive in SDK 1.8.0) — only validate when present.
305
+ // Present at all is now an error (sc-4879) — see validateManifestActions.
365
306
  if (manifest.actions !== undefined) {
366
307
  validateManifestActions(manifest.actions, errors);
367
308
  }
@@ -0,0 +1,209 @@
1
+ // sc-15080 — the bridge between the markdown subset and the editor's document.
2
+ //
3
+ // `<MarkdownInput>` edits a rich document but stores markdown, so `<RichText>`,
4
+ // saved rows and every widget's `value`/`onChange` stay as they were. Reading
5
+ // goes through `parseMarkdown`, the SAME grammar `<RichText>` renders, so the
6
+ // editor can never show a construct differently from the reader (CLAUDE.md §3).
7
+ // Writing emits the canonical spelling of each construct; a value the author
8
+ // never edits is never re-written.
9
+ //
10
+ // Pure and DOM-free: both hosts' editors and the unit tests import it.
11
+
12
+ import {
13
+ parseMarkdown,
14
+ formatMarkdownImage,
15
+ escapeMarkdownLabel,
16
+ } from "./markdown.js";
17
+
18
+ const textNode = (text, marks) =>
19
+ marks.length > 0 ? { type: "text", text, marks } : { type: "text", text };
20
+
21
+ function inlineContent(spans) {
22
+ const nodes = [];
23
+ for (const span of spans || []) {
24
+ if (!span.text) continue;
25
+ const marks = [];
26
+ if (span.bold) marks.push({ type: "bold" });
27
+ if (span.italic) marks.push({ type: "italic" });
28
+ if (span.code) marks.push({ type: "code" });
29
+ if (span.href) marks.push({ type: "link", attrs: { href: span.href } });
30
+ nodes.push(textNode(span.text, marks));
31
+ }
32
+ return nodes;
33
+ }
34
+
35
+ const paragraph = (spans) => {
36
+ const content = inlineContent(spans);
37
+ return content.length > 0 ? { type: "paragraph", content } : { type: "paragraph" };
38
+ };
39
+
40
+ function tableNode(block) {
41
+ const width = Math.max(1, ...block.rows.map((row) => row.cells.length));
42
+ const rows = block.rows.map((row) => {
43
+ const cells = [];
44
+ for (let i = 0; i < width; i += 1) {
45
+ cells.push({
46
+ type: row.header ? "tableHeader" : "tableCell",
47
+ content: [paragraph(row.cells[i])],
48
+ });
49
+ }
50
+ return { type: "tableRow", content: cells };
51
+ });
52
+ const align = [];
53
+ for (let i = 0; i < width; i += 1) align.push(block.align[i] || null);
54
+ return { type: "table", attrs: { align }, content: rows };
55
+ }
56
+
57
+ const ordinal = (marker) => Number.parseInt(marker, 10) || 1;
58
+
59
+ /**
60
+ * Markdown text → the editor's ProseMirror JSON document. `options` go to
61
+ * `parseMarkdown`; the editor passes `stripLegacyHtml: false`, because the
62
+ * strip would delete text like `Promise<void>` the author typed.
63
+ */
64
+ export function markdownToDoc(text, options) {
65
+ const content = [];
66
+ let list = null;
67
+ for (const block of parseMarkdown(text, options)) {
68
+ const listType = block.type === "bullet" ? "bulletList" : block.type === "ordered" ? "orderedList" : null;
69
+ if (listType) {
70
+ if (!list || list.type !== listType) {
71
+ list = { type: listType, content: [] };
72
+ if (listType === "orderedList") list.attrs = { start: ordinal(block.marker) };
73
+ content.push(list);
74
+ }
75
+ list.content.push({ type: "listItem", content: [paragraph(block.spans)] });
76
+ continue;
77
+ }
78
+ list = null;
79
+ if (block.type === "heading") {
80
+ content.push({ type: "heading", attrs: { level: block.level }, content: inlineContent(block.spans) });
81
+ } else if (block.type === "paragraph") {
82
+ content.push(paragraph(block.spans));
83
+ } else if (block.type === "table") {
84
+ content.push(tableNode(block));
85
+ } else if (block.type === "image") {
86
+ content.push({ type: "image", attrs: { src: block.src, alt: block.alt, title: block.size } });
87
+ } else if (block.type === "code") {
88
+ content.push({
89
+ type: "codeBlock",
90
+ attrs: { language: block.lang || null },
91
+ ...(block.text ? { content: [{ type: "text", text: block.text }] } : null),
92
+ });
93
+ }
94
+ }
95
+ return { type: "doc", content: content.length > 0 ? content : [{ type: "paragraph" }] };
96
+ }
97
+
98
+ const markOf = (node, type) => (node.marks || []).find((mark) => mark.type === type);
99
+
100
+ // The grammar's emphasis cannot open or close next to whitespace, so edge
101
+ // spaces move outside the markers instead of breaking the construct.
102
+ function wrap(text, marker) {
103
+ const lead = /^\s*/.exec(text)[0];
104
+ const trail = /\s*$/.exec(text.slice(lead.length))[0];
105
+ const core = text.slice(lead.length, text.length - trail.length);
106
+ return core ? `${lead}${marker}${core}${marker}${trail}` : text;
107
+ }
108
+
109
+ function styledText(node, inTable) {
110
+ let text = node.text || "";
111
+ if (inTable) text = text.replace(/\|/g, "\\|");
112
+ if (markOf(node, "code")) return wrap(text.replace(/`/g, "'"), "`");
113
+ if (markOf(node, "bold")) return wrap(text, "**");
114
+ if (markOf(node, "italic")) return wrap(text, "*");
115
+ return text;
116
+ }
117
+
118
+ // The grammar ends a target at whitespace or `)`, and a table cell at `|`.
119
+ const linkTarget = (href) =>
120
+ String(href || "").replace(/[\s|()<>]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
121
+
122
+ function inlineMarkdown(nodes, inTable) {
123
+ let out = "";
124
+ let link = null;
125
+ let label = "";
126
+ const closeLink = () => {
127
+ if (link) out += `[${escapeMarkdownLabel(label)}](${linkTarget(link)})`;
128
+ link = null;
129
+ label = "";
130
+ };
131
+ for (const node of nodes || []) {
132
+ if (node.type !== "text") continue;
133
+ const href = markOf(node, "link")?.attrs?.href || null;
134
+ if (href !== link) closeLink();
135
+ if (href) {
136
+ link = href;
137
+ label += styledText(node, inTable);
138
+ } else {
139
+ out += styledText(node, inTable);
140
+ }
141
+ }
142
+ closeLink();
143
+ return out.replace(/\n/g, " ");
144
+ }
145
+
146
+ const cellMarkdown = (cell) =>
147
+ (cell.content || []).map((child) => inlineMarkdown(child.content, true)).join(" ").trim();
148
+
149
+ function alignDivider(align) {
150
+ if (align === "center") return ":---:";
151
+ if (align === "right") return "---:";
152
+ if (align === "left") return ":---";
153
+ return "---";
154
+ }
155
+
156
+ function tableMarkdown(node) {
157
+ const rows = (node.content || []).map((row) => (row.content || []).map(cellMarkdown));
158
+ if (rows.length === 0) return "";
159
+ const width = rows[0].length;
160
+ const align = node.attrs?.align || [];
161
+ const line = (cells) => `| ${cells.join(" | ")} |`;
162
+ const divider = [];
163
+ for (let i = 0; i < width; i += 1) divider.push(alignDivider(align[i]));
164
+ return [line(rows[0]), line(divider), ...rows.slice(1).map(line)].join("\n");
165
+ }
166
+
167
+ const listItemText = (item) =>
168
+ (item.content || []).map((child) => inlineMarkdown(child.content, false)).join(" ").trim();
169
+
170
+ // An empty item or heading would read back as a stray `3.` or `##` paragraph.
171
+ const filledItems = (list) => (list.content || []).map(listItemText).filter((text) => text !== "");
172
+
173
+ function blockMarkdown(node) {
174
+ switch (node.type) {
175
+ case "heading": {
176
+ const text = inlineMarkdown(node.content, false).trim();
177
+ return text ? `${"#".repeat(node.attrs?.level || 1)} ${text}` : "";
178
+ }
179
+ case "paragraph":
180
+ return inlineMarkdown(node.content, false);
181
+ case "bulletList":
182
+ return filledItems(node).map((text) => `- ${text}`).join("\n");
183
+ case "orderedList": {
184
+ const start = node.attrs?.start || 1;
185
+ return filledItems(node).map((text, i) => `${start + i}. ${text}`).join("\n");
186
+ }
187
+ case "table":
188
+ return tableMarkdown(node);
189
+ case "image":
190
+ // An unsafe source was emptied on read; written back it would be prose.
191
+ return node.attrs?.src
192
+ ? formatMarkdownImage({ src: node.attrs.src, alt: node.attrs.alt, size: node.attrs.title })
193
+ : "";
194
+ case "codeBlock": {
195
+ const body = (node.content || []).map((child) => child.text || "").join("");
196
+ return `\`\`\`${node.attrs?.language || ""}\n${body}\n\`\`\``;
197
+ }
198
+ default:
199
+ return "";
200
+ }
201
+ }
202
+
203
+ /** The editor's ProseMirror JSON document → markdown text. */
204
+ export function docToMarkdown(doc) {
205
+ return (doc?.content || [])
206
+ .map(blockMarkdown)
207
+ .filter((block) => block.trim() !== "")
208
+ .join("\n\n");
209
+ }
@@ -0,0 +1,162 @@
1
+ // sc-15080 — the bridge between `<MarkdownInput>`'s native field and the editor
2
+ // page it hosts in a WebView (./markdown-editor-webview.js).
3
+ //
4
+ // React Native has no editable rich-text view, so the shared editor runs in a
5
+ // WebView from a page bundled into the SDK: no network, no remote code. The
6
+ // field grows with its content between `minHeight` and `maxHeight`, then the
7
+ // page scrolls. The primitives are passed in, so the bridge is testable
8
+ // without a device; ./markdown-editor-field.native.js binds the real ones.
9
+
10
+ import React from "react";
11
+ import { MARKDOWN_EDITOR_HTML } from "./markdown-editor-html.js";
12
+
13
+ const SOURCE = { html: MARKDOWN_EDITOR_HTML };
14
+
15
+ /** The script that hands `message` to the page; JSON twice makes it a literal. */
16
+ export function editorMessageScript(message) {
17
+ return `window.mdEditor&&window.mdEditor.receive(${JSON.stringify(JSON.stringify(message))});true;`;
18
+ }
19
+
20
+ function readMessage(event) {
21
+ try {
22
+ const message = JSON.parse(event.nativeEvent.data);
23
+ return message && typeof message.type === "string" ? message : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
30
+
31
+ /** Builds the field from React Native's `View` and the WebView component. */
32
+ export function makeMarkdownEditorField({ View, WebView }) {
33
+ return React.forwardRef(function MarkdownEditorField(
34
+ {
35
+ value,
36
+ onChangeText,
37
+ onStateChange,
38
+ placeholder,
39
+ accessibilityLabel,
40
+ tokens,
41
+ minHeight,
42
+ maxHeight,
43
+ testID,
44
+ },
45
+ ref,
46
+ ) {
47
+ const web = React.useRef(null);
48
+ const loaded = React.useRef(false);
49
+ const ready = React.useRef(false);
50
+ const shown = React.useRef(value);
51
+ const selection = React.useRef("");
52
+ const [height, setHeight] = React.useState(minHeight);
53
+ const [focused, setFocused] = React.useState(false);
54
+
55
+ const send = React.useCallback((message) => {
56
+ if (ready.current && web.current)
57
+ web.current.injectJavaScript(editorMessageScript(message));
58
+ }, []);
59
+
60
+ React.useEffect(() => {
61
+ if (value === shown.current) return;
62
+ shown.current = value;
63
+ send({ type: "set", markdown: value });
64
+ }, [value, send]);
65
+
66
+ // A theme switch repaints the page, as it does the web field.
67
+ React.useEffect(() => {
68
+ send({ type: "style", tokens, minHeight });
69
+ }, [tokens, minHeight, send]);
70
+
71
+ // The OS may kill the page's process; reload it and re-send the value.
72
+ const restart = React.useCallback(() => {
73
+ ready.current = false;
74
+ loaded.current = false;
75
+ if (web.current) web.current.reload();
76
+ }, []);
77
+
78
+ React.useImperativeHandle(
79
+ ref,
80
+ () => ({
81
+ command: (name, args) => {
82
+ send({ type: "command", name, args });
83
+ return ready.current;
84
+ },
85
+ selectedText: () => selection.current,
86
+ }),
87
+ [send],
88
+ );
89
+
90
+ const onMessage = (event) => {
91
+ const message = readMessage(event);
92
+ if (!message) return;
93
+ if (message.type === "ready") {
94
+ ready.current = true;
95
+ send({
96
+ type: "init",
97
+ markdown: shown.current,
98
+ placeholder,
99
+ label: accessibilityLabel,
100
+ tokens,
101
+ minHeight,
102
+ });
103
+ } else if (
104
+ message.type === "change" &&
105
+ typeof message.markdown === "string"
106
+ ) {
107
+ shown.current = message.markdown;
108
+ if (typeof onChangeText === "function") onChangeText(message.markdown);
109
+ } else if (message.type === "height" && Number.isFinite(message.height)) {
110
+ setHeight(clamp(message.height, minHeight, maxHeight));
111
+ } else if (message.type === "focus") {
112
+ setFocused(message.focused === true);
113
+ } else if (
114
+ message.type === "selection" &&
115
+ typeof message.text === "string"
116
+ ) {
117
+ selection.current = message.text;
118
+ } else if (message.type === "state" && typeof onStateChange === "function") {
119
+ onStateChange({ inTable: message.inTable === true });
120
+ }
121
+ };
122
+
123
+ return React.createElement(
124
+ View,
125
+ {
126
+ testID,
127
+ style: {
128
+ height,
129
+ borderWidth: 1,
130
+ borderColor: focused ? tokens.accent : tokens.border,
131
+ borderRadius: tokens.radius,
132
+ backgroundColor: tokens.surface,
133
+ overflow: "hidden",
134
+ },
135
+ },
136
+ React.createElement(WebView, {
137
+ ref: web,
138
+ source: SOURCE,
139
+ originWhitelist: ["*"],
140
+ // The page is the editor; nothing in it may navigate the field away.
141
+ onShouldStartLoadWithRequest: () => !loaded.current,
142
+ onLoadEnd: () => {
143
+ loaded.current = true;
144
+ },
145
+ onMessage,
146
+ onContentProcessDidTerminate: restart,
147
+ onRenderProcessGone: restart,
148
+ javaScriptEnabled: true,
149
+ setSupportMultipleWindows: false,
150
+ keyboardDisplayRequiresUserAction: false,
151
+ hideKeyboardAccessoryView: true,
152
+ automaticallyAdjustContentInsets: false,
153
+ textZoom: 100,
154
+ scrollEnabled: height >= maxHeight,
155
+ // Android hands a parent ScrollView's gesture to the page only with this.
156
+ nestedScrollEnabled: true,
157
+ style: { backgroundColor: "transparent" },
158
+ testID: testID ? `${testID}-webview` : undefined,
159
+ }),
160
+ );
161
+ });
162
+ }