@duffcloudservices/cli 0.1.0 → 0.1.2
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 +158 -158
- package/dist/index.js +795 -180
- package/dist/index.js.map +1 -1
- package/package.json +65 -62
package/dist/index.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import chalk8 from "chalk";
|
|
6
6
|
|
|
7
7
|
// package.json
|
|
8
|
-
var version = "0.1.
|
|
8
|
+
var version = "0.1.2";
|
|
9
9
|
|
|
10
10
|
// src/commands/auth.ts
|
|
11
11
|
import chalk2 from "chalk";
|
|
@@ -251,7 +251,7 @@ var PortalClient = class {
|
|
|
251
251
|
/**
|
|
252
252
|
* Make an authenticated API request.
|
|
253
253
|
*/
|
|
254
|
-
async request(
|
|
254
|
+
async request(path5, options = {}) {
|
|
255
255
|
let accessToken = getAccessToken();
|
|
256
256
|
if (!isAuthenticated() && getRefreshToken()) {
|
|
257
257
|
const refreshToken = getRefreshToken();
|
|
@@ -268,7 +268,7 @@ var PortalClient = class {
|
|
|
268
268
|
if (!accessToken) {
|
|
269
269
|
throw new Error("Not authenticated. Please login with `dcs login`.");
|
|
270
270
|
}
|
|
271
|
-
const response = await fetch(`${this.apiUrl}${
|
|
271
|
+
const response = await fetch(`${this.apiUrl}${path5}`, {
|
|
272
272
|
...options,
|
|
273
273
|
headers: {
|
|
274
274
|
...options.headers,
|
|
@@ -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
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
echo "
|
|
538
|
-
echo "
|
|
539
|
-
echo "
|
|
540
|
-
echo "
|
|
541
|
-
echo "
|
|
542
|
-
echo "
|
|
543
|
-
echo "
|
|
544
|
-
echo "
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
EOF
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
|
@@ -2082,6 +2099,594 @@ Once verified:
|
|
|
2082
2099
|
`;
|
|
2083
2100
|
}
|
|
2084
2101
|
|
|
2102
|
+
// src/commands/capture-snapshots.ts
|
|
2103
|
+
import fs4 from "fs";
|
|
2104
|
+
import path4 from "path";
|
|
2105
|
+
import yaml3 from "js-yaml";
|
|
2106
|
+
import chalk7 from "chalk";
|
|
2107
|
+
import ora3 from "ora";
|
|
2108
|
+
var SECTION_SELECTORS = {
|
|
2109
|
+
explicit: "[data-section]",
|
|
2110
|
+
header: 'header, [role="banner"], .header, #header',
|
|
2111
|
+
footer: 'footer, [role="contentinfo"], .footer, #footer',
|
|
2112
|
+
hero: '[class*="hero"], [data-section-type="hero"], .hero-section, #hero',
|
|
2113
|
+
content: 'main, [role="main"], article, .content, .main-content',
|
|
2114
|
+
gallery: '[class*="gallery"], [data-section-type="gallery"]',
|
|
2115
|
+
cta: '[class*="cta"], [data-section-type="cta"], .call-to-action'
|
|
2116
|
+
};
|
|
2117
|
+
function loadPagesConfig(targetDir) {
|
|
2118
|
+
const configPath = path4.join(targetDir, ".dcs", "pages.yaml");
|
|
2119
|
+
if (!fs4.existsSync(configPath)) {
|
|
2120
|
+
throw new Error(`Configuration file not found: ${configPath}`);
|
|
2121
|
+
}
|
|
2122
|
+
const content = fs4.readFileSync(configPath, "utf-8");
|
|
2123
|
+
return yaml3.load(content);
|
|
2124
|
+
}
|
|
2125
|
+
function loadContentConfig(targetDir) {
|
|
2126
|
+
const contentPath = path4.join(targetDir, ".dcs", "content.yaml");
|
|
2127
|
+
if (!fs4.existsSync(contentPath)) {
|
|
2128
|
+
return null;
|
|
2129
|
+
}
|
|
2130
|
+
const content = fs4.readFileSync(contentPath, "utf-8");
|
|
2131
|
+
return yaml3.load(content);
|
|
2132
|
+
}
|
|
2133
|
+
function loadTargetedSnapshotsConfig(targetDir) {
|
|
2134
|
+
const targetedPath = path4.join(targetDir, ".dcs", "targeted-snapshots.yaml");
|
|
2135
|
+
if (!fs4.existsSync(targetedPath)) {
|
|
2136
|
+
return null;
|
|
2137
|
+
}
|
|
2138
|
+
const content = fs4.readFileSync(targetedPath, "utf-8");
|
|
2139
|
+
return yaml3.load(content);
|
|
2140
|
+
}
|
|
2141
|
+
function getValidTextKeysForPage(contentConfig, pageSlug) {
|
|
2142
|
+
const keys = /* @__PURE__ */ new Map();
|
|
2143
|
+
if (!contentConfig) return keys;
|
|
2144
|
+
if (contentConfig.global) {
|
|
2145
|
+
for (const [key, value] of Object.entries(contentConfig.global)) {
|
|
2146
|
+
keys.set(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
const pageContent = contentConfig.pages?.[pageSlug];
|
|
2150
|
+
if (pageContent) {
|
|
2151
|
+
for (const [key, value] of Object.entries(pageContent)) {
|
|
2152
|
+
const fullKey = `${pageSlug}.${key}`;
|
|
2153
|
+
keys.set(fullKey, typeof value === "string" ? value : JSON.stringify(value));
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
return keys;
|
|
2157
|
+
}
|
|
2158
|
+
async function resolveAllPages(config2) {
|
|
2159
|
+
const resolved = [];
|
|
2160
|
+
for (const page of config2.pages) {
|
|
2161
|
+
if (page.type === "dynamic" && page.instances) {
|
|
2162
|
+
for (const instance of page.instances) {
|
|
2163
|
+
const instancePath = page.pathTemplate ? page.pathTemplate.replace(/:[\w]+/, instance) : `${page.path}/${instance}`;
|
|
2164
|
+
resolved.push({
|
|
2165
|
+
slug: `${page.slug}-${instance}`,
|
|
2166
|
+
path: instancePath,
|
|
2167
|
+
config: { ...page, slug: `${page.slug}-${instance}` }
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
} else {
|
|
2171
|
+
resolved.push({
|
|
2172
|
+
slug: page.slug,
|
|
2173
|
+
path: page.path,
|
|
2174
|
+
config: page
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
return resolved;
|
|
2179
|
+
}
|
|
2180
|
+
async function getDomPath(element) {
|
|
2181
|
+
return element.evaluate((el) => {
|
|
2182
|
+
const parts = [];
|
|
2183
|
+
let current = el;
|
|
2184
|
+
while (current && current !== document.body) {
|
|
2185
|
+
let selector = current.tagName.toLowerCase();
|
|
2186
|
+
if (current.id) {
|
|
2187
|
+
selector += `#${current.id}`;
|
|
2188
|
+
} else {
|
|
2189
|
+
const classes = Array.from(current.classList).filter((c) => !c.match(/^(v-|nuxt-|vue-|react-|ng-|_)/)).slice(0, 2).join(".");
|
|
2190
|
+
if (classes) {
|
|
2191
|
+
selector += `.${classes}`;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
parts.unshift(selector);
|
|
2195
|
+
current = current.parentElement;
|
|
2196
|
+
}
|
|
2197
|
+
return parts.join(" > ");
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
async function generateSectionDisplayName(element, sectionType, index) {
|
|
2201
|
+
const headingText = await element.evaluate((el) => {
|
|
2202
|
+
const heading = el.querySelector('h1, h2, h3, h4, [class*="title"], [class*="heading"]');
|
|
2203
|
+
if (heading && heading.textContent) {
|
|
2204
|
+
return heading.textContent.trim().slice(0, 50);
|
|
2205
|
+
}
|
|
2206
|
+
return null;
|
|
2207
|
+
});
|
|
2208
|
+
if (headingText) return headingText;
|
|
2209
|
+
const typeLabels = {
|
|
2210
|
+
header: "Header",
|
|
2211
|
+
footer: "Footer",
|
|
2212
|
+
hero: "Hero Section",
|
|
2213
|
+
content: "Content Section",
|
|
2214
|
+
gallery: "Gallery",
|
|
2215
|
+
cta: "Call to Action"
|
|
2216
|
+
};
|
|
2217
|
+
return typeLabels[sectionType] || `Section ${index + 1}`;
|
|
2218
|
+
}
|
|
2219
|
+
async function detectTextKeys(_page, sectionElement, _pageSlug, _validTextKeys) {
|
|
2220
|
+
const textKeys = [];
|
|
2221
|
+
const textKeyElements = await sectionElement.$$("[data-text-key]");
|
|
2222
|
+
for (const element of textKeyElements) {
|
|
2223
|
+
const keyInfo = await element.evaluate((el) => {
|
|
2224
|
+
const rect = el.getBoundingClientRect();
|
|
2225
|
+
return {
|
|
2226
|
+
key: el.getAttribute("data-text-key") || "",
|
|
2227
|
+
elementType: el.tagName.toLowerCase(),
|
|
2228
|
+
currentValue: el.textContent?.trim().slice(0, 500) || "",
|
|
2229
|
+
bounds: {
|
|
2230
|
+
x: rect.left + window.scrollX,
|
|
2231
|
+
y: rect.top + window.scrollY,
|
|
2232
|
+
width: rect.width,
|
|
2233
|
+
height: rect.height
|
|
2234
|
+
}
|
|
2235
|
+
};
|
|
2236
|
+
});
|
|
2237
|
+
if (keyInfo.key) {
|
|
2238
|
+
const domPath = await getDomPath(element);
|
|
2239
|
+
textKeys.push({
|
|
2240
|
+
key: keyInfo.key,
|
|
2241
|
+
currentValue: keyInfo.currentValue,
|
|
2242
|
+
elementType: keyInfo.elementType,
|
|
2243
|
+
domPath,
|
|
2244
|
+
bounds: {
|
|
2245
|
+
x: Math.round(keyInfo.bounds.x),
|
|
2246
|
+
y: Math.round(keyInfo.bounds.y),
|
|
2247
|
+
width: Math.round(keyInfo.bounds.width),
|
|
2248
|
+
height: Math.round(keyInfo.bounds.height)
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
return textKeys;
|
|
2254
|
+
}
|
|
2255
|
+
async function detectFallbackContentSections(page, existingSections) {
|
|
2256
|
+
const candidates = await page.$$('section, article, div[class*="section"], div[class*="container"]');
|
|
2257
|
+
const results = [];
|
|
2258
|
+
const existingBounds = existingSections.map((s) => s.bounds);
|
|
2259
|
+
for (const candidate of candidates) {
|
|
2260
|
+
const bounds = await candidate.evaluate((el) => {
|
|
2261
|
+
const rect = el.getBoundingClientRect();
|
|
2262
|
+
return {
|
|
2263
|
+
y: rect.top + window.scrollY,
|
|
2264
|
+
height: rect.height
|
|
2265
|
+
};
|
|
2266
|
+
});
|
|
2267
|
+
if (bounds.height < 100) continue;
|
|
2268
|
+
const overlaps = existingBounds.some(
|
|
2269
|
+
(eb) => bounds.y < eb.y + eb.height && bounds.y + bounds.height > eb.y
|
|
2270
|
+
);
|
|
2271
|
+
if (!overlaps) {
|
|
2272
|
+
results.push(candidate);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
return results;
|
|
2276
|
+
}
|
|
2277
|
+
async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug, validTextKeys, outputDir, verbose) {
|
|
2278
|
+
const context = await browser.newContext({
|
|
2279
|
+
viewport: snapshotConfig.viewport,
|
|
2280
|
+
ignoreHTTPSErrors: true
|
|
2281
|
+
});
|
|
2282
|
+
const page = await context.newPage();
|
|
2283
|
+
const rawUrl = pageInfo.path.startsWith("http") ? pageInfo.path : `${process.env.SITE_BASE_URL || "http://localhost:5173"}${pageInfo.path}`;
|
|
2284
|
+
const pageUrl = new URL(rawUrl);
|
|
2285
|
+
pageUrl.searchParams.set("dcs-hide-ribbon", "");
|
|
2286
|
+
const url = pageUrl.toString();
|
|
2287
|
+
if (verbose) {
|
|
2288
|
+
console.log(` Capturing: ${pageInfo.slug} (${url})`);
|
|
2289
|
+
}
|
|
2290
|
+
try {
|
|
2291
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
2292
|
+
await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
|
|
2293
|
+
if (verbose) console.log(` Note: networkidle timeout, continuing anyway`);
|
|
2294
|
+
});
|
|
2295
|
+
} catch (error) {
|
|
2296
|
+
console.error(` Failed to load: ${error}`);
|
|
2297
|
+
await context.close();
|
|
2298
|
+
throw error;
|
|
2299
|
+
}
|
|
2300
|
+
await page.waitForTimeout(snapshotConfig.waitAfterLoad);
|
|
2301
|
+
const pageOutputDir = path4.join(outputDir, siteSlug, pageInfo.slug);
|
|
2302
|
+
fs4.mkdirSync(path4.join(pageOutputDir, "sections"), { recursive: true });
|
|
2303
|
+
const title = await page.title();
|
|
2304
|
+
let pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
2305
|
+
const scrollStep = snapshotConfig.viewport.height;
|
|
2306
|
+
for (let scrollY2 = 0; scrollY2 < pageHeight; scrollY2 += scrollStep) {
|
|
2307
|
+
await page.evaluate((y) => window.scrollTo(0, y), scrollY2);
|
|
2308
|
+
await page.waitForTimeout(100);
|
|
2309
|
+
}
|
|
2310
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
2311
|
+
await page.waitForTimeout(200);
|
|
2312
|
+
pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
2313
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
2314
|
+
await page.waitForTimeout(100);
|
|
2315
|
+
const scrollY = await page.evaluate(() => window.scrollY);
|
|
2316
|
+
if (scrollY !== 0) {
|
|
2317
|
+
console.warn(` Warning: Could not scroll to top (scrollY=${scrollY})`);
|
|
2318
|
+
}
|
|
2319
|
+
const fullPagePath = path4.join(pageOutputDir, "full-page.png");
|
|
2320
|
+
await page.screenshot({
|
|
2321
|
+
path: fullPagePath,
|
|
2322
|
+
fullPage: snapshotConfig.captureFullPage
|
|
2323
|
+
});
|
|
2324
|
+
const thumbnailViewport = { width: 640, height: 360 };
|
|
2325
|
+
await page.setViewportSize(thumbnailViewport);
|
|
2326
|
+
await page.waitForTimeout(200);
|
|
2327
|
+
const thumbnailPath = path4.join(pageOutputDir, "thumbnail.png");
|
|
2328
|
+
await page.screenshot({
|
|
2329
|
+
path: thumbnailPath,
|
|
2330
|
+
fullPage: false
|
|
2331
|
+
});
|
|
2332
|
+
await page.setViewportSize(snapshotConfig.viewport);
|
|
2333
|
+
await page.waitForTimeout(100);
|
|
2334
|
+
const sections = [];
|
|
2335
|
+
const seenDomPaths = /* @__PURE__ */ new Set();
|
|
2336
|
+
const getAccurateBounds = async (el, sectionType) => {
|
|
2337
|
+
return el.evaluate((element, type) => {
|
|
2338
|
+
const rect = element.getBoundingClientRect();
|
|
2339
|
+
const style = window.getComputedStyle(element);
|
|
2340
|
+
const position = style.position;
|
|
2341
|
+
const isFixed = position === "fixed" || position === "sticky";
|
|
2342
|
+
let y = rect.top + window.scrollY;
|
|
2343
|
+
if (isFixed && (type === "header" || element.tagName.toLowerCase() === "header")) {
|
|
2344
|
+
y = 0;
|
|
2345
|
+
} else if (isFixed && (type === "footer" || element.tagName.toLowerCase() === "footer")) {
|
|
2346
|
+
y = document.documentElement.scrollHeight - rect.height;
|
|
2347
|
+
}
|
|
2348
|
+
return {
|
|
2349
|
+
x: rect.left + window.scrollX,
|
|
2350
|
+
y,
|
|
2351
|
+
width: rect.width,
|
|
2352
|
+
height: rect.height,
|
|
2353
|
+
isFixed
|
|
2354
|
+
};
|
|
2355
|
+
}, sectionType);
|
|
2356
|
+
};
|
|
2357
|
+
const explicitElements = await page.$$(SECTION_SELECTORS.explicit);
|
|
2358
|
+
for (let i = 0; i < explicitElements.length; i++) {
|
|
2359
|
+
const element = explicitElements[i];
|
|
2360
|
+
const domPath = await getDomPath(element);
|
|
2361
|
+
if (seenDomPaths.has(domPath)) continue;
|
|
2362
|
+
seenDomPaths.add(domPath);
|
|
2363
|
+
const basicMetadata = await element.evaluate((el) => {
|
|
2364
|
+
return {
|
|
2365
|
+
sectionId: el.getAttribute("data-section") || "",
|
|
2366
|
+
sectionLabel: el.getAttribute("data-section-label") || "",
|
|
2367
|
+
sectionType: el.getAttribute("data-section-type") || "",
|
|
2368
|
+
isDynamic: el.hasAttribute("data-dynamic")
|
|
2369
|
+
};
|
|
2370
|
+
});
|
|
2371
|
+
let resolvedType = "content";
|
|
2372
|
+
if (basicMetadata.sectionType) {
|
|
2373
|
+
resolvedType = basicMetadata.sectionType;
|
|
2374
|
+
} else if (["header", "footer"].includes(basicMetadata.sectionId)) {
|
|
2375
|
+
resolvedType = basicMetadata.sectionId;
|
|
2376
|
+
} else if (basicMetadata.sectionId.includes("hero")) {
|
|
2377
|
+
resolvedType = "hero";
|
|
2378
|
+
} else if (basicMetadata.sectionId.includes("gallery")) {
|
|
2379
|
+
resolvedType = "gallery";
|
|
2380
|
+
}
|
|
2381
|
+
const bounds = await getAccurateBounds(element, resolvedType);
|
|
2382
|
+
if (!bounds || bounds.height < 10) continue;
|
|
2383
|
+
const sectionId = basicMetadata.sectionId || `section-${i}`;
|
|
2384
|
+
const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2385
|
+
try {
|
|
2386
|
+
await element.screenshot({ path: sectionPath });
|
|
2387
|
+
} catch {
|
|
2388
|
+
if (verbose) console.log(` Skipped section screenshot: ${sectionId}`);
|
|
2389
|
+
}
|
|
2390
|
+
const isStructuralOnly = ["header", "footer"].includes(resolvedType);
|
|
2391
|
+
let textKeys = [];
|
|
2392
|
+
if (!isStructuralOnly) {
|
|
2393
|
+
textKeys = await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
|
|
2394
|
+
}
|
|
2395
|
+
const isEditable = !isStructuralOnly && textKeys.length > 0;
|
|
2396
|
+
let displayName = basicMetadata.sectionLabel;
|
|
2397
|
+
if (!displayName) {
|
|
2398
|
+
displayName = await generateSectionDisplayName(element, resolvedType, i);
|
|
2399
|
+
}
|
|
2400
|
+
if (verbose) {
|
|
2401
|
+
console.log(
|
|
2402
|
+
` Section: ${displayName} (${sectionId}) at y=${Math.round(bounds.y)}${basicMetadata.isDynamic ? " [dynamic]" : ""} - ${textKeys.length} text keys`
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
sections.push({
|
|
2406
|
+
sectionId,
|
|
2407
|
+
sectionType: resolvedType,
|
|
2408
|
+
displayName,
|
|
2409
|
+
domPath,
|
|
2410
|
+
bounds: {
|
|
2411
|
+
x: Math.round(bounds.x),
|
|
2412
|
+
y: Math.round(bounds.y),
|
|
2413
|
+
width: Math.round(bounds.width),
|
|
2414
|
+
height: Math.round(bounds.height)
|
|
2415
|
+
},
|
|
2416
|
+
textKeys,
|
|
2417
|
+
isEditable,
|
|
2418
|
+
isStructuralOnly
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
for (const [sectionType, selector] of Object.entries(SECTION_SELECTORS)) {
|
|
2422
|
+
if (sectionType === "explicit") continue;
|
|
2423
|
+
const elements = await page.$$(selector);
|
|
2424
|
+
for (let i = 0; i < elements.length; i++) {
|
|
2425
|
+
const element = elements[i];
|
|
2426
|
+
const domPath = await getDomPath(element);
|
|
2427
|
+
if (seenDomPaths.has(domPath)) continue;
|
|
2428
|
+
const isInsideExplicitSection = await element.evaluate((el) => {
|
|
2429
|
+
let current = el.parentElement;
|
|
2430
|
+
while (current) {
|
|
2431
|
+
if (current.dataset?.section) {
|
|
2432
|
+
return true;
|
|
2433
|
+
}
|
|
2434
|
+
current = current.parentElement;
|
|
2435
|
+
}
|
|
2436
|
+
return false;
|
|
2437
|
+
});
|
|
2438
|
+
if (isInsideExplicitSection) {
|
|
2439
|
+
continue;
|
|
2440
|
+
}
|
|
2441
|
+
const isAncestorOfExplicitSection = await element.evaluate((el) => {
|
|
2442
|
+
const explicitSections = el.querySelectorAll("[data-section]");
|
|
2443
|
+
return explicitSections.length > 0;
|
|
2444
|
+
});
|
|
2445
|
+
if (isAncestorOfExplicitSection) {
|
|
2446
|
+
if (verbose) {
|
|
2447
|
+
console.log(` Skipping ${sectionType} container (contains explicit sections)`);
|
|
2448
|
+
}
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
seenDomPaths.add(domPath);
|
|
2452
|
+
const bounds = await getAccurateBounds(element, sectionType);
|
|
2453
|
+
if (!bounds || bounds.height < 10) continue;
|
|
2454
|
+
const sectionId = `${sectionType}-${i}`;
|
|
2455
|
+
const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2456
|
+
try {
|
|
2457
|
+
await element.screenshot({ path: sectionPath });
|
|
2458
|
+
} catch {
|
|
2459
|
+
if (verbose) console.log(` Skipped section screenshot: ${sectionId}`);
|
|
2460
|
+
}
|
|
2461
|
+
const isStructuralOnly = ["header", "footer"].includes(sectionType);
|
|
2462
|
+
const textKeys = isStructuralOnly ? [] : await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
|
|
2463
|
+
if (sectionType === "content" && textKeys.length === 0) {
|
|
2464
|
+
continue;
|
|
2465
|
+
}
|
|
2466
|
+
const isEditable = !isStructuralOnly && textKeys.length > 0;
|
|
2467
|
+
const displayName = await generateSectionDisplayName(element, sectionType, i);
|
|
2468
|
+
if (verbose) {
|
|
2469
|
+
console.log(` Section: ${displayName} at y=${Math.round(bounds.y)}${bounds.isFixed ? " [fixed]" : ""}`);
|
|
2470
|
+
}
|
|
2471
|
+
sections.push({
|
|
2472
|
+
sectionId,
|
|
2473
|
+
sectionType,
|
|
2474
|
+
displayName,
|
|
2475
|
+
domPath,
|
|
2476
|
+
bounds: {
|
|
2477
|
+
x: Math.round(bounds.x),
|
|
2478
|
+
y: Math.round(bounds.y),
|
|
2479
|
+
width: Math.round(bounds.width),
|
|
2480
|
+
height: Math.round(bounds.height)
|
|
2481
|
+
},
|
|
2482
|
+
textKeys,
|
|
2483
|
+
isEditable,
|
|
2484
|
+
isStructuralOnly
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
const hasContentSection = sections.some((s) => s.sectionType === "content");
|
|
2489
|
+
if (!hasContentSection) {
|
|
2490
|
+
if (verbose) console.log(` No content sections found, trying fallback detection...`);
|
|
2491
|
+
const fallbackElements = await detectFallbackContentSections(page, sections);
|
|
2492
|
+
for (let i = 0; i < fallbackElements.length; i++) {
|
|
2493
|
+
const element = fallbackElements[i];
|
|
2494
|
+
const domPath = await getDomPath(element);
|
|
2495
|
+
if (seenDomPaths.has(domPath)) continue;
|
|
2496
|
+
seenDomPaths.add(domPath);
|
|
2497
|
+
const bounds = await element.evaluate((el) => {
|
|
2498
|
+
const rect = el.getBoundingClientRect();
|
|
2499
|
+
return {
|
|
2500
|
+
x: rect.left + window.scrollX,
|
|
2501
|
+
y: rect.top + window.scrollY,
|
|
2502
|
+
width: rect.width,
|
|
2503
|
+
height: rect.height
|
|
2504
|
+
};
|
|
2505
|
+
});
|
|
2506
|
+
if (!bounds || bounds.height < 10) continue;
|
|
2507
|
+
const sectionId = `content-fallback-${i}`;
|
|
2508
|
+
const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2509
|
+
try {
|
|
2510
|
+
await element.screenshot({ path: sectionPath });
|
|
2511
|
+
} catch {
|
|
2512
|
+
if (verbose) console.log(` Skipped fallback section screenshot: ${sectionId}`);
|
|
2513
|
+
}
|
|
2514
|
+
const textKeys = await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
|
|
2515
|
+
const displayName = await generateSectionDisplayName(element, "content", i);
|
|
2516
|
+
if (verbose) {
|
|
2517
|
+
console.log(
|
|
2518
|
+
` Fallback Section: ${displayName} at y=${Math.round(bounds.y)} (${textKeys.length} text keys)`
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
sections.push({
|
|
2522
|
+
sectionId,
|
|
2523
|
+
sectionType: "content",
|
|
2524
|
+
displayName,
|
|
2525
|
+
domPath,
|
|
2526
|
+
bounds: {
|
|
2527
|
+
x: Math.round(bounds.x),
|
|
2528
|
+
y: Math.round(bounds.y),
|
|
2529
|
+
width: Math.round(bounds.width),
|
|
2530
|
+
height: Math.round(bounds.height)
|
|
2531
|
+
},
|
|
2532
|
+
textKeys,
|
|
2533
|
+
isEditable: textKeys.length > 0,
|
|
2534
|
+
isStructuralOnly: false
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
await context.close();
|
|
2539
|
+
sections.sort((a, b) => a.bounds.y - b.bounds.y);
|
|
2540
|
+
return {
|
|
2541
|
+
pageSlug: pageInfo.slug,
|
|
2542
|
+
path: pageInfo.path,
|
|
2543
|
+
type: pageInfo.config.type,
|
|
2544
|
+
deletable: pageInfo.config.deletable,
|
|
2545
|
+
title: title || pageInfo.config.title,
|
|
2546
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2547
|
+
previewUrl: url,
|
|
2548
|
+
viewport: snapshotConfig.viewport,
|
|
2549
|
+
pageHeight,
|
|
2550
|
+
sections
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
async function captureSnapshotsCommand(options) {
|
|
2554
|
+
const { target, baseUrl, dryRun, verbose, pages: targetedPages } = options;
|
|
2555
|
+
const targetDir = path4.resolve(target);
|
|
2556
|
+
console.log(chalk7.blue("\u{1F4F8} Page Snapshot Capture"));
|
|
2557
|
+
console.log(chalk7.gray("========================"));
|
|
2558
|
+
console.log(chalk7.gray(`Target: ${targetDir}`));
|
|
2559
|
+
console.log(chalk7.gray(`Base URL: ${baseUrl}`));
|
|
2560
|
+
process.env.SITE_BASE_URL = baseUrl;
|
|
2561
|
+
let config2;
|
|
2562
|
+
try {
|
|
2563
|
+
config2 = loadPagesConfig(targetDir);
|
|
2564
|
+
} catch (error) {
|
|
2565
|
+
console.error(chalk7.red("Error:"), error.message);
|
|
2566
|
+
console.log(chalk7.yellow("\nMake sure .dcs/pages.yaml exists in the target directory."));
|
|
2567
|
+
process.exit(1);
|
|
2568
|
+
}
|
|
2569
|
+
console.log(chalk7.gray(`Site: ${config2.siteSlug}`));
|
|
2570
|
+
const outputDir = path4.join(targetDir, config2.snapshot.outputDir || ".dcs/snapshots");
|
|
2571
|
+
const contentConfig = loadContentConfig(targetDir);
|
|
2572
|
+
if (contentConfig) {
|
|
2573
|
+
const pageCount = Object.keys(contentConfig.pages || {}).length;
|
|
2574
|
+
const globalCount = Object.keys(contentConfig.global || {}).length;
|
|
2575
|
+
console.log(chalk7.gray(`Content: Loaded ${pageCount} pages, ${globalCount} global keys`));
|
|
2576
|
+
}
|
|
2577
|
+
const targetedConfig = loadTargetedSnapshotsConfig(targetDir);
|
|
2578
|
+
if (targetedConfig?.mode === "skip") {
|
|
2579
|
+
console.log("");
|
|
2580
|
+
console.log(chalk7.yellow("\u23ED\uFE0F Skip mode activated!"));
|
|
2581
|
+
console.log(chalk7.gray(` Reason: ${targetedConfig.reason}`));
|
|
2582
|
+
console.log(chalk7.gray(" No snapshots will be captured."));
|
|
2583
|
+
if (!dryRun) {
|
|
2584
|
+
fs4.mkdirSync(outputDir, { recursive: true });
|
|
2585
|
+
const skipMarker = {
|
|
2586
|
+
skipped: true,
|
|
2587
|
+
reason: targetedConfig.reason,
|
|
2588
|
+
triggeredBy: targetedConfig.triggeredBy,
|
|
2589
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2590
|
+
};
|
|
2591
|
+
fs4.writeFileSync(path4.join(outputDir, "skipped.json"), JSON.stringify(skipMarker, null, 2));
|
|
2592
|
+
}
|
|
2593
|
+
console.log(chalk7.green("\n\u2705 Snapshot capture skipped (as requested)"));
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
let pagesToCapture = await resolveAllPages(config2);
|
|
2597
|
+
console.log(chalk7.gray(`Found ${pagesToCapture.length} total pages in configuration`));
|
|
2598
|
+
if (targetedPages && targetedPages.length > 0) {
|
|
2599
|
+
const targetedSlugs = new Set(targetedPages);
|
|
2600
|
+
const originalCount = pagesToCapture.length;
|
|
2601
|
+
pagesToCapture = pagesToCapture.filter((p) => targetedSlugs.has(p.slug));
|
|
2602
|
+
console.log("");
|
|
2603
|
+
console.log(chalk7.cyan(`\u{1F3AF} Targeted mode: Filtering to ${pagesToCapture.length} of ${originalCount} pages`));
|
|
2604
|
+
} else if (targetedConfig?.mode === "targeted" && targetedConfig.pages.length > 0) {
|
|
2605
|
+
const targetedSlugs = new Set(targetedConfig.pages);
|
|
2606
|
+
const originalCount = pagesToCapture.length;
|
|
2607
|
+
pagesToCapture = pagesToCapture.filter((p) => targetedSlugs.has(p.slug));
|
|
2608
|
+
console.log("");
|
|
2609
|
+
console.log(chalk7.cyan(`\u{1F3AF} Targeted mode: Filtering to ${pagesToCapture.length} of ${originalCount} pages`));
|
|
2610
|
+
console.log(chalk7.gray(` Reason: ${targetedConfig.reason}`));
|
|
2611
|
+
}
|
|
2612
|
+
console.log("");
|
|
2613
|
+
console.log("Pages to capture:");
|
|
2614
|
+
for (const p of pagesToCapture) {
|
|
2615
|
+
console.log(chalk7.gray(` - ${p.slug} (${p.config.type}) ${p.path}`));
|
|
2616
|
+
}
|
|
2617
|
+
if (pagesToCapture.length === 0) {
|
|
2618
|
+
console.log(chalk7.yellow("\n\u26A0\uFE0F No pages to capture!"));
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
if (dryRun) {
|
|
2622
|
+
console.log(chalk7.yellow("\n--dry-run: Stopping before browser launch"));
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
const { chromium } = await import("playwright");
|
|
2626
|
+
if (fs4.existsSync(outputDir)) {
|
|
2627
|
+
fs4.rmSync(outputDir, { recursive: true });
|
|
2628
|
+
}
|
|
2629
|
+
fs4.mkdirSync(outputDir, { recursive: true });
|
|
2630
|
+
const spinner = ora3("Launching browser...").start();
|
|
2631
|
+
const browser = await chromium.launch();
|
|
2632
|
+
spinner.succeed("Browser launched");
|
|
2633
|
+
const snapshots = [];
|
|
2634
|
+
const failures = [];
|
|
2635
|
+
for (const pageInfo of pagesToCapture) {
|
|
2636
|
+
const pageSpinner = ora3(`Capturing ${pageInfo.slug}...`).start();
|
|
2637
|
+
try {
|
|
2638
|
+
const validTextKeys = getValidTextKeysForPage(contentConfig, pageInfo.slug);
|
|
2639
|
+
const snapshot = await capturePageSnapshot(
|
|
2640
|
+
browser,
|
|
2641
|
+
pageInfo,
|
|
2642
|
+
config2.snapshot,
|
|
2643
|
+
config2.siteSlug,
|
|
2644
|
+
validTextKeys,
|
|
2645
|
+
outputDir,
|
|
2646
|
+
verbose || false
|
|
2647
|
+
);
|
|
2648
|
+
snapshots.push(snapshot);
|
|
2649
|
+
const snapshotPath = path4.join(outputDir, config2.siteSlug, pageInfo.slug, "snapshot.json");
|
|
2650
|
+
fs4.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
|
|
2651
|
+
pageSpinner.succeed(`${pageInfo.slug}: ${snapshot.sections.length} sections captured`);
|
|
2652
|
+
} catch (error) {
|
|
2653
|
+
const message = `${pageInfo.slug}: ${error.message}`;
|
|
2654
|
+
failures.push(message);
|
|
2655
|
+
pageSpinner.fail(`${pageInfo.slug}: Failed - ${error.message}`);
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
await browser.close();
|
|
2659
|
+
const manifest = {
|
|
2660
|
+
siteSlug: config2.siteSlug,
|
|
2661
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2662
|
+
capturedVersion: process.env.GITHUB_SHA,
|
|
2663
|
+
deploymentRef: process.env.GITHUB_REF || "local",
|
|
2664
|
+
pagesConfigVersion: config2.version,
|
|
2665
|
+
pages: snapshots.map((s) => ({
|
|
2666
|
+
pageSlug: s.pageSlug,
|
|
2667
|
+
pagePath: s.path,
|
|
2668
|
+
pageType: s.type,
|
|
2669
|
+
deletable: s.deletable,
|
|
2670
|
+
title: s.title,
|
|
2671
|
+
capturedAt: s.capturedAt,
|
|
2672
|
+
sectionCount: s.sections.length,
|
|
2673
|
+
hasSnapshot: true
|
|
2674
|
+
}))
|
|
2675
|
+
};
|
|
2676
|
+
const manifestPath = path4.join(outputDir, config2.siteSlug, "manifest.json");
|
|
2677
|
+
fs4.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
2678
|
+
console.log("");
|
|
2679
|
+
console.log(chalk7.green("\u2705 Snapshot capture complete!"));
|
|
2680
|
+
console.log(chalk7.gray(` Captured ${snapshots.length} pages`));
|
|
2681
|
+
console.log(chalk7.gray(` - Static: ${snapshots.filter((s) => s.type === "static").length}`));
|
|
2682
|
+
console.log(chalk7.gray(` - Index: ${snapshots.filter((s) => s.type === "index").length}`));
|
|
2683
|
+
console.log(chalk7.gray(` - Dynamic: ${snapshots.filter((s) => s.type === "dynamic").length}`));
|
|
2684
|
+
console.log(chalk7.gray(` Output: ${outputDir}`));
|
|
2685
|
+
if (failures.length > 0) {
|
|
2686
|
+
throw new Error(`Snapshot capture failed for ${failures.length} page(s): ${failures.join("; ")}`);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2085
2690
|
// src/index.ts
|
|
2086
2691
|
var program = new Command();
|
|
2087
2692
|
program.name("dcs").description("DCS (Duff Cloud Services) CLI for customer site management").version(version);
|
|
@@ -2124,6 +2729,16 @@ program.command("plans").description("Generate DCS integration plan files for AI
|
|
|
2124
2729
|
force: options.force
|
|
2125
2730
|
});
|
|
2126
2731
|
});
|
|
2732
|
+
program.command("capture-snapshots").description("Capture visual snapshots of site pages for the portal page editor").option("-t, --target <dir>", "Target directory containing .dcs/pages.yaml", ".").option("-u, --base-url <url>", "Base URL of the running site", "http://localhost:5173").option("-p, --pages <slugs>", "Comma-separated list of page slugs to capture").option("--dry-run", "Show what would be captured without launching browser").option("-v, --verbose", "Show detailed output").action(async (options) => {
|
|
2733
|
+
const pages = options.pages ? options.pages.split(",").map((s) => s.trim()) : void 0;
|
|
2734
|
+
await captureSnapshotsCommand({
|
|
2735
|
+
target: options.target,
|
|
2736
|
+
baseUrl: options.baseUrl,
|
|
2737
|
+
dryRun: options.dryRun,
|
|
2738
|
+
verbose: options.verbose,
|
|
2739
|
+
pages
|
|
2740
|
+
});
|
|
2741
|
+
});
|
|
2127
2742
|
program.exitOverride();
|
|
2128
2743
|
try {
|
|
2129
2744
|
await program.parseAsync(process.argv);
|
|
@@ -2132,7 +2747,7 @@ try {
|
|
|
2132
2747
|
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
|
|
2133
2748
|
process.exit(0);
|
|
2134
2749
|
}
|
|
2135
|
-
console.error(
|
|
2750
|
+
console.error(chalk8.red("Error:"), error.message);
|
|
2136
2751
|
process.exit(1);
|
|
2137
2752
|
}
|
|
2138
2753
|
}
|