@opul/cli 0.1.1 → 0.1.3
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/dist/index.js +84 -20
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -243,6 +243,10 @@ function overlayInitSource() {
|
|
|
243
243
|
const POS_KEY = "__opul_cursor_pos";
|
|
244
244
|
const INK2 = "#0E0E0E";
|
|
245
245
|
const PAPER = "#F4F4F2";
|
|
246
|
+
const DEFAULT_COLOR = "#C8F24E";
|
|
247
|
+
if (typeof config.color !== "string" || !/^#[0-9a-fA-F]{3,8}$|^(?:rgb|rgba|hsl|hsla)\([0-9.,%\s]+\)$|^[a-zA-Z]{1,20}$/.test(config.color.trim())) {
|
|
248
|
+
config = { color: DEFAULT_COLOR };
|
|
249
|
+
}
|
|
246
250
|
function ensureFont() {
|
|
247
251
|
if (document.getElementById("__opul_font"))
|
|
248
252
|
return;
|
|
@@ -280,7 +284,11 @@ function overlayInitSource() {
|
|
|
280
284
|
lower.style.cssText = "position:absolute;left:24px;bottom:24px;max-width:60%;background:" + INK2 + ";color:" + PAPER + ";font-size:16px;font-weight:600;line-height:1.2;letter-spacing:-0.025em;padding:8px 12px;border-radius:6px;opacity:0;transition:opacity 150ms ease;white-space:nowrap;";
|
|
281
285
|
root.appendChild(lower);
|
|
282
286
|
root.appendChild(cursor);
|
|
283
|
-
|
|
287
|
+
document.documentElement.appendChild(root);
|
|
288
|
+
}
|
|
289
|
+
function ensure() {
|
|
290
|
+
if (!root || !document.documentElement.contains(root))
|
|
291
|
+
build();
|
|
284
292
|
}
|
|
285
293
|
function ready() {
|
|
286
294
|
return new Promise((res) => {
|
|
@@ -298,6 +306,7 @@ function overlayInitSource() {
|
|
|
298
306
|
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
|
|
299
307
|
function move2(x, y, dur) {
|
|
300
308
|
return new Promise((res) => {
|
|
309
|
+
ensure();
|
|
301
310
|
const sx = pos.x;
|
|
302
311
|
const sy = pos.y;
|
|
303
312
|
const dx = x - sx;
|
|
@@ -321,6 +330,7 @@ function overlayInitSource() {
|
|
|
321
330
|
}
|
|
322
331
|
function click() {
|
|
323
332
|
return new Promise((res) => {
|
|
333
|
+
ensure();
|
|
324
334
|
cursor.animate([
|
|
325
335
|
{ transform: "translate(" + pos.x + "px," + pos.y + "px) scale(1)" },
|
|
326
336
|
{
|
|
@@ -340,6 +350,7 @@ function overlayInitSource() {
|
|
|
340
350
|
}
|
|
341
351
|
function showLower(text) {
|
|
342
352
|
return new Promise((res) => {
|
|
353
|
+
ensure();
|
|
343
354
|
lower.textContent = text;
|
|
344
355
|
void lower.offsetWidth;
|
|
345
356
|
lower.style.opacity = "1";
|
|
@@ -348,6 +359,7 @@ function overlayInitSource() {
|
|
|
348
359
|
}
|
|
349
360
|
function hideLower2() {
|
|
350
361
|
return new Promise((res) => {
|
|
362
|
+
ensure();
|
|
351
363
|
lower.style.opacity = "0";
|
|
352
364
|
setTimeout(res, 150);
|
|
353
365
|
});
|
|
@@ -487,6 +499,7 @@ async function runStep(page, cast, step) {
|
|
|
487
499
|
}
|
|
488
500
|
case "hover": {
|
|
489
501
|
const c2 = await centerOf(page, step.selector, timeout);
|
|
502
|
+
await ensureReady(page);
|
|
490
503
|
if (step.name)
|
|
491
504
|
await moveOverlay(page, c2.x, c2.y, MOVE_MS, step.name);
|
|
492
505
|
else
|
|
@@ -497,6 +510,7 @@ async function runStep(page, cast, step) {
|
|
|
497
510
|
}
|
|
498
511
|
case "click": {
|
|
499
512
|
const c2 = await centerOf(page, step.selector, timeout);
|
|
513
|
+
await ensureReady(page);
|
|
500
514
|
await moveOverlay(page, c2.x, c2.y, MOVE_MS, step.name);
|
|
501
515
|
await sleep(PRE_CLICK_MS);
|
|
502
516
|
await overlayClick(page);
|
|
@@ -508,6 +522,7 @@ async function runStep(page, cast, step) {
|
|
|
508
522
|
}
|
|
509
523
|
case "type": {
|
|
510
524
|
const c2 = await centerOf(page, step.selector, timeout);
|
|
525
|
+
await ensureReady(page);
|
|
511
526
|
await moveOverlay(page, c2.x, c2.y, MOVE_MS, step.name);
|
|
512
527
|
await sleep(PRE_CLICK_MS);
|
|
513
528
|
await overlayClick(page);
|
|
@@ -889,29 +904,48 @@ function captureSource() {
|
|
|
889
904
|
if (w.__opulCap || !w.__opulEmit)
|
|
890
905
|
return;
|
|
891
906
|
w.__opulCap = true;
|
|
907
|
+
const uniq = (s) => {
|
|
908
|
+
try {
|
|
909
|
+
return document.querySelectorAll(s).length === 1;
|
|
910
|
+
} catch {
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
const q = (s) => s.replace(/'/g, "\\'");
|
|
892
915
|
const sel = (el) => {
|
|
893
916
|
if (!el || el.nodeType !== 1)
|
|
894
917
|
return null;
|
|
895
918
|
const dd = el.closest("[data-demo]");
|
|
896
919
|
if (dd)
|
|
897
920
|
return `[data-demo='${dd.getAttribute("data-demo")}']`;
|
|
898
|
-
if (el.id &&
|
|
921
|
+
if (el.id && uniq(`#${CSS.escape(el.id)}`))
|
|
899
922
|
return `#${CSS.escape(el.id)}`;
|
|
923
|
+
const al = el.getAttribute("aria-label");
|
|
924
|
+
if (al && uniq(`[aria-label='${q(al)}']`))
|
|
925
|
+
return `[aria-label='${q(al)}']`;
|
|
926
|
+
if (el.tagName === "A") {
|
|
927
|
+
const href = el.getAttribute("href");
|
|
928
|
+
if (href && href !== "#" && uniq(`a[href='${q(href)}']`))
|
|
929
|
+
return `a[href='${q(href)}']`;
|
|
930
|
+
}
|
|
900
931
|
const parts = [];
|
|
901
932
|
let node = el;
|
|
902
|
-
while (node && node.nodeType === 1 &&
|
|
933
|
+
while (node && node.nodeType === 1 && node.tagName !== "HTML") {
|
|
903
934
|
if (node.id) {
|
|
904
935
|
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
part += `:nth-
|
|
936
|
+
if (uniq(parts.join(" > ")))
|
|
937
|
+
return parts.join(" > ");
|
|
938
|
+
} else {
|
|
939
|
+
const parent = node.parentElement;
|
|
940
|
+
let part = node.tagName.toLowerCase();
|
|
941
|
+
if (parent) {
|
|
942
|
+
const idx = Array.from(parent.children).indexOf(node) + 1;
|
|
943
|
+
part += `:nth-child(${idx})`;
|
|
944
|
+
}
|
|
945
|
+
parts.unshift(part);
|
|
946
|
+
if (uniq(parts.join(" > ")))
|
|
947
|
+
return parts.join(" > ");
|
|
913
948
|
}
|
|
914
|
-
parts.unshift(part);
|
|
915
949
|
node = node.parentElement;
|
|
916
950
|
}
|
|
917
951
|
return parts.join(" > ");
|
|
@@ -947,6 +981,18 @@ var ACTIONS = [
|
|
|
947
981
|
];
|
|
948
982
|
var StepsValidationError = class extends Error {
|
|
949
983
|
};
|
|
984
|
+
var SAFE_COLOR = /^#[0-9a-fA-F]{3,8}$|^(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%\s]+\)$|^[a-zA-Z]{1,20}$/;
|
|
985
|
+
function isSafeColor(c2) {
|
|
986
|
+
return SAFE_COLOR.test(c2.trim());
|
|
987
|
+
}
|
|
988
|
+
function isSafeUrl(u) {
|
|
989
|
+
try {
|
|
990
|
+
const p = new URL(u);
|
|
991
|
+
return p.protocol === "http:" || p.protocol === "https:";
|
|
992
|
+
} catch {
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
950
996
|
function parseSteps(raw) {
|
|
951
997
|
if (typeof raw !== "object" || raw === null) {
|
|
952
998
|
throw new StepsValidationError("steps file must be a JSON object");
|
|
@@ -959,6 +1005,16 @@ function parseSteps(raw) {
|
|
|
959
1005
|
throw new StepsValidationError('"steps" must be a non-empty array');
|
|
960
1006
|
}
|
|
961
1007
|
const steps = obj.steps.map((s, i) => validateStep(s, i));
|
|
1008
|
+
for (const key of ["url", "startUrl"]) {
|
|
1009
|
+
const v = obj[key];
|
|
1010
|
+
if (typeof v === "string" && v !== "" && !isSafeUrl(v)) {
|
|
1011
|
+
throw new StepsValidationError(`"${key}" must be an http(s) URL`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const cursorColor = obj.cursor?.color;
|
|
1015
|
+
if (typeof cursorColor === "string" && !isSafeColor(cursorColor)) {
|
|
1016
|
+
throw new StepsValidationError(`cursor.color "${cursorColor}" is not a valid CSS color`);
|
|
1017
|
+
}
|
|
962
1018
|
const vp = obj.viewport;
|
|
963
1019
|
const viewport = vp && typeof vp.width === "number" && typeof vp.height === "number" ? { width: vp.width, height: vp.height } : { width: 1440, height: 900 };
|
|
964
1020
|
return {
|
|
@@ -986,8 +1042,12 @@ function validateStep(s, i) {
|
|
|
986
1042
|
throw new StepsValidationError(`step ${i} (${a}): "${field}" is required`);
|
|
987
1043
|
}
|
|
988
1044
|
};
|
|
989
|
-
if (a === "goto")
|
|
1045
|
+
if (a === "goto") {
|
|
990
1046
|
need("url");
|
|
1047
|
+
if (typeof step.url === "string" && !isSafeUrl(step.url)) {
|
|
1048
|
+
throw new StepsValidationError(`step ${i} (goto): "url" must be http(s)`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
991
1051
|
if (a === "click" || a === "hover" || a === "wait")
|
|
992
1052
|
need("selector");
|
|
993
1053
|
if (a === "type") {
|
|
@@ -1212,7 +1272,7 @@ import { createInterface as createInterface2 } from "readline/promises";
|
|
|
1212
1272
|
import { stdin, stdout } from "process";
|
|
1213
1273
|
|
|
1214
1274
|
// src/config.ts
|
|
1215
|
-
import { readFile as readFile2, writeFile as writeFile7, mkdir as mkdir3 } from "fs/promises";
|
|
1275
|
+
import { readFile as readFile2, writeFile as writeFile7, mkdir as mkdir3, chmod } from "fs/promises";
|
|
1216
1276
|
import { homedir } from "os";
|
|
1217
1277
|
import { join as join6 } from "path";
|
|
1218
1278
|
var dir = join6(homedir(), ".opul");
|
|
@@ -1226,8 +1286,12 @@ async function loadConfig() {
|
|
|
1226
1286
|
}
|
|
1227
1287
|
}
|
|
1228
1288
|
async function saveConfig(cfg) {
|
|
1229
|
-
await mkdir3(dir, { recursive: true });
|
|
1289
|
+
await mkdir3(dir, { recursive: true, mode: 448 });
|
|
1290
|
+
await chmod(dir, 448).catch(() => {
|
|
1291
|
+
});
|
|
1230
1292
|
await writeFile7(file, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
|
|
1293
|
+
await chmod(file, 384).catch(() => {
|
|
1294
|
+
});
|
|
1231
1295
|
}
|
|
1232
1296
|
async function requireToken() {
|
|
1233
1297
|
const { token } = await loadConfig();
|
|
@@ -1291,15 +1355,15 @@ async function publishCommand(file2, opts) {
|
|
|
1291
1355
|
}
|
|
1292
1356
|
const videoPath = resolve3(process.cwd(), file2);
|
|
1293
1357
|
const posterPath = opts.poster ? resolve3(process.cwd(), opts.poster) : join7(dirname(videoPath), "poster.jpg");
|
|
1294
|
-
let
|
|
1358
|
+
let size;
|
|
1295
1359
|
try {
|
|
1296
|
-
|
|
1360
|
+
size = (await stat2(videoPath)).size;
|
|
1297
1361
|
} catch {
|
|
1298
1362
|
return fail3(`file not found: ${file2}`);
|
|
1299
1363
|
}
|
|
1300
|
-
const poster = await readFile3(posterPath).catch(() => null);
|
|
1301
|
-
const size = (await stat2(videoPath)).size;
|
|
1302
1364
|
if (size > 200 * 1024 * 1024) return fail3("file exceeds 200 MB upload cap.");
|
|
1365
|
+
const video = await readFile3(videoPath);
|
|
1366
|
+
const poster = await readFile3(posterPath).catch(() => null);
|
|
1303
1367
|
const meta = await readFile3(join7(dirname(videoPath), "demo.json"), "utf8").then((s) => JSON.parse(s)).catch(() => ({}));
|
|
1304
1368
|
const title = opts.title ?? meta.title ?? basename(videoPath).replace(/\.[^.]+$/, "");
|
|
1305
1369
|
const auth = { authorization: `Bearer ${token}` };
|
|
@@ -1403,7 +1467,7 @@ function fail4(msg) {
|
|
|
1403
1467
|
|
|
1404
1468
|
// src/index.ts
|
|
1405
1469
|
var program = new Command();
|
|
1406
|
-
program.name("opul").description("Record a product demo from your running web app.").version("0.1.
|
|
1470
|
+
program.name("opul").description("Record a product demo from your running web app.").version("0.1.3");
|
|
1407
1471
|
program.command("init").description("Create an opul.steps.json stub").option("--out <file>", "output path", "opul.steps.json").action(initCommand);
|
|
1408
1472
|
program.command("doctor").description("Check that Chrome and ffmpeg are installed").action(doctorCommand);
|
|
1409
1473
|
program.command("record").description("Click through your app to generate a steps file").option("--url <url>", "app URL to open").option("--out <file>", "steps file to write", "opul.steps.json").option("--name <name>", "product/demo name").option("--chrome <path>", "path to Chrome executable").action(recordCommand);
|