@friggframework/core 2.0.0--canary.697.20e3e54.0 → 2.0.0--canary.696.5d9f1be.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.
@@ -144,29 +144,6 @@ different document at the same version is refused. So is a version that is not n
144
144
  semver bump smaller than the changes. Publishing goes through a conditional write, so two concurrent publishes of one
145
145
  version cannot both win. With `memoryDefinitions()` (definitions bundled in the app) the admin routes answer 501.
146
146
 
147
- ## Previews: in the app
148
-
149
- An integration shows up inside the apps it connects: a HubSpot card, a Slack message, digest or approval request. A
150
- preview renders exactly that from the current settings and sample or real records, with the same query, view and step
151
- code production runs (`renderers/`):
152
-
153
- | Function | Does |
154
- |---|---|
155
- | `previewSurface({ def, installation, surfaceId, record, ports, host? })` | `{ surface, view, html, slack?, hubspot? }`. `slack` is Block Kit (`{ blocks, text, truncated }`), `hubspot` a data description of `@hubspot/ui-extensions` components, `html` the same card framed as its host looks. `host` defaults to the surface's own |
156
- | `previewWorkflow({ def, installation, workflowId, event?, record?, manifests, fixtures? })` | Dry run: `{ status, sends, calls, trace, waiting? }`. Each call is `{ stepId, module, capability, kind, args, output, rendered }`; `rendered` is `{ html, slack? }` for writes (a Slack post renders as the message, anything else as a "would call" card) |
157
- | `recordingPorts({ def, manifests, fixtures })` | Ports that record and answer instead of calling an API; give them to `previewSurface` for sample data |
158
- | `viewToSlack`, `viewToHubSpot`, `viewToHtml`, `slackMessageToHtml` | The renderers on their own. `decodeAction(value)` reads a Slack button click back to `{ integrationId?, workflow, surface }` |
159
-
160
- Dry-run rules: nothing is performed (the base ports' API function throws, and the recorder sits in front of it).
161
- `fixtures` answers calls, keyed `"<module key>.<capability>"` (a value, or `(args) => value`; `code:<ref>` for code
162
- steps, which are never run), otherwise the manifest's own record shape with ids filled. A webhook workflow with no
163
- `event` runs on the manifest's sample payload. A pausing step ends the preview with `waiting`.
164
-
165
- Everything that is record data is escaped for its target (mrkdwn `& < >` plus fenced `* _ ~`, HTML, text children for
166
- HubSpot), links are http(s) only, Block Kit limits are met by truncating with a marker (50 blocks, 3000/2000/150
167
- characters, 25 buttons), and nothing throws on an odd tree. Text a *definition* writes into a Slack message (templates)
168
- is sent as authored: a preview shows it as sent, including any raw record data a template interpolates.
169
-
170
147
  ## Syncs
171
148
 
172
149
  `sync-engine.js` runs `syncs`: backfill (a button or `onInstall`, one queued page per message, progress at
@@ -178,14 +178,14 @@
178
178
  "use": "slack.message.post",
179
179
  "with": {
180
180
  "channel": "$.settings.channel",
181
- "text": ":white_check_mark: {{steps.task.completed_by.name | default: 'Someone' | mrkdwn}} completed <{{steps.task.permalink_url}}|{{steps.task.name | mrkdwn}}>"
181
+ "text": ":white_check_mark: {{steps.task.completed_by.name | default: 'Someone'}} completed <{{steps.task.permalink_url}}|{{steps.task.name}}>"
182
182
  }
183
183
  }
184
184
  ]
185
185
  }
186
186
  ],
187
187
  "templates": {
188
- "assignee-digest": ":spiral_calendar_pad: Hi {{item.firstName | default: item.name | mrkdwn}}, you have {{item.count}} Asana task(s) due tomorrow:\n{{item.lines}}",
188
+ "assignee-digest": ":spiral_calendar_pad: Hi {{item.firstName | default: item.name}}, you have {{item.count}} Asana task(s) due tomorrow:\n{{item.lines}}",
189
189
  "unassigned-digest": ":warning: {{steps.groups.unassignedCount}} task(s) due tomorrow have no assignee:\n{{steps.groups.unassignedLines}}"
190
190
  },
191
191
  "lifecycle": {
@@ -258,7 +258,7 @@
258
258
  "when": "$.steps.slackUser.id exists",
259
259
  "with": {
260
260
  "channel": "$.steps.slackUser.id",
261
- "text": "Due today: {{item.properties.hs_task_subject | mrkdwn}}",
261
+ "text": "Due today: {{item.properties.hs_task_subject}}",
262
262
  "metadata": {
263
263
  "event_type": "hubspot_task",
264
264
  "event_payload": {
@@ -401,9 +401,9 @@
401
401
  }
402
402
  ],
403
403
  "templates": {
404
- "deal-won": ":tada: *{{steps.deal.properties.dealname | mrkdwn}}* closed won for ${{steps.deal.properties.amount | default: '?'}}. Nice work {{steps.owner.firstName | default: 'team' | mrkdwn}}! <https://app.hubspot.com/contacts/{{install.portalId}}/record/0-3/{{steps.deal.id}}|Open deal>",
405
- "high-value": ":moneybag: New deal over ${{settings.threshold}}: *{{steps.deal.properties.dealname | mrkdwn}}* (${{steps.deal.properties.amount}}), closing {{steps.deal.properties.closedate | date}}. <https://app.hubspot.com/contacts/{{install.portalId}}/record/0-3/{{steps.deal.id}}|Open deal>",
406
- "digest-line": "\u2022 <https://app.hubspot.com/contacts/{{install.portalId}}/record/0-3/{{item.id}}|{{item.properties.dealname | mrkdwn}}>: ${{item.properties.amount | default: 0}}, closes {{item.properties.closedate | date}}",
404
+ "deal-won": ":tada: *{{steps.deal.properties.dealname}}* closed won for ${{steps.deal.properties.amount | default: '?'}}. Nice work {{steps.owner.firstName | default: 'team'}}! <https://app.hubspot.com/contacts/{{install.portalId}}/record/0-3/{{steps.deal.id}}|Open deal>",
405
+ "high-value": ":moneybag: New deal over ${{settings.threshold}}: *{{steps.deal.properties.dealname}}* (${{steps.deal.properties.amount}}), closing {{steps.deal.properties.closedate | date}}. <https://app.hubspot.com/contacts/{{install.portalId}}/record/0-3/{{steps.deal.id}}|Open deal>",
406
+ "digest-line": "\u2022 <https://app.hubspot.com/contacts/{{install.portalId}}/record/0-3/{{item.id}}|{{item.properties.dealname}}>: ${{item.properties.amount | default: 0}}, closes {{item.properties.closedate | date}}",
407
407
  "digest": ":calendar: *Closing in the next 7 days* ({{steps.deals | size}} deals)\n{{steps.build | join: '\\n'}}"
408
408
  }
409
409
  }
@@ -46,8 +46,6 @@ const FILTERS = {
46
46
  seconds: (v) => (v == null ? '' : String(Math.round(Number(v) / 1000))),
47
47
  date: (v) => (v ? new Date(v).toISOString().slice(0, 10) : ''),
48
48
  json: (v) => JSON.stringify(v),
49
- // Record data inside a Slack message: escaped so a name like "*x* <!channel>" neither formats nor pings.
50
- mrkdwn: (v) => require('./renderers/slack').escapeMrkdwn(v),
51
49
  default: (v, arg) => (v == null || v === '' ? arg : v),
52
50
  truncate: (v, arg) => { const s = String(v ?? ''); const n = Number(arg) || 80; return s.length > n ? s.slice(0, n - 1) + '…' : s; },
53
51
  // lists
@@ -15,11 +15,6 @@ const { TRANSFORMS, applyMapping } = require('./transforms');
15
15
  const { FILTERS } = require('./expressions');
16
16
  const { validateManifest } = require('./manifests');
17
17
  const { checkDefinition, checkManifest, checkInstallation } = require('./schema-check');
18
- const { viewToSlack, escapeMrkdwn } = require('./renderers/slack');
19
- const { viewToHubSpot } = require('./renderers/hubspot');
20
- const { viewToHtml, slackMessageToHtml } = require('./renderers/html');
21
- const { previewSurface, previewWorkflow, recordingPorts } = require('./renderers/preview');
22
- const { decodeAction } = require('./renderers/common');
23
18
  const useCases = require('./use-cases/definition-use-cases');
24
19
 
25
20
  /** Load every capabilities/*.capabilities.json (bundled mocks; real modules ship their own). */
@@ -40,6 +35,5 @@ module.exports = {
40
35
  defineIntegrationClass, defineSharedIntegrationClass, extendIntegrationClass, memoryDefinitions,
41
36
  BUILTINS, TRANSFORMS, FILTERS, applyMapping, resolveOptions,
42
37
  loadManifests, loadExamples,
43
- viewToSlack, escapeMrkdwn, viewToHubSpot, viewToHtml, slackMessageToHtml, previewSurface, previewWorkflow, recordingPorts, decodeAction,
44
38
  ...useCases,
45
39
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.697.20e3e54.0",
4
+ "version": "2.0.0--canary.696.5d9f1be.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -47,9 +47,9 @@
47
47
  }
48
48
  },
49
49
  "devDependencies": {
50
- "@friggframework/eslint-config": "2.0.0--canary.697.20e3e54.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.697.20e3e54.0",
52
- "@friggframework/test": "2.0.0--canary.697.20e3e54.0",
50
+ "@friggframework/eslint-config": "2.0.0--canary.696.5d9f1be.0",
51
+ "@friggframework/prettier-config": "2.0.0--canary.696.5d9f1be.0",
52
+ "@friggframework/test": "2.0.0--canary.696.5d9f1be.0",
53
53
  "@prisma/client": "^6.19.3",
54
54
  "@types/lodash": "4.17.15",
55
55
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -90,5 +90,5 @@
90
90
  "publishConfig": {
91
91
  "access": "public"
92
92
  },
93
- "gitHead": "20e3e54c23da86b3bad547cc8688e6e106b31e5d"
93
+ "gitHead": "5d9f1be29b1cec33fd7ca6ec81b831a3c5a9be48"
94
94
  }
@@ -1,67 +0,0 @@
1
- /**
2
- * What every renderer shares: coercion of view props (they come from record data, so any type can arrive), the one
3
- * URL rule, truncation that never leaves half an entity behind, and the button <-> Slack round trip.
4
- */
5
-
6
- /** A view prop as text. Objects are JSON, null/undefined are empty: a renderer never prints "[object Object]" or "undefined". */
7
- function str(v) {
8
- if (v == null) return '';
9
- if (typeof v === 'string') return v;
10
- if (typeof v === 'object') { try { return JSON.stringify(v); } catch (_) { return ''; } }
11
- return String(v);
12
- }
13
-
14
- /**
15
- * Only http(s) links leave a preview or a card. Record data decides these URLs ("{{data.proposal.recording}}"), and a
16
- * `javascript:` or `data:` URL in a vendor field must not become a clickable thing in someone's HubSpot or Slack.
17
- * @returns {string|null}
18
- */
19
- function safeUrl(u) {
20
- const s = str(u).trim();
21
- if (!/^https?:\/\/[^\s<>"']+$/i.test(s)) return null;
22
- try { new URL(s); } catch (_) { return null; }
23
- return s;
24
- }
25
-
26
- /**
27
- * Cut to at most `max` characters, ending in the marker. Never leaves a dangling half of a Slack entity ("&am"), which
28
- * would render as literal junk instead of the character it stood for.
29
- */
30
- function truncate(s, max, marker = '…') {
31
- s = String(s);
32
- if (s.length <= max) return s;
33
- let cut = s.slice(0, Math.max(0, max - marker.length));
34
- cut = cut.replace(/&(?:[a-z]{1,4}|#\d{0,6}|#x[0-9a-f]{0,5})?$/i, '');
35
- return cut + marker;
36
- }
37
-
38
- /** Badge tone (`neutral`, or a Jira statusCategory key like `done`) -> one of four. Unknown is neutral, never an error. */
39
- function toneOf(tone) {
40
- const t = str(tone).toLowerCase();
41
- if (['positive', 'success', 'good', 'done', 'green', 'complete', 'completed'].includes(t)) return 'positive';
42
- if (['warning', 'caution', 'indeterminate', 'yellow', 'in_progress', 'pending'].includes(t)) return 'warning';
43
- if (['critical', 'danger', 'error', 'bad', 'red', 'failed'].includes(t)) return 'critical';
44
- return 'neutral';
45
- }
46
-
47
- const ACTION_VALUE_MAX = 2000; // Slack's cap on a button's value
48
-
49
- /**
50
- * A userAction button's identity as a Slack button value: which installation, which workflow, which surface it was on.
51
- * `needsInput` marks a button whose workflow asks for values first (a Slack button cannot, so the handler opens a form).
52
- */
53
- function encodeAction({ integrationId, workflow, surface, needsInput }) {
54
- const value = JSON.stringify({ ...(integrationId ? { integrationId: String(integrationId) } : {}), workflow, surface, ...(needsInput ? { needsInput: true } : {}) });
55
- return value.length <= ACTION_VALUE_MAX ? value : null;
56
- }
57
-
58
- /** The inverse, for the handler that receives a click. null for anything that is not one of ours. */
59
- function decodeAction(value) {
60
- let v;
61
- try { v = JSON.parse(value); } catch (_) { return null; }
62
- if (!v || typeof v !== 'object' || typeof v.workflow !== 'string' || !v.workflow || typeof v.surface !== 'string') return null;
63
- if (v.integrationId !== undefined && typeof v.integrationId !== 'string') return null;
64
- return { ...(v.integrationId ? { integrationId: v.integrationId } : {}), workflow: v.workflow, surface: v.surface, ...(v.needsInput ? { needsInput: true } : {}) };
65
- }
66
-
67
- module.exports = { str, safeUrl, truncate, toneOf, encodeAction, decodeAction, ACTION_VALUE_MAX };
@@ -1,129 +0,0 @@
1
- /**
2
- * Safe, self-contained HTML for a preview pane. Every piece of data goes through `esc`, links are http(s) only, styles
3
- * are inline attributes and there is no script, no `<style>`, no event handler and no external resource, so a pane can
4
- * show a vendor's record next to the definition's chrome without that record being able to do anything.
5
- */
6
- const { str, safeUrl, toneOf } = require('./common');
7
-
8
- const esc = (v) => str(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9
- const FONT = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif";
10
-
11
- /** How each host frames what it shows. `generic` is for hosts with no look of their own (a phone app, Zendesk, Shopify…). */
12
- const HOSTS = {
13
- hubspot: { label: 'HubSpot card', frame: `${FONT};background:#fff;border:1px solid #cbd6e2;border-radius:3px;max-width:420px;color:#33475b;font-size:14px;box-shadow:0 1px 2px rgba(0,0,0,.08)`, head: 'background:#f5f8fa;border-bottom:1px solid #cbd6e2;padding:8px 14px;font-size:12px;color:#516f90;font-weight:600', body: 'padding:14px', accent: '#ff7a59' },
14
- slack: { label: 'Slack message', frame: `${FONT};background:#fff;border:1px solid #e0e0e0;border-radius:8px;max-width:560px;color:#1d1c1d;font-size:15px`, head: 'padding:8px 14px 0;font-size:13px;color:#616061', body: 'padding:6px 14px 12px', accent: '#4a154b' },
15
- generic: { label: 'Preview', frame: `${FONT};background:#fff;border:1px solid #d9dde3;border-radius:6px;max-width:480px;color:#1f2937;font-size:14px`, head: 'background:#f3f4f6;border-bottom:1px solid #d9dde3;padding:8px 14px;font-size:12px;color:#6b7280;font-weight:600', body: 'padding:14px', accent: '#2563eb' },
16
- };
17
- const hostOf = (h) => HOSTS[h] || HOSTS.generic;
18
- const TONE = { positive: 'background:#e6f4ea;color:#1e6b34', warning: 'background:#fff4d6;color:#8a5a00', critical: 'background:#fde8e6;color:#a4261b', neutral: 'background:#eceff3;color:#44505f' };
19
-
20
- /** The view tree as HTML, without the host frame. */
21
- function viewToHtmlBody(view, host = 'generic') {
22
- const H = hostOf(host);
23
- const go = (v) => {
24
- if (!v || typeof v !== 'object') return '';
25
- const p = v.props || {};
26
- const kids = () => (v.children || []).map(go).join('');
27
- switch (v.component) {
28
- case 'Stack': return `<div style="display:flex;flex-direction:column;gap:10px">${kids()}</div>`;
29
- case 'Heading': return `<h3 style="margin:0;font-size:16px;font-weight:700">${esc(p.text)}</h3>`;
30
- case 'Text': return `<p style="margin:0;line-height:1.4">${esc(p.text)}</p>`;
31
- case 'KeyValue': return `<dl style="margin:0;display:grid;grid-template-columns:max-content 1fr;gap:4px 14px">${(Array.isArray(p.items) ? p.items : []).map((it) => `<dt style="color:#6b7280">${esc(it?.label)}</dt><dd style="margin:0">${esc(it?.value) || '—'}</dd>`).join('')}</dl>`;
32
- case 'List': return `<ul style="margin:0;padding-left:18px">${(Array.isArray(p.items) ? p.items : []).map((it) => `<li>${esc(it?.title)}${it?.meta ? ` <span style="color:#6b7280;font-size:12px">${esc(it.meta)}</span>` : ''}</li>`).join('')}</ul>`;
33
- case 'Table': return `<table style="border-collapse:collapse;width:100%"><thead><tr>${(Array.isArray(p.columns) ? p.columns : []).map((c) => `<th style="text-align:left;border-bottom:1px solid #d9dde3;padding:4px 8px;font-size:12px;color:#6b7280">${esc(c)}</th>`).join('')}</tr></thead><tbody>${(Array.isArray(p.rows) ? p.rows : []).map((r) => `<tr>${(Array.isArray(r) ? r : []).map((c) => `<td style="border-bottom:1px solid #eef0f3;padding:4px 8px">${esc(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
34
- case 'Badge': return `<span style="display:inline-block;align-self:flex-start;padding:2px 8px;border-radius:10px;font-size:12px;font-weight:600;${TONE[toneOf(p.tone)]}">${esc(p.text)}</span>`;
35
- case 'Empty': return `<p style="margin:0;color:#6b7280;font-style:italic">${esc(p.text)}</p>`;
36
- case 'Divider': return '<hr style="border:0;border-top:1px solid #e5e7eb;margin:2px 0">';
37
- case 'Button': return buttonHtml({ label: p.label, action: p.action, primary: p.style === 'primary', accent: H.accent });
38
- default: return kids();
39
- }
40
- };
41
- return go(view);
42
- }
43
-
44
- /** A button in a preview does nothing (no script), but says what it would do: a link for open_url, a `data-` marker for a workflow. */
45
- function buttonHtml({ label, action, primary, accent }) {
46
- const style = `display:inline-block;align-self:flex-start;padding:6px 14px;border-radius:4px;font-size:14px;font-weight:600;text-decoration:none;cursor:default;${primary ? `background:${accent};color:#fff;border:1px solid ${accent}` : `background:#fff;color:${accent};border:1px solid ${accent}`}`;
47
- if (action?.type === 'open_url') {
48
- const url = safeUrl(action.url);
49
- if (url) return `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer" style="${style}">${esc(label || 'Open')}</a>`;
50
- }
51
- const wf = action?.type === 'userAction' ? ` data-workflow="${esc(action.workflow)}" title="${esc(`Runs the "${action.workflow}" workflow`)}"` : '';
52
- return `<button type="button"${wf} style="${style}">${esc(label || 'Open')}</button>`;
53
- }
54
-
55
- /** The framed preview: a head line saying what this is and where it appears, then the body. */
56
- function frame(host, title, bodyHtml) {
57
- const H = hostOf(host);
58
- return `<div data-preview-host="${esc(host in HOSTS ? host : 'generic')}" style="${H.frame}"><div style="${H.head}">${esc(title || H.label)}</div><div style="${H.body}">${bodyHtml}</div></div>`;
59
- }
60
-
61
- /**
62
- * @param {object|null} view
63
- * @param {{ host?: string, title?: string }} [opts]
64
- */
65
- function viewToHtml(view, { host = 'generic', title } = {}) {
66
- const body = view ? viewToHtmlBody(view, host) : `<p style="margin:0;color:#6b7280;font-style:italic">Nothing to show for this record.</p>`;
67
- return frame(host, title, body);
68
- }
69
-
70
- // ---- Slack: mrkdwn and Block Kit as HTML, so a posted message previews as it will look, not as a data dump ----
71
-
72
- /** Slack mrkdwn (already Slack-encoded: `&amp;`, `<url|label>`) to HTML. Only `* _ ~ \`` and links are interpreted. */
73
- function mrkdwnToHtml(text) {
74
- const parts = str(text).split(/(<[^<>\n]*>)/);
75
- const out = parts.map((seg) => {
76
- const tok = /^<([^<>\n]*)>$/.exec(seg);
77
- if (tok) {
78
- // the target is Slack-encoded like the rest (`&amp;` in a query string): decode it once, then esc() it as an attribute
79
- const [rawTarget, label] = tok[1].split('|');
80
- const target = rawTarget.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
81
- if (/^https?:\/\//i.test(target)) { const url = safeUrl(target); return url ? `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer" style="color:#1264a3">${esc(label || target)}</a>` : esc(label || ''); }
82
- if (target.startsWith('!')) return `<span style="background:#fff3cd">@${esc(label || target.slice(1).split('^')[0])}</span>`;
83
- if (target.startsWith('@')) return `<span style="background:#e8f5fa;color:#1264a3">@${esc(label || target.slice(1))}</span>`;
84
- if (target.startsWith('#')) return `<span style="color:#1264a3">#${esc(label || target.slice(1))}</span>`;
85
- return esc(label || '');
86
- }
87
- // decode Slack's three entities once, then escape for HTML, then emphasis on the escaped text
88
- const raw = seg.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
89
- return esc(raw)
90
- .replace(/`([^`\n]+)`/g, '<code style="background:#f4f4f4;border:1px solid #e0e0e0;border-radius:3px;padding:0 3px">$1</code>')
91
- .replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, '$1<strong>$2</strong>')
92
- .replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, '$1<em>$2</em>')
93
- .replace(/(^|[\s(])~([^~\n]+)~(?=$|[\s).,!?:;])/g, '$1<del>$2</del>');
94
- }).join('');
95
- return out.replace(/\n/g, '<br>');
96
- }
97
-
98
- const textOf = (t) => (t && t.type === 'mrkdwn' ? mrkdwnToHtml(t.text) : esc(t?.text));
99
-
100
- function slackBlocksToHtml(blocks) {
101
- return (Array.isArray(blocks) ? blocks : []).map((b) => {
102
- switch (b?.type) {
103
- case 'header': return `<div style="font-size:18px;font-weight:700;margin:6px 0">${esc(b.text?.text)}</div>`;
104
- case 'section': return `<div style="margin:4px 0">${b.text ? `<div>${textOf(b.text)}</div>` : ''}${Array.isArray(b.fields) ? `<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px 14px;margin-top:4px">${b.fields.map((f) => `<div>${textOf(f)}</div>`).join('')}</div>` : ''}</div>`;
105
- case 'context': return `<div style="color:#616061;font-size:12px;margin:4px 0">${(b.elements || []).map(textOf).join(' ')}</div>`;
106
- case 'divider': return '<hr style="border:0;border-top:1px solid #e0e0e0;margin:8px 0">';
107
- case 'actions': return `<div style="display:flex;flex-wrap:wrap;gap:8px;margin:6px 0">${(b.elements || []).map((e) => (e.url
108
- ? buttonHtml({ label: e.text?.text, action: { type: 'open_url', url: e.url }, accent: '#1264a3' })
109
- : buttonHtml({ label: e.text?.text, action: { type: 'userAction', workflow: safeWorkflow(e.value) }, primary: e.style === 'primary', accent: e.style === 'primary' ? '#007a5a' : '#1264a3' }))).join('')}</div>`;
110
- default: return `<div style="color:#616061;font-size:12px">(${esc(b?.type || 'unknown')} block)</div>`;
111
- }
112
- }).join('');
113
- }
114
- function safeWorkflow(value) { try { const w = JSON.parse(value)?.workflow; return typeof w === 'string' ? w : ''; } catch (_) { return ''; } }
115
-
116
- /** A Slack message as the channel will show it: who posts it, where, then the blocks (or the text). */
117
- function slackMessageToHtml({ channel, text, blocks, appName = 'Your integration' }) {
118
- const to = channel ? esc(channel) : '<em>(channel not chosen yet)</em>';
119
- const body = Array.isArray(blocks) && blocks.length ? slackBlocksToHtml(blocks) : `<div>${mrkdwnToHtml(text)}</div>`;
120
- return `<div data-preview-host="slack" style="${HOSTS.slack.frame}"><div style="${HOSTS.slack.head}"><strong style="color:#1d1c1d">${esc(appName)}</strong> <span style="background:#e8e8e8;border-radius:3px;padding:0 4px;font-size:11px">APP</span> to ${to}</div><div style="${HOSTS.slack.body}">${body}</div></div>`;
121
- }
122
-
123
- /** Any other outbound call: what it is and what it carries. The values are escaped, and objects are shown as JSON. */
124
- function callToHtml({ module, capability, args }) {
125
- const rows = Object.entries(args || {}).map(([k, v]) => `<dt style="color:#6b7280">${esc(k)}</dt><dd style="margin:0;white-space:pre-wrap;word-break:break-word">${esc(typeof v === 'string' ? v : JSON.stringify(v, null, 2))}</dd>`).join('');
126
- return frame('generic', `Would call ${module} · ${capability}`, `<dl style="margin:0;display:grid;grid-template-columns:max-content 1fr;gap:4px 14px">${rows}</dl>`);
127
- }
128
-
129
- module.exports = { viewToHtml, viewToHtmlBody, slackBlocksToHtml, slackMessageToHtml, mrkdwnToHtml, callToHtml, esc, HOSTS };
@@ -1,54 +0,0 @@
1
- /**
2
- * Resolved view tree -> a JSON description of HubSpot UI-extension components (`@hubspot/ui-extensions` names). Data,
3
- * not React: a thin card deployed once maps `{ type, props, children }` onto `React.createElement(components[type], ...)`
4
- * and routes `props.action` to the actions endpoint, so a changed card is a publish, not a card deploy.
5
- *
6
- * A node is `{ type, props?, children? }`; a child is a string (text) or a node. Strings are text, never markup.
7
- */
8
- const { str, safeUrl, toneOf } = require('./common');
9
-
10
- /** The component names this emits, so the card can fail closed on one it does not know. */
11
- const HUBSPOT_COMPONENTS = ['Flex', 'Heading', 'Text', 'List', 'Tag', 'Button', 'EmptyState', 'DescriptionList', 'DescriptionListItem', 'Divider', 'Table', 'TableHead', 'TableBody', 'TableRow', 'TableHeader', 'TableCell'];
12
- const TAG_VARIANT = { positive: 'success', warning: 'warning', critical: 'error', neutral: 'default' };
13
-
14
- const node = (type, props, children) => ({ type, ...(props && Object.keys(props).length ? { props } : {}), ...(children && children.length ? { children } : {}) });
15
-
16
- /**
17
- * @param {object|null} view
18
- * @param {{ integrationId?: string, surface?: string }} [opts] carried on a button's `action`, so the card can post it back
19
- * @returns {object|null}
20
- */
21
- function viewToHubSpot(view, { integrationId, surface } = {}) {
22
- const go = (v) => {
23
- if (!v || typeof v !== 'object') return null;
24
- const p = v.props || {};
25
- const kids = () => (v.children || []).map(go).filter(Boolean);
26
- switch (v.component) {
27
- case 'Stack': return node('Flex', { direction: 'column', gap: 'sm' }, kids());
28
- case 'Heading': return node('Heading', {}, [str(p.text)]);
29
- case 'Text': return node('Text', {}, [str(p.text)]);
30
- case 'KeyValue': return node('DescriptionList', { direction: 'row' }, (Array.isArray(p.items) ? p.items : []).map((it) => node('DescriptionListItem', { label: str(it?.label) }, [node('Text', {}, [str(it?.value) || '—'])])));
31
- case 'List': return node('List', { variant: 'unordered' }, (Array.isArray(p.items) ? p.items : []).map((it) => node('Flex', { direction: 'column', gap: 'flush' }, [node('Text', { format: { fontWeight: 'demibold' } }, [str(it?.title)]), ...(it?.meta ? [node('Text', { variant: 'microcopy' }, [str(it.meta)])] : [])])));
32
- case 'Table': return node('Table', {}, [
33
- node('TableHead', {}, [node('TableRow', {}, (Array.isArray(p.columns) ? p.columns : []).map((c) => node('TableHeader', {}, [str(c)])))]),
34
- node('TableBody', {}, (Array.isArray(p.rows) ? p.rows : []).map((r) => node('TableRow', {}, (Array.isArray(r) ? r : []).map((c) => node('TableCell', {}, [str(c)]))))),
35
- ]);
36
- case 'Badge': return node('Tag', { variant: TAG_VARIANT[toneOf(p.tone)] }, [str(p.text)]);
37
- case 'Empty': return node('EmptyState', { title: str(p.text), layout: 'vertical' });
38
- case 'Divider': return node('Divider', { distance: 'sm' });
39
- case 'Button': {
40
- const a = p.action;
41
- const variant = p.style === 'primary' ? 'primary' : p.style === 'danger' ? 'destructive' : 'secondary';
42
- let action;
43
- if (a?.type === 'userAction' && typeof a.workflow === 'string') action = { type: 'userAction', workflow: a.workflow, surface, ...(integrationId ? { integrationId: String(integrationId) } : {}), ...(a.input ? { input: a.input } : {}) };
44
- else if (a?.type === 'open_url' && safeUrl(a.url)) action = { type: 'open_url', url: safeUrl(a.url) };
45
- // A button with no usable action still renders (disabled), so the card matches the definition.
46
- return node('Button', { variant, ...(action ? { action } : { disabled: true }) }, [str(p.label) || 'Open']);
47
- }
48
- default: return kids().length === 1 ? kids()[0] : node('Flex', { direction: 'column', gap: 'sm' }, kids());
49
- }
50
- };
51
- return go(view);
52
- }
53
-
54
- module.exports = { viewToHubSpot, HUBSPOT_COMPONENTS };
@@ -1,154 +0,0 @@
1
- /**
2
- * Previews: what an integration will show inside the apps it connects, rendered from the current settings and sample
3
- * or real records. Same code path as production for everything that decides the content (queries, view resolution,
4
- * the interpreter and its steps); the only thing swapped is where a capability call goes.
5
- *
6
- * previewSurface a UI surface (a HubSpot card, a Slack message menu...) -> view + html (+ slack blocks / hubspot tree)
7
- * previewWorkflow dry-run a workflow: every outbound call is RECORDED with the args it would send, none is made
8
- */
9
- const { makePorts, renderSurface, runWorkflow, recordOf } = require('../runtime');
10
- const { isBuiltin } = require('../steps');
11
- const { viewToSlack } = require('./slack');
12
- const { viewToHubSpot } = require('./hubspot');
13
- const { viewToHtml, slackMessageToHtml, callToHtml, slackBlocksToHtml, esc, HOSTS } = require('./html');
14
-
15
- class DryRunError extends Error { constructor(what) { super(`dry run: ${what} was not performed`); this.name = 'DryRunError'; } }
16
-
17
- const clone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
18
-
19
- /** A plausible empty record for a manifest object: ids are filled (a step that checks `$.steps.x.id exists` proceeds), the rest is empty of its type. */
20
- function sampleObject(manifest, name) {
21
- const o = manifest?.objects?.[name];
22
- const out = {};
23
- for (const f of o?.fields || []) {
24
- const keys = String(f.name).split('.');
25
- let cur = out;
26
- keys.slice(0, -1).forEach((k) => { cur = (cur[k] = cur[k] && typeof cur[k] === 'object' ? cur[k] : {}); });
27
- const empty = f.type === 'id' ? `preview-${name}-${f.name}` : f.type === 'number' ? 0 : f.type === 'boolean' ? false : f.type === 'json' ? {} : f.type === 'array' ? [] : '';
28
- cur[keys[keys.length - 1]] = empty;
29
- }
30
- return out;
31
- }
32
- function sampleOutput(manifest, cap) {
33
- const o = String(cap?.output || '');
34
- if (o.endsWith('[]')) return [];
35
- if (/^page</.test(o)) return { results: [] };
36
- return o && manifest?.objects?.[o] ? sampleObject(manifest, o) : {};
37
- }
38
-
39
- /**
40
- * Ports for previews. `ports.call` answers from `fixtures` (key "<module key>.<capability>", a value or a function of
41
- * the args), else from the manifest's own output shape, and records the call. The real `callApi` the base ports would
42
- * use throws, so a call that somehow bypassed the recorder fails loudly instead of reaching an API.
43
- *
44
- * @param {{ def, manifests, fixtures?: object, store?: Map }} p fixtures.store pre-seeds the link store (keys as the real store's)
45
- * @returns {{ ports, calls: object[], refusing: object }} `refusing`: the unwrapped ports, whose call() always throws (kept so a test can prove it)
46
- */
47
- function recordingPorts({ def, manifests, fixtures = {}, store }) {
48
- const calls = [];
49
- const rows = store || new Map(Object.entries(clone(fixtures.store) || {}));
50
- const mem = {
51
- get: async (k) => rows.get(k) ?? null,
52
- put: async (k, v) => { rows.set(k, v); },
53
- claim: async (k, v) => { if (rows.has(k)) return false; rows.set(k, v); return true; },
54
- release: async (k) => { rows.delete(k); },
55
- };
56
- const base = makePorts({
57
- def, manifests, store: mem,
58
- callApi: async (moduleKey, method) => { throw new DryRunError(`${moduleKey}.${method}`); },
59
- runCode: async (ref, args) => answer('code', ref, 'code', args, fixtures[`code:${ref}`], {}),
60
- });
61
- const ports = { ...base }; // the seam below replaces call() on this copy; `base` keeps the one that reaches callApi
62
- const manifestFor = (key) => manifests[def.modules[key]?.module];
63
- async function answer(module, capability, kind, args, fixture, fallback) {
64
- const call = { module, capability, kind, args: clone(args) };
65
- calls.push(call);
66
- try {
67
- call.output = typeof fixture === 'function' ? clone(await fixture(clone(args), { module, capability })) : fixture !== undefined ? clone(fixture) : fallback;
68
- return clone(call.output);
69
- } catch (e) { call.error = String(e.message || e); throw e; }
70
- }
71
- // `refusing` is the base ports as they were before the seam below: their call() reaches callApi, which throws.
72
- // The seam: the interpreter only ever reaches a module through ports.call.
73
- ports.call = async (moduleKey, capability, args) => {
74
- const m = manifestFor(moduleKey);
75
- const c = m?.capabilities?.[capability];
76
- if (!c) {
77
- calls.push({ module: moduleKey, capability, kind: 'unknown', args: clone(args), error: 'not declared by the module manifest' });
78
- throw new Error(`${moduleKey}.${capability} is not declared by the module manifest`);
79
- }
80
- return answer(moduleKey, capability, c.kind, args, fixtures[`${moduleKey}.${capability}`], sampleOutput(m, c));
81
- };
82
- return { ports, calls, refusing: base };
83
- }
84
-
85
- /** read/search/subscribe calls fetch; the rest (and code) is what leaves the integration. */
86
- const isSend = (call) => call.kind === 'write' || call.kind === 'delete';
87
-
88
- /** How one outbound call would look where it lands. Slack messages get the real Block Kit/mrkdwn rendering. */
89
- function renderCall(call, manifest, appName) {
90
- if (manifest?.module === 'slack' && /^message\./.test(call.capability)) {
91
- const a = call.args || {};
92
- const slack = { channel: a.channel, text: a.text, ...(Array.isArray(a.blocks) ? { blocks: a.blocks } : {}) };
93
- return { html: slackMessageToHtml({ ...slack, appName }), slack };
94
- }
95
- return { html: callToHtml(call) };
96
- }
97
-
98
- /**
99
- * Dry-run one workflow. Nothing is performed: every capability call is recorded with the args it would carry and
100
- * answered from `fixtures` (or an empty record shaped by the manifest). A webhook workflow with no `event` runs on
101
- * the manifest's own sample payload for its trigger.
102
- *
103
- * @param {{ def, installation?, workflowId: string, event?, record?, manifests, fixtures?: object, now?: Function }} p
104
- * @returns {Promise<{ workflow: string, status: string, reason?: string, error?: string, waiting?: object, sends: object[], calls: object[], trace: object[] }>}
105
- * `calls`: every call in order, `{ stepId, at, module, capability, kind, args, output, rendered }`; `sends`: the writes
106
- * among them (`rendered` is `{ html, slack? }` for those, null for reads)
107
- */
108
- async function previewWorkflow({ def, installation = {}, workflowId, event, record, manifests, fixtures = {}, now }) {
109
- const w = (def.workflows || []).find((x) => x.id === workflowId);
110
- if (!w) throw new Error(`Unknown workflow "${workflowId}"`);
111
- if (event === undefined) {
112
- const t = w.trigger;
113
- event = (t.type === 'webhook' && clone(manifests[def.modules[t.module]?.module]?.events?.[t.topic]?.payload)) || {};
114
- }
115
- const { ports, calls } = recordingPorts({ def, manifests, fixtures });
116
- const run = await runWorkflow({ def, installation, workflowId, event, record, ports, manifests, ...(now ? { now } : {}) });
117
-
118
- // The trace has one entry per step that ran; the calls, in the same order, belong to the entries that are capability steps (or code).
119
- const callSteps = (run.trace || []).filter((t) => (!isBuiltin(t.use) || t.use === 'code') && (t.status === 'ok' || t.status === 'error'));
120
- const aligned = callSteps.length === calls.length;
121
- const out = calls.map((c, i) => {
122
- const manifest = manifests[def.modules[c.module]?.module];
123
- return { stepId: aligned ? callSteps[i].id : null, at: aligned ? callSteps[i].at : null, ...c, rendered: isSend(c) && manifest ? renderCall(c, manifest, def.label || def.id) : null };
124
- });
125
- const waiting = run.status === 'waiting' ? run.waitingFor : undefined;
126
- return {
127
- workflow: workflowId, status: run.status, ...(run.reason ? { reason: run.reason } : {}), ...(run.error ? { error: run.error } : {}),
128
- ...(waiting ? { waiting: { ...waiting, ...(waiting.kind === 'approval' ? { html: `<div style="${HOSTS.generic.frame}"><div style="${HOSTS.generic.head}">Waits for approval</div><div style="${HOSTS.generic.body}">${esc(waiting.prompt)}</div></div>` } : {}) } } : {}),
129
- sends: out.filter(isSend), calls: out, trace: run.trace || [],
130
- };
131
- }
132
-
133
- /**
134
- * Render one UI surface. `ports` answer the surface's query (real ports for real records; `recordingPorts(...).ports`
135
- * with fixtures for samples). `host` defaults to the surface's own.
136
- *
137
- * @returns {Promise<{ surface, view, html: string, slack?: { blocks, text, truncated }, hubspot?: object }>}
138
- */
139
- async function previewSurface({ def, installation = {}, surfaceId, record, ports, host }) {
140
- const { surface, view } = await renderSurface({ def, installation, surfaceId, record, ports });
141
- const h = host || surface.host;
142
- const opts = { integrationId: installation.integrationId, surface: surface.id };
143
- const out = { surface, view };
144
- if (h === 'slack') {
145
- out.slack = viewToSlack(view, opts);
146
- out.html = `<div data-preview-host="slack" style="${HOSTS.slack.frame}"><div style="${HOSTS.slack.head}">${esc(surface.label || def.label || def.id)} · ${esc(surface.placement)}</div><div style="${HOSTS.slack.body}">${view ? slackBlocksToHtml(out.slack.blocks) : '<em>Nothing to show for this record.</em>'}</div></div>`;
147
- } else {
148
- if (h === 'hubspot') out.hubspot = viewToHubSpot(view, opts);
149
- out.html = viewToHtml(view, { host: h, title: `${def.ui.find((u) => u.id === surface.id)?.label || surface.id} · ${surface.placement}` });
150
- }
151
- return out;
152
- }
153
-
154
- module.exports = { previewSurface, previewWorkflow, recordingPorts, DryRunError, recordOf };
@@ -1,146 +0,0 @@
1
- /**
2
- * Resolved view tree -> Slack Block Kit. Data comes from vendor records, so everything that is data is escaped for
3
- * mrkdwn, and everything is cut to Slack's limits with a visible marker instead of letting chat.postMessage reject the
4
- * whole message (a long deal name must not cost the approval request).
5
- */
6
- const { str, safeUrl, truncate, toneOf, encodeAction } = require('./common');
7
-
8
- const LIMITS = { blocks: 50, header: 150, sectionText: 3000, fieldText: 2000, fields: 10, contextText: 3000, contextElements: 10, button: 75, buttonUrl: 3000, actionId: 255, actions: 25, listItems: 25, itemText: 300 };
9
- const MARK = '…';
10
- const ZWSP = '\u200b';
11
-
12
- /**
13
- * Text that is DATA, for a mrkdwn field. `&`, `<` and `>` are the three characters Slack requires escaping (this also
14
- * defuses `<!channel>` and `<https://evil|click>` in a vendor field). Slack has no backslash escape for `* _ ~ \``, so
15
- * `* _ ~` are fenced with a zero-width space, which stops them pairing into bold/italic/strike without changing how they
16
- * look. A fenced backtick would still pair into a code span, so it becomes the look-alike U+02CB.
17
- */
18
- function escapeMrkdwn(v) {
19
- return str(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/[*_~]/g, `${ZWSP}$&${ZWSP}`).replace(/`/g, '\u02cb');
20
- }
21
- const mrkdwn = (t) => ({ type: 'mrkdwn', text: t });
22
- const plain = (t) => ({ type: 'plain_text', text: t, emoji: false });
23
- const clip = (s, max) => truncate(s, max, MARK);
24
-
25
- /** Lines packed into as few sections as fit 3000 characters; one line is never split. */
26
- function linesToSections(lines) {
27
- const out = [];
28
- let cur = '';
29
- for (const l of lines) {
30
- const line = clip(l, LIMITS.itemText);
31
- if (cur && cur.length + 1 + line.length > LIMITS.sectionText) { out.push(cur); cur = ''; }
32
- cur = cur ? `${cur}\n${line}` : line;
33
- }
34
- if (cur) out.push(cur);
35
- return out.map((text) => ({ type: 'section', text: mrkdwn(clip(text, LIMITS.sectionText)) }));
36
- }
37
-
38
- const TONE_EMOJI = { positive: ':large_green_circle:', warning: ':large_yellow_circle:', critical: ':red_circle:', neutral: ':white_circle:' };
39
-
40
- /**
41
- * @param {object|null} view resolveView output ({ component, props, children })
42
- * @param {{ integrationId?: string, surface?: string }} [opts] what a button's click carries back
43
- * @returns {{ blocks: object[], text: string, truncated: boolean }} `text` is the notification/fallback text
44
- */
45
- function viewToSlack(view, { integrationId, surface } = {}) {
46
- const blocks = [];
47
- let text = '';
48
- let truncated = false;
49
- let pending = []; // consecutive buttons share one actions block
50
- let n = 0;
51
-
52
- const flush = () => {
53
- if (!pending.length) return;
54
- const els = pending.slice(0, LIMITS.actions);
55
- if (pending.length > LIMITS.actions) truncated = true;
56
- blocks.push({ type: 'actions', elements: els });
57
- pending = [];
58
- };
59
- const push = (b) => { flush(); blocks.push(b); };
60
-
61
- const walk = (node) => {
62
- if (!node || typeof node !== 'object') return;
63
- const p = node.props || {};
64
- switch (node.component) {
65
- case 'Heading': {
66
- flush();
67
- const t = clip(str(p.text), LIMITS.header);
68
- if (!text) text = t;
69
- if (t) blocks.push({ type: 'header', text: plain(t) });
70
- break;
71
- }
72
- case 'Text': {
73
- const t = str(p.text);
74
- if (!text) text = t;
75
- if (t) push({ type: 'section', text: mrkdwn(clip(escapeMrkdwn(t), LIMITS.sectionText)) });
76
- break;
77
- }
78
- case 'KeyValue': {
79
- const items = Array.isArray(p.items) ? p.items : [];
80
- for (let i = 0; i < items.length; i += LIMITS.fields) {
81
- const fields = items.slice(i, i + LIMITS.fields).map((it) => mrkdwn(clip(`*${escapeMrkdwn(it?.label)}*\n${escapeMrkdwn(it?.value) || '—'}`, LIMITS.fieldText)));
82
- if (fields.length) push({ type: 'section', fields });
83
- }
84
- break;
85
- }
86
- case 'List': {
87
- const items = Array.isArray(p.items) ? p.items : [];
88
- const lines = items.slice(0, LIMITS.listItems).map((it) => `• ${escapeMrkdwn(it?.title)}${it?.meta ? ` — _${escapeMrkdwn(it.meta)}_` : ''}`);
89
- if (items.length > LIMITS.listItems) { lines.push(`_${MARK} and ${items.length - LIMITS.listItems} more_`); truncated = true; }
90
- flush();
91
- blocks.push(...linesToSections(lines));
92
- break;
93
- }
94
- case 'Table': {
95
- const cols = Array.isArray(p.columns) ? p.columns : [];
96
- const rows = Array.isArray(p.rows) ? p.rows : [];
97
- const lines = [];
98
- if (cols.length) lines.push(cols.map((c) => `*${escapeMrkdwn(c)}*`).join(' | '));
99
- for (const r of rows.slice(0, LIMITS.listItems)) lines.push((Array.isArray(r) ? r : []).map((c) => escapeMrkdwn(c) || '—').join(' | '));
100
- if (rows.length > LIMITS.listItems) { lines.push(`_${MARK} and ${rows.length - LIMITS.listItems} more_`); truncated = true; }
101
- flush();
102
- blocks.push(...linesToSections(lines));
103
- break;
104
- }
105
- case 'Badge': {
106
- const t = str(p.text);
107
- if (t) push({ type: 'context', elements: [mrkdwn(clip(`${TONE_EMOJI[toneOf(p.tone)]} *${escapeMrkdwn(t)}*`, LIMITS.contextText))] });
108
- break;
109
- }
110
- case 'Empty': {
111
- const t = str(p.text);
112
- if (t) push({ type: 'context', elements: [mrkdwn(clip(`_${escapeMrkdwn(t)}_`, LIMITS.contextText))] });
113
- break;
114
- }
115
- case 'Divider': push({ type: 'divider' }); break;
116
- case 'Button': {
117
- const label = clip(str(p.label) || 'Open', LIMITS.button);
118
- const style = p.style === 'primary' ? { style: 'primary' } : p.style === 'danger' ? { style: 'danger' } : {};
119
- const a = p.action;
120
- if (a?.type === 'userAction' && typeof a.workflow === 'string') {
121
- const value = encodeAction({ integrationId, workflow: a.workflow, surface, needsInput: !!a.input });
122
- if (value) pending.push({ type: 'button', text: plain(label), action_id: clip(`frigg:${a.workflow}`, LIMITS.actionId - 8) + `:${n++}`, value, ...style });
123
- } else if (a?.type === 'open_url') {
124
- const url = safeUrl(a.url);
125
- if (url && url.length <= LIMITS.buttonUrl) pending.push({ type: 'button', text: plain(label), action_id: `frigg:open:${n++}`, url });
126
- }
127
- break;
128
- }
129
- default: break; // Stack, and anything a newer definition adds: just its children
130
- }
131
- for (const c of node.children || []) walk(c);
132
- };
133
- walk(view);
134
- flush();
135
-
136
- // 50 blocks is Slack's whole-message cap. Keep 49 and say so, rather than lose the message.
137
- let out = blocks;
138
- if (blocks.length > LIMITS.blocks) {
139
- out = [...blocks.slice(0, LIMITS.blocks - 1), { type: 'context', elements: [mrkdwn(`_${MARK} ${blocks.length - (LIMITS.blocks - 1)} more blocks not shown_`)] }];
140
- truncated = true;
141
- }
142
- // `text` is the notification fallback and Slack parses it as mrkdwn too, so a record's `<!channel>` must not ping from there either.
143
- return { blocks: out, text: clip(escapeMrkdwn(text || 'Update'), LIMITS.sectionText), truncated };
144
- }
145
-
146
- module.exports = { viewToSlack, escapeMrkdwn, LIMITS };