heroshot 0.5.0 → 0.6.1
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 +26 -68
- package/dist/cli.js +175 -48
- package/editor/dist/editor.js +348 -125
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -18,24 +18,22 @@ The manual fix is tedious: open browser, navigate, log in, screenshot, crop, sav
|
|
|
18
18
|
|
|
19
19
|
**Heroshot fixes this.** Define your screenshots once - point and click, no CSS selectors - and regenerate them with one command whenever you need.
|
|
20
20
|
|
|
21
|
+
```bash
|
|
22
|
+
npx heroshot
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
First run opens a browser with a visual picker. Click what you want, name it, done. Screenshots land in `heroshots/`, config saves to `.heroshot/config.json`. Next run regenerates everything headlessly.
|
|
26
|
+
|
|
21
27
|
<table align="center">
|
|
22
28
|
<tr>
|
|
23
|
-
<th></th>
|
|
24
|
-
<th>Light</th>
|
|
25
|
-
<th>Dark</th>
|
|
26
|
-
</tr>
|
|
27
|
-
<tr>
|
|
28
|
-
<th>Desktop</th>
|
|
29
29
|
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-light.png?raw=true" alt="Desktop Light"></td>
|
|
30
30
|
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-dark.png?raw=true" alt="Desktop Dark"></td>
|
|
31
31
|
</tr>
|
|
32
32
|
<tr>
|
|
33
|
-
<th>Tablet</th>
|
|
34
33
|
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-light.png?raw=true" alt="Tablet Light"></td>
|
|
35
34
|
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-dark.png?raw=true" alt="Tablet Dark"></td>
|
|
36
35
|
</tr>
|
|
37
36
|
<tr>
|
|
38
|
-
<th>Mobile</th>
|
|
39
37
|
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-light.png?raw=true" alt="Mobile Light"></td>
|
|
40
38
|
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-dark.png?raw=true" alt="Mobile Dark"></td>
|
|
41
39
|
</tr>
|
|
@@ -43,17 +41,9 @@ The manual fix is tedious: open browser, navigate, log in, screenshot, crop, sav
|
|
|
43
41
|
|
|
44
42
|
<p align="center"><em>6 screenshots from one config entry - always in sync with the live site.</em></p>
|
|
45
43
|
|
|
46
|
-
## Get Started
|
|
47
|
-
|
|
48
|
-
```bash
|
|
49
|
-
npx heroshot
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
First run opens a browser with a visual picker. Click what you want, name it, done. Screenshots land in `heroshots/`, config saves to `.heroshot/config.json`. Next run regenerates everything headlessly.
|
|
53
|
-
|
|
54
44
|
## Use in Your Docs
|
|
55
45
|
|
|
56
|
-
**VitePress**
|
|
46
|
+
**VitePress** · [Full guide](https://heroshot.sh/docs/integrations/vitepress)
|
|
57
47
|
|
|
58
48
|
```ts
|
|
59
49
|
// .vitepress/config.ts
|
|
@@ -62,26 +52,26 @@ export default defineConfig({ vite: { plugins: [heroshot()] } });
|
|
|
62
52
|
```
|
|
63
53
|
|
|
64
54
|
```vue
|
|
55
|
+
<script setup>
|
|
56
|
+
import { Heroshot } from 'heroshot/vue';
|
|
57
|
+
</script>
|
|
58
|
+
|
|
65
59
|
<Heroshot name="dashboard" alt="Dashboard" />
|
|
66
60
|
```
|
|
67
61
|
|
|
68
|
-
**Docusaurus**
|
|
62
|
+
**Docusaurus** · [Full guide](https://heroshot.sh/docs/integrations/docusaurus)
|
|
69
63
|
|
|
70
64
|
```js
|
|
71
65
|
// docusaurus.config.js
|
|
72
66
|
plugins: [['heroshot/plugins/docusaurus', {}]];
|
|
73
67
|
```
|
|
74
68
|
|
|
75
|
-
```
|
|
69
|
+
```tsx
|
|
76
70
|
import { Heroshot } from 'heroshot/docusaurus';
|
|
77
|
-
<Heroshot name="dashboard" alt="Dashboard"
|
|
71
|
+
<Heroshot name="dashboard" alt="Dashboard" />;
|
|
78
72
|
```
|
|
79
73
|
|
|
80
|
-
**MkDocs**
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
pip install heroshot
|
|
84
|
-
```
|
|
74
|
+
**MkDocs** · [Full guide](https://heroshot.sh/docs/integrations/mkdocs)
|
|
85
75
|
|
|
86
76
|
```yaml
|
|
87
77
|
# mkdocs.yml
|
|
@@ -96,53 +86,21 @@ plugins:
|
|
|
96
86
|
|
|
97
87
|
One component/macro, all variants - light/dark mode switches automatically, responsive sizes via srcset.
|
|
98
88
|
|
|
99
|
-
##
|
|
100
|
-
|
|
101
|
-
Keep screenshots always current by running heroshot in CI. Quick setup:
|
|
102
|
-
|
|
103
|
-
```bash
|
|
104
|
-
# Get your session key (for authenticated sites)
|
|
105
|
-
npx heroshot session-key
|
|
106
|
-
|
|
107
|
-
# Add to GitHub secrets
|
|
108
|
-
gh secret set HEROSHOT_SESSION_KEY
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
Then create `.github/workflows/heroshot.yml`:
|
|
112
|
-
|
|
113
|
-
```yaml
|
|
114
|
-
name: Update Screenshots
|
|
115
|
-
|
|
116
|
-
on:
|
|
117
|
-
workflow_dispatch:
|
|
118
|
-
|
|
119
|
-
jobs:
|
|
120
|
-
screenshots:
|
|
121
|
-
runs-on: ubuntu-latest
|
|
122
|
-
steps:
|
|
123
|
-
- uses: actions/checkout@v4
|
|
124
|
-
- uses: actions/setup-node@v4
|
|
125
|
-
with:
|
|
126
|
-
node-version: 20
|
|
127
|
-
- run: npx heroshot
|
|
128
|
-
env:
|
|
129
|
-
HEROSHOT_SESSION_KEY: ${{ secrets.HEROSHOT_SESSION_KEY }}
|
|
130
|
-
- run: |
|
|
131
|
-
git config user.name "github-actions[bot]"
|
|
132
|
-
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
133
|
-
git add heroshots/
|
|
134
|
-
git diff --staged --quiet || git commit -m "chore: update screenshots" && git push
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
Go to Actions → Update Screenshots → Run workflow. Done.
|
|
89
|
+
## Learn More
|
|
138
90
|
|
|
139
|
-
|
|
91
|
+
| | |
|
|
92
|
+
| ------------------- | --------------------------------------------------------------------- |
|
|
93
|
+
| **Documentation** | [heroshot.sh](https://heroshot.sh) |
|
|
94
|
+
| **Getting Started** | [Quick start guide](https://heroshot.sh/docs/getting-started) |
|
|
95
|
+
| **Configuration** | [Config options](https://heroshot.sh/docs/config) |
|
|
96
|
+
| **CI/CD Setup** | [Automated updates](https://heroshot.sh/docs/guide/automated-updates) |
|
|
97
|
+
| **CLI Reference** | [All commands & flags](https://heroshot.sh/docs/cli) |
|
|
140
98
|
|
|
141
|
-
##
|
|
99
|
+
## Contributing
|
|
142
100
|
|
|
143
|
-
|
|
101
|
+
This is a community project aiming to solve screenshot automation end-to-end and any feedback is valuable. Open an [issue](https://github.com/omachala/heroshot/issues) for bugs, questions, or feature requests. Pull requests are more than welcome.
|
|
144
102
|
|
|
145
|
-
|
|
103
|
+
If you like it, give the repo a ⭐
|
|
146
104
|
|
|
147
105
|
## License
|
|
148
106
|
|
package/dist/cli.js
CHANGED
|
@@ -46,6 +46,8 @@ var scrollPositionSchema = z.object({
|
|
|
46
46
|
x: z.number().int().min(0).default(0),
|
|
47
47
|
y: z.number().int().min(0).default(0)
|
|
48
48
|
});
|
|
49
|
+
var paddingFillSchema = z.enum(["inherit", "solid", "transparent"]);
|
|
50
|
+
var elementFillSchema = z.enum(["original", "solid", "transparent"]);
|
|
49
51
|
var viewportVariantSchema = z.string().refine(
|
|
50
52
|
(value) => {
|
|
51
53
|
if (value in VIEWPORT_PRESETS) return true;
|
|
@@ -66,8 +68,10 @@ var screenshotSchema = z.object({
|
|
|
66
68
|
padding: paddingSchema.optional(),
|
|
67
69
|
/** Scroll position to restore when capturing */
|
|
68
70
|
scroll: scrollPositionSchema.optional(),
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
+
/** Background fill mode for padding area */
|
|
72
|
+
paddingFill: paddingFillSchema.optional(),
|
|
73
|
+
/** Background fill mode for element area */
|
|
74
|
+
elementFill: elementFillSchema.optional(),
|
|
71
75
|
/** Viewport variants - generates screenshot for each (e.g., ["desktop", "mobile", "400x500"]) */
|
|
72
76
|
viewports: z.array(viewportVariantSchema).optional(),
|
|
73
77
|
/** Text overrides - selector (relative to main element) -> replacement text */
|
|
@@ -108,6 +112,8 @@ var shotCliOptionsSchema = z.object({
|
|
|
108
112
|
quality: z.number().int().min(1).max(100).optional(),
|
|
109
113
|
/** Omit background for transparent PNG */
|
|
110
114
|
omitBackground: z.boolean().optional(),
|
|
115
|
+
/** Capture only viewport instead of full page */
|
|
116
|
+
viewportOnly: z.boolean().optional(),
|
|
111
117
|
/** Timeout in milliseconds */
|
|
112
118
|
timeout: z.number().int().positive().optional()
|
|
113
119
|
});
|
|
@@ -415,7 +421,8 @@ function toConfigScreenshot(data) {
|
|
|
415
421
|
selector: data.selector,
|
|
416
422
|
...data.padding && { padding: data.padding },
|
|
417
423
|
...data.scroll && { scroll: data.scroll },
|
|
418
|
-
...data.
|
|
424
|
+
...data.paddingFill && { paddingFill: data.paddingFill },
|
|
425
|
+
...data.elementFill && { elementFill: data.elementFill },
|
|
419
426
|
...data.textOverrides && Object.keys(data.textOverrides).length > 0 && { textOverrides: data.textOverrides }
|
|
420
427
|
};
|
|
421
428
|
}
|
|
@@ -502,7 +509,8 @@ async function setup(options = {}) {
|
|
|
502
509
|
createdAt: index,
|
|
503
510
|
...screenshot.padding && { padding: screenshot.padding },
|
|
504
511
|
...screenshot.scroll && { scroll: screenshot.scroll },
|
|
505
|
-
...screenshot.
|
|
512
|
+
...screenshot.paddingFill && { paddingFill: screenshot.paddingFill },
|
|
513
|
+
...screenshot.elementFill && { elementFill: screenshot.elementFill },
|
|
506
514
|
...screenshot.textOverrides && { textOverrides: screenshot.textOverrides }
|
|
507
515
|
}));
|
|
508
516
|
let pendingJob = null;
|
|
@@ -784,6 +792,58 @@ async function injectPaddingMask(page, element, padding, bgColor) {
|
|
|
784
792
|
async function removePaddingMask(page) {
|
|
785
793
|
await page.evaluate(`document.querySelector('#heroshot-padding-mask')?.remove()`);
|
|
786
794
|
}
|
|
795
|
+
async function applyElementBackground(page, selector, bgColor) {
|
|
796
|
+
await page.evaluate(`
|
|
797
|
+
(() => {
|
|
798
|
+
const selector = ${JSON.stringify(selector)};
|
|
799
|
+
const bgColor = ${JSON.stringify(bgColor)};
|
|
800
|
+
|
|
801
|
+
const parts = selector.split('>>>').map((p) => p.trim());
|
|
802
|
+
let current = document;
|
|
803
|
+
|
|
804
|
+
for (const part of parts) {
|
|
805
|
+
if (!part) continue;
|
|
806
|
+
const root = current instanceof Element ? (current.shadowRoot ?? current) : current;
|
|
807
|
+
const found = root.querySelector(part);
|
|
808
|
+
if (!found) return;
|
|
809
|
+
current = found;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (!(current instanceof Element)) return;
|
|
813
|
+
|
|
814
|
+
// Store original background for restoration
|
|
815
|
+
current.dataset.heroshotOriginalBg = current.style.backgroundColor;
|
|
816
|
+
current.style.backgroundColor = bgColor;
|
|
817
|
+
})()
|
|
818
|
+
`);
|
|
819
|
+
}
|
|
820
|
+
async function restoreElementBackground(page, selector) {
|
|
821
|
+
await page.evaluate(`
|
|
822
|
+
(() => {
|
|
823
|
+
const selector = ${JSON.stringify(selector)};
|
|
824
|
+
|
|
825
|
+
const parts = selector.split('>>>').map((p) => p.trim());
|
|
826
|
+
let current = document;
|
|
827
|
+
|
|
828
|
+
for (const part of parts) {
|
|
829
|
+
if (!part) continue;
|
|
830
|
+
const root = current instanceof Element ? (current.shadowRoot ?? current) : current;
|
|
831
|
+
const found = root.querySelector(part);
|
|
832
|
+
if (!found) return;
|
|
833
|
+
current = found;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (!(current instanceof Element)) return;
|
|
837
|
+
|
|
838
|
+
// Restore original background
|
|
839
|
+
const original = current.dataset.heroshotOriginalBg;
|
|
840
|
+
if (original !== undefined) {
|
|
841
|
+
current.style.backgroundColor = original;
|
|
842
|
+
delete current.dataset.heroshotOriginalBg;
|
|
843
|
+
}
|
|
844
|
+
})()
|
|
845
|
+
`);
|
|
846
|
+
}
|
|
787
847
|
async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
|
|
788
848
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
789
849
|
const handle = await page.evaluateHandle(`
|
|
@@ -819,22 +879,23 @@ async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
|
|
|
819
879
|
}
|
|
820
880
|
return null;
|
|
821
881
|
}
|
|
822
|
-
async function takeScreenshot(
|
|
882
|
+
async function takeScreenshot(options) {
|
|
883
|
+
const { target, outputPath, format, quality, clip, omitBackground, fullPage = true } = options;
|
|
823
884
|
const isPage = "goto" in target;
|
|
824
885
|
if (format === "jpeg") {
|
|
825
886
|
if (isPage && clip) {
|
|
826
887
|
await target.screenshot({ path: outputPath, type: "jpeg", quality, clip });
|
|
827
888
|
} else if (isPage) {
|
|
828
|
-
await target.screenshot({ path: outputPath, type: "jpeg", quality, fullPage
|
|
889
|
+
await target.screenshot({ path: outputPath, type: "jpeg", quality, fullPage });
|
|
829
890
|
} else {
|
|
830
891
|
await target.screenshot({ path: outputPath, type: "jpeg", quality });
|
|
831
892
|
}
|
|
832
893
|
} else if (isPage && clip) {
|
|
833
|
-
await target.screenshot({ path: outputPath, type: "png", clip });
|
|
894
|
+
await target.screenshot({ path: outputPath, type: "png", clip, omitBackground });
|
|
834
895
|
} else if (isPage) {
|
|
835
|
-
await target.screenshot({ path: outputPath, type: "png", fullPage
|
|
896
|
+
await target.screenshot({ path: outputPath, type: "png", fullPage, omitBackground });
|
|
836
897
|
} else {
|
|
837
|
-
await target.screenshot({ path: outputPath, type: "png" });
|
|
898
|
+
await target.screenshot({ path: outputPath, type: "png", omitBackground });
|
|
838
899
|
}
|
|
839
900
|
}
|
|
840
901
|
async function applyTextOverrides(page, selector, textOverrides) {
|
|
@@ -867,37 +928,60 @@ async function applyTextOverrides(page, selector, textOverrides) {
|
|
|
867
928
|
`);
|
|
868
929
|
await page.waitForTimeout(50);
|
|
869
930
|
}
|
|
931
|
+
async function getElementBackgroundColor(page, selector) {
|
|
932
|
+
const bgColorResult = await page.evaluate(`
|
|
933
|
+
(() => {
|
|
934
|
+
const selector = ${JSON.stringify(selector)};
|
|
935
|
+
const parts = selector.split('>>>').map((p) => p.trim());
|
|
936
|
+
let current = document;
|
|
937
|
+
|
|
938
|
+
for (const part of parts) {
|
|
939
|
+
if (!part) continue;
|
|
940
|
+
const root = current instanceof Element ? (current.shadowRoot ?? current) : current;
|
|
941
|
+
const found = root.querySelector(part);
|
|
942
|
+
if (!found) return '#ffffff';
|
|
943
|
+
current = found;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (!(current instanceof Element)) return '#ffffff';
|
|
947
|
+
|
|
948
|
+
const detectBg = ${GET_BACKGROUND_COLOR_SCRIPT};
|
|
949
|
+
return detectBg(current);
|
|
950
|
+
})()
|
|
951
|
+
`);
|
|
952
|
+
return typeof bgColorResult === "string" ? bgColorResult : "#ffffff";
|
|
953
|
+
}
|
|
870
954
|
async function captureElementScreenshot(options) {
|
|
871
|
-
const {
|
|
955
|
+
const {
|
|
956
|
+
page,
|
|
957
|
+
element,
|
|
958
|
+
selector,
|
|
959
|
+
outputPath,
|
|
960
|
+
format,
|
|
961
|
+
quality,
|
|
962
|
+
padding,
|
|
963
|
+
paddingFill,
|
|
964
|
+
elementFill
|
|
965
|
+
} = options;
|
|
872
966
|
const hasPadding = padding && (padding.top > 0 || padding.right > 0 || padding.bottom > 0 || padding.left > 0);
|
|
967
|
+
const needsTransparent = format === "png" && (paddingFill === "transparent" || elementFill === "transparent");
|
|
968
|
+
const needsBgColor = paddingFill === "solid" || elementFill === "solid";
|
|
969
|
+
let bgColor = "#ffffff";
|
|
970
|
+
if (needsBgColor) {
|
|
971
|
+
bgColor = await getElementBackgroundColor(page, selector);
|
|
972
|
+
verbose(`Detected background color: ${bgColor}`);
|
|
973
|
+
}
|
|
974
|
+
if (elementFill === "solid") {
|
|
975
|
+
await applyElementBackground(page, selector, bgColor);
|
|
976
|
+
} else if (elementFill === "transparent") {
|
|
977
|
+
await applyElementBackground(page, selector, "transparent");
|
|
978
|
+
}
|
|
873
979
|
if (hasPadding) {
|
|
874
980
|
const box = await element.boundingBox();
|
|
875
981
|
if (!box) {
|
|
876
982
|
return { success: false, error: "Could not get element bounding box" };
|
|
877
983
|
}
|
|
878
|
-
if (
|
|
879
|
-
const bgColorResult = await page.evaluate(`
|
|
880
|
-
(() => {
|
|
881
|
-
const selector = ${JSON.stringify(selector)};
|
|
882
|
-
const parts = selector.split('>>>').map((p) => p.trim());
|
|
883
|
-
let current = document;
|
|
884
|
-
|
|
885
|
-
for (const part of parts) {
|
|
886
|
-
if (!part) continue;
|
|
887
|
-
const root = current instanceof Element ? (current.shadowRoot ?? current) : current;
|
|
888
|
-
const found = root.querySelector(part);
|
|
889
|
-
if (!found) return '#ffffff';
|
|
890
|
-
current = found;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
if (!(current instanceof Element)) return '#ffffff';
|
|
894
|
-
|
|
895
|
-
const detectBg = ${GET_BACKGROUND_COLOR_SCRIPT};
|
|
896
|
-
return detectBg(current);
|
|
897
|
-
})()
|
|
898
|
-
`);
|
|
899
|
-
const bgColor = typeof bgColorResult === "string" ? bgColorResult : "#ffffff";
|
|
900
|
-
verbose(`Background color: ${bgColor}`);
|
|
984
|
+
if (paddingFill === "solid") {
|
|
901
985
|
await injectPaddingMask(page, element, padding, bgColor);
|
|
902
986
|
}
|
|
903
987
|
const clip = {
|
|
@@ -906,18 +990,34 @@ async function captureElementScreenshot(options) {
|
|
|
906
990
|
width: box.width + padding.left + padding.right,
|
|
907
991
|
height: box.height + padding.top + padding.bottom
|
|
908
992
|
};
|
|
909
|
-
await takeScreenshot(
|
|
910
|
-
|
|
993
|
+
await takeScreenshot({
|
|
994
|
+
target: page,
|
|
995
|
+
outputPath,
|
|
996
|
+
format,
|
|
997
|
+
quality,
|
|
998
|
+
clip,
|
|
999
|
+
omitBackground: needsTransparent
|
|
1000
|
+
});
|
|
1001
|
+
if (paddingFill === "solid") {
|
|
911
1002
|
await removePaddingMask(page);
|
|
912
1003
|
}
|
|
913
1004
|
} else {
|
|
914
|
-
await takeScreenshot(
|
|
1005
|
+
await takeScreenshot({
|
|
1006
|
+
target: element,
|
|
1007
|
+
outputPath,
|
|
1008
|
+
format,
|
|
1009
|
+
quality,
|
|
1010
|
+
omitBackground: needsTransparent
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
if (elementFill === "solid" || elementFill === "transparent") {
|
|
1014
|
+
await restoreElementBackground(page, selector);
|
|
915
1015
|
}
|
|
916
1016
|
return { success: true };
|
|
917
1017
|
}
|
|
918
1018
|
async function captureScreenshot(page, screenshot, outputDirectory, captureOptions, variant = {}) {
|
|
919
|
-
const { name, url, selector, padding, scroll,
|
|
920
|
-
const { format, quality } = captureOptions;
|
|
1019
|
+
const { name, url, selector, padding, scroll, paddingFill, elementFill, textOverrides } = screenshot;
|
|
1020
|
+
const { format, quality, fullPage } = captureOptions;
|
|
921
1021
|
const filename = generateScreenshotFilename({
|
|
922
1022
|
name,
|
|
923
1023
|
viewport: variant.viewportName,
|
|
@@ -926,12 +1026,27 @@ async function captureScreenshot(page, screenshot, outputDirectory, captureOptio
|
|
|
926
1026
|
});
|
|
927
1027
|
const suffix = [variant.viewportName, variant.colorScheme].filter(Boolean).join("-");
|
|
928
1028
|
verbose(`Capturing: ${name}${suffix ? ` (${suffix})` : ""}`);
|
|
1029
|
+
if (variant.colorScheme) {
|
|
1030
|
+
await page.emulateMedia({ colorScheme: variant.colorScheme });
|
|
1031
|
+
}
|
|
929
1032
|
try {
|
|
930
1033
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
931
1034
|
} catch (error2) {
|
|
932
1035
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
933
1036
|
return { success: false, error: `Failed to navigate: ${message}`, filename };
|
|
934
1037
|
}
|
|
1038
|
+
if (variant.colorScheme) {
|
|
1039
|
+
await page.evaluate(`
|
|
1040
|
+
(() => {
|
|
1041
|
+
const isDark = ${variant.colorScheme === "dark"};
|
|
1042
|
+
if (isDark) {
|
|
1043
|
+
document.documentElement.classList.add('dark');
|
|
1044
|
+
} else {
|
|
1045
|
+
document.documentElement.classList.remove('dark');
|
|
1046
|
+
}
|
|
1047
|
+
})()
|
|
1048
|
+
`);
|
|
1049
|
+
}
|
|
935
1050
|
await page.waitForTimeout(2e3);
|
|
936
1051
|
if (scroll) {
|
|
937
1052
|
await page.evaluate(`window.scrollTo(${scroll.x}, ${scroll.y})`);
|
|
@@ -959,13 +1074,14 @@ async function captureScreenshot(page, screenshot, outputDirectory, captureOptio
|
|
|
959
1074
|
format,
|
|
960
1075
|
quality,
|
|
961
1076
|
padding,
|
|
962
|
-
|
|
1077
|
+
paddingFill,
|
|
1078
|
+
elementFill
|
|
963
1079
|
});
|
|
964
1080
|
if (!captureResult.success) {
|
|
965
1081
|
return { ...captureResult, filename };
|
|
966
1082
|
}
|
|
967
1083
|
} else {
|
|
968
|
-
await takeScreenshot(page, outputPath, format, quality);
|
|
1084
|
+
await takeScreenshot({ target: page, outputPath, format, quality, fullPage });
|
|
969
1085
|
}
|
|
970
1086
|
} catch (error2) {
|
|
971
1087
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
@@ -1063,7 +1179,11 @@ function showResults(results, outputDirectory, staleFiles, deletedFiles) {
|
|
|
1063
1179
|
} else if (staleFiles.length > 0) {
|
|
1064
1180
|
parts.push(colors.dim(`${staleFiles.length} stale`));
|
|
1065
1181
|
}
|
|
1066
|
-
outro(parts.join(", ")
|
|
1182
|
+
outro(parts.join(", "));
|
|
1183
|
+
for (const result of successfulResults) {
|
|
1184
|
+
const fullPath = path4.join(outputDirectory, result.filename);
|
|
1185
|
+
log(` ${colors.dim(fullPath)}`);
|
|
1186
|
+
}
|
|
1067
1187
|
if (staleFiles.length > 0 && deletedFiles.length === 0) {
|
|
1068
1188
|
warn(`Stale files found: ${staleFiles.join(", ")}`);
|
|
1069
1189
|
verbose("Run with --clean to delete stale files");
|
|
@@ -1113,7 +1233,9 @@ async function sync(options = {}) {
|
|
|
1113
1233
|
const schemes = getColorSchemes(colorSchemeSetting);
|
|
1114
1234
|
const captureOptions = {
|
|
1115
1235
|
format: config.outputFormat ?? "png",
|
|
1116
|
-
quality: config.jpegQuality
|
|
1236
|
+
quality: config.jpegQuality,
|
|
1237
|
+
fullPage: !options.viewportOnly
|
|
1238
|
+
// Default true, false when --viewport-only
|
|
1117
1239
|
};
|
|
1118
1240
|
const defaultViewport = config.browser?.viewport ?? { width: 1280, height: 800 };
|
|
1119
1241
|
const deviceScaleFactor = config.browser?.deviceScaleFactor;
|
|
@@ -1197,7 +1319,7 @@ async function sync(options = {}) {
|
|
|
1197
1319
|
captureSpinner.stop("Screenshots captured");
|
|
1198
1320
|
let staleFiles = [];
|
|
1199
1321
|
let deletedFiles = [];
|
|
1200
|
-
if (!filterPattern) {
|
|
1322
|
+
if (!filterPattern && !options.skipStaleCheck) {
|
|
1201
1323
|
const existingFiles = getExistingFiles(outputDirectory);
|
|
1202
1324
|
const writtenFiles = new Set(
|
|
1203
1325
|
results.filter(({ success }) => success).map(({ filename }) => filename)
|
|
@@ -1263,10 +1385,11 @@ function buildScreenshotEntry(url, options) {
|
|
|
1263
1385
|
}
|
|
1264
1386
|
return screenshot;
|
|
1265
1387
|
}
|
|
1266
|
-
function getColorScheme(options) {
|
|
1388
|
+
function getColorScheme(options, bothVariants) {
|
|
1389
|
+
if (options?.dark && options?.light) return void 0;
|
|
1267
1390
|
if (options?.dark) return "dark";
|
|
1268
1391
|
if (options?.light) return "light";
|
|
1269
|
-
return void 0;
|
|
1392
|
+
return bothVariants ? void 0 : "light";
|
|
1270
1393
|
}
|
|
1271
1394
|
function getDeviceScaleFactor(options, existingConfig) {
|
|
1272
1395
|
if (options?.retina) return 2;
|
|
@@ -1295,7 +1418,8 @@ function buildShotConfig(url, options, existingConfig) {
|
|
|
1295
1418
|
jpegQuality: options?.quality ?? existingConfig?.jpegQuality ?? 80,
|
|
1296
1419
|
browser: {
|
|
1297
1420
|
viewport: getViewport(options, existingConfig),
|
|
1298
|
-
colorScheme: getColorScheme(options),
|
|
1421
|
+
colorScheme: getColorScheme(options, false),
|
|
1422
|
+
// false = oneshot mode, default to light-only
|
|
1299
1423
|
deviceScaleFactor: getDeviceScaleFactor(options, existingConfig)
|
|
1300
1424
|
},
|
|
1301
1425
|
screenshots: [screenshot]
|
|
@@ -1326,7 +1450,10 @@ async function handleUrlCapture(url, options, configPath, sessionKey) {
|
|
|
1326
1450
|
const result = await sync({
|
|
1327
1451
|
config: shotConfig,
|
|
1328
1452
|
outputDirectory,
|
|
1329
|
-
sessionKey
|
|
1453
|
+
sessionKey,
|
|
1454
|
+
skipStaleCheck: true,
|
|
1455
|
+
// Don't check for stale files in oneshot mode
|
|
1456
|
+
viewportOnly: options?.viewportOnly
|
|
1330
1457
|
});
|
|
1331
1458
|
if (options?.save && result.failed === 0) {
|
|
1332
1459
|
const screenshot = shotConfig.screenshots[0];
|
|
@@ -1352,7 +1479,7 @@ async function handleDefaultCommand(configPath, sessionKey, hasExplicitConfig, c
|
|
|
1352
1479
|
}
|
|
1353
1480
|
return true;
|
|
1354
1481
|
}
|
|
1355
|
-
program.command("shot [url]", { isDefault: true
|
|
1482
|
+
program.command("shot [url]", { isDefault: true }).description("Capture URL directly, or sync all screenshots from config").option("--selector <selector...>", "CSS selector(s) to capture").option("-o, --output <file>", "Output filename").option("-p, --padding <pixels>", "Padding around element", parseInt).option("-w, --width <pixels>", "Viewport width", parseInt).option("--height <pixels>", "Viewport height", parseInt).option("--mobile", "Use mobile viewport (375x667)").option("--tablet", "Use tablet viewport (768x1024)").option("--desktop", "Use desktop viewport (1280x800)").option("--dark", "Force dark color scheme").option("--light", "Force light color scheme").option("--scale <factor>", "Device scale factor (1, 2, 3)", parseInt).option("--retina", "Use retina scale (2x)").option("-q, --quality <percent>", "JPEG quality (1-100), outputs JPEG", parseInt).option("--omit-background", "Transparent background (PNG only)").option("--viewport-only", "Capture only viewport (not full page)").option("--timeout <ms>", "Timeout in milliseconds", parseInt).option("--save", "Save screenshot definition to config").option("--clean", "Delete stale files in output directory").action(async (url, options) => {
|
|
1356
1483
|
const globalOptions = program.opts();
|
|
1357
1484
|
const configPath = globalOptions.config ? path5.resolve(globalOptions.config) : getConfigPath();
|
|
1358
1485
|
if (url?.startsWith("http")) {
|