@revenexx/studio-integrations 2.2.0 → 2.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 2.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 95d8a13: A run somebody terminated is no longer offered a resume from its row (PO-355). The studio shows a terminated run as failed, and the row's actions menu offered it **Resume**, which the platform refuses: it resumes only a run that failed on its own. The row now asks the platform's own status word, as the run's record already did, so the resume appears only where it can work. The run still reads as failed, and a retry is still offered. `WorkflowRun` gains an optional `apiStatus` carrying that word.
8
+
9
+ ### Patch Changes
10
+
11
+ - 903b632: A credential form no longer dies on a list option meaning "no choice" (PO-424). A system that offered an option with an empty value in one of its lists stopped that list opening, in the New credential dialog (from the credentials list or from a node's credential setting) and in a credential's detail view, and nothing in the dialog could be closed afterwards. Those lists now carry their values the way a node's settings have since PO-422: the option is offered and can be chosen like any other, and the credential stores what the system declared.
12
+ - 76ec989: A template's description can no longer run script in the studio (PO-524). The "About" section of a template's page rendered the registry's Markdown as whatever HTML it carried, so an image with an `onerror` handler or a `javascript:` link in a description ran inside the tenant's session. The description is now sanitised with DOMPurify after it is rendered: headings, lists, emphasis, code, links and images still show, while script, event handlers, script links, style sheets, inline styles and form controls are removed. A task-list checkbox in a description now shows as its text alone. The module gains `dompurify` as a runtime dependency.
13
+ - 1231fcc: PO-552: the Linear nodes draw their own icons instead of an empty box.
14
+
15
+ Create Issue, Get Issue and Add Comment named icons the studio's table did not
16
+ know, so all three showed the generic box under Linear → Issues. They now draw a
17
+ ticket with a plus, a ticket and a speech bubble with text, and List Projects,
18
+ new in the same package, draws a folder.
19
+
20
+ - 1070ae7: The blank form of the New workflow dialog opens with empty fields (PO-562): the example name and description are gone, so nothing reads as already filled in. The name is marked as required from the start, with the same red asterisk the node settings use.
21
+ - 44ced2f: A node icon declared as a Lucide name (`lucide:<kebab-name>`) is drawn as named, without the studio's translation table, so a new node package shows its icons without a studio change (PO-566). The `mdi:*` names older packages declare are still translated; that table takes no new entries. A template's flow preview now draws node icons the same way as the canvas and the palette — it used to skip the table and draw most of them as a box.
22
+
3
23
  ## 2.2.0
4
24
 
5
25
  ### Minor Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "2.2.0",
4
+ "version": "2.3.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -20,6 +20,7 @@ import {
20
20
  } from "@revenexx/studio";
21
21
  import { toast } from "vue-sonner";
22
22
  import { resolveLocalized } from "../../utils/localize";
23
+ import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
23
24
  import { errorReason } from "../../utils/apiError";
24
25
  const props = defineProps({
25
26
  open: { type: Boolean, required: true },
@@ -89,7 +90,7 @@ function buildConfigPayload() {
89
90
  const out = {};
90
91
  for (const f of fieldsOf(pickedType.value)) {
91
92
  const v = form.config[f.key];
92
- if (v !== void 0 && v !== "") out[f.key] = v;
93
+ if (v !== void 0) out[f.key] = v;
93
94
  }
94
95
  return out;
95
96
  }
@@ -217,14 +218,14 @@ async function submit() {
217
218
  </div>
218
219
  <Select
219
220
  v-else-if="f.type === 'select'"
220
- :model-value="form.config[f.key] ?? ''"
221
- @update:model-value="(v) => form.config[f.key] = v"
221
+ :model-value="toModelValue(form.config[f.key], f.options)"
222
+ @update:model-value="(v) => form.config[f.key] = fromModelValue(v)"
222
223
  >
223
224
  <SelectTrigger :id="`create-field-${f.key}`" class="bg-white">
224
225
  <SelectValue :placeholder="t('integration.credentials.shared.field.select.placeholder')" />
225
226
  </SelectTrigger>
226
227
  <SelectContent>
227
- <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
228
+ <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="toItemValue(opt.value)">
228
229
  {{ resolveLocalized(opt.label) }}
229
230
  </SelectItem>
230
231
  </SelectContent>
@@ -36,6 +36,7 @@ import { relativeTime } from "../../utils/relativeTime";
36
36
  import { useNow } from "../../composables/useNow";
37
37
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
38
38
  import { resolveLocalized } from "../../utils/localize";
39
+ import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
39
40
  const props = defineProps({
40
41
  open: { type: Boolean, required: true },
41
42
  credential: { type: [Object, null], required: true },
@@ -305,14 +306,14 @@ async function connect() {
305
306
  </div>
306
307
  <Select
307
308
  v-else-if="f.type === 'select'"
308
- :model-value="form.config[f.key] ?? ''"
309
- @update:model-value="(v) => form.config[f.key] = v"
309
+ :model-value="toModelValue(form.config[f.key], f.options)"
310
+ @update:model-value="(v) => form.config[f.key] = fromModelValue(v)"
310
311
  >
311
312
  <SelectTrigger :id="`detail-field-${f.key}`" class="bg-white" v-bind="configFieldAria(f.key)">
312
313
  <SelectValue :placeholder="t('integration.credentials.shared.field.select.placeholder')" />
313
314
  </SelectTrigger>
314
315
  <SelectContent>
315
- <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
316
+ <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="toItemValue(opt.value)">
316
317
  {{ resolveLocalized(opt.label) }}
317
318
  </SelectItem>
318
319
  </SelectContent>
@@ -11,6 +11,7 @@ import {
11
11
  } from "@revenexx/studio";
12
12
  import { toast } from "vue-sonner";
13
13
  import { errorReason } from "../../utils/apiError";
14
+ import { acceptsResume } from "../../utils/runFeedQuery";
14
15
  const props = defineProps({
15
16
  run: { type: Object, required: true }
16
17
  });
@@ -27,7 +28,7 @@ function viewReplay() {
27
28
  const hasExecution = computed(() => !!props.run.temporalRunId);
28
29
  const isActive = computed(() => (props.run.status === "running" || props.run.status === "queued") && hasExecution.value);
29
30
  const isTerminal = computed(() => ["succeeded", "failed", "cancelled"].includes(props.run.status));
30
- const canResume = computed(() => props.run.status === "failed" && hasExecution.value);
31
+ const canResume = computed(() => acceptsResume(props.run.apiStatus, props.run.status) && hasExecution.value);
31
32
  const hasAny = computed(() => isActive.value || isTerminal.value);
32
33
  const busy = ref(false);
33
34
  function actionLabel(a) {
@@ -7,6 +7,7 @@ import { IntegrationsApiError, errorReason } from "../../utils/apiError";
7
7
  import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
8
8
  import { formatDuration } from "../../utils/formatDuration";
9
9
  import { unwrapRun } from "../../utils/runEnvelope";
10
+ import { acceptsResume } from "../../utils/runFeedQuery";
10
11
  import { splitRunOutputs } from "../../utils/runOutputs";
11
12
  const props = defineProps({
12
13
  run: { type: Object, required: true },
@@ -527,8 +528,7 @@ const apiStatusRaw = computed(() => pickStr(runData.value, ["status"]));
527
528
  const isActive = computed(() => !isTerminal.value);
528
529
  const canStop = computed(() => isActive.value && hasTemporal.value);
529
530
  const canResume = computed(() => {
530
- const failed = apiStatusRaw.value ? apiStatusRaw.value === "failed" : props.run.status === "failed";
531
- return failed && hasTemporal.value;
531
+ return acceptsResume(apiStatusRaw.value || props.run.apiStatus, props.run.status) && hasTemporal.value;
532
532
  });
533
533
  const hasActions = computed(() => canStop.value || canResume.value);
534
534
  const armed = ref(null);
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, onMounted } from "vue";
3
- import * as Lucide from "lucide-vue-next";
3
+ import { nodeIcon } from "../../data/nodeIcons";
4
4
  import { triggerGlyph } from "../../data/triggerGlyphs";
5
5
  import { findManifest, NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
6
6
  import { resolveLocalized } from "../../utils/localize";
@@ -64,7 +64,7 @@ const boxes = computed(() => {
64
64
  label: manifest ? resolveLocalized(manifest.name) : prettify(n.nodeSlug),
65
65
  subtitle: groups[groups.length - 1] ?? "",
66
66
  manifest,
67
- icon: iconFor(manifest?.icon),
67
+ icon: nodeIcon(manifest?.icon),
68
68
  categoryColor: NODE_CATEGORIES.find((c) => c.key === manifest?.category)?.color ?? "bg-muted text-foreground"
69
69
  });
70
70
  });
@@ -74,11 +74,6 @@ function prettify(slug) {
74
74
  const tail = slug.includes(":") ? slug.split(":").pop() : slug;
75
75
  return tail.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
76
76
  }
77
- function iconFor(name) {
78
- if (!name) return Lucide.Box;
79
- const cleaned = (name.includes(":") ? name.split(":").pop() : name).split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
80
- return Lucide[cleaned] || Lucide.Box;
81
- }
82
77
  function stepPath(x1, y1, x2, y2) {
83
78
  if (y1 === y2) return `M ${x1} ${y1} L ${x2} ${y2}`;
84
79
  if (x2 - x1 >= 48) {
@@ -161,8 +161,13 @@ async function installed(id) {
161
161
  <form v-if="mode === 'blank'" class="-m-1 max-h-[65vh] space-y-3 overflow-y-auto p-1" @submit.prevent="submit">
162
162
  <!-- Two-column rows: label rail left, field right. -->
163
163
  <div class="grid grid-cols-[130px_1fr] items-center gap-x-4">
164
- <Label for="wf-name">{{ t("integration.workflows.create.field.name.label") }}</Label>
165
- <Input id="wf-name" v-model="name" :placeholder="t('integration.workflows.create.field.name.placeholder')" class="bg-white" />
164
+ <!-- The asterisk sits beside the label, not in it: it is for the eye,
165
+ aria-required says it to everyone else, and the field's name stays "Name". -->
166
+ <div class="flex items-center">
167
+ <Label for="wf-name">{{ t("integration.workflows.create.field.name.label") }}</Label>
168
+ <span class="text-sm text-rose-500" aria-hidden="true" :title="t('integration.nodeConfig.field.requiredMark')">&nbsp;*</span>
169
+ </div>
170
+ <Input id="wf-name" v-model="name" aria-required="true" class="bg-white" />
166
171
  </div>
167
172
 
168
173
  <div class="grid grid-cols-[130px_1fr] items-start gap-x-4">
@@ -171,7 +176,6 @@ async function installed(id) {
171
176
  id="wf-desc"
172
177
  v-model="description"
173
178
  rows="2"
174
- :placeholder="t('integration.workflows.create.field.description.placeholder')"
175
179
  class="bg-white"
176
180
  />
177
181
  </div>
@@ -49,6 +49,9 @@ export function useIntegrationsRuns() {
49
49
  workflowId: r.workflow_id,
50
50
  workflowName: r.workflow_name ?? workflowName,
51
51
  status: mapRunStatus(r.status),
52
+ // Kept beside the mapped word, because the mapping folds `terminated` into
53
+ // `failed` and the platform resumes only the literal `failed` (PO-355).
54
+ apiStatus: r.status,
52
55
  temporalRunId: r.temporal_run_id ?? null,
53
56
  triggerId: r.trigger_handle ?? "",
54
57
  // The platform reports what started the run. This was hardcoded to
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Node manifest icon → Lucide component.
3
3
  *
4
- * Authors declare icons as `mdi:*` names (the node SDK's convention), but the
5
- * studio renders Lucide. The naive PascalCase lookup almost never hits a
6
- * Lucide export, so every node degraded to the generic Box. This curated map
7
- * covers the registry's actual vocabulary; unmapped names still try the
8
- * PascalCase lookup before falling back to Box.
4
+ * Node icons are the studio's one deliberate Lucide exception (`docs/icons.md`):
5
+ * a package declares `lucide:<kebab-name>`, and that name is drawn as declared —
6
+ * no table, no studio change for a new package (PO-566).
7
+ *
8
+ * `MDI_TO_LUCIDE` is for packages still published with `mdi:*` names, the
9
+ * convention before PO-566. It is frozen: a missing name is fixed by the package
10
+ * declaring its Lucide name, not by a new entry here. Unmapped names still try
11
+ * the PascalCase lookup before falling back to Box.
9
12
  *
10
13
  * Exported for its test, which checks every entry against Lucide's exports: a
11
14
  * test that found the entries by reading this file's text stopped seeing every
@@ -24,6 +24,7 @@ export const MDI_TO_LUCIDE = {
24
24
  "code-brackets": "Brackets",
25
25
  "code-json": "Braces",
26
26
  "code-tags": "Code",
27
+ "comment-text-outline": "MessageSquareText",
27
28
  "compare-horizontal": "ArrowLeftRight",
28
29
  "content-duplicate": "Copy",
29
30
  "currency-eur": "Euro",
@@ -51,6 +52,7 @@ export const MDI_TO_LUCIDE = {
51
52
  filter: "Filter",
52
53
  "find-replace": "Replace",
53
54
  "folder-download-outline": "FolderDown",
55
+ "folder-outline": "Folder",
54
56
  "folder-plus-outline": "FolderPlus",
55
57
  "folder-search-outline": "FolderSearch",
56
58
  "folder-upload-outline": "FolderUp",
@@ -93,6 +95,8 @@ export const MDI_TO_LUCIDE = {
93
95
  "tag-multiple-outline": "Tags",
94
96
  "tag-plus-outline": "Tag",
95
97
  telegram: "Send",
98
+ "ticket-confirmation-outline": "TicketPlus",
99
+ "ticket-outline": "Ticket",
96
100
  "timeline-text-outline": "Activity",
97
101
  transfer: "ArrowRightLeft",
98
102
  translate: "Languages",
@@ -107,7 +111,7 @@ export const MDI_TO_LUCIDE = {
107
111
  export function nodeIcon(name) {
108
112
  if (!name) return Lucide.Box;
109
113
  const bare = name.includes(":") ? name.split(":").pop() : name;
110
- const mapped = MDI_TO_LUCIDE[bare];
114
+ const mapped = name.startsWith("lucide:") ? void 0 : MDI_TO_LUCIDE[bare];
111
115
  if (mapped && Lucide[mapped]) {
112
116
  return Lucide[mapped];
113
117
  }
@@ -1201,11 +1201,9 @@
1201
1201
  "integration.workflows.create.executionMode.syncOnly.hint": "Wartet auf das Ergebnis.",
1202
1202
  "integration.workflows.create.executionMode.syncOnly.label": "Nur synchron",
1203
1203
  "integration.workflows.create.field.description.label": "Beschreibung",
1204
- "integration.workflows.create.field.description.placeholder": "Optional: Was dieser Workflow macht und warum es ihn gibt.",
1205
1204
  "integration.workflows.create.field.executionMode.label": "Ausführungsmodus",
1206
1205
  "integration.workflows.create.field.icon.label": "Symbol",
1207
1206
  "integration.workflows.create.field.name.label": "Name",
1208
- "integration.workflows.create.field.name.placeholder": "Nächtliche Bestellsynchronisierung",
1209
1207
  "integration.workflows.create.field.tags.label": "Tags",
1210
1208
  "integration.workflows.create.field.trigger.label": "Trigger",
1211
1209
  "integration.workflows.create.tab.blank": "Leer starten",
@@ -1201,11 +1201,9 @@
1201
1201
  "integration.workflows.create.executionMode.syncOnly.hint": "Waits for the result.",
1202
1202
  "integration.workflows.create.executionMode.syncOnly.label": "Sync only",
1203
1203
  "integration.workflows.create.field.description.label": "Description",
1204
- "integration.workflows.create.field.description.placeholder": "Optional: what this workflow does and why it exists.",
1205
1204
  "integration.workflows.create.field.executionMode.label": "Execution mode",
1206
1205
  "integration.workflows.create.field.icon.label": "Icon",
1207
1206
  "integration.workflows.create.field.name.label": "Name",
1208
- "integration.workflows.create.field.name.placeholder": "Sync orders nightly",
1209
1207
  "integration.workflows.create.field.tags.label": "Tags",
1210
1208
  "integration.workflows.create.field.trigger.label": "Trigger",
1211
1209
  "integration.workflows.create.tab.blank": "Start blank",
@@ -35,6 +35,7 @@ import { apiRefusal, errorReason } from "../../utils/apiError";
35
35
  import { CREDENTIAL_STATES, credentialState, credentialStateLabel, providerStepWord } from "../../utils/credentialState";
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
38
+ import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
38
39
  const t = useIntegrationsT();
39
40
  useHead({ title: () => t("integration.credentials.list.head.title") });
40
41
  const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
@@ -422,14 +423,14 @@ async function confirmDelete() {
422
423
  <!-- select -->
423
424
  <Select
424
425
  v-else-if="f.type === 'select'"
425
- :model-value="form.config[f.key] ?? ''"
426
- @update:model-value="(v) => form.config[f.key] = v"
426
+ :model-value="toModelValue(form.config[f.key], f.options)"
427
+ @update:model-value="(v) => form.config[f.key] = fromModelValue(v)"
427
428
  >
428
429
  <SelectTrigger :id="`cred-field-${f.key}`" class="bg-white" v-bind="configFieldAria(f.key)">
429
430
  <SelectValue :placeholder="t('integration.credentials.shared.field.select.placeholder')" />
430
431
  </SelectTrigger>
431
432
  <SelectContent>
432
- <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
433
+ <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="toItemValue(opt.value)">
433
434
  {{ resolveLocalized(opt.label) }}
434
435
  </SelectItem>
435
436
  </SelectContent>
@@ -1,6 +1,5 @@
1
1
  <script setup>
2
2
  import { computed, ref } from "vue";
3
- import { marked } from "marked";
4
3
  import { AltArrowRight as Chevron, CheckCircle as CheckCircle2, Key, RefreshCircle as Loader2, Shield as ShieldAlert, User } from "#solar-icons";
5
4
  import { triggerGlyph as glyphForTrigger } from "../../../data/triggerGlyphs";
6
5
  import {
@@ -18,6 +17,7 @@ import LoadingAnnouncement from "../../../components/Integrations/LoadingAnnounc
18
17
  import NodeMark from "../../../components/Integrations/NodeMark.vue";
19
18
  import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
20
19
  import { describeCron } from "../../../utils/schedule";
20
+ import { renderTemplateDescription } from "../../../utils/templateDescription";
21
21
  const t = useIntegrationsT();
22
22
  const route = useRoute();
23
23
  const router = useRouter();
@@ -34,12 +34,7 @@ onMounted(async () => {
34
34
  await Promise.all([fetchDetail(slug.value), loadNodeCatalog(), loadCredentials(), loadSecrets()]);
35
35
  reqs.value = await requirements(slug.value);
36
36
  });
37
- const descriptionHtml = computed(() => {
38
- const md = tpl.value?.descriptionMd ?? "";
39
- if (!md.trim()) return "";
40
- const withoutH1 = md.replace(/^#\s[^\n]*\n+/, "");
41
- return marked.parse(withoutH1);
42
- });
37
+ const descriptionHtml = computed(() => renderTemplateDescription(tpl.value?.descriptionMd));
43
38
  const steps = computed(() => {
44
39
  const blob = tpl.value?.blob;
45
40
  if (!blob?.nodes.length) return [];
@@ -221,10 +216,12 @@ function levelDot(level) {
221
216
  class="rounded-lg border border-black/[0.06] bg-white p-4"
222
217
  >
223
218
  <h3 id="tpl-about-heading" class="text-sm font-semibold mb-2">{{ t("integration.templates.detail.about.title") }}</h3>
219
+ <!-- eslint-disable vue/no-v-html -- renderTemplateDescription sanitises marked's output with DOMPurify (PO-524) -->
224
220
  <div
225
221
  class="text-sm leading-relaxed text-foreground/90 space-y-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:mt-1 [&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[13px] [&_a]:text-primary [&_a]:underline [&_strong]:font-semibold"
226
222
  v-html="descriptionHtml"
227
223
  />
224
+ <!-- eslint-enable vue/no-v-html -->
228
225
  </div>
229
226
 
230
227
  <!-- Step by step, humanized from the actual nodes -->
@@ -695,6 +695,12 @@ export interface WorkflowRun {
695
695
  workflowId: number;
696
696
  workflowName: string;
697
697
  status: RunStatus;
698
+ /**
699
+ * The platform's own status word, as it sent it (`failed`, `terminated`, …).
700
+ * For deciding what the platform will accept (`acceptsResume`), never for
701
+ * drawing the outcome — that is `status`, which folds several of these into one.
702
+ */
703
+ apiStatus?: string;
698
704
  /** Temporal run id for this execution; null when the row predates Temporal. */
699
705
  temporalRunId?: string | null;
700
706
  triggerId: string;
@@ -47,6 +47,20 @@ export declare const API_RUN_STATUSES: readonly ["pending", "running", "complete
47
47
  * are deliberate, and only the table is shared.
48
48
  */
49
49
  export declare function mapRunStatus(status: string): RunStatus;
50
+ /**
51
+ * Would the platform accept a resume of this run? Its own status word decides,
52
+ * not the studio's.
53
+ *
54
+ * `mapRunStatus` folds `terminated` into the studio's `failed`, which is right for
55
+ * the outcome a row draws and wrong for this question: the platform resumes only a
56
+ * run whose stored status is literally `failed`, and answers a terminated one with
57
+ * a 409 (PO-355). So the platform's word is read where it is known, and the
58
+ * studio's only as the fallback for a run that arrived without one.
59
+ *
60
+ * Whether there is an execution to resume from is a separate condition, which each
61
+ * caller already asks in its own terms (specs/run-row.md AC-12).
62
+ */
63
+ export declare function acceptsResume(apiStatus: string | null | undefined, status: RunStatus): boolean;
50
64
  /**
51
65
  * The platform statuses behind the studio's words.
52
66
  *
@@ -11,6 +11,9 @@ export function mapRunStatus(status) {
11
11
  const word = normalisedRunStatus(status);
12
12
  return isRunStatusWord(word) ? word : "failed";
13
13
  }
14
+ export function acceptsResume(apiStatus, status) {
15
+ return apiStatus ? apiStatus === "failed" : status === "failed";
16
+ }
14
17
  export function apiStatusesFor(words) {
15
18
  if (!words.length) return [];
16
19
  return API_RUN_STATUSES.filter((status) => words.includes(mapRunStatus(status)));
@@ -0,0 +1 @@
1
+ export declare function renderTemplateDescription(md: string | null | undefined): string;
@@ -0,0 +1,38 @@
1
+ import DOMPurify from "dompurify";
2
+ import { marked } from "marked";
3
+ const ALLOWED_TAGS = [
4
+ "h1",
5
+ "h2",
6
+ "h3",
7
+ "h4",
8
+ "h5",
9
+ "h6",
10
+ "p",
11
+ "br",
12
+ "hr",
13
+ "strong",
14
+ "em",
15
+ "del",
16
+ "code",
17
+ "pre",
18
+ "blockquote",
19
+ "ul",
20
+ "ol",
21
+ "li",
22
+ "table",
23
+ "thead",
24
+ "tbody",
25
+ "tr",
26
+ "th",
27
+ "td",
28
+ "a",
29
+ "img"
30
+ ];
31
+ const ALLOWED_ATTR = ["href", "title", "src", "alt", "align", "start"];
32
+ export function renderTemplateDescription(md) {
33
+ if (!md?.trim()) return "";
34
+ if (!DOMPurify.isSupported) return "";
35
+ const withoutH1 = md.replace(/^#\s[^\n]*\n+/, "");
36
+ const html = marked.parse(withoutH1, { async: false });
37
+ return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR });
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {
@@ -29,6 +29,7 @@
29
29
  "@revenexx/studio-shared": "^0.3.2",
30
30
  "@solar-icons/nuxt": "^1.2.0",
31
31
  "@solar-icons/vue": "^1.2.0",
32
+ "dompurify": "^3.4.16",
32
33
  "marked": "^18.0.5"
33
34
  },
34
35
  "peerDependencies": {