tiendu 0.4.0 → 0.6.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/lib/preview.mjs CHANGED
@@ -1,13 +1,54 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { loadConfigOrFail, writeConfig } from "./config.mjs";
3
- import { apiFetch } from "./api.mjs";
3
+ import { apiFetch, fetchPreview } from "./api.mjs";
4
4
 
5
- const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
5
+ export const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
6
6
  const base = new URL(apiBaseUrl);
7
7
  const hasExplicitPort = previewHostname.includes(":");
8
8
  return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
9
9
  };
10
10
 
11
+ const formatShortDateTime = (value) => {
12
+ if (!value) return "Unknown";
13
+ const date = new Date(value);
14
+ if (Number.isNaN(date.getTime())) return "Unknown";
15
+
16
+ const months = [
17
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
18
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
19
+ ];
20
+ const month = months[date.getMonth()];
21
+ const day = date.getDate();
22
+ const hours = String(date.getHours()).padStart(2, "0");
23
+ const minutes = String(date.getMinutes()).padStart(2, "0");
24
+ return `${month} ${day} ${hours}:${minutes}`;
25
+ };
26
+
27
+ export const getPreviewDisplayName = (preview) =>
28
+ preview.name || `${formatShortDateTime(preview.createdAt)} preview (no name)`;
29
+
30
+ export const getPreviewUrl = (apiBaseUrl, preview) =>
31
+ buildPreviewUrl(apiBaseUrl, preview.previewHostname);
32
+
33
+ export const fetchPreviewDetails = async (
34
+ apiBaseUrl,
35
+ apiKey,
36
+ storeId,
37
+ previewKey,
38
+ ) => {
39
+ const result = await fetchPreview(apiBaseUrl, apiKey, storeId, previewKey);
40
+ if (!result.ok) return result;
41
+
42
+ return {
43
+ ok: true,
44
+ data: {
45
+ preview: result.data,
46
+ displayName: getPreviewDisplayName(result.data),
47
+ url: getPreviewUrl(apiBaseUrl, result.data),
48
+ },
49
+ };
50
+ };
51
+
11
52
  /**
12
53
  * @param {Array<any>} previews
13
54
  * @param {string | undefined} previewKey
@@ -42,18 +83,10 @@ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
42
83
  `/api/v2/stores/${storeId}/theme-previews`,
43
84
  {
44
85
  method: "POST",
45
- body: JSON.stringify({ name: name ?? "Dev" }),
86
+ body: JSON.stringify({ name: name ?? "" }),
46
87
  },
47
88
  );
48
89
 
49
- if (response.status === 409) {
50
- const body = await response.json().catch(() => ({}));
51
- const message =
52
- body?.error?.message ??
53
- "A preview already exists for this store. Delete it first with: tiendu preview delete";
54
- return { ok: false, error: message };
55
- }
56
-
57
90
  if (!response.ok) {
58
91
  const body = await response.text().catch(() => "");
59
92
  return {
@@ -158,6 +191,116 @@ export const publishPreview = async (
158
191
  }
159
192
  };
160
193
 
194
+ // ---------------------------------------------------------------------------
195
+ // Shared interactive preview picker
196
+ // ---------------------------------------------------------------------------
197
+
198
+ const CREATE_NEW_VALUE = "__create_new__";
199
+
200
+ /**
201
+ * Interactively resolve a preview key. Uses attached key if valid, otherwise
202
+ * prompts the user to pick an existing preview or create a new one.
203
+ *
204
+ * @param {{ config: import("./config.mjs").TienduConfig, credentials: import("./config.mjs").TienduCredentials }} opts
205
+ * @returns {Promise<string>} The resolved preview key
206
+ */
207
+ export const resolvePreviewKeyInteractively = async ({ config, credentials }) => {
208
+ const { apiBaseUrl, storeId } = config;
209
+ const { apiKey } = credentials;
210
+
211
+ // 1. Validate stored key
212
+ if (config.previewKey) {
213
+ const result = await fetchPreview(apiBaseUrl, apiKey, storeId, config.previewKey);
214
+ if (result.ok) {
215
+ return config.previewKey;
216
+ }
217
+
218
+ p.log.warn(`Stored preview ${config.previewKey} was not found. Please select a preview.`);
219
+ const { previewKey: _, ...rest } = config;
220
+ await writeConfig(rest);
221
+ } else {
222
+ p.log.warn("No preview attached.");
223
+ }
224
+
225
+ // 2. List previews
226
+ const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
227
+ if (!listResult.ok) {
228
+ p.log.error(listResult.error);
229
+ process.exit(1);
230
+ }
231
+
232
+ const previews = listResult.data;
233
+
234
+ if (previews.length === 0) {
235
+ p.log.info("No previews found for this store.");
236
+ }
237
+
238
+ // 3. Show picker
239
+ const options = [
240
+ ...previews.map((preview) => ({
241
+ value: preview.previewKey,
242
+ label: getPreviewDisplayName(preview),
243
+ hint: preview.previewKey,
244
+ })),
245
+ {
246
+ value: CREATE_NEW_VALUE,
247
+ label: "Create a new preview",
248
+ },
249
+ ];
250
+
251
+ const selected = await p.select({
252
+ message: "Select a preview",
253
+ options,
254
+ });
255
+
256
+ if (p.isCancel(selected)) {
257
+ p.cancel("Cancelled.");
258
+ process.exit(0);
259
+ }
260
+
261
+ // 4. Handle create new
262
+ if (selected === CREATE_NEW_VALUE) {
263
+ const nameInput = await p.text({
264
+ message: "Preview name (optional)",
265
+ placeholder: "Press Enter to skip",
266
+ defaultValue: "",
267
+ });
268
+
269
+ if (p.isCancel(nameInput)) {
270
+ p.cancel("Cancelled.");
271
+ process.exit(0);
272
+ }
273
+
274
+ const name = (nameInput ?? "").trim();
275
+ const spinner = p.spinner();
276
+ spinner.start("Creating preview...");
277
+
278
+ const createResult = await createPreview(apiBaseUrl, apiKey, storeId, name);
279
+ if (!createResult.ok) {
280
+ spinner.stop("Failed to create preview.", 1);
281
+ p.log.error(createResult.error);
282
+ process.exit(1);
283
+ }
284
+
285
+ const preview = createResult.data;
286
+ const displayName = getPreviewDisplayName(preview);
287
+ const url = getPreviewUrl(apiBaseUrl, preview);
288
+ spinner.stop(`Preview "${displayName}" created (${preview.previewKey})`);
289
+ p.log.message(` ${url}`);
290
+
291
+ await writeConfig({ ...config, previewKey: preview.previewKey });
292
+ return preview.previewKey;
293
+ }
294
+
295
+ // 5. Attach to selected preview
296
+ await writeConfig({ ...config, previewKey: selected });
297
+ const selectedPreview = previews.find((p) => p.previewKey === selected);
298
+ const displayName = selectedPreview ? getPreviewDisplayName(selectedPreview) : selected;
299
+ p.log.success(`Attached to "${displayName}" (${selected})`);
300
+
301
+ return selected;
302
+ };
303
+
161
304
  // ---------------------------------------------------------------------------
162
305
  // CLI commands
163
306
  // ---------------------------------------------------------------------------
@@ -182,8 +325,10 @@ export const previewCreate = async (name) => {
182
325
  }
183
326
 
184
327
  const preview = result.data;
185
- const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
186
- spinner.stop(`Preview created: ${url}`);
328
+ const url = getPreviewUrl(config.apiBaseUrl, preview);
329
+ const displayName = getPreviewDisplayName(preview);
330
+ spinner.stop(`Preview "${displayName}" created (${preview.previewKey})`);
331
+ p.log.message(` ${url}`);
187
332
 
188
333
  await writeConfig({ ...config, previewKey: preview.previewKey });
189
334
  };
@@ -215,14 +360,15 @@ export const previewList = async () => {
215
360
  `${result.data.length} preview${result.data.length === 1 ? "" : "s"}:`,
216
361
  );
217
362
 
218
- const activePreview = resolveActivePreview(result.data, config.previewKey);
219
-
220
363
  for (const preview of result.data) {
221
- const active =
222
- activePreview?.previewKey === preview.previewKey ? " ← active" : "";
364
+ const isAttached = config.previewKey === preview.previewKey;
365
+ const indicator = isAttached ? " \u2190 attached" : "";
223
366
  const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
224
- p.log.message(` ${preview.name} ${url}${active}`);
367
+ const displayName = getPreviewDisplayName(preview);
368
+ p.log.message(` ${displayName} ${url}${indicator}`);
225
369
  }
370
+
371
+ p.log.info("Tip: run tiendu preview attach <key> to switch previews.");
226
372
  };
227
373
 
228
374
  const formatRelativeDate = (value) => {
@@ -245,67 +391,119 @@ const formatRelativeDate = (value) => {
245
391
  export const previewShow = async () => {
246
392
  const { config, credentials } = await loadConfigOrFail();
247
393
 
248
- const result = await listPreviews(
394
+ if (!config.previewKey) {
395
+ p.log.warn("No preview attached. Run tiendu preview list or tiendu preview create.");
396
+ process.exit(0);
397
+ }
398
+
399
+ const result = await fetchPreview(
249
400
  config.apiBaseUrl,
250
401
  credentials.apiKey,
251
402
  config.storeId,
403
+ config.previewKey,
252
404
  );
253
405
 
254
406
  if (!result.ok) {
255
- p.log.error(result.error);
407
+ p.log.warn(`Stored preview ${config.previewKey} was not found.`);
408
+ p.log.info("Run tiendu preview list to see available previews.");
256
409
  process.exit(1);
257
410
  }
258
411
 
259
- const preview = resolveActivePreview(result.data, config.previewKey);
260
- if (!preview) {
261
- p.log.error(
262
- result.data.length === 0
263
- ? "No previews found for this store."
264
- : "Run tiendu preview list to inspect available previews.",
265
- );
266
- process.exit(1);
267
- }
268
-
269
- const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
412
+ const preview = result.data;
413
+ const url = getPreviewUrl(config.apiBaseUrl, preview);
414
+ const displayName = getPreviewDisplayName(preview);
270
415
 
271
416
  p.note(
272
417
  [
273
- `Name: ${preview.name || "Unnamed preview"}`,
418
+ `Name: ${displayName}`,
419
+ `Key: ${preview.previewKey}`,
274
420
  `URL: ${url}`,
275
421
  `Created: ${formatRelativeDate(preview.createdAt)}`,
276
422
  ].join("\n"),
277
- "Active preview",
423
+ "Attached preview",
278
424
  );
279
425
  };
280
426
 
281
- export const previewDelete = async () => {
427
+ export const previewAttach = async (keyArg) => {
282
428
  const { config, credentials } = await loadConfigOrFail();
283
429
 
284
- const listResult = await listPreviews(
430
+ if (!keyArg) {
431
+ await resolvePreviewKeyInteractively({ config, credentials });
432
+ return;
433
+ }
434
+
435
+ const spinner = p.spinner();
436
+ spinner.start("Validating preview...");
437
+
438
+ const result = await fetchPreview(
285
439
  config.apiBaseUrl,
286
440
  credentials.apiKey,
287
441
  config.storeId,
442
+ keyArg,
288
443
  );
289
- if (!listResult.ok) {
290
- p.log.error(listResult.error);
444
+
445
+ if (!result.ok) {
446
+ spinner.stop("Preview not found.", 1);
447
+ p.log.error("Preview not found. Run tiendu preview list to see available previews.");
291
448
  process.exit(1);
292
449
  }
293
450
 
294
- const activePreview = resolveActivePreview(
295
- listResult.data,
296
- config.previewKey,
451
+ const preview = result.data;
452
+ const url = getPreviewUrl(config.apiBaseUrl, preview);
453
+ const displayName = getPreviewDisplayName(preview);
454
+ spinner.stop(`Attached to preview "${displayName}" (${preview.previewKey})`);
455
+ p.log.message(` ${url}`);
456
+
457
+ await writeConfig({ ...config, previewKey: preview.previewKey });
458
+ };
459
+
460
+ export const previewDetach = async () => {
461
+ const { config } = await loadConfigOrFail();
462
+
463
+ if (!config.previewKey) {
464
+ p.log.warn("No preview is currently attached.");
465
+ process.exit(0);
466
+ }
467
+
468
+ const detachedKey = config.previewKey;
469
+ const { previewKey: _, ...rest } = config;
470
+ await writeConfig(rest);
471
+
472
+ p.log.success(`Detached from preview ${detachedKey}. No active preview.`);
473
+ };
474
+
475
+ export const previewDelete = async (keyArg) => {
476
+ const { config, credentials } = await loadConfigOrFail();
477
+
478
+ let previewKey = keyArg;
479
+
480
+ if (!previewKey) {
481
+ if (!config.previewKey) {
482
+ p.log.warn("No preview attached and no key provided.");
483
+ p.log.info("Run tiendu preview delete <key> or tiendu preview attach first.");
484
+ process.exit(1);
485
+ }
486
+ previewKey = config.previewKey;
487
+ }
488
+
489
+ // Fetch preview to show its name in the confirmation
490
+ const fetchResult = await fetchPreview(
491
+ config.apiBaseUrl,
492
+ credentials.apiKey,
493
+ config.storeId,
494
+ previewKey,
297
495
  );
298
- if (!activePreview) {
299
- p.log.error(
300
- listResult.data.length === 0
301
- ? "No previews found for this store."
302
- : "Could not determine the active preview. Run tiendu preview list first.",
303
- );
496
+
497
+ if (!fetchResult.ok) {
498
+ p.log.error(`Preview ${previewKey} not found.`);
304
499
  process.exit(1);
305
500
  }
306
501
 
502
+ const displayName = getPreviewDisplayName(fetchResult.data);
503
+ const url = getPreviewUrl(config.apiBaseUrl, fetchResult.data);
504
+
307
505
  const confirmed = await p.confirm({
308
- message: `Delete preview ${activePreview.previewKey}?`,
506
+ message: `Delete preview ${previewKey} "${displayName}" (${url})?`,
309
507
  });
310
508
 
311
509
  if (p.isCancel(confirmed) || !confirmed) {
@@ -320,7 +518,7 @@ export const previewDelete = async () => {
320
518
  config.apiBaseUrl,
321
519
  credentials.apiKey,
322
520
  config.storeId,
323
- activePreview.previewKey,
521
+ previewKey,
324
522
  );
325
523
 
326
524
  if (!result.ok) {
@@ -331,48 +529,56 @@ export const previewDelete = async () => {
331
529
 
332
530
  spinner.stop("Preview deleted.");
333
531
 
334
- const { previewKey, ...rest } = config;
335
- await writeConfig(rest);
532
+ if (config.previewKey === previewKey) {
533
+ const { previewKey: _, ...rest } = config;
534
+ await writeConfig(rest);
535
+ }
336
536
  };
337
537
 
338
538
  export const previewOpen = async () => {
339
539
  const { config, credentials } = await loadConfigOrFail();
340
540
 
541
+ if (!config.previewKey) {
542
+ p.log.warn("No preview attached. Run tiendu preview attach or tiendu preview create.");
543
+ process.exit(1);
544
+ }
545
+
341
546
  const spinner = p.spinner();
342
547
  spinner.start("Fetching preview URL...");
343
548
 
344
- const result = await listPreviews(
549
+ const result = await fetchPreview(
345
550
  config.apiBaseUrl,
346
551
  credentials.apiKey,
347
552
  config.storeId,
553
+ config.previewKey,
348
554
  );
349
555
 
350
556
  if (!result.ok) {
351
- spinner.stop("Failed to fetch previews.", 1);
352
- p.log.error(result.error);
353
- process.exit(1);
354
- }
355
-
356
- const preview = resolveActivePreview(result.data, config.previewKey);
357
- if (!preview) {
358
- spinner.stop("Could not determine the active preview.", 1);
359
- p.log.error(
360
- result.data.length === 0
361
- ? "No previews found for this store."
362
- : "Run tiendu preview list and then set or recreate the preview.",
363
- );
557
+ spinner.stop("Preview not found.", 1);
558
+ p.log.error("Stored preview was not found. Run tiendu preview list.");
364
559
  process.exit(1);
365
560
  }
366
561
 
367
- const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
562
+ const preview = result.data;
563
+ const url = getPreviewUrl(config.apiBaseUrl, preview);
368
564
  spinner.stop(`Opening ${url}`);
369
565
 
370
- const { exec } = await import("node:child_process");
371
- const cmd =
372
- process.platform === "darwin"
373
- ? "open"
374
- : process.platform === "win32"
375
- ? "start"
376
- : "xdg-open";
377
- exec(`${cmd} ${url}`);
566
+ const { spawn } = await import("node:child_process");
567
+
568
+ if (process.platform === "win32") {
569
+ const child = spawn("cmd", ["/c", "start", "", url], {
570
+ detached: true,
571
+ stdio: "ignore",
572
+ windowsHide: true,
573
+ });
574
+ child.unref();
575
+ return;
576
+ }
577
+
578
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
579
+ const child = spawn(cmd, [url], {
580
+ detached: true,
581
+ stdio: "ignore",
582
+ });
583
+ child.unref();
378
584
  };
package/lib/publish.mjs CHANGED
@@ -1,18 +1,35 @@
1
1
  import * as p from "@clack/prompts";
2
- import { loadConfigOrFail, writeConfig, isBuiltTheme } from "./config.mjs";
3
- import { publishPreview } from "./preview.mjs";
2
+ import { loadConfigOrFail, isBuiltTheme } from "./config.mjs";
3
+ import {
4
+ fetchPreviewDetails,
5
+ publishPreview,
6
+ resolvePreviewKeyInteractively,
7
+ } from "./preview.mjs";
4
8
  import { push } from "./push.mjs";
5
9
 
6
- export const publish = async ({ skipBuild = false } = {}) => {
10
+ export const publish = async ({ skipBuild = false, previewKey: previewKeyArg } = {}) => {
7
11
  const { config, credentials } = await loadConfigOrFail();
8
12
 
9
- if (!config.previewKey) {
10
- p.log.error("No active preview. Create one with: tiendu preview create");
11
- process.exit(1);
12
- }
13
+ // Resolve preview key: explicit arg > interactive picker
14
+ const previewKey = previewKeyArg ?? await resolvePreviewKeyInteractively({ config, credentials });
15
+
16
+ // Fetch preview to show its name in the confirmation
17
+ const fetchResult = await fetchPreviewDetails(
18
+ config.apiBaseUrl,
19
+ credentials.apiKey,
20
+ config.storeId,
21
+ previewKey,
22
+ );
23
+
24
+ const displayName = fetchResult.ok
25
+ ? fetchResult.data.displayName
26
+ : previewKey;
27
+ const previewUrl = fetchResult.ok ? fetchResult.data.url : null;
13
28
 
14
29
  const confirmed = await p.confirm({
15
- message: `Publish preview ${config.previewKey} to the live storefront?`,
30
+ message: previewUrl
31
+ ? `Publish preview "${displayName}" (${previewKey}) at ${previewUrl} to the live storefront?`
32
+ : `Publish preview "${displayName}" (${previewKey}) to the live storefront?`,
16
33
  });
17
34
 
18
35
  if (p.isCancel(confirmed) || !confirmed) {
@@ -26,17 +43,17 @@ export const publish = async ({ skipBuild = false } = {}) => {
26
43
  ? "Syncing existing dist/ output to the preview before publishing..."
27
44
  : "Building and syncing the latest dist/ output before publishing...",
28
45
  );
29
- await push({ skipBuild });
46
+ await push({ skipBuild, previewKey });
30
47
  }
31
48
 
32
49
  const spinner = p.spinner();
33
- spinner.start("Publishing preview...");
50
+ spinner.start("Publishing preview to live storefront...");
34
51
 
35
52
  const result = await publishPreview(
36
53
  config.apiBaseUrl,
37
54
  credentials.apiKey,
38
55
  config.storeId,
39
- config.previewKey,
56
+ previewKey,
40
57
  );
41
58
 
42
59
  if (!result.ok) {
@@ -45,10 +62,8 @@ export const publish = async ({ skipBuild = false } = {}) => {
45
62
  process.exit(1);
46
63
  }
47
64
 
48
- spinner.stop("Preview published. Your live storefront has been updated.");
49
- p.log.info("All previews for this store have been removed.");
50
-
51
- // Remove preview key from config
52
- const { previewKey, ...rest } = config;
53
- await writeConfig(rest);
65
+ spinner.stop(`Preview ${previewKey} published. Your live storefront has been updated.`);
66
+ if (previewUrl) {
67
+ p.log.message(` ${previewUrl}`);
68
+ }
54
69
  };
package/lib/pull.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { loadConfigOrFail, isBuiltTheme, getDistDir } from "./config.mjs";
3
- import { downloadStorefrontArchive } from "./api.mjs";
3
+ import { downloadStorefrontArchive, downloadPreviewArchive } from "./api.mjs";
4
+ import { fetchPreviewDetails } from "./preview.mjs";
4
5
  import { extractZip } from "./zip.mjs";
5
6
 
6
7
  /** @param {number} bytes */
@@ -10,32 +11,56 @@ const formatBytes = (bytes) => {
10
11
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
11
12
  };
12
13
 
13
- export const pull = async () => {
14
+ export const pull = async ({ previewKey } = {}) => {
14
15
  const { config, credentials } = await loadConfigOrFail();
16
+ const previewDetails = previewKey
17
+ ? await fetchPreviewDetails(
18
+ config.apiBaseUrl,
19
+ credentials.apiKey,
20
+ config.storeId,
21
+ previewKey,
22
+ )
23
+ : null;
15
24
 
16
25
  const spinner = p.spinner();
17
- spinner.start(`Downloading theme from store #${config.storeId}...`);
26
+ const isPreviewPull = Boolean(previewKey);
18
27
 
19
- const result = await downloadStorefrontArchive(
20
- config.apiBaseUrl,
21
- credentials.apiKey,
22
- config.storeId,
28
+ spinner.start(
29
+ isPreviewPull
30
+ ? `Downloading preview ${previewKey} from store #${config.storeId}...`
31
+ : `Downloading live theme from store #${config.storeId}...`,
23
32
  );
24
33
 
34
+ const result = isPreviewPull
35
+ ? await downloadPreviewArchive(
36
+ config.apiBaseUrl,
37
+ credentials.apiKey,
38
+ config.storeId,
39
+ previewKey,
40
+ )
41
+ : await downloadStorefrontArchive(
42
+ config.apiBaseUrl,
43
+ credentials.apiKey,
44
+ config.storeId,
45
+ );
46
+
25
47
  if (!result.ok) {
26
48
  spinner.stop("Download failed.", 1);
27
49
  p.log.error(result.error);
28
50
  process.exit(1);
29
51
  }
30
52
 
31
- spinner.stop(
32
- `Archive received (${formatBytes(result.data.length)}). Extracting...`,
33
- );
53
+ spinner.message(`Extracting archive (${formatBytes(result.data.length)})...`);
34
54
 
35
55
  const outputDir = (await isBuiltTheme()) ? getDistDir() : process.cwd();
36
56
  const extractedFiles = await extractZip(result.data, outputDir);
37
57
 
38
- p.log.success(
39
- `${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted.`,
58
+ const suffix = isPreviewPull ? ` from preview ${previewKey}` : "";
59
+ spinner.stop(
60
+ `${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted${suffix}.`,
40
61
  );
62
+
63
+ if (previewDetails?.ok) {
64
+ p.log.message(` ${previewDetails.data.url}`);
65
+ }
41
66
  };