image-exporter 0.0.1 → 1.0.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.
Files changed (56) hide show
  1. package/README.md +137 -2
  2. package/bun.lockb +0 -0
  3. package/dist/image-exporter.es.js +511 -612
  4. package/dist/image-exporter.umd.js +518 -619
  5. package/package.json +18 -15
  6. package/src/capture/capture-element.ts +78 -0
  7. package/src/capture/determine-total-elements.ts +40 -0
  8. package/src/capture/download-images.ts +69 -0
  9. package/src/capture/get-image-options.ts +196 -0
  10. package/src/capture/handle-filenames.ts +52 -0
  11. package/src/capture/index.ts +99 -0
  12. package/src/capture/remove-hidden-elements.ts +19 -0
  13. package/src/config.ts +19 -0
  14. package/src/cors-proxy/cleanup.ts +43 -0
  15. package/src/cors-proxy/index.ts +6 -21
  16. package/src/{utils → cors-proxy}/is-valid-url.ts +5 -3
  17. package/src/cors-proxy/proxy-css.ts +51 -44
  18. package/src/cors-proxy/proxy-images.ts +21 -77
  19. package/src/cors-proxy/run.ts +26 -0
  20. package/src/index.ts +10 -4
  21. package/src/logger.ts +61 -0
  22. package/src/types.d.ts +51 -0
  23. package/vite.config.js +3 -7
  24. package/example/example.css +0 -122
  25. package/example/example.html +0 -152
  26. package/example/github.jpg +0 -0
  27. package/example/poll-h.svg +0 -1
  28. package/src/capture-images.ts +0 -129
  29. package/src/clean-up.ts +0 -50
  30. package/src/default-options.ts +0 -58
  31. package/src/download-images.ts +0 -52
  32. package/src/get-capture-element.test.html +0 -21
  33. package/src/get-capture-element.test.ts +0 -36
  34. package/src/get-capture-element.ts +0 -175
  35. package/src/get-options/get-input-options.test.html +0 -217
  36. package/src/get-options/get-input-options.test.ts +0 -109
  37. package/src/get-options/get-input-options.ts +0 -40
  38. package/src/get-options/get-item-options.ts +0 -46
  39. package/src/get-options/get-wrapper-options.test.html +0 -33
  40. package/src/get-options/get-wrapper-options.test.ts +0 -109
  41. package/src/get-options/get-wrapper-options.ts +0 -84
  42. package/src/get-options/index.ts +0 -28
  43. package/src/image-exporter.ts +0 -108
  44. package/src/types/image.ts +0 -2
  45. package/src/types/index.ts +0 -2
  46. package/src/types/options.ts +0 -69
  47. package/src/utils/convert-to-slug.ts +0 -15
  48. package/src/utils/get-attribute-values.ts +0 -68
  49. package/src/utils/get-date-MMDDYY.ts +0 -11
  50. package/src/utils/ignore-items.ts +0 -11
  51. package/src/utils/index.ts +0 -18
  52. package/src/utils/is-visible.ts +0 -12
  53. package/src/utils/parse-labels.ts +0 -55
  54. package/src/utils/push-to-window.ts +0 -3
  55. package/tests/index.html +0 -88
  56. package/tests/input-tests.html +0 -169
@@ -1,22 +1,7 @@
1
- import { isValidUrl } from "../utils";
2
- import { proxyCSS } from "./proxy-css";
3
- import { proxyImages } from "./proxy-images";
4
- import { Options } from "../types/options";
1
+ import { runCorsProxy } from "./run";
2
+ import { cleanUpCorsProxy } from "./cleanup";
5
3
 
6
- /**
7
- * runCorsProxy - Initializes a CORS proxy by processing image and CSS resources on a web page.
8
- * Logs the total number of calls made to the proxy server.
9
- */
10
-
11
- export async function runCorsProxy(options: Options): Promise<void> {
12
- try {
13
- if (!options.corsProxyBaseUrl || !isValidUrl(options.corsProxyBaseUrl)) return;
14
-
15
- await proxyCSS(options);
16
- await proxyImages(options);
17
-
18
- return;
19
- } catch (e) {
20
- console.error("ImageExporter: Error in runCorsProxy", e);
21
- }
22
- }
4
+ export const corsProxy = {
5
+ run: runCorsProxy,
6
+ cleanUp: cleanUpCorsProxy,
7
+ };
@@ -1,14 +1,16 @@
1
- // Helper function to check if a URL is valid and not a data URL
1
+ /**
2
+ * isValidUrl
3
+ *
4
+ * Checks if a string is a valid external URL.
5
+ */
2
6
  export function isValidUrl(string: string): boolean {
3
7
  try {
4
8
  const url = new URL(string);
5
9
 
6
- // Check if the URL is a data URL
7
10
  if (url.protocol === "data:") {
8
11
  return false;
9
12
  }
10
13
 
11
- // Optionally, check for HTTP and HTTPS protocols specifically
12
14
  if (url.protocol !== "http:" && url.protocol !== "https:") {
13
15
  return false;
14
16
  }
@@ -1,52 +1,59 @@
1
- import { isValidUrl } from "../utils";
2
- import * as types from "../types";
1
+ import { log } from "../logger";
2
+ import { Config } from "../types";
3
+ import { isValidUrl } from "./is-valid-url";
4
+
3
5
  /**
4
- * proxyCSS - Processes CSS stylesheets linked in the document to use the CORS proxy.
5
- * Each valid and non-data URL stylesheet's href attribute is updated with the proxy URL.
6
+ * proxyCSS
6
7
  *
7
- * @param {Object} options - Configuration settings, including the CORS proxy base URL.
8
- * Expected properties:
9
- * - corsProxyBaseURL: String - The base URL of the CORS proxy server.
10
- * @param {number} proxyPings - Initial count of proxy server pings.
11
- * @returns {Promise<number>} - Returns the updated count of proxy server pings after processing stylesheets.
8
+ * Proxies all linked CSS files and the absolute URLs inside them, including fonts and images.
9
+ * Upon completion of capture, the links will be restored and the style elements removed.
12
10
  */
11
+ export async function proxyCSS(config: Config) {
12
+ const stylesheetElements = document.querySelectorAll('link[rel="stylesheet"]');
13
+ log.verbose("stylesheet elements to proxy", stylesheetElements.length);
14
+
15
+ for (let stylesheetElement of stylesheetElements) {
16
+ const stylesheetURL = stylesheetElement.getAttribute("href");
17
+
18
+ // Exclude data URLs, invalid URLs, and already proxied URLs
19
+ if (!stylesheetURL) continue;
20
+ if (stylesheetURL.startsWith("data:")) continue;
21
+ if (stylesheetURL.startsWith(config.corsProxyBaseUrl)) continue;
22
+ if (!isValidUrl(stylesheetURL)) continue;
23
+
24
+ stylesheetElement.setAttribute("crossorigin", "anonymous");
25
+ const proxiedURL = config.corsProxyBaseUrl + encodeURIComponent(stylesheetURL);
13
26
 
14
- export async function proxyCSS(options: types.Options) {
15
- try {
16
- const css = document.querySelectorAll('link[rel="stylesheet"]');
17
-
18
- for (let stylesheetElement of css) {
19
- let stylesheetURL = stylesheetElement.getAttribute("href");
20
-
21
- // Check if the URL is valid and not a base64 encoded string
22
- if (
23
- stylesheetURL &&
24
- !stylesheetURL.startsWith("data:") &&
25
- isValidUrl(stylesheetURL) &&
26
- !stylesheetURL.startsWith(options.corsProxyBaseUrl)
27
- ) {
28
- const url = options.corsProxyBaseUrl + encodeURIComponent(stylesheetURL);
29
-
30
- try {
31
- // Fetch the CSS content
32
- const response = await fetch(url);
33
- const css = await response.text();
34
-
35
- // Create a <style> element and set its content
36
- const styleEl = document.createElement("style");
37
- styleEl.textContent = css;
38
-
39
- // Append the <style> element to the document's <head>
40
- document.head.appendChild(styleEl);
41
-
42
- // Remove the original <link> element
43
- stylesheetElement.remove();
44
- } catch (error) {
45
- console.error("Error fetching CSS:", error);
27
+ try {
28
+ // Fetch the CSS content
29
+ const response = await fetch(proxiedURL);
30
+ let cssContent = await response.text();
31
+
32
+ // Proxy absolute URLs (http/https) within the CSS content
33
+ cssContent = cssContent.replace(
34
+ /url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/g,
35
+ (match, url) => {
36
+ // Skip if already proxied
37
+ if (url.startsWith(config.corsProxyBaseUrl)) return match;
38
+ // Otherwise return proxied URL
39
+ return `url("${config.corsProxyBaseUrl}${encodeURIComponent(url)}")`;
46
40
  }
47
- }
41
+ );
42
+
43
+ // Insert the parsed CSS content into a <style> element
44
+ const styleElement = document.createElement("style");
45
+ styleElement.textContent = cssContent;
46
+ styleElement.setAttribute(
47
+ "original-link-element",
48
+ encodeURIComponent(stylesheetElement.outerHTML)
49
+ );
50
+
51
+ // Insert the <style> element directly after the original <link> element to easy restoration
52
+ stylesheetElement.insertAdjacentElement("afterend", styleElement);
53
+ stylesheetElement.remove();
54
+ log.verbose("Proxied: ", stylesheetURL);
55
+ } catch (error) {
56
+ console.error("Error fetching CSS:", error);
48
57
  }
49
- } catch (e) {
50
- console.error("ImageExporter: Error in proxyCSS", e);
51
58
  }
52
59
  }
@@ -1,71 +1,30 @@
1
- import { blobToDataURL, isValidUrl } from "../utils";
2
- import * as types from "../types";
1
+ import { Config } from "../types";
2
+ import { isValidUrl } from "./is-valid-url";
3
+ import { log } from "../logger";
4
+
3
5
  /**
4
- * proxyImages - Processes images within a specified wrapper element to use the CORS proxy.
5
- * Groups images by their source, fetches and replaces the src with a data URL for duplicates,
6
- * and prefixes the proxy URL for unique images.
6
+ * proxyImages
7
7
  *
8
- * @param {Object} options - Configuration settings, including the selector for the wrapper and CORS proxy base URL.
9
- * Expected properties:
10
- * - wrapperSelector: String - The CSS selector for the wrapper element containing images.
11
- * - corsProxyBaseURL: String - The base URL of the CORS proxy server.
12
- * @returns {Promise<number>} - Returns the number of times the proxy server was pinged.
8
+ * Proxies all images inside capture elements.
9
+ * The original src is stored for later restoration.
13
10
  */
14
- export async function proxyImages(options: types.Options) {
11
+ export async function proxyImages(
12
+ config: Config,
13
+ elements: HTMLElement[] | NodeListOf<HTMLElement>
14
+ ) {
15
15
  try {
16
- // find all link tags in head and add crossorigin="anonymous"
17
- const links = document.querySelectorAll("link");
18
- links.forEach((link) => {
19
- link.setAttribute("crossorigin", "anonymous");
20
- });
21
-
22
- const wrapper = document.querySelector(options.selectors.wrapper);
23
- if (!wrapper) {
24
- console.error("ImageExporter: Wrapper element not found.");
25
- return;
26
- }
27
- const images = Array.from(wrapper.querySelectorAll("img")) as HTMLImageElement[];
28
-
29
- const srcMap = new Map<string, HTMLImageElement[]>();
30
-
31
- // Group images by src
32
- images.forEach((img) => {
33
- const srcs = srcMap.get(img.src) || [];
34
- srcs.push(img);
35
- srcMap.set(img.src, srcs);
36
- });
37
-
38
- for (const [src, duplicates] of srcMap) {
39
- if (
40
- !isValidUrl(src) ||
41
- (options.corsProxyBaseUrl && src.startsWith(options.corsProxyBaseUrl))
42
- ) {
43
- continue;
44
- }
45
- if (duplicates.length > 1) {
46
- // Fetch and replace src for duplicate images
47
- try {
48
- const response = await fetch(
49
- options.corsProxyBaseUrl + encodeURIComponent(src)
50
- );
16
+ const elementArray = Array.from(elements);
17
+ if (!elementArray.length) return;
18
+ log.verbose("images to proxy", elementArray.length);
51
19
 
52
- const blob = await response.blob();
53
- const dataURL = await blobToDataURL(blob);
54
- duplicates.forEach((dupImg) => {
55
- if (dupImg.src === src) {
56
- dupImg.src = dataURL;
57
- }
58
- });
59
- } catch (error) {
60
- console.error("Error fetching image:", error);
20
+ for (const element of elementArray) {
21
+ const images = Array.from(element.querySelectorAll("img")) as HTMLImageElement[];
22
+ for (const img of images) {
23
+ if (isValidUrl(img.src) && !img.src.startsWith(config.corsProxyBaseUrl)) {
24
+ img.setAttribute("original-src", img.src);
25
+ img.src = config.corsProxyBaseUrl + encodeURIComponent(img.src);
26
+ log.verbose("Proxied: ", img.src);
61
27
  }
62
- } else {
63
- // Prefix src for unique images
64
- images.forEach((img) => {
65
- if (img.src === src) {
66
- img.src = options.corsProxyBaseUrl + encodeURIComponent(src);
67
- }
68
- });
69
28
  }
70
29
  }
71
30
  } catch (e) {
@@ -73,18 +32,3 @@ export async function proxyImages(options: types.Options) {
73
32
  return;
74
33
  }
75
34
  }
76
-
77
- /**
78
- * blobToDataURL - Converts a Blob object to a data URL.
79
- *
80
- * @param {Blob} blob - The Blob object to be converted.
81
- * @returns {Promise<string>} - Returns a Promise that resolves to a data URL string.
82
- */
83
- function blobToDataURL(blob: Blob): Promise<string> {
84
- return new Promise((resolve, reject) => {
85
- const reader = new FileReader();
86
- reader.onloadend = () => resolve(reader.result as string);
87
- reader.onerror = reject;
88
- reader.readAsDataURL(blob);
89
- });
90
- }
@@ -0,0 +1,26 @@
1
+ import { proxyCSS } from "./proxy-css";
2
+ import { proxyImages } from "./proxy-images";
3
+ import { Config } from "../types";
4
+ import { isValidUrl } from "./is-valid-url";
5
+ import { log } from "../logger";
6
+
7
+ /**
8
+ * runCorsProxy
9
+ *
10
+ * Proxies all images inside capture elements, as well as all linked CSS files and the absolute URLs inside them.
11
+ * Upon completion of capture, these will be reverted to their original state.
12
+ */
13
+ export async function runCorsProxy(
14
+ config: Config,
15
+ elements: HTMLElement[] | NodeListOf<HTMLElement>
16
+ ): Promise<void> {
17
+ try {
18
+ log.verbose("running CORS proxy");
19
+ if (!config.corsProxyBaseUrl || !isValidUrl(config.corsProxyBaseUrl)) return;
20
+
21
+ await proxyCSS(config);
22
+ await proxyImages(config, elements);
23
+ } catch (e) {
24
+ console.error("ImageExporter: Error in runCorsProxy", e);
25
+ }
26
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,14 @@
1
- import { ImageExporter } from "./image-exporter";
1
+ /* -------------------------------------------------------------------------- */
2
+ /* Image Exporter */
3
+ /* */
4
+ /* by briantuckerdesign */
5
+ /* -------------------------------------------------------------------------- */
6
+ import { capture } from "./capture";
2
7
 
3
- // Pushes imageExporter to window object so it can be called in Webflow
8
+ /** Exports for use in browser */
4
9
  if (typeof window !== "undefined") {
5
- (window as any).ImageExporter = ImageExporter;
10
+ (window as any).imageExporter = capture;
6
11
  }
7
12
 
8
- export { ImageExporter };
13
+ /** Exports for use as an imported package */
14
+ export { capture };
package/src/logger.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { windowLogging, loggingLevel } from "./capture";
2
+
3
+ export const log = {
4
+ info: (...messages: any[]) => logAction(messages, "info"),
5
+ error: (...messages: any[]) => logAction(messages, "error"),
6
+ verbose: (...messages: any[]) => logAction(messages, "verbose"),
7
+ progress: (progress: number, total: number) => logProgress(progress, total),
8
+ };
9
+
10
+ async function logAction(messages: any[], type: LogType = "info") {
11
+ const combinedMessage = messages
12
+ .map((msg) => (typeof msg === "object" ? JSON.stringify(msg) : msg))
13
+ .join(" ");
14
+
15
+ switch (type) {
16
+ case "info":
17
+ if (loggingLevel === "info" || loggingLevel === "verbose") {
18
+ console.log(...messages);
19
+ }
20
+ break;
21
+ case "error":
22
+ if (loggingLevel === "error" || loggingLevel === "verbose") {
23
+ console.error(...messages);
24
+ }
25
+ break;
26
+ case "verbose":
27
+ if (loggingLevel === "verbose") {
28
+ console.log(...messages);
29
+ }
30
+ break;
31
+ }
32
+
33
+ if (windowLogging) window.imageExporterLogs.push({ message: combinedMessage, type });
34
+ }
35
+
36
+ async function logProgress(progress: number, total: number) {
37
+ if (windowLogging) {
38
+ window.imageExporterProgress.push([progress, total]);
39
+ }
40
+ }
41
+
42
+ type LogType = "info" | "error" | "verbose" | "progress";
43
+
44
+ type Log = {
45
+ message: string;
46
+ type: LogType;
47
+ progress?: number;
48
+ total?: number;
49
+ };
50
+
51
+ type Progress = [number, number];
52
+
53
+ declare global {
54
+ interface Window {
55
+ imageExporterLogs: Log[];
56
+ imageExporterProgress: Progress[];
57
+ }
58
+ }
59
+
60
+ window.imageExporterLogs = [];
61
+ window.imageExporterProgress = [];
package/src/types.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ export interface ImageOptions {
2
+ /** Label for image. Does not include file extension or scale. */
3
+ label: Label;
4
+ /** File format, jpg, png, or svg. */
5
+ format: Format;
6
+ /** Scale of image. Can be a number or a comma-separated list of numbers. */
7
+ scale: Scale;
8
+ /** Quality of image. 0.0 to 1.0, only applies to jpg.*/
9
+ quality: Quality;
10
+ /** Include scale in label. True or false. Automatically true if scale is an array. */
11
+ includeScaleInLabel: IncludeScaleInLabel;
12
+ }
13
+
14
+ export interface Config extends ImageOptions {
15
+ /** Download images as files upon capture. */
16
+ downloadImages: boolean;
17
+ /** Default label for images. Does not include file extension or scale. */
18
+ defaultImageLabel: string;
19
+ /** Label for zip file. Does not include file extension or scale. */
20
+ zipLabel: Label;
21
+ /** Base URL for CORS proxy used when fetching external images.
22
+ *
23
+ * URLs will be encoded and appended without a `?`. Include your own trailing slash.
24
+ *
25
+ * I recommend [cors-proxy-worker](https://github.com/briantuckerdesign/cors-proxy-worker) for production and [local-cors-proxy-encoded](https://github.com/briantuckerdesign/local-cors-proxy-encoded) for development.
26
+ *
27
+ * Example: `https://cors-proxy.com/` -> `https://cors-proxy.com/https%3A%2F%2FmyEncodedUrl.com`
28
+ */
29
+ corsProxyBaseUrl: string;
30
+ /** Enable window logging for use by external scripts. */
31
+ enableWindowLogging: boolean;
32
+ /** Enable verbose logging for debugging. */
33
+ loggingLevel: LoggingLevel;
34
+ }
35
+
36
+ export interface ParsedImageOptions extends ImageOptions {
37
+ /** After parsing, this will always be a number rather than possibly an array. */
38
+ scale: number;
39
+ }
40
+
41
+ export interface Image {
42
+ dataURL: string;
43
+ fileName: string;
44
+ }
45
+
46
+ export type Label = string;
47
+ export type Format = "jpg" | "png" | "svg";
48
+ export type Scale = number | number[];
49
+ export type Quality = number;
50
+ export type IncludeScaleInLabel = boolean;
51
+ export type LoggingLevel = "none" | "info" | "error" | "verbose";
package/vite.config.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { defineConfig } from "vite";
2
- import strip from "@rollup/plugin-strip";
3
2
 
4
3
  export default defineConfig({
4
+ test: {
5
+ environment: "jsdom",
6
+ },
5
7
  build: {
6
8
  outDir: "dist",
7
9
  emptyOutDir: true,
@@ -27,12 +29,6 @@ export default defineConfig({
27
29
  }
28
30
  },
29
31
  },
30
- strip({
31
- include: "**/*.(js|ts)",
32
- functions: ["console.log"],
33
- // Only apply this plugin during build, not during watch
34
- exclude: process.env.NODE_ENV === "development" ? "**/*" : "",
35
- }),
36
32
  ],
37
33
  },
38
34
  },
@@ -1,122 +0,0 @@
1
- body {
2
- display: flex;
3
- justify-content: center;
4
- margin: 0;
5
- background-color: #151224;
6
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
7
- Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue",
8
- sans-serif;
9
- }
10
-
11
- .wrapper {
12
- display: grid;
13
- width: 800px;
14
- height: 500px;
15
- grid-auto-columns: 1fr;
16
- grid-column-gap: 16px;
17
- grid-row-gap: 16px;
18
- grid-template-columns: 1fr 2fr;
19
- grid-template-rows: auto;
20
- }
21
-
22
- .input-wrapper {
23
- display: flex;
24
- flex-direction: column;
25
- align-items: flex-start;
26
- color: #fff;
27
- }
28
-
29
- .input-label {
30
- margin-bottom: 0px;
31
- font-weight: 700;
32
- }
33
-
34
- .example-input {
35
- margin-bottom: 1rem;
36
- padding: 6px;
37
- -webkit-align-self: stretch;
38
- -ms-flex-item-align: stretch;
39
- -ms-grid-row-align: stretch;
40
- align-self: stretch;
41
- border: 1px solid #7000ff;
42
- border-radius: 4px;
43
- color: #000;
44
- }
45
-
46
- .example-button {
47
- display: -webkit-box;
48
- display: -webkit-flex;
49
- display: -ms-flexbox;
50
- display: flex;
51
- padding: 11px 22px;
52
- background-color: #7000ff;
53
- }
54
-
55
- .example-wrapper {
56
- display: flex;
57
- flex-direction: column;
58
- justify-content: center;
59
- align-items: center;
60
- grid-column-gap: 16px;
61
- grid-row-gap: 16px;
62
- border-style: dashed;
63
- border-width: 2px;
64
- border-color: #7000ff;
65
- border-radius: 8px;
66
- }
67
-
68
- .example-2 {
69
- display: flex;
70
- width: 200px;
71
- height: 200px;
72
- padding: 22px;
73
- justify-content: center;
74
- align-items: center;
75
- background-color: #7000ff;
76
- font-size: 24px;
77
- line-height: 1;
78
- font-weight: 700;
79
- text-align: center;
80
- }
81
-
82
- .example-1 {
83
- display: flex;
84
- width: 400px;
85
- height: 200px;
86
- justify-content: center;
87
- align-items: center;
88
- background-color: #fff;
89
- font-size: 24px;
90
- font-weight: 700;
91
- text-align: center;
92
- }
93
-
94
- .example-text-2 {
95
- color: #fff;
96
- }
97
-
98
- .example-text-1 {
99
- color: #7000ff;
100
- }
101
-
102
- .gf_loader {
103
- display: none;
104
- opacity: 0;
105
- position: absolute;
106
- width: 100dvw;
107
- height: 100dvh;
108
- z-index: 99;
109
- background-color: rgba(25, 25, 25, 0.9);
110
- color: #fff;
111
- }
112
- .gf_loader-message {
113
- display: flex;
114
- width: 100dvw;
115
- height: 100dvh;
116
- align-items: center;
117
- justify-content: center;
118
- }
119
- .img {
120
- max-width: 100%;
121
- height: 100%;
122
- }