@duffcloudservices/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 CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import chalk8 from "chalk";
6
6
 
7
7
  // package.json
8
- var version = "0.1.1";
8
+ var version = "0.1.3";
9
9
 
10
10
  // src/commands/auth.ts
11
11
  import chalk2 from "chalk";
@@ -441,181 +441,198 @@ import chalk4 from "chalk";
441
441
  import ora2 from "ora";
442
442
 
443
443
  // src/templates/site-deploy.yml
444
- var site_deploy_default = `# Site Deployment Workflow\r
445
- # Generated by @duffcloudservices/cli\r
446
- # This workflow deploys the site to Azure Static Web Apps using OIDC authentication.\r
447
- \r
448
- name: Site Deployment\r
449
- \r
450
- on:\r
451
- push:\r
452
- branches:\r
453
- - 'release/**'\r
454
- - 'master'\r
455
- workflow_dispatch:\r
456
- inputs:\r
457
- trigger_reason:\r
458
- description: 'Reason for manual deployment'\r
459
- required: false\r
460
- type: string\r
461
- default: 'Manual deployment'\r
462
- \r
463
- # Prevent concurrent deployments for the same branch\r
464
- concurrency:\r
465
- group: site-deploy-\${{ github.ref }}\r
466
- cancel-in-progress: false\r
467
- \r
468
- permissions:\r
469
- contents: read\r
470
- issues: write\r
471
- pull-requests: read\r
472
- id-token: write # Required for Azure OIDC authentication\r
473
- \r
474
- jobs:\r
475
- deploy:\r
476
- name: Deploy to Azure Static Web App\r
477
- runs-on: ubuntu-latest\r
478
- # Skip deployment on branch creation events\r
479
- if: github.event.before != '0000000000000000000000000000000000000000'\r
480
- \r
481
- steps:\r
482
- - name: Determine deployment environment\r
483
- id: environment\r
484
- run: |\r
485
- BRANCH="\${{ github.ref_name }}"\r
486
- echo "Branch: $BRANCH"\r
487
- \r
488
- if [ "$BRANCH" == "master" ]; then\r
489
- echo "environment=Production" >> $GITHUB_OUTPUT\r
490
- echo "environment_name=Production" >> $GITHUB_OUTPUT\r
491
- echo "swa_environment=" >> $GITHUB_OUTPUT\r
492
- else\r
493
- echo "environment=preview" >> $GITHUB_OUTPUT\r
494
- echo "environment_name=Preview" >> $GITHUB_OUTPUT\r
495
- echo "swa_environment=preview" >> $GITHUB_OUTPUT\r
496
- fi\r
497
- \r
498
- - name: Checkout repository\r
499
- uses: actions/checkout@v4\r
500
- \r
501
- - name: Read site configuration\r
502
- id: config\r
503
- run: |\r
504
- CONFIG_FILE=".dcs/site.yaml"\r
505
- \r
506
- if [ ! -f "$CONFIG_FILE" ]; then\r
507
- echo "::error::Missing DCS site configuration file: $CONFIG_FILE"\r
508
- exit 1\r
509
- fi\r
510
- \r
511
- SITE_NAME=$(grep -E '^site_name:' "$CONFIG_FILE" | sed 's/site_name:\\s*//' | tr -d '"' | tr -d "'")\r
512
- SITE_SLUG=$(grep -E '^site_slug:' "$CONFIG_FILE" | sed 's/site_slug:\\s*//' | tr -d '"' | tr -d "'")\r
513
- SWA_RESOURCE_ID=$(grep -E '^swa_resource_id:' "$CONFIG_FILE" | sed 's/swa_resource_id:\\s*//' | tr -d '"' | tr -d "'")\r
514
- PORTAL_API_URL=$(grep -E '^portal_api_url:' "$CONFIG_FILE" | sed 's/portal_api_url:\\s*//' | tr -d '"' | tr -d "'")\r
515
- PORTAL_API_URL="\${PORTAL_API_URL:-https://portal.duffcloudservices.com}"\r
516
- GOOGLE_ANALYTICS_ID=$(grep -E '^google_analytics_id:' "$CONFIG_FILE" | sed 's/google_analytics_id:\\s*//' | tr -d '"' | tr -d "'")\r
517
- \r
518
- # Azure Authentication\r
519
- AZURE_CLIENT_ID=$(grep -E '^\\s*client_id:' "$CONFIG_FILE" | sed 's/.*client_id:\\s*//' | tr -d '"' | tr -d "'")\r
520
- AZURE_TENANT_ID=$(grep -E '^\\s*tenant_id:' "$CONFIG_FILE" | sed 's/.*tenant_id:\\s*//' | tr -d '"' | tr -d "'")\r
521
- AZURE_SUBSCRIPTION_ID=$(grep -E '^\\s*subscription_id:' "$CONFIG_FILE" | sed 's/.*subscription_id:\\s*//' | tr -d '"' | tr -d "'")\r
522
- \r
523
- # Build Configuration\r
524
- APP_LOCATION=$(grep -E '^\\s*app_location:' "$CONFIG_FILE" | sed 's/.*app_location:\\s*//' | tr -d '"' | tr -d "'")\r
525
- OUTPUT_LOCATION=$(grep -E '^\\s*output_location:' "$CONFIG_FILE" | sed 's/.*output_location:\\s*//' | tr -d '"' | tr -d "'")\r
526
- \r
527
- APP_LOCATION="\${APP_LOCATION:-dist}"\r
528
- OUTPUT_LOCATION="\${OUTPUT_LOCATION:-.}"\r
529
- \r
530
- if [ -z "$SWA_RESOURCE_ID" ]; then\r
531
- echo "::error::Missing swa_resource_id in $CONFIG_FILE"\r
532
- exit 1\r
533
- fi\r
534
- \r
535
- echo "site_name=$SITE_NAME" >> $GITHUB_OUTPUT\r
536
- echo "site_slug=$SITE_SLUG" >> $GITHUB_OUTPUT\r
537
- echo "swa_resource_id=$SWA_RESOURCE_ID" >> $GITHUB_OUTPUT\r
538
- echo "app_location=$APP_LOCATION" >> $GITHUB_OUTPUT\r
539
- echo "output_location=$OUTPUT_LOCATION" >> $GITHUB_OUTPUT\r
540
- echo "azure_client_id=$AZURE_CLIENT_ID" >> $GITHUB_OUTPUT\r
541
- echo "azure_tenant_id=$AZURE_TENANT_ID" >> $GITHUB_OUTPUT\r
542
- echo "azure_subscription_id=$AZURE_SUBSCRIPTION_ID" >> $GITHUB_OUTPUT\r
543
- echo "portal_api_url=$PORTAL_API_URL" >> $GITHUB_OUTPUT\r
544
- echo "google_analytics_id=$GOOGLE_ANALYTICS_ID" >> $GITHUB_OUTPUT\r
545
- \r
546
- - name: Azure login via OIDC\r
547
- uses: azure/login@v2\r
548
- with:\r
549
- client-id: \${{ steps.config.outputs.azure_client_id || vars.AZURE_CLIENT_ID }}\r
550
- tenant-id: \${{ steps.config.outputs.azure_tenant_id || vars.AZURE_TENANT_ID }}\r
551
- subscription-id: \${{ steps.config.outputs.azure_subscription_id || vars.AZURE_SUBSCRIPTION_ID }}\r
552
- \r
553
- - name: Get deployment tokens\r
554
- id: tokens\r
555
- run: |\r
556
- # Get access token for DCS API\r
557
- ACCESS_TOKEN=$(az account get-access-token --resource "api://304711b9-8532-4659-beb7-c85726de9ae5" --query accessToken -o tsv)\r
558
- \r
559
- SITE_NAME="\${{ steps.config.outputs.site_name }}"\r
560
- SITE_SLUG="\${{ steps.config.outputs.site_slug }}"\r
561
- PORTAL_API_URL="\${{ steps.config.outputs.portal_api_url }}"\r
562
- \r
563
- RESPONSE=$(curl -s -X POST "\${PORTAL_API_URL}/api/v1/sites/deployment-tokens" \\\r
564
- -H "Authorization: Bearer $ACCESS_TOKEN" \\\r
565
- -H "Content-Type: application/json" \\\r
566
- -d "{\\"siteName\\": \\"$SITE_NAME\\", \\"siteSlug\\": \\"$SITE_SLUG\\"}")\r
567
- \r
568
- SWA_TOKEN=$(echo $RESPONSE | jq -r .swaToken)\r
569
- \r
570
- if [ -z "$SWA_TOKEN" ] || [ "$SWA_TOKEN" == "null" ]; then\r
571
- echo "::error::Failed to retrieve SWA token from DCS API"\r
572
- echo "Response: $RESPONSE"\r
573
- exit 1\r
574
- fi\r
575
- \r
576
- echo "::add-mask::$SWA_TOKEN"\r
577
- echo "swa_token=$SWA_TOKEN" >> $GITHUB_OUTPUT\r
578
- \r
579
- - name: Setup Node.js\r
580
- uses: actions/setup-node@v4\r
581
- with:\r
582
- node-version: "22"\r
583
- \r
584
- - name: Install pnpm\r
585
- uses: pnpm/action-setup@v4\r
586
- \r
587
- - name: Setup pnpm cache\r
588
- uses: actions/cache@v4\r
589
- with:\r
590
- path: ~/.pnpm-store\r
591
- key: \${{ runner.os }}-pnpm-\${{ hashFiles('**/pnpm-lock.yaml') }}\r
592
- restore-keys: |\r
593
- \${{ runner.os }}-pnpm-\r
594
- \r
595
- - name: Install dependencies\r
596
- run: pnpm install --frozen-lockfile\r
597
- \r
598
- - name: Create environment file\r
599
- run: |\r
600
- cat > .env << EOF\r
601
- VITE_BACKEND_URI="\${{ steps.config.outputs.portal_api_url }}"\r
602
- VITE_SITE_SLUG="\${{ steps.config.outputs.site_slug }}"\r
603
- VITE_GOOGLE_ANALYTICS_ID="\${{ steps.config.outputs.google_analytics_id }}"\r
604
- EOF\r
605
- \r
606
- - name: Build site\r
607
- run: pnpm run build\r
608
- \r
609
- - name: Deploy to Azure Static Web Apps\r
610
- uses: Azure/static-web-apps-deploy@v1\r
611
- with:\r
612
- azure_static_web_apps_api_token: \${{ steps.tokens.outputs.swa_token }}\r
613
- repo_token: \${{ secrets.GITHUB_TOKEN }}\r
614
- action: "upload"\r
615
- app_location: \${{ steps.config.outputs.app_location }}\r
616
- output_location: \${{ steps.config.outputs.output_location }}\r
617
- skip_app_build: true\r
618
- deployment_environment: \${{ steps.environment.outputs.swa_environment != '' && steps.environment.outputs.swa_environment || '' }}\r
444
+ var site_deploy_default = `# Site Deployment Workflow
445
+ # Generated by @duffcloudservices/cli
446
+ # This workflow deploys the site to Azure Static Web Apps using OIDC authentication.
447
+
448
+ name: Site Deployment
449
+
450
+ on:
451
+ push:
452
+ branches:
453
+ - 'release/**'
454
+ - 'master'
455
+ workflow_dispatch:
456
+ inputs:
457
+ trigger_reason:
458
+ description: 'Reason for manual deployment'
459
+ required: false
460
+ type: string
461
+ default: 'Manual deployment'
462
+
463
+ # Prevent concurrent deployments for the same branch
464
+ concurrency:
465
+ group: site-deploy-\${{ github.ref }}
466
+ cancel-in-progress: false
467
+
468
+ permissions:
469
+ contents: read
470
+ issues: write
471
+ pull-requests: read
472
+ id-token: write # Required for Azure OIDC authentication
473
+
474
+ jobs:
475
+ deploy:
476
+ name: Deploy to Azure Static Web App
477
+ runs-on: ubuntu-latest
478
+ # Skip deployment on branch creation events
479
+ if: github.event.before != '0000000000000000000000000000000000000000'
480
+
481
+ steps:
482
+ - name: Determine deployment environment
483
+ id: environment
484
+ run: |
485
+ BRANCH="\${{ github.ref_name }}"
486
+ echo "Branch: $BRANCH"
487
+
488
+ if [ "$BRANCH" == "master" ]; then
489
+ echo "environment=Production" >> $GITHUB_OUTPUT
490
+ echo "environment_name=Production" >> $GITHUB_OUTPUT
491
+ echo "swa_environment=" >> $GITHUB_OUTPUT
492
+ else
493
+ echo "environment=preview" >> $GITHUB_OUTPUT
494
+ echo "environment_name=Preview" >> $GITHUB_OUTPUT
495
+ echo "swa_environment=preview" >> $GITHUB_OUTPUT
496
+ fi
497
+
498
+ - name: Checkout repository
499
+ uses: actions/checkout@v4
500
+
501
+ - name: Read site configuration
502
+ id: config
503
+ run: |
504
+ CONFIG_FILE=".dcs/site.yaml"
505
+
506
+ if [ ! -f "$CONFIG_FILE" ]; then
507
+ echo "::error::Missing DCS site configuration file: $CONFIG_FILE"
508
+ exit 1
509
+ fi
510
+
511
+ SITE_NAME=$(grep -E '^site_name:' "$CONFIG_FILE" | sed 's/site_name:\\s*//' | tr -d '"' | tr -d "'")
512
+ SITE_SLUG=$(grep -E '^site_slug:' "$CONFIG_FILE" | sed 's/site_slug:\\s*//' | tr -d '"' | tr -d "'")
513
+ SWA_RESOURCE_ID=$(grep -E '^swa_resource_id:' "$CONFIG_FILE" | sed 's/swa_resource_id:\\s*//' | tr -d '"' | tr -d "'")
514
+ PORTAL_API_URL=$(grep -E '^portal_api_url:' "$CONFIG_FILE" | sed 's/portal_api_url:\\s*//' | tr -d '"' | tr -d "'")
515
+ PORTAL_API_URL="\${PORTAL_API_URL:-https://portal.duffcloudservices.com}"
516
+ GOOGLE_ANALYTICS_ID=$(grep -E '^google_analytics_id:' "$CONFIG_FILE" | sed 's/google_analytics_id:\\s*//' | tr -d '"' | tr -d "'")
517
+ PROD_APPINSIGHTS_CONNECTION_STRING=$(grep -E '^prod_appinsights_connection_string:' "$CONFIG_FILE" | sed 's/prod_appinsights_connection_string:\\s*//' | tr -d '"' | tr -d "'" || true)
518
+ DEV_APPINSIGHTS_CONNECTION_STRING=$(grep -E '^dev_appinsights_connection_string:' "$CONFIG_FILE" | sed 's/dev_appinsights_connection_string:\\s*//' | tr -d '"' | tr -d "'" || true)
519
+
520
+ # Azure Authentication
521
+ AZURE_CLIENT_ID=$(grep -E '^\\s*client_id:' "$CONFIG_FILE" | sed 's/.*client_id:\\s*//' | tr -d '"' | tr -d "'")
522
+ AZURE_TENANT_ID=$(grep -E '^\\s*tenant_id:' "$CONFIG_FILE" | sed 's/.*tenant_id:\\s*//' | tr -d '"' | tr -d "'")
523
+ AZURE_SUBSCRIPTION_ID=$(grep -E '^\\s*subscription_id:' "$CONFIG_FILE" | sed 's/.*subscription_id:\\s*//' | tr -d '"' | tr -d "'")
524
+
525
+ # Build Configuration
526
+ APP_LOCATION=$(grep -E '^\\s*app_location:' "$CONFIG_FILE" | sed 's/.*app_location:\\s*//' | tr -d '"' | tr -d "'")
527
+ OUTPUT_LOCATION=$(grep -E '^\\s*output_location:' "$CONFIG_FILE" | sed 's/.*output_location:\\s*//' | tr -d '"' | tr -d "'")
528
+
529
+ APP_LOCATION="\${APP_LOCATION:-dist}"
530
+ OUTPUT_LOCATION="\${OUTPUT_LOCATION:-.}"
531
+
532
+ if [ -z "$SWA_RESOURCE_ID" ]; then
533
+ echo "::error::Missing swa_resource_id in $CONFIG_FILE"
534
+ exit 1
535
+ fi
536
+
537
+ echo "site_name=$SITE_NAME" >> $GITHUB_OUTPUT
538
+ echo "site_slug=$SITE_SLUG" >> $GITHUB_OUTPUT
539
+ echo "swa_resource_id=$SWA_RESOURCE_ID" >> $GITHUB_OUTPUT
540
+ echo "app_location=$APP_LOCATION" >> $GITHUB_OUTPUT
541
+ echo "output_location=$OUTPUT_LOCATION" >> $GITHUB_OUTPUT
542
+ echo "azure_client_id=$AZURE_CLIENT_ID" >> $GITHUB_OUTPUT
543
+ echo "azure_tenant_id=$AZURE_TENANT_ID" >> $GITHUB_OUTPUT
544
+ echo "azure_subscription_id=$AZURE_SUBSCRIPTION_ID" >> $GITHUB_OUTPUT
545
+ echo "portal_api_url=$PORTAL_API_URL" >> $GITHUB_OUTPUT
546
+ echo "google_analytics_id=$GOOGLE_ANALYTICS_ID" >> $GITHUB_OUTPUT
547
+ echo "prod_appinsights_connection_string=$PROD_APPINSIGHTS_CONNECTION_STRING" >> $GITHUB_OUTPUT
548
+ echo "dev_appinsights_connection_string=$DEV_APPINSIGHTS_CONNECTION_STRING" >> $GITHUB_OUTPUT
549
+
550
+ - name: Azure login via OIDC
551
+ uses: azure/login@v2
552
+ with:
553
+ client-id: \${{ steps.config.outputs.azure_client_id || vars.AZURE_CLIENT_ID }}
554
+ tenant-id: \${{ steps.config.outputs.azure_tenant_id || vars.AZURE_TENANT_ID }}
555
+ subscription-id: \${{ steps.config.outputs.azure_subscription_id || vars.AZURE_SUBSCRIPTION_ID }}
556
+
557
+ - name: Get deployment tokens
558
+ id: tokens
559
+ run: |
560
+ # Get access token for DCS API
561
+ ACCESS_TOKEN=$(az account get-access-token --resource "api://304711b9-8532-4659-beb7-c85726de9ae5" --query accessToken -o tsv)
562
+
563
+ SITE_NAME="\${{ steps.config.outputs.site_name }}"
564
+ SITE_SLUG="\${{ steps.config.outputs.site_slug }}"
565
+ PORTAL_API_URL="\${{ steps.config.outputs.portal_api_url }}"
566
+
567
+ RESPONSE=$(curl -s -X POST "\${PORTAL_API_URL}/api/v1/sites/deployment-tokens" \\
568
+ -H "Authorization: Bearer $ACCESS_TOKEN" \\
569
+ -H "Content-Type: application/json" \\
570
+ -d "{\\"siteName\\": \\"$SITE_NAME\\", \\"siteSlug\\": \\"$SITE_SLUG\\"}")
571
+
572
+ SWA_TOKEN=$(echo $RESPONSE | jq -r .swaToken)
573
+
574
+ if [ -z "$SWA_TOKEN" ] || [ "$SWA_TOKEN" == "null" ]; then
575
+ echo "::error::Failed to retrieve SWA token from DCS API"
576
+ echo "Response: $RESPONSE"
577
+ exit 1
578
+ fi
579
+
580
+ echo "::add-mask::$SWA_TOKEN"
581
+ echo "swa_token=$SWA_TOKEN" >> $GITHUB_OUTPUT
582
+
583
+ - name: Setup Node.js
584
+ uses: actions/setup-node@v4
585
+ with:
586
+ node-version: "22"
587
+
588
+ - name: Install pnpm
589
+ uses: pnpm/action-setup@v4
590
+
591
+ - name: Setup pnpm cache
592
+ uses: actions/cache@v4
593
+ with:
594
+ path: ~/.pnpm-store
595
+ key: \${{ runner.os }}-pnpm-\${{ hashFiles('**/pnpm-lock.yaml') }}
596
+ restore-keys: |
597
+ \${{ runner.os }}-pnpm-
598
+
599
+ - name: Install dependencies
600
+ run: pnpm install --frozen-lockfile
601
+
602
+ - name: Create environment file
603
+ run: |
604
+ cat > .env << EOF
605
+ VITE_APP_VERSION="\${{ github.sha }}"
606
+ VITE_BACKEND_URI="\${{ steps.config.outputs.portal_api_url }}"
607
+ VITE_SITE_SLUG="\${{ steps.config.outputs.site_slug }}"
608
+ VITE_GOOGLE_ANALYTICS_ID="\${{ steps.config.outputs.google_analytics_id }}"
609
+ VITE_PROD_APPINSIGHTS_CONNECTION_STRING="\${{ steps.config.outputs.prod_appinsights_connection_string }}"
610
+ VITE_DEV_APPINSIGHTS_CONNECTION_STRING="\${{ steps.config.outputs.dev_appinsights_connection_string }}"
611
+ EOF
612
+
613
+ - name: Build site
614
+ run: pnpm run build
615
+
616
+ - name: Inject Google Analytics
617
+ if: steps.config.outputs.google_analytics_id != ''
618
+ run: |
619
+ GA_ID="\${{ steps.config.outputs.google_analytics_id }}"
620
+ OUTPUT_DIR="\${{ steps.config.outputs.app_location }}"
621
+ echo "Injecting Google Analytics tag \${GA_ID} into \${OUTPUT_DIR}..."
622
+ find "$OUTPUT_DIR" -name "*.html" -exec sed -i "s|</head>|<script async src=\\"https://www.googletagmanager.com/gtag/js?id=\${GA_ID}\\"></script><script>window.dataLayer=window.dataLayer\\|\\|[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','\${GA_ID}')</script>\\n</head>|" {} +
623
+ INJECTED=$(grep -rl "googletagmanager" "$OUTPUT_DIR" --include="*.html" | wc -l)
624
+ echo "Injected GA tag into \${INJECTED} HTML file(s)"
625
+
626
+ - name: Deploy to Azure Static Web Apps
627
+ uses: Azure/static-web-apps-deploy@v1
628
+ with:
629
+ azure_static_web_apps_api_token: \${{ steps.tokens.outputs.swa_token }}
630
+ repo_token: \${{ secrets.GITHUB_TOKEN }}
631
+ action: "upload"
632
+ app_location: \${{ steps.config.outputs.app_location }}
633
+ output_location: \${{ steps.config.outputs.output_location }}
634
+ skip_app_build: true
635
+ deployment_environment: \${{ steps.environment.outputs.swa_environment != '' && steps.environment.outputs.swa_environment || '' }}
619
636
  `;
620
637
 
621
638
  // src/commands/init.ts
@@ -2088,6 +2105,85 @@ import path4 from "path";
2088
2105
  import yaml3 from "js-yaml";
2089
2106
  import chalk7 from "chalk";
2090
2107
  import ora3 from "ora";
2108
+ var CARD_THUMBNAIL_ASPECT_RATIO = 16 / 9;
2109
+ var ASSET_WAIT_TIMEOUT_MS = 5e3;
2110
+ async function disableCaptureMotion(page) {
2111
+ await page.addStyleTag({
2112
+ content: `
2113
+ *, *::before, *::after {
2114
+ animation-delay: -1ms !important;
2115
+ animation-duration: 1ms !important;
2116
+ animation-iteration-count: 1 !important;
2117
+ scroll-behavior: auto !important;
2118
+ transition-delay: 0s !important;
2119
+ transition-duration: 0s !important;
2120
+ }
2121
+ `
2122
+ }).catch(() => {
2123
+ });
2124
+ }
2125
+ async function waitForAboveFoldAssets(page) {
2126
+ await page.evaluate(async (timeoutMs) => {
2127
+ const waitForFonts = typeof document.fonts?.ready?.then === "function" ? document.fonts.ready.catch(() => void 0) : Promise.resolve();
2128
+ const visibleImages = Array.from(document.images).filter((img) => {
2129
+ const rect = img.getBoundingClientRect();
2130
+ return rect.bottom >= -100 && rect.top <= window.innerHeight * 1.5;
2131
+ });
2132
+ const waitForImages = Promise.all(visibleImages.map((img) => {
2133
+ if (img.complete && img.naturalWidth > 0) {
2134
+ return Promise.resolve();
2135
+ }
2136
+ return new Promise((resolve) => {
2137
+ const done = () => resolve();
2138
+ img.addEventListener("load", done, { once: true });
2139
+ img.addEventListener("error", done, { once: true });
2140
+ });
2141
+ }));
2142
+ await Promise.race([
2143
+ Promise.all([waitForFonts, waitForImages]),
2144
+ new Promise((resolve) => window.setTimeout(resolve, timeoutMs))
2145
+ ]);
2146
+ }, ASSET_WAIT_TIMEOUT_MS).catch((error) => {
2147
+ console.warn(` Warning: could not verify above-fold assets: ${error instanceof Error ? error.message : String(error)}`);
2148
+ });
2149
+ }
2150
+ async function lazyLoadPage(page, viewportHeight) {
2151
+ let pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
2152
+ const scrollStep = Math.max(viewportHeight, 1);
2153
+ for (let scrollY = 0; scrollY < pageHeight; scrollY += scrollStep) {
2154
+ await page.evaluate((y) => window.scrollTo(0, y), scrollY);
2155
+ await page.waitForTimeout(100);
2156
+ }
2157
+ await page.waitForTimeout(200);
2158
+ pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
2159
+ return pageHeight;
2160
+ }
2161
+ async function resetScrollForFullPageCapture(page) {
2162
+ await page.evaluate(() => {
2163
+ window.scrollTo(0, 0);
2164
+ window.dispatchEvent(new Event("scroll"));
2165
+ });
2166
+ await page.waitForFunction(() => Math.abs(window.scrollY) < 1, void 0, { timeout: 1e3 }).catch(() => void 0);
2167
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))).catch(() => void 0);
2168
+ await page.waitForTimeout(350);
2169
+ await waitForAboveFoldAssets(page);
2170
+ }
2171
+ async function captureHeaderThumbnail(page, outputPath, viewport) {
2172
+ await page.setViewportSize(viewport);
2173
+ await page.evaluate(() => window.scrollTo(0, 0));
2174
+ await page.waitForTimeout(200);
2175
+ await waitForAboveFoldAssets(page);
2176
+ const clip = await page.evaluate((aspectRatio) => {
2177
+ const width = Math.max(document.documentElement.clientWidth, window.innerWidth, 1);
2178
+ const pageHeight = Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0, 1);
2179
+ const height = Math.min(Math.round(width / aspectRatio), pageHeight);
2180
+ return { x: 0, y: 0, width, height };
2181
+ }, CARD_THUMBNAIL_ASPECT_RATIO);
2182
+ await page.screenshot({
2183
+ path: outputPath,
2184
+ clip
2185
+ });
2186
+ }
2091
2187
  var SECTION_SELECTORS = {
2092
2188
  explicit: "[data-section]",
2093
2189
  header: 'header, [role="banner"], .header, #header',
@@ -2201,14 +2297,16 @@ async function generateSectionDisplayName(element, sectionType, index) {
2201
2297
  }
2202
2298
  async function detectTextKeys(_page, sectionElement, _pageSlug, _validTextKeys) {
2203
2299
  const textKeys = [];
2204
- const textKeyElements = await sectionElement.$$("[data-text-key]");
2300
+ const textKeyElements = await sectionElement.$$("[data-text-key], [data-dcs-text], [data-dcs-image-key]");
2205
2301
  for (const element of textKeyElements) {
2206
2302
  const keyInfo = await element.evaluate((el) => {
2207
2303
  const rect = el.getBoundingClientRect();
2304
+ const explicitImageUrl = el.getAttribute("data-dcs-image-url");
2305
+ const imageUrl = el instanceof HTMLImageElement ? el.currentSrc || el.src : "";
2208
2306
  return {
2209
- key: el.getAttribute("data-text-key") || "",
2307
+ key: el.getAttribute("data-text-key") || el.getAttribute("data-dcs-text") || el.getAttribute("data-dcs-image-key") || "",
2210
2308
  elementType: el.tagName.toLowerCase(),
2211
- currentValue: el.textContent?.trim().slice(0, 500) || "",
2309
+ currentValue: (explicitImageUrl || imageUrl || el.textContent?.trim() || "").slice(0, 500),
2212
2310
  bounds: {
2213
2311
  x: rect.left + window.scrollX,
2214
2312
  y: rect.top + window.scrollY,
@@ -2217,7 +2315,7 @@ async function detectTextKeys(_page, sectionElement, _pageSlug, _validTextKeys)
2217
2315
  }
2218
2316
  };
2219
2317
  });
2220
- if (keyInfo.key) {
2318
+ if (keyInfo.key && !textKeys.some((textKey) => textKey.key === keyInfo.key)) {
2221
2319
  const domPath = await getDomPath(element);
2222
2320
  textKeys.push({
2223
2321
  key: keyInfo.key,
@@ -2259,15 +2357,21 @@ async function detectFallbackContentSections(page, existingSections) {
2259
2357
  }
2260
2358
  async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug, validTextKeys, outputDir, verbose) {
2261
2359
  const context = await browser.newContext({
2262
- viewport: snapshotConfig.viewport
2360
+ viewport: snapshotConfig.viewport,
2361
+ ignoreHTTPSErrors: true,
2362
+ reducedMotion: "reduce"
2263
2363
  });
2264
2364
  const page = await context.newPage();
2265
- const url = pageInfo.path.startsWith("http") ? pageInfo.path : `${process.env.SITE_BASE_URL || "http://localhost:5173"}${pageInfo.path}`;
2365
+ const rawUrl = pageInfo.path.startsWith("http") ? pageInfo.path : `${process.env.SITE_BASE_URL || "http://localhost:5173"}${pageInfo.path}`;
2366
+ const pageUrl = new URL(rawUrl);
2367
+ pageUrl.searchParams.set("dcs-hide-ribbon", "");
2368
+ const url = pageUrl.toString();
2266
2369
  if (verbose) {
2267
2370
  console.log(` Capturing: ${pageInfo.slug} (${url})`);
2268
2371
  }
2269
2372
  try {
2270
2373
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
2374
+ await disableCaptureMotion(page);
2271
2375
  await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
2272
2376
  if (verbose) console.log(` Note: networkidle timeout, continuing anyway`);
2273
2377
  });
@@ -2277,29 +2381,21 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
2277
2381
  throw error;
2278
2382
  }
2279
2383
  await page.waitForTimeout(snapshotConfig.waitAfterLoad);
2384
+ await waitForAboveFoldAssets(page);
2280
2385
  const pageOutputDir = path4.join(outputDir, siteSlug, pageInfo.slug);
2281
2386
  fs4.mkdirSync(path4.join(pageOutputDir, "sections"), { recursive: true });
2282
2387
  const title = await page.title();
2283
- let pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
2284
- const scrollStep = snapshotConfig.viewport.height;
2285
- for (let scrollY2 = 0; scrollY2 < pageHeight; scrollY2 += scrollStep) {
2286
- await page.evaluate((y) => window.scrollTo(0, y), scrollY2);
2287
- await page.waitForTimeout(100);
2288
- }
2289
- await page.evaluate(() => window.scrollTo(0, 0));
2290
- await page.waitForTimeout(200);
2291
- pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
2292
- await page.evaluate(() => window.scrollTo(0, 0));
2293
- await page.waitForTimeout(100);
2294
- const scrollY = await page.evaluate(() => window.scrollY);
2295
- if (scrollY !== 0) {
2296
- console.warn(` Warning: Could not scroll to top (scrollY=${scrollY})`);
2297
- }
2388
+ let pageHeight = await lazyLoadPage(page, snapshotConfig.viewport.height);
2389
+ await resetScrollForFullPageCapture(page);
2298
2390
  const fullPagePath = path4.join(pageOutputDir, "full-page.png");
2299
2391
  await page.screenshot({
2300
2392
  path: fullPagePath,
2301
2393
  fullPage: snapshotConfig.captureFullPage
2302
2394
  });
2395
+ const thumbnailPath = path4.join(pageOutputDir, "thumbnail.png");
2396
+ await captureHeaderThumbnail(page, thumbnailPath, snapshotConfig.viewport);
2397
+ await page.evaluate(() => window.scrollTo(0, 0));
2398
+ await page.waitForTimeout(100);
2303
2399
  const sections = [];
2304
2400
  const seenDomPaths = /* @__PURE__ */ new Set();
2305
2401
  const getAccurateBounds = async (el, sectionType) => {
@@ -2508,8 +2604,8 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
2508
2604
  sections.sort((a, b) => a.bounds.y - b.bounds.y);
2509
2605
  return {
2510
2606
  pageSlug: pageInfo.slug,
2511
- pagePath: pageInfo.path,
2512
- pageType: pageInfo.config.type,
2607
+ path: pageInfo.path,
2608
+ type: pageInfo.config.type,
2513
2609
  deletable: pageInfo.config.deletable,
2514
2610
  title: title || pageInfo.config.title,
2515
2611
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2544,6 +2640,9 @@ async function captureSnapshotsCommand(options) {
2544
2640
  console.log(chalk7.gray(`Content: Loaded ${pageCount} pages, ${globalCount} global keys`));
2545
2641
  }
2546
2642
  const targetedConfig = loadTargetedSnapshotsConfig(targetDir);
2643
+ const isTargetedCapture = Boolean(
2644
+ targetedPages && targetedPages.length > 0 || targetedConfig?.mode === "targeted" && targetedConfig.pages.length > 0
2645
+ );
2547
2646
  if (targetedConfig?.mode === "skip") {
2548
2647
  console.log("");
2549
2648
  console.log(chalk7.yellow("\u23ED\uFE0F Skip mode activated!"));
@@ -2600,6 +2699,7 @@ async function captureSnapshotsCommand(options) {
2600
2699
  const browser = await chromium.launch();
2601
2700
  spinner.succeed("Browser launched");
2602
2701
  const snapshots = [];
2702
+ const failures = [];
2603
2703
  for (const pageInfo of pagesToCapture) {
2604
2704
  const pageSpinner = ora3(`Capturing ${pageInfo.slug}...`).start();
2605
2705
  try {
@@ -2618,34 +2718,45 @@ async function captureSnapshotsCommand(options) {
2618
2718
  fs4.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
2619
2719
  pageSpinner.succeed(`${pageInfo.slug}: ${snapshot.sections.length} sections captured`);
2620
2720
  } catch (error) {
2721
+ const message = `${pageInfo.slug}: ${error.message}`;
2722
+ failures.push(message);
2621
2723
  pageSpinner.fail(`${pageInfo.slug}: Failed - ${error.message}`);
2622
2724
  }
2623
2725
  }
2624
2726
  await browser.close();
2625
2727
  const manifest = {
2626
2728
  siteSlug: config2.siteSlug,
2627
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
2729
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
2730
+ capturedVersion: process.env.GITHUB_SHA,
2628
2731
  deploymentRef: process.env.GITHUB_REF || "local",
2629
2732
  pagesConfigVersion: config2.version,
2630
2733
  pages: snapshots.map((s) => ({
2631
2734
  pageSlug: s.pageSlug,
2632
- pagePath: s.pagePath,
2633
- pageType: s.pageType,
2735
+ pagePath: s.path,
2736
+ pageType: s.type,
2634
2737
  deletable: s.deletable,
2635
2738
  title: s.title,
2636
2739
  capturedAt: s.capturedAt,
2637
- sectionCount: s.sections.length
2740
+ sectionCount: s.sections.length,
2741
+ hasSnapshot: true
2638
2742
  }))
2639
2743
  };
2640
2744
  const manifestPath = path4.join(outputDir, config2.siteSlug, "manifest.json");
2641
- fs4.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
2745
+ if (isTargetedCapture) {
2746
+ console.log(chalk7.gray(" Targeted capture: leaving the existing remote manifest intact"));
2747
+ } else {
2748
+ fs4.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
2749
+ }
2642
2750
  console.log("");
2643
2751
  console.log(chalk7.green("\u2705 Snapshot capture complete!"));
2644
2752
  console.log(chalk7.gray(` Captured ${snapshots.length} pages`));
2645
- console.log(chalk7.gray(` - Static: ${snapshots.filter((s) => s.pageType === "static").length}`));
2646
- console.log(chalk7.gray(` - Index: ${snapshots.filter((s) => s.pageType === "index").length}`));
2647
- console.log(chalk7.gray(` - Dynamic: ${snapshots.filter((s) => s.pageType === "dynamic").length}`));
2753
+ console.log(chalk7.gray(` - Static: ${snapshots.filter((s) => s.type === "static").length}`));
2754
+ console.log(chalk7.gray(` - Index: ${snapshots.filter((s) => s.type === "index").length}`));
2755
+ console.log(chalk7.gray(` - Dynamic: ${snapshots.filter((s) => s.type === "dynamic").length}`));
2648
2756
  console.log(chalk7.gray(` Output: ${outputDir}`));
2757
+ if (failures.length > 0) {
2758
+ throw new Error(`Snapshot capture failed for ${failures.length} page(s): ${failures.join("; ")}`);
2759
+ }
2649
2760
  }
2650
2761
 
2651
2762
  // src/index.ts