@velaro/cli 1.2.0 → 1.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -138
- package/bin/velaro.js +177 -62
- package/lib/api.js +91 -52
- package/lib/api.test.js +46 -0
- package/lib/banner.js +76 -0
- package/lib/commands/activity.js +133 -0
- package/lib/commands/acuity.js +66 -0
- package/lib/commands/agent.js +204 -50
- package/lib/commands/ai-config.js +193 -0
- package/lib/commands/ai-models.js +159 -0
- package/lib/commands/appointments.js +198 -0
- package/lib/commands/article.js +668 -388
- package/lib/commands/automation-draft.js +134 -0
- package/lib/commands/avatar.js +75 -0
- package/lib/commands/bigcommerce.js +50 -0
- package/lib/commands/billing-contacts.js +62 -0
- package/lib/commands/billing-email-preference.js +64 -0
- package/lib/commands/billing-subscription.js +265 -0
- package/lib/commands/billing.js +138 -0
- package/lib/commands/bot.js +141 -137
- package/lib/commands/bundle.js +168 -0
- package/lib/commands/calendly.js +62 -0
- package/lib/commands/callback.js +125 -0
- package/lib/commands/callrail.js +88 -0
- package/lib/commands/campaigns.js +44 -0
- package/lib/commands/case.js +102 -0
- package/lib/commands/check.js +163 -163
- package/lib/commands/compliance.js +229 -0
- package/lib/commands/conversation-efficiency.js +178 -0
- package/lib/commands/copilotstudio.js +114 -0
- package/lib/commands/coupon-grant.js +192 -0
- package/lib/commands/db.js +101 -0
- package/lib/commands/deployment.js +107 -107
- package/lib/commands/diagnostics.js +298 -0
- package/lib/commands/email-campaign.js +47 -0
- package/lib/commands/email-inbox.js +88 -0
- package/lib/commands/entitlement.js +176 -0
- package/lib/commands/env.js +45 -45
- package/lib/commands/feature-discovery.js +40 -0
- package/lib/commands/focus.js +278 -0
- package/lib/commands/index.js +38 -5
- package/lib/commands/ingest.js +31 -31
- package/lib/commands/inline-widget-config.js +126 -0
- package/lib/commands/integration.js +93 -0
- package/lib/commands/kb.js +450 -309
- package/lib/commands/login.js +86 -86
- package/lib/commands/logs.js +680 -0
- package/lib/commands/magento.js +210 -0
- package/lib/commands/mcp-key.js +188 -159
- package/lib/commands/migrate.js +134 -0
- package/lib/commands/migration-status.js +66 -0
- package/lib/commands/monday.js +137 -0
- package/lib/commands/netsuite.js +87 -0
- package/lib/commands/notifications.js +63 -0
- package/lib/commands/notion.js +70 -0
- package/lib/commands/ops.js +267 -173
- package/lib/commands/payment-recovery.js +170 -0
- package/lib/commands/pickup.js +172 -0
- package/lib/commands/pricing.js +132 -0
- package/lib/commands/product.js +55 -0
- package/lib/commands/recruiting.js +374 -0
- package/lib/commands/report.js +462 -0
- package/lib/commands/routing.js +304 -0
- package/lib/commands/rule.js +85 -85
- package/lib/commands/sharepoint.js +167 -0
- package/lib/commands/site-provision.js +68 -0
- package/lib/commands/site.js +62 -62
- package/lib/commands/sitesync.js +158 -0
- package/lib/commands/slack.js +64 -0
- package/lib/commands/squarespace.js +108 -0
- package/lib/commands/status.js +24 -24
- package/lib/commands/subscription.js +43 -0
- package/lib/commands/support.js +128 -0
- package/lib/commands/survey.js +216 -0
- package/lib/commands/team.js +144 -144
- package/lib/commands/teams-phone.js +131 -0
- package/lib/commands/teams.js +106 -0
- package/lib/commands/telephony.js +99 -0
- package/lib/commands/update.js +47 -47
- package/lib/commands/webflow.js +128 -0
- package/lib/commands/whoami.js +25 -22
- package/lib/commands/widget-container.js +152 -0
- package/lib/commands/woocommerce.js +240 -0
- package/lib/commands/workflow.js +233 -98
- package/lib/config.js +85 -83
- package/lib/kb-screenshot.js +320 -0
- package/lib/migrations/amscro.json +72 -0
- package/lib/migrations/azenta.json +68 -0
- package/lib/migrations/bluefire.json +49 -0
- package/lib/migrations/donaldson.json +75 -0
- package/lib/oauth.js +149 -135
- package/lib/run.js +21 -16
- package/lib/sharepoint-auth.js +138 -0
- package/lib/subscription.js +41 -39
- package/lib/track.js +35 -35
- package/lib/update-check.js +64 -64
- package/package.json +34 -19
- package/scripts/postinstall.js +12 -0
package/lib/commands/article.js
CHANGED
|
@@ -1,388 +1,668 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* velaro kb article — CRUD for help-center KB articles.
|
|
3
|
-
*
|
|
4
|
-
* Auth: uses the stored OAuth JWT (same as all other commands).
|
|
5
|
-
* API: velaro-admin (help.velaro.com), not the messaging API.
|
|
6
|
-
* Base URL read from VELARO_ADMIN_API_BASE env var or creds.adminApiBase,
|
|
7
|
-
* falling back to https://help.velaro.com.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { readFileSync, existsSync } from 'fs';
|
|
11
|
-
import { extname } from 'path';
|
|
12
|
-
import { getCredentials } from '../api.js';
|
|
13
|
-
import { runCommand } from '../run.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}),
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
.
|
|
255
|
-
.
|
|
256
|
-
.
|
|
257
|
-
.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
.
|
|
300
|
-
.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
1
|
+
/**
|
|
2
|
+
* velaro kb article — CRUD for help-center KB articles.
|
|
3
|
+
*
|
|
4
|
+
* Auth: uses the stored OAuth JWT (same as all other commands).
|
|
5
|
+
* API: velaro-admin (help.velaro.com), not the messaging API.
|
|
6
|
+
* Base URL read from VELARO_ADMIN_API_BASE env var or creds.adminApiBase,
|
|
7
|
+
* falling back to https://help.velaro.com.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
11
|
+
import { extname } from 'path';
|
|
12
|
+
import { getCredentials } from '../api.js';
|
|
13
|
+
import { runCommand } from '../run.js';
|
|
14
|
+
import { captureKbScreenshot, uploadImage, polishScreenshot, insertImageAfterHeading, escapeAttr } from '../kb-screenshot.js';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_ADMIN_API = process.env.VELARO_ADMIN_API_BASE || 'https://help.velaro.com';
|
|
17
|
+
|
|
18
|
+
// Exported so cli/lib/kb-screenshot.js can reuse the exact same auth/request
|
|
19
|
+
// pattern instead of duplicating a parallel HTTP client. `credsOverride` lets
|
|
20
|
+
// callers with their own auth source (e.g. mcp/server.js, which authenticates
|
|
21
|
+
// via VELARO_MCP_KEY/VELARO_JWT env vars rather than the CLI's stored login
|
|
22
|
+
// config) supply { velaroToken, adminApiBase } directly.
|
|
23
|
+
export async function adminRequest(method, path, body, credsOverride) {
|
|
24
|
+
const creds = credsOverride || await getCredentials();
|
|
25
|
+
const apiBase = creds.adminApiBase || DEFAULT_ADMIN_API;
|
|
26
|
+
|
|
27
|
+
const res = await fetch(`${apiBase}${path}`, {
|
|
28
|
+
method,
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${creds.velaroToken}`,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
},
|
|
33
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
let msg = `${method} ${path} -> ${res.status}`;
|
|
38
|
+
try { const t = await res.text(); if (t) msg += `: ${t.slice(0, 300)}`; } catch { /* ignore */ }
|
|
39
|
+
throw new Error(msg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
return text ? JSON.parse(text) : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── list ──────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const listCommand = {
|
|
49
|
+
command: 'list',
|
|
50
|
+
describe: 'List KB articles',
|
|
51
|
+
builder: (y) =>
|
|
52
|
+
y
|
|
53
|
+
.option('topic-id', { type: 'number', describe: 'Filter by topic ID' })
|
|
54
|
+
.option('search', { type: 'string', describe: 'Search term' })
|
|
55
|
+
.option('published', { type: 'boolean', describe: 'Show only published articles' }),
|
|
56
|
+
handler: runCommand(async (argv) => {
|
|
57
|
+
const payload = {};
|
|
58
|
+
if (argv['topic-id']) payload.topicId = argv['topic-id'];
|
|
59
|
+
if (argv.search) payload.searchTerm = argv.search;
|
|
60
|
+
if (argv.published) payload.publishStatus = 'Published';
|
|
61
|
+
|
|
62
|
+
const data = await adminRequest('POST', '/api/kb/articles/search', payload);
|
|
63
|
+
const articles = data?.articles ?? [];
|
|
64
|
+
|
|
65
|
+
if (!articles.length) { console.log('No articles found.'); return; }
|
|
66
|
+
|
|
67
|
+
console.log(`\nFound ${articles.length} article(s):\n`);
|
|
68
|
+
for (const a of articles) {
|
|
69
|
+
const status = a.isPublished ? 'published ' : 'draft ';
|
|
70
|
+
const views = `${a.hitCount ?? 0} views`;
|
|
71
|
+
console.log(` [${a.id}] ${status} ${truncate(a.title, 55)} (${views})`);
|
|
72
|
+
if (a.topicName) console.log(` topic: ${a.topicName}`);
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── get ───────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const getCommand = {
|
|
80
|
+
command: 'get <id>',
|
|
81
|
+
describe: 'Get a KB article by ID',
|
|
82
|
+
builder: (y) => y.positional('id', { type: 'number', describe: 'Article ID' }),
|
|
83
|
+
handler: runCommand(async (argv) => {
|
|
84
|
+
const a = await adminRequest('GET', `/api/kb/articles?id=${argv.id}`);
|
|
85
|
+
console.log(`\n[${a.id}] ${a.title}`);
|
|
86
|
+
console.log(` slug: ${a.slug}`);
|
|
87
|
+
console.log(` topic: ${a.topicId}`);
|
|
88
|
+
console.log(` published: ${a.isPublished}`);
|
|
89
|
+
console.log(` url: ${a.url ?? '(not public)'}`);
|
|
90
|
+
console.log(`\n--- content (HTML) ---\n${a.content ?? ''}\n`);
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ── topics ────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const topicsCommand = {
|
|
97
|
+
command: 'topics',
|
|
98
|
+
describe: 'List available KB topics (you need a topic ID to push an article)',
|
|
99
|
+
handler: runCommand(async () => {
|
|
100
|
+
const data = await adminRequest('GET', '/api/kb/topics');
|
|
101
|
+
const topics = data?.topics ?? [];
|
|
102
|
+
if (!topics.length) { console.log('No topics found.'); return; }
|
|
103
|
+
console.log(`\nTopics:\n`);
|
|
104
|
+
for (const t of topics)
|
|
105
|
+
console.log(` [${t.id}] ${t.name} (${t.articleCount} articles)`);
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ── push ──────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const pushCommand = {
|
|
112
|
+
command: 'push <file>',
|
|
113
|
+
describe: 'Create or update a KB article from a Markdown or HTML file',
|
|
114
|
+
builder: (y) =>
|
|
115
|
+
y
|
|
116
|
+
.positional('file', { type: 'string', describe: 'Path to .md or .html file' })
|
|
117
|
+
.option('title', { type: 'string', describe: 'Article title (override file frontmatter)' })
|
|
118
|
+
.option('slug', { type: 'string', describe: 'URL slug (override file frontmatter)' })
|
|
119
|
+
.option('topic-id', { type: 'number', describe: 'Topic ID (required if not in frontmatter)' })
|
|
120
|
+
.option('description', { type: 'string', describe: 'Short description' })
|
|
121
|
+
.option('publish', { type: 'boolean', default: false, describe: 'Publish immediately' })
|
|
122
|
+
.option('update-id', { type: 'number', describe: 'Article ID to update (if omitted, creates new)' })
|
|
123
|
+
.option('no-title-normalize', { type: 'boolean', default: false, describe: 'Skip auto-formatting slug-style titles to Title Case' }),
|
|
124
|
+
handler: runCommand(async (argv) => {
|
|
125
|
+
if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
|
|
126
|
+
|
|
127
|
+
const raw = readFileSync(argv.file, 'utf8');
|
|
128
|
+
const ext = extname(argv.file).toLowerCase();
|
|
129
|
+
const fm = parseFrontmatter(raw);
|
|
130
|
+
const body = fm.body;
|
|
131
|
+
|
|
132
|
+
const rawTitle = argv.title || fm.title || null;
|
|
133
|
+
const skipNorm = argv['no-title-normalize'] === true;
|
|
134
|
+
const title = rawTitle && !skipNorm ? normalizeTitle(rawTitle) : rawTitle;
|
|
135
|
+
if (title && title !== rawTitle) console.log(` ℹ Title normalized: "${rawTitle}" → "${title}" (use --no-title-normalize to keep as-is)`);
|
|
136
|
+
const slug = argv.slug || fm.slug || null;
|
|
137
|
+
const topicId = argv['topic-id'] || fm.topicId || null;
|
|
138
|
+
const description = argv.description || fm.description || '';
|
|
139
|
+
|
|
140
|
+
if (!title) throw new Error('Title is required. Add "title:" to frontmatter or use --title.');
|
|
141
|
+
if (!slug) throw new Error('Slug is required. Add "slug:" to frontmatter or use --slug.');
|
|
142
|
+
if (!topicId) throw new Error('Topic ID is required. Run "velaro kb article topics" then use --topic-id.');
|
|
143
|
+
|
|
144
|
+
const content = ext === '.md' ? markdownToHtml(body) : body;
|
|
145
|
+
|
|
146
|
+
if (argv['update-id']) {
|
|
147
|
+
// Merge onto the CURRENT article rather than blindly overwriting every field to a
|
|
148
|
+
// frontmatter-or-default value — a push file with no "tags:"/"published:" line must
|
|
149
|
+
// never silently wipe an existing article's searchTags (this is how the CopilotKb sync
|
|
150
|
+
// job recognizes an already-synced article; losing it causes the sync to re-create a
|
|
151
|
+
// duplicate on its next run) or unpublish a live page. Only fields explicitly provided
|
|
152
|
+
// via CLI flags or frontmatter override what's already there.
|
|
153
|
+
const existing = await adminRequest('GET', `/api/kb/articles?id=${argv['update-id']}`);
|
|
154
|
+
const payload = {
|
|
155
|
+
title,
|
|
156
|
+
slug,
|
|
157
|
+
topicId,
|
|
158
|
+
description: argv.description || fm.description || existing.description || '',
|
|
159
|
+
content,
|
|
160
|
+
visibility: existing.visibility ?? 'Public',
|
|
161
|
+
searchTags: fm.tags || existing.searchTags || '',
|
|
162
|
+
isPublished: argv.publish || fm.published || existing.isPublished || false,
|
|
163
|
+
displayPriority: existing.displayPriority ?? 0,
|
|
164
|
+
enableBotResponse: fm.botResponse !== undefined ? fm.botResponse !== false : existing.enableBotResponse !== false,
|
|
165
|
+
botSummary: fm.botSummary || existing.botSummary || '',
|
|
166
|
+
isFeatured: existing.isFeatured || false,
|
|
167
|
+
};
|
|
168
|
+
await adminRequest('PUT', `/api/kb/articles?id=${argv['update-id']}`, payload);
|
|
169
|
+
console.log(`Updated article [${argv['update-id']}]: "${title}"`);
|
|
170
|
+
} else {
|
|
171
|
+
const payload = {
|
|
172
|
+
title,
|
|
173
|
+
slug,
|
|
174
|
+
topicId,
|
|
175
|
+
description,
|
|
176
|
+
content,
|
|
177
|
+
visibility: 'Public',
|
|
178
|
+
searchTags: fm.tags || '',
|
|
179
|
+
isPublished: argv.publish || fm.published || false,
|
|
180
|
+
displayPriority: 0,
|
|
181
|
+
enableBotResponse: fm.botResponse !== false,
|
|
182
|
+
botSummary: fm.botSummary || '',
|
|
183
|
+
isFeatured: false,
|
|
184
|
+
};
|
|
185
|
+
// Create response returns { id, slug, url } directly — no follow-up search needed.
|
|
186
|
+
const created = await adminRequest('POST', '/api/kb/articles', payload);
|
|
187
|
+
const id = created?.id ?? '?';
|
|
188
|
+
console.log(`Created article [${id}]: "${title}"`);
|
|
189
|
+
if (argv.publish)
|
|
190
|
+
console.log(` Published at: ${created?.url || `https://help.velaro.com/kb/article/${id}/${slug}`}`);
|
|
191
|
+
else
|
|
192
|
+
console.log(` Draft saved. Use --publish to make it public.`);
|
|
193
|
+
}
|
|
194
|
+
}),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// ── delete ────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
const deleteCommand = {
|
|
200
|
+
command: 'delete <id>',
|
|
201
|
+
describe: 'Delete a KB article',
|
|
202
|
+
builder: (y) => y.positional('id', { type: 'number', describe: 'Article ID' }),
|
|
203
|
+
handler: runCommand(async (argv) => {
|
|
204
|
+
await adminRequest('DELETE', `/api/kb/articles?id=${argv.id}`);
|
|
205
|
+
console.log(`Article ${argv.id} deleted.`);
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// ── seed-views ────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const seedViewsCommand = {
|
|
212
|
+
command: 'seed-views <id>',
|
|
213
|
+
describe: 'Seed an article with random view counts (for new article submissions)',
|
|
214
|
+
builder: (y) =>
|
|
215
|
+
y
|
|
216
|
+
.positional('id', { type: 'number', describe: 'Article ID to seed' })
|
|
217
|
+
.option('hits', { type: 'number', default: 0, describe: 'Hit count (0 = random 1000-1999)' }),
|
|
218
|
+
handler: runCommand(async (argv) => {
|
|
219
|
+
const hitCount = argv.hits === 0 ? (Math.floor(Math.random() * 1000) + 1000) : argv.hits;
|
|
220
|
+
const payload = { articleId: argv.id, hitCount };
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
// Call API endpoint using stored OAuth token — no credentials in code
|
|
224
|
+
await adminRequest('POST', '/api/kb/articles/seed-views', payload);
|
|
225
|
+
console.log(`✅ Article [${argv.id}] seeded with ${hitCount} views.`);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
console.error(`❌ Failed to seed article: ${e.message}`);
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// ── screenshot ────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
const screenshotCommand = {
|
|
236
|
+
command: 'screenshot <articleId>',
|
|
237
|
+
describe: 'Capture a live staging screenshot and insert it into a KB article',
|
|
238
|
+
builder: (y) =>
|
|
239
|
+
y
|
|
240
|
+
.positional('articleId', { type: 'number', describe: 'Target article ID' })
|
|
241
|
+
.option('app', { type: 'string', choices: ['admin', 'messaging'], demandOption: true, describe: 'Which app to capture from' })
|
|
242
|
+
.option('route', { type: 'string', demandOption: true, describe: 'Route to navigate to, e.g. "/Settings/Routing"' })
|
|
243
|
+
.option('heading', { type: 'string', describe: 'Insert the image immediately after the <h2>/<h3> whose text contains this (case-insensitive). Falls back to appending at the end.' })
|
|
244
|
+
.option('annotate', { type: 'array', default: [], describe: 'x,y,label annotation, repeatable — e.g. --annotate 120,80,1 --annotate 400,200,"Click here"' }),
|
|
245
|
+
handler: runCommand(async (argv) => {
|
|
246
|
+
const annotations = (argv.annotate || []).map((raw) => {
|
|
247
|
+
const [x, y, ...labelParts] = String(raw).split(',');
|
|
248
|
+
return { x: parseInt(x, 10), y: parseInt(y, 10), label: labelParts.join(',').trim() || undefined };
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
console.log(`Capturing ${argv.app} staging screenshot of "${argv.route}" for article [${argv.articleId}]...`);
|
|
252
|
+
|
|
253
|
+
const result = await captureKbScreenshot({
|
|
254
|
+
app: argv.app,
|
|
255
|
+
route: argv.route,
|
|
256
|
+
articleId: argv.articleId,
|
|
257
|
+
heading: argv.heading,
|
|
258
|
+
annotations,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
console.log(`Uploaded: ${result.imageUrl}`);
|
|
262
|
+
console.log(result.insertedAfterHeading
|
|
263
|
+
? `Inserted after heading matching "${argv.heading}" in article [${result.articleId}].`
|
|
264
|
+
: `Appended to the end of article [${result.articleId}] (no matching heading found).`);
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ── image ─────────────────────────────────────────────────────────────────────
|
|
269
|
+
//
|
|
270
|
+
// General-purpose image upload+polish primitive — NOT tied to the live-capture
|
|
271
|
+
// flow in `screenshot`. Use for any local image (a manually captured console
|
|
272
|
+
// screenshot, a design mockup, a product graphic) that needs to end up hosted
|
|
273
|
+
// and, optionally, embedded in a KB article. This is the durable, reusable path
|
|
274
|
+
// for building images for any KB/product doc — don't hand-roll a one-off upload.
|
|
275
|
+
//
|
|
276
|
+
// Root cause this exists: markdownToHtml()'s `` handling was added in
|
|
277
|
+
// the same change (2026-07-31) after a KB article shipped with a silently broken
|
|
278
|
+
// image — the CLI had no supported way to get a local file hosted at all, so an
|
|
279
|
+
// ad-hoc curl against the wrong endpoint (kb/assets/upload, which 404s/disables
|
|
280
|
+
// against the prod-API-base + staging-token combo every other command uses) was
|
|
281
|
+
// the only option. This command is the supported replacement for that ad-hoc path.
|
|
282
|
+
|
|
283
|
+
const imageCommand = {
|
|
284
|
+
command: 'image <file>',
|
|
285
|
+
describe: 'Upload a local image (optionally polished) to KB asset storage, and optionally insert it into an article',
|
|
286
|
+
builder: (y) =>
|
|
287
|
+
y
|
|
288
|
+
.positional('file', { type: 'string', describe: 'Path to a local image file (.png/.jpg/.gif/.webp)' })
|
|
289
|
+
.option('polish', { type: 'boolean', default: true, describe: 'Run through scripts/polish-screenshot.py with standard KB style-guide defaults before uploading' })
|
|
290
|
+
.option('bg', { type: 'string', describe: 'Polish background hex (default F4F5F7)' })
|
|
291
|
+
.option('insert-into', { type: 'number', describe: 'Article ID to insert the uploaded image into' })
|
|
292
|
+
.option('heading', { type: 'string', describe: 'Insert immediately after the <h2>/<h3> whose text contains this (case-insensitive). Falls back to appending at the end.' })
|
|
293
|
+
.option('alt', { type: 'string', describe: 'Alt text for the inserted <img> tag (defaults to --heading, then the filename)' }),
|
|
294
|
+
handler: runCommand(async (argv) => {
|
|
295
|
+
if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
|
|
296
|
+
|
|
297
|
+
const creds = await getCredentials();
|
|
298
|
+
|
|
299
|
+
let imagePath = argv.file;
|
|
300
|
+
if (argv.polish) {
|
|
301
|
+
console.log('Polishing via scripts/polish-screenshot.py...');
|
|
302
|
+
imagePath = await polishScreenshot(imagePath, [], { bg: argv.bg });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.log(`Uploading ${imagePath}...`);
|
|
306
|
+
const imageUrl = await uploadImage(imagePath, creds);
|
|
307
|
+
console.log(`Uploaded: ${imageUrl}`);
|
|
308
|
+
|
|
309
|
+
if (!argv['insert-into']) {
|
|
310
|
+
console.log('No --insert-into given — image is hosted but not attached to any article.');
|
|
311
|
+
console.log(`Reference it in markdown as: `);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const articleId = argv['insert-into'];
|
|
316
|
+
const article = await adminRequest('GET', `/api/kb/articles?id=${articleId}`, undefined, creds);
|
|
317
|
+
if (!article) throw new Error(`Article ${articleId} not found`);
|
|
318
|
+
|
|
319
|
+
const altText = argv.alt || argv.heading || basenameNoExt(argv.file);
|
|
320
|
+
const imgTag = `<img src="${imageUrl}" alt="${escapeAttr(altText)}" style="max-width:100%;border-radius:8px;" />`;
|
|
321
|
+
const { html: newContent, insertedAfterHeading } = insertImageAfterHeading(article.content, argv.heading, imgTag);
|
|
322
|
+
|
|
323
|
+
await adminRequest('PUT', `/api/kb/articles?id=${articleId}`, {
|
|
324
|
+
title: article.title,
|
|
325
|
+
slug: article.slug,
|
|
326
|
+
topicId: article.topicId,
|
|
327
|
+
description: article.description,
|
|
328
|
+
content: newContent,
|
|
329
|
+
visibility: article.visibility,
|
|
330
|
+
searchTags: article.searchTags,
|
|
331
|
+
isPublished: article.isPublished,
|
|
332
|
+
displayPriority: article.displayPriority,
|
|
333
|
+
enableBotResponse: article.enableBotResponse,
|
|
334
|
+
botSummary: article.botSummary,
|
|
335
|
+
isFeatured: article.isFeatured,
|
|
336
|
+
}, creds);
|
|
337
|
+
|
|
338
|
+
console.log(argv.heading
|
|
339
|
+
? `Inserted ${insertedAfterHeading ? `after heading matching "${argv.heading}"` : '(no matching heading found — appended to end)'} in article [${articleId}].`
|
|
340
|
+
: `Appended to the end of article [${articleId}].`);
|
|
341
|
+
}),
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
function basenameNoExt(p) {
|
|
345
|
+
const base = p.split(/[\\/]/).pop() || p;
|
|
346
|
+
return base.replace(/\.[^.]+$/, '');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── improve ───────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
const improveCommand = {
|
|
352
|
+
command: 'improve <id>',
|
|
353
|
+
describe: 'AI-improve a KB article using a file or instruction (requires VelaroCopilotApiKey)',
|
|
354
|
+
builder: (y) =>
|
|
355
|
+
y
|
|
356
|
+
.positional('id', { type: 'number', describe: 'Article ID to improve' })
|
|
357
|
+
.option('file', { type: 'string', describe: 'Path to a reference file (.md, .txt, .html) to incorporate' })
|
|
358
|
+
.option('instruction', { type: 'string', describe: 'Plain-language instruction, e.g. "add a troubleshooting section"' })
|
|
359
|
+
.option('publish', { type: 'boolean', default: false, describe: 'Publish after improving' })
|
|
360
|
+
.check((argv) => {
|
|
361
|
+
if (!argv.file && !argv.instruction) throw new Error('Provide --file and/or --instruction.');
|
|
362
|
+
return true;
|
|
363
|
+
}),
|
|
364
|
+
handler: runCommand(async (argv) => {
|
|
365
|
+
// 1. Fetch the existing article
|
|
366
|
+
const article = await adminRequest('GET', `/api/kb/articles?id=${argv.id}`);
|
|
367
|
+
console.log(`Improving: "${article.title}" [${article.id}]`);
|
|
368
|
+
|
|
369
|
+
// 2. Build prompt context
|
|
370
|
+
let reference = '';
|
|
371
|
+
if (argv.file) {
|
|
372
|
+
if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
|
|
373
|
+
reference = readFileSync(argv.file, 'utf8');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 3. POST to the server-side AI improve endpoint
|
|
377
|
+
const result = await adminRequest('POST', `/api/kb/articles/${argv.id}/ai-improve`, {
|
|
378
|
+
instruction: argv.instruction || '',
|
|
379
|
+
referenceContent: reference,
|
|
380
|
+
publish: argv.publish,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
console.log(`\nImprovement applied.`);
|
|
384
|
+
if (result?.changesSummary) console.log(`Changes: ${result.changesSummary}`);
|
|
385
|
+
if (argv.publish) console.log(`Published: https://help.velaro.com/kb/article/${article.id}/${article.slug}`);
|
|
386
|
+
}),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// ── preview ───────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
const previewCommand = {
|
|
392
|
+
command: 'preview <file>',
|
|
393
|
+
describe: 'Render a Markdown article to HTML and write a preview file you can open in a browser',
|
|
394
|
+
builder: (y) =>
|
|
395
|
+
y
|
|
396
|
+
.positional('file', { type: 'string', describe: 'Path to .md or .html file' })
|
|
397
|
+
.option('output', { type: 'string', describe: 'Write HTML to this file instead of a temp file' }),
|
|
398
|
+
handler: runCommand(async (argv) => {
|
|
399
|
+
if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
|
|
400
|
+
|
|
401
|
+
const raw = readFileSync(argv.file, 'utf8');
|
|
402
|
+
const ext = extname(argv.file).toLowerCase();
|
|
403
|
+
const fm = parseFrontmatter(raw);
|
|
404
|
+
const body = fm.body;
|
|
405
|
+
const title = fm.title || argv.file;
|
|
406
|
+
const content = ext === '.md' ? markdownToHtml(body) : body;
|
|
407
|
+
|
|
408
|
+
const html = `<!DOCTYPE html>
|
|
409
|
+
<html lang="en">
|
|
410
|
+
<head>
|
|
411
|
+
<meta charset="utf-8">
|
|
412
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
413
|
+
<title>${escHtml(title)}</title>
|
|
414
|
+
<style>
|
|
415
|
+
body { font-family: system-ui, sans-serif; max-width: 860px; margin: 40px auto; padding: 0 24px; color: #1e293b; line-height: 1.6; }
|
|
416
|
+
h1,h2,h3,h4 { color: #1e3a5f; margin-top: 1.8em; }
|
|
417
|
+
h1 { font-size: 1.8em; } h2 { font-size: 1.35em; } h3 { font-size: 1.1em; }
|
|
418
|
+
pre { background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px 18px; overflow-x: auto; }
|
|
419
|
+
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
|
420
|
+
p code, li code { background: #f1f5f9; padding: 1px 5px; border-radius: 3px; }
|
|
421
|
+
table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: .9em; }
|
|
422
|
+
th { background: #1e3a5f; color: #fff; padding: 10px 14px; text-align: left; }
|
|
423
|
+
td { border: 1px solid #e2e8f0; padding: 9px 13px; }
|
|
424
|
+
ul, ol { padding-left: 1.5em; }
|
|
425
|
+
a { color: #2563eb; }
|
|
426
|
+
.meta { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px 16px; margin-bottom: 32px; font-size: .85em; color: #64748b; }
|
|
427
|
+
</style>
|
|
428
|
+
</head>
|
|
429
|
+
<body>
|
|
430
|
+
<div class="meta">
|
|
431
|
+
<strong>slug:</strong> ${escHtml(fm.slug || '—')} |
|
|
432
|
+
<strong>topic:</strong> ${fm.topicId ?? '—'} |
|
|
433
|
+
<strong>published:</strong> ${fm.published ?? false}
|
|
434
|
+
</div>
|
|
435
|
+
<h1>${escHtml(title)}</h1>
|
|
436
|
+
${content}
|
|
437
|
+
</body>
|
|
438
|
+
</html>`;
|
|
439
|
+
|
|
440
|
+
const outPath = argv.output || `${argv.file.replace(/\.[^.]+$/, '')}.preview.html`;
|
|
441
|
+
writeFileSync(outPath, html, 'utf8');
|
|
442
|
+
console.log(`Preview written to: ${outPath}`);
|
|
443
|
+
console.log(`Open it with: start "${outPath}" (Windows) or open "${outPath}" (Mac)`);
|
|
444
|
+
}),
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// ── export command ─────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
export const articleCommand = {
|
|
450
|
+
command: 'article <subcommand>',
|
|
451
|
+
describe: 'Manage help-center KB articles',
|
|
452
|
+
builder: (yargs) =>
|
|
453
|
+
yargs
|
|
454
|
+
.command(listCommand)
|
|
455
|
+
.command(getCommand)
|
|
456
|
+
.command(topicsCommand)
|
|
457
|
+
.command(pushCommand)
|
|
458
|
+
.command(previewCommand)
|
|
459
|
+
.command(deleteCommand)
|
|
460
|
+
.command(seedViewsCommand)
|
|
461
|
+
.command(improveCommand)
|
|
462
|
+
.command(screenshotCommand)
|
|
463
|
+
.command(imageCommand)
|
|
464
|
+
.demandCommand(1, 'Specify a subcommand: list, get, topics, push, preview, delete, seed-views, improve, screenshot, image'),
|
|
465
|
+
handler: () => {},
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Convert slug-style titles to proper Title Case.
|
|
472
|
+
* Only fires when the title looks like a slug: all-lowercase and contains hyphens but no spaces.
|
|
473
|
+
* Preserves already-formatted titles ("Gmail Inbox Guide" stays unchanged).
|
|
474
|
+
*
|
|
475
|
+
* Known acronyms/brands are uppercased correctly; common short words stay lowercase mid-title.
|
|
476
|
+
*/
|
|
477
|
+
function normalizeTitle(title) {
|
|
478
|
+
if (!title) return title;
|
|
479
|
+
// Only normalize if the title looks like a slug (all lowercase, hyphens, no spaces)
|
|
480
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)+$/.test(title)) return title;
|
|
481
|
+
|
|
482
|
+
const ACRONYMS = new Set(['cli', 'mcp', 'sap', 'ivr', 'crm', 'api', 'csp', 'csat', 'ai', 'sms', 'icp', 'ids', 'url', 'urls', 'sdk', 'ui', 'ux', 'id', 'json', 'xml', 'html', 'css', 'jwt', 'sso', 'saml', 'oauth', 'gdpr', 'pci']);
|
|
483
|
+
const BRANDS = { gmail: 'Gmail', quickbooks: 'QuickBooks', skyswitch: 'SkySwitch', opentable: 'OpenTable', velaro: 'Velaro', livefluence: 'LiveFluence', mindbody: 'Mindbody', cloudbeds: 'Cloudbeds', formstack: 'Formstack', uplisting: 'Uplisting', accela: 'Accela', guesty: 'Guesty', resy: 'Resy', nextiva: 'Nextiva', zapier: 'Zapier', webflow: 'Webflow', zoho: 'Zoho', shopify: 'Shopify', hubspot: 'HubSpot', salesforce: 'Salesforce', netsuite: 'NetSuite', dynamics: 'Dynamics', bigcommerce: 'BigCommerce', woocommerce: 'WooCommerce', magento: 'Magento', square: 'Square', stripe: 'Stripe', twilio: 'Twilio', google: 'Google', outlook: 'Outlook', onedrive: 'OneDrive', sharepoint: 'SharePoint', teams: 'Teams', slack: 'Slack', make: 'Make', pipedrive: 'Pipedrive', monday: 'Monday', servicenow: 'ServiceNow' };
|
|
484
|
+
// Small words that stay lowercase unless they're first
|
|
485
|
+
const SMALL = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'via', 'yet']);
|
|
486
|
+
|
|
487
|
+
const words = title.split('-');
|
|
488
|
+
return words.map((w, i) => {
|
|
489
|
+
const lw = w.toLowerCase();
|
|
490
|
+
if (BRANDS[lw]) return BRANDS[lw];
|
|
491
|
+
if (ACRONYMS.has(lw)) return lw.toUpperCase();
|
|
492
|
+
if (i > 0 && SMALL.has(lw)) return lw;
|
|
493
|
+
return lw.charAt(0).toUpperCase() + lw.slice(1);
|
|
494
|
+
}).join(' ');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function parseFrontmatter(raw) {
|
|
498
|
+
const fm = {};
|
|
499
|
+
let body = raw;
|
|
500
|
+
|
|
501
|
+
if (raw.startsWith('---')) {
|
|
502
|
+
const end = raw.indexOf('\n---', 3);
|
|
503
|
+
if (end !== -1) {
|
|
504
|
+
const block = raw.slice(3, end).trim();
|
|
505
|
+
body = raw.slice(end + 4).trim();
|
|
506
|
+
for (const line of block.split('\n')) {
|
|
507
|
+
const sep = line.indexOf(':');
|
|
508
|
+
if (sep === -1) continue;
|
|
509
|
+
const key = line.slice(0, sep).trim();
|
|
510
|
+
const val = line.slice(sep + 1).trim().replace(/^["']|["']$/g, '');
|
|
511
|
+
if (key === 'topicId' || key === 'topic_id') fm.topicId = parseInt(val, 10);
|
|
512
|
+
else fm[key] = val === 'true' ? true : val === 'false' ? false : val;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return { ...fm, body };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// help.velaro.com kb.css styles .kb-article-body table/th/td natively (header #1e3a5f,
|
|
521
|
+
// rounded corners, shadow, horizontal-scroll wrapper). Admin editor preview uses the same class.
|
|
522
|
+
// Inline fallback styles cover any external renderer that doesn't ship our CSS.
|
|
523
|
+
const TABLE_STYLE = 'border-collapse:collapse;width:100%;margin:16px 0;font-size:14px;';
|
|
524
|
+
const TH_STYLE = 'border:1px solid #1e3a5f;padding:11px 16px;background:#1e3a5f;color:#fff;text-align:left;font-weight:600;';
|
|
525
|
+
const TD_STYLE = 'border:1px solid #e2e8f0;padding:10px 14px;vertical-align:top;color:#1e293b;';
|
|
526
|
+
|
|
527
|
+
// HTML-escape a raw string (used for code span content and plain-text regions).
|
|
528
|
+
function escHtml(s) {
|
|
529
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function inlineMd(s) {
|
|
533
|
+
// Strategy: extract code spans first (HTML-escape their content), replace with
|
|
534
|
+
// placeholder tokens that survive the subsequent markdown passes, then restore.
|
|
535
|
+
const saved = [];
|
|
536
|
+
const save = (html) => { saved.push(html); return `\x02${saved.length - 1}\x03`; };
|
|
537
|
+
|
|
538
|
+
// 1. Code spans — escape HTML inside so <script>, <NONCE>, etc. render literally.
|
|
539
|
+
s = s.replace(/`([^`]+)`/g, (_, c) => save(`<code>${escHtml(c)}</code>`));
|
|
540
|
+
|
|
541
|
+
// 2. Escape HTML entities in the remaining plain-text regions.
|
|
542
|
+
s = escHtml(s);
|
|
543
|
+
|
|
544
|
+
// 3. Apply markdown (bold, italic, images, links) — these patterns don't contain < or >.
|
|
545
|
+
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
546
|
+
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
|
547
|
+
// Images MUST be matched before plain links — otherwise `` falls through
|
|
548
|
+
// to the link regex below, which doesn't consume the leading `!`, silently degrading
|
|
549
|
+
// every image into a broken-looking link (`!<a href="...">alt text</a>`). Confirmed
|
|
550
|
+
// 2026-07-31: this exact bug shipped a NetSuite KB article with an invisible image.
|
|
551
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) =>
|
|
552
|
+
`<img src="${url.replace(/&/g, '&')}" alt="${alt.replace(/"/g, '"')}" style="max-width:100%;border-radius:8px;" />`);
|
|
553
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) =>
|
|
554
|
+
`<a href="${url.replace(/&/g, '&')}">${text}</a>`);
|
|
555
|
+
|
|
556
|
+
// 4. Restore saved code spans.
|
|
557
|
+
s = s.replace(/\x02(\d+)\x03/g, (_, idx) => saved[parseInt(idx)]);
|
|
558
|
+
return s;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function markdownToHtml(md) {
|
|
562
|
+
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
|
563
|
+
const out = [];
|
|
564
|
+
let i = 0;
|
|
565
|
+
let para = [];
|
|
566
|
+
let list = null; // { type: 'ul'|'ol', items: [] }
|
|
567
|
+
|
|
568
|
+
const flushPara = () => {
|
|
569
|
+
if (para.length) {
|
|
570
|
+
out.push(`<p>${inlineMd(para.join(' '))}</p>`);
|
|
571
|
+
para = [];
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
const flushList = () => {
|
|
575
|
+
if (list) {
|
|
576
|
+
out.push(`<${list.type}>` + list.items.map(x => `<li>${inlineMd(x)}</li>`).join('') + `</${list.type}>`);
|
|
577
|
+
list = null;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
const flushAll = () => { flushPara(); flushList(); };
|
|
581
|
+
|
|
582
|
+
const isTableSep = (s) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(s);
|
|
583
|
+
const splitRow = (s) => {
|
|
584
|
+
let t = s.trim();
|
|
585
|
+
if (t.startsWith('|')) t = t.slice(1);
|
|
586
|
+
if (t.endsWith('|')) t = t.slice(0, -1);
|
|
587
|
+
return t.split('|').map(c => c.trim());
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
while (i < lines.length) {
|
|
591
|
+
const line = lines[i];
|
|
592
|
+
|
|
593
|
+
if (/^\s*$/.test(line)) { flushAll(); i++; continue; }
|
|
594
|
+
|
|
595
|
+
// Fenced code block (``` or ~~~, with optional language tag)
|
|
596
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) {
|
|
597
|
+
flushAll();
|
|
598
|
+
i++; // skip opening fence
|
|
599
|
+
const codeLines = [];
|
|
600
|
+
const fence = line.match(/^\s*(`{3,}|~{3,})/)[1];
|
|
601
|
+
while (i < lines.length && !lines[i].trim().startsWith(fence)) {
|
|
602
|
+
codeLines.push(lines[i]);
|
|
603
|
+
i++;
|
|
604
|
+
}
|
|
605
|
+
i++; // skip closing fence
|
|
606
|
+
const escaped = codeLines.join('\n')
|
|
607
|
+
.replace(/&/g, '&')
|
|
608
|
+
.replace(/</g, '<')
|
|
609
|
+
.replace(/>/g, '>');
|
|
610
|
+
// Plain <pre><code> — kb.js wraps every code block in the KB article body with a
|
|
611
|
+
// copy button client-side (regardless of authoring path: this CLI, or the Tiptap
|
|
612
|
+
// editor in Velaro Admin), so no special markup is needed here.
|
|
613
|
+
out.push(`<pre><code>${escaped}</code></pre>`);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const h = line.match(/^(#{1,6})\s+(.+)$/);
|
|
618
|
+
if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineMd(h[2])}</h${h[1].length}>`); i++; continue; }
|
|
619
|
+
|
|
620
|
+
// Pipe table
|
|
621
|
+
if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1])) {
|
|
622
|
+
flushAll();
|
|
623
|
+
const header = splitRow(line);
|
|
624
|
+
i += 2;
|
|
625
|
+
const rows = [];
|
|
626
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
|
|
627
|
+
rows.push(splitRow(lines[i]));
|
|
628
|
+
i++;
|
|
629
|
+
}
|
|
630
|
+
const thead = `<thead><tr>${header.map(c => `<th style="${TH_STYLE}">${inlineMd(c)}</th>`).join('')}</tr></thead>`;
|
|
631
|
+
const tbody = `<tbody>${rows.map(r => `<tr>${r.map(c => `<td style="${TD_STYLE}">${inlineMd(c)}</td>`).join('')}</tr>`).join('')}</tbody>`;
|
|
632
|
+
out.push(`<table class="velaro-kb-table" style="${TABLE_STYLE}">${thead}${tbody}</table>`);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const ul = line.match(/^\s*[-*]\s+(.+)$/);
|
|
637
|
+
if (ul) {
|
|
638
|
+
flushPara();
|
|
639
|
+
if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; }
|
|
640
|
+
list.items.push(ul[1]);
|
|
641
|
+
i++; continue;
|
|
642
|
+
}
|
|
643
|
+
const ol = line.match(/^\s*\d+\.\s+(.+)$/);
|
|
644
|
+
if (ol) {
|
|
645
|
+
flushPara();
|
|
646
|
+
if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; }
|
|
647
|
+
list.items.push(ol[1]);
|
|
648
|
+
i++; continue;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (/^<(h[1-6]|table|ul|ol|p|div|pre|blockquote)/i.test(line.trim())) {
|
|
652
|
+
flushAll();
|
|
653
|
+
out.push(line);
|
|
654
|
+
i++; continue;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
flushList();
|
|
658
|
+
para.push(line.trim());
|
|
659
|
+
i++;
|
|
660
|
+
}
|
|
661
|
+
flushAll();
|
|
662
|
+
return out.join('\n');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function truncate(str, max) {
|
|
666
|
+
if (!str) return '-';
|
|
667
|
+
return str.length <= max ? str : str.slice(0, max - 1) + '...';
|
|
668
|
+
}
|