portosaurus 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +116 -0
  3. package/bin/portosaurus.js +337 -0
  4. package/internal/notes/index.md +10 -0
  5. package/internal/sidebars.js +20 -0
  6. package/internal/src/components/AboutSection/index.js +67 -0
  7. package/internal/src/components/AboutSection/styles.module.css +492 -0
  8. package/internal/src/components/ContactSection/index.js +94 -0
  9. package/internal/src/components/ContactSection/styles.module.css +327 -0
  10. package/internal/src/components/ExperienceSection/index.js +25 -0
  11. package/internal/src/components/ExperienceSection/styles.module.css +180 -0
  12. package/internal/src/components/HeroSection/index.js +61 -0
  13. package/internal/src/components/HeroSection/styles.module.css +471 -0
  14. package/internal/src/components/NoteIndex/index.js +127 -0
  15. package/internal/src/components/NoteIndex/styles.module.css +143 -0
  16. package/internal/src/components/ProjectsSection/index.js +529 -0
  17. package/internal/src/components/ProjectsSection/styles.module.css +830 -0
  18. package/internal/src/components/ScrollToTop/index.js +98 -0
  19. package/internal/src/components/ScrollToTop/styles.module.css +96 -0
  20. package/internal/src/components/SocialLinks/index.js +129 -0
  21. package/internal/src/components/SocialLinks/styles.module.css +55 -0
  22. package/internal/src/components/Tooltip/index.js +30 -0
  23. package/internal/src/components/Tooltip/styles.module.css +92 -0
  24. package/internal/src/config/iconMappings.js +329 -0
  25. package/internal/src/config/metaTags.js +240 -0
  26. package/internal/src/config/prism.js +179 -0
  27. package/internal/src/config/sidebar.js +20 -0
  28. package/internal/src/css/bootstrap.css +6 -0
  29. package/internal/src/css/catppuccin.css +632 -0
  30. package/internal/src/css/custom.css +186 -0
  31. package/internal/src/css/tasks.css +868 -0
  32. package/internal/src/pages/index.js +98 -0
  33. package/internal/src/pages/notes.js +88 -0
  34. package/internal/src/pages/tasks.js +310 -0
  35. package/internal/src/utils/HashNavigation.js +250 -0
  36. package/internal/src/utils/appVersion.js +27 -0
  37. package/internal/src/utils/compileConfig.js +82 -0
  38. package/internal/src/utils/cssUtils.js +99 -0
  39. package/internal/src/utils/filterEnabledItems.js +21 -0
  40. package/internal/src/utils/generateFavicon.js +256 -0
  41. package/internal/src/utils/generateRobotsTxt.js +97 -0
  42. package/internal/src/utils/iconExtractor.js +159 -0
  43. package/internal/src/utils/imageDownloader.js +88 -0
  44. package/internal/src/utils/imageProcessor.js +134 -0
  45. package/internal/src/utils/linkShortner.js +0 -0
  46. package/internal/src/utils/updateTitle.js +107 -0
  47. package/package.json +51 -0
  48. package/template/.github/workflows/deploy.yml +57 -0
  49. package/template/README.md +70 -0
  50. package/template/blog/authors.yml +5 -0
  51. package/template/blog/welcome.md +10 -0
  52. package/template/config.js +233 -0
  53. package/template/notes/getting-started.md +7 -0
  54. package/template/static/README.md +33 -0
  55. package/utils/createConfig.js +227 -0
  56. package/utils/logger.js +19 -0
  57. package/utils/packageManager.js +88 -0
@@ -0,0 +1,256 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const { favicons } = require('favicons');
5
+ const { downloadImage } = require('./imageDownloader');
6
+ const { reshapeImage } = require('./imageProcessor');
7
+ const { getCssVar } = require('./cssUtils');
8
+ const { extractSvg } = require('./iconExtractor');
9
+
10
+ function createDirectoryIfNotExists(dir) {
11
+ if (!fs.existsSync(dir)) {
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ return true;
14
+ }
15
+ return false;
16
+ }
17
+
18
+ function cleanupFile(filePath) {
19
+ if (fs.existsSync(filePath)) {
20
+ try {
21
+ fs.unlinkSync(filePath);
22
+ return true;
23
+ } catch (e) {
24
+ console.warn(`[WARNING] Failed to clean up file ${filePath}:`, e.message);
25
+ }
26
+ }
27
+ return false;
28
+ }
29
+
30
+ function processManifest(manifestFile, outputDir, appVersion) {
31
+ try {
32
+ const manifest = JSON.parse(manifestFile.contents);
33
+ manifest.version = appVersion;
34
+
35
+ fs.writeFileSync(
36
+ path.join(outputDir, manifestFile.name),
37
+ JSON.stringify(manifest, null, 2)
38
+ );
39
+ return true;
40
+ } catch (err) {
41
+ console.error('[ERROR] Failed to process manifest:', err.message);
42
+ fs.writeFileSync(path.join(outputDir, manifestFile.name), manifestFile.contents);
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function generateFavicons(context, options = {}) {
48
+ console.log('\n[INFO] Generating favicons...');
49
+
50
+ const {siteConfig} = context;
51
+ const profilePicUrl = options.imagePath || siteConfig.customFields.heroSection.profilePic;
52
+ const appVersion = siteConfig.customFields.version || '1.0';
53
+ const circular = options.circular !== false;
54
+ const shape = options.shape || 'circle';
55
+
56
+ const staticBaseDir = path.resolve(context.siteDir, 'static');
57
+ const imgDir = path.join(staticBaseDir, 'img', 'svg');
58
+ const imgStaticPath = '/img/svg';
59
+ const outputDir = path.join(staticBaseDir, options.outputPath || 'favicon');
60
+
61
+ const tempDir = path.resolve(context.siteDir);
62
+ const reshapedImagePath = path.join(tempDir, 'temp_reshaped_pic.png');
63
+
64
+ const tempFiles = [];
65
+
66
+ try {
67
+ const iconColor = { color: getCssVar('--ifm-color-primary') };
68
+ const iconsToGenerate = ['note', 'blog'];
69
+
70
+ for (const icon of iconsToGenerate) {
71
+ await extractSvg(icon, imgDir, iconColor);
72
+ }
73
+
74
+ const configuration = {
75
+ path: `/${options.outputPath || 'favicon'}/`,
76
+ appName: siteConfig.title || 'Portfolio',
77
+ appDescription: siteConfig.tagline || 'Portfolio',
78
+ background: getCssVar('--ifm-background-color'),
79
+ theme_color: getCssVar('--ifm-color-primary'),
80
+ appleStatusBarStyle: 'black-translucent',
81
+ display: 'standalone',
82
+ scope: '/',
83
+ start_url: '/',
84
+ version: appVersion,
85
+ orientation: 'natural',
86
+ logging: false,
87
+ loadManifestWithCredentials: true,
88
+ manifestMaskable: true,
89
+ icons: {
90
+ android: {
91
+ offset: 0,
92
+ background: false,
93
+ mask: true,
94
+ overlayGlow: false,
95
+ androidPlayStore: true,
96
+ },
97
+ favicons: true,
98
+ appleIcon: true,
99
+ appleStartup: false,
100
+ windows: false,
101
+ yandex: false,
102
+ },
103
+ shortcuts: [
104
+ {
105
+ name: "Notes",
106
+ short_name: "Notes",
107
+ description: "View my collection of notes",
108
+ url: "/notes",
109
+ icons: [
110
+ {
111
+ src: `${imgStaticPath}/icon-note.svg`,
112
+ sizes: "any",
113
+ type: "image/svg+xml",
114
+ purpose: "any"
115
+ },
116
+ {
117
+ src: "/img/project-blank.png",
118
+ sizes: "192x192",
119
+ type: "image/png",
120
+ purpose: "any"
121
+ }
122
+ ]
123
+ },
124
+ {
125
+ name: "Blog",
126
+ short_name: "Blog",
127
+ description: "Read my latest blog posts",
128
+ url: "/blog",
129
+ icons: [
130
+ {
131
+ src: `${imgStaticPath}/icon-blog.svg`,
132
+ sizes: "any",
133
+ type: "image/svg+xml",
134
+ purpose: "any"
135
+ },
136
+ {
137
+ src: "/favicon/android-chrome-192x192.png",
138
+ sizes: "192x192",
139
+ type: "image/png",
140
+ purpose: "any"
141
+ }
142
+ ]
143
+ }
144
+ ]
145
+ };
146
+
147
+ const downloadedImage = await downloadImage(profilePicUrl, context.siteDir, 'temp_profile_pic.png');
148
+
149
+ tempFiles.push(downloadedImage);
150
+
151
+ let finalImagePath = downloadedImage;
152
+ if (circular) {
153
+ finalImagePath = await reshapeImage(downloadedImage, reshapedImagePath, shape);
154
+ tempFiles.push(finalImagePath);
155
+ cleanupFile(downloadedImage);
156
+ }
157
+
158
+ createDirectoryIfNotExists(outputDir);
159
+
160
+ console.log(`[INFO] Generating favicon assets from ${finalImagePath}`);
161
+ const response = await favicons(finalImagePath, configuration);
162
+
163
+ let imageCount = 0, fileCount = 0;
164
+
165
+ if (Array.isArray(response.images) && response.images.length > 0) {
166
+ for (const image of response.images) {
167
+ if (!image || !image.name || !image.contents) continue;
168
+
169
+ try {
170
+ fs.writeFileSync(path.join(outputDir, image.name), image.contents);
171
+ imageCount++;
172
+ } catch (err) {
173
+ console.error(`[ERROR] Failed to write image ${image.name}:`, err.message);
174
+ }
175
+ }
176
+ }
177
+
178
+ if (Array.isArray(response.files) && response.files.length > 0) {
179
+ for (const file of response.files) {
180
+ if (!file || !file.name || !file.contents) continue;
181
+
182
+ try {
183
+ if (file.name.includes('manifest')) {
184
+ processManifest(file, outputDir, appVersion);
185
+ } else {
186
+ fs.writeFileSync(path.join(outputDir, file.name), file.contents);
187
+ }
188
+ fileCount++;
189
+ } catch (err) {
190
+ console.error(`[ERROR] Failed to write file ${file.name}:`, err.message);
191
+ }
192
+ }
193
+ }
194
+
195
+ tempFiles.forEach(cleanupFile);
196
+
197
+ console.log(`[SUCCESS] Generated ${imageCount} favicon images and ${fileCount} support files\n`);
198
+ return true;
199
+
200
+ } catch (error) {
201
+ console.error('[ERROR] Error generating favicons:', error.message);
202
+ tempFiles.forEach(cleanupFile);
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ module.exports = {
208
+ generateFavicons,
209
+ default: function(context, options = {}) {
210
+ const {
211
+ imagePath = null,
212
+ outputPath = 'favicon',
213
+ circular = true,
214
+ shape = 'circle',
215
+ generateOnDev = true,
216
+ } = options;
217
+
218
+ return {
219
+ name: 'favicon-generator',
220
+
221
+ async loadContent() {
222
+ const shouldGenerate =
223
+ process.env.NODE_ENV === 'production' ||
224
+ process.env.GENERATE_FAVICONS ||
225
+ generateOnDev;
226
+
227
+ if (shouldGenerate) {
228
+ try {
229
+ await generateFavicons(context, {
230
+ imagePath,
231
+ outputPath,
232
+ circular,
233
+ shape,
234
+ });
235
+ } catch (error) {
236
+ console.error('[FATAL] Favicon generation failed:', error);
237
+ process.exit(1);
238
+ }
239
+ }
240
+ }
241
+ };
242
+ }
243
+ };
244
+
245
+ if (require.main === module) {
246
+ const siteDir = path.resolve(__dirname, '../..');
247
+ const siteConfig = require('../../docusaurus.config.js').default;
248
+
249
+ generateFavicons({
250
+ siteDir,
251
+ siteConfig
252
+ }).catch(error => {
253
+ console.error('[FATAL] Error in CLI mode:', error);
254
+ process.exit(1);
255
+ });
256
+ }
@@ -0,0 +1,97 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
4
+
5
+ function formatRules(rules) {
6
+ if (!Array.isArray(rules) || !rules.length) return '';
7
+
8
+ let content = '';
9
+
10
+ rules.forEach(rule => {
11
+
12
+ // Default userAgent to '*' if not specified
13
+ const userAgent = rule.userAgent || '*';
14
+
15
+ content += `User-agent: ${userAgent}\n`;
16
+
17
+ // Check if root path is explicitly disallowed
18
+ const disallowsRoot = Array.isArray(rule.disallow) &&
19
+ rule.disallow.some(path => path === '/' || path === '/*');
20
+
21
+ // Process allow directives
22
+ if (Array.isArray(rule.allow) && rule.allow.length > 0) {
23
+
24
+ // Use explicitly defined allow rules
25
+ for (const allowPath of rule.allow) {
26
+ content += `Allow: ${allowPath}\n`;
27
+ }
28
+
29
+ } else if (!disallowsRoot) {
30
+ content += `Allow: /\n`;
31
+ }
32
+
33
+ // Process disallow directives
34
+ if (Array.isArray(rule.disallow)) {
35
+ for (const disallowPath of rule.disallow) {
36
+ content += `Disallow: ${disallowPath}\n`;
37
+ }
38
+ }
39
+
40
+ content += '\n';
41
+ });
42
+
43
+ return content;
44
+ }
45
+
46
+
47
+ module.exports = function() {
48
+ return {
49
+ name: 'generate-robots-txt',
50
+
51
+ async postBuild({ outDir, siteConfig }) {
52
+ const robotsConfig = siteConfig.customFields?.robotsTxt;
53
+
54
+ // Early return if robots.txt is disabled
55
+ if (!robotsConfig || robotsConfig.enable === false) {
56
+ console.log('ℹ️ robots.txt generation is disabled in config');
57
+ return;
58
+ }
59
+
60
+ try {
61
+ const robotsTxtPath = path.join(outDir, 'robots.txt');
62
+ let content = '';
63
+
64
+ // Process the rules structure
65
+ if (robotsConfig.rules) {
66
+ content += formatRules(robotsConfig.rules);
67
+ }
68
+
69
+ // Always include sitemap reference
70
+ content += `Sitemap: ${siteConfig.url}/sitemap.xml\n`;
71
+
72
+ if (Array.isArray(robotsConfig.customLines) && robotsConfig.customLines.length) {
73
+
74
+ const filteredCustomLines = robotsConfig.customLines.filter(
75
+ line => !line.trim().toLowerCase().startsWith('sitemap:')
76
+ );
77
+
78
+ if (filteredCustomLines.length > 0) {
79
+ content += filteredCustomLines.join('\n') + '\n';
80
+ }
81
+ }
82
+
83
+ // Only write file if we have content
84
+ if (content) {
85
+ fs.writeFileSync(robotsTxtPath, content, 'utf8');
86
+ console.log('✅ robots.txt generated successfully');
87
+
88
+ } else {
89
+ console.warn('⚠️ Warning: No robots.txt rules defined in config');
90
+ }
91
+
92
+ } catch (error) {
93
+ console.error('❌ Error generating robots.txt:', error);
94
+ }
95
+ }
96
+ };
97
+ };
@@ -0,0 +1,159 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const React = require('react');
4
+ const ReactDOMServer = require('react-dom/server');
5
+ const { iconMap } = require('../config/iconMappings');
6
+
7
+ /// AI Generated
8
+
9
+ /**
10
+ * Dynamically requires a React Icon
11
+ * @param {string} iconPath - Path in format "package/IconName" (e.g., "ai/AiFillAlert")
12
+ * @returns {Object|null} The icon component or null if not found
13
+ */
14
+ function requireDynamicIcon(iconPath) {
15
+ try {
16
+ const [packageName, iconName] = iconPath.split('/');
17
+
18
+ if (packageName && iconName) {
19
+ const iconPackage = require(`react-icons/${packageName}`);
20
+ return iconPackage[iconName];
21
+ }
22
+ return null;
23
+ } catch (error) {
24
+ console.error(`Failed to import icon ${iconPath}:`, error.message);
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Cleans SVG string to create a properly formatted standalone SVG file
31
+ * @param {string} svgString - The SVG string from ReactDOMServer
32
+ * @param {string} color - The color to apply to the SVG
33
+ * @returns {string} Clean SVG content
34
+ */
35
+ function cleanSvgString(svgString, color) {
36
+
37
+ // Remove React-specific attributes and add proper XML declaration
38
+ let cleanedSvg = svgString
39
+ .replace(/<!--.*?-->/g, '') // Remove comments
40
+ .replace(/stroke="currentColor"/g, `stroke="${color}"`)
41
+ .replace(/fill="currentColor"/g, `fill="${color}"`);
42
+
43
+ // Add XML declaration if not present
44
+ if (!cleanedSvg.includes('<?xml')) {
45
+ cleanedSvg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n${cleanedSvg}`;
46
+ }
47
+
48
+ // Add viewBox if not present
49
+ if (!cleanedSvg.includes('viewBox')) {
50
+ cleanedSvg = cleanedSvg.replace('<svg', '<svg viewBox="0 0 24 24"');
51
+ }
52
+
53
+ return cleanedSvg;
54
+ }
55
+
56
+ /**
57
+ * Extracts SVG content from a React Icon component and saves it to a file
58
+ *
59
+ * @param {React.ComponentType|string} icon - Icon component, name from iconMap, or path like "ai/AiFillAlert"
60
+ * @param {string} outputPath - Path to save the SVG file, defaults to static/img/svg if not provided
61
+ * @param {object} options - Additional options
62
+ * @param {string} options.color - Icon color (default: 'white')
63
+ * @param {number} options.size - Icon size (default: 24)
64
+ * @param {string} options.filename - Override filename (default: icon-<name>.svg)
65
+ * @param {string} options.iconName - Specify icon name instead of using component name
66
+ * @returns {string} The path to the saved SVG file
67
+ */
68
+ function extractSvg(icon, outputPath = null, options = {}) {
69
+
70
+ // Default output path
71
+ if (!outputPath) {
72
+ outputPath = path.join(path.resolve(__dirname, '../..'), 'static', 'img', 'svg');
73
+ }
74
+
75
+ // Ensure the base output directory exists
76
+ const baseOutputDir = typeof outputPath === 'string' && !path.extname(outputPath)
77
+ ? outputPath
78
+ : path.dirname(outputPath);
79
+
80
+ if (!fs.existsSync(baseOutputDir)) {
81
+ fs.mkdirSync(baseOutputDir, { recursive: true });
82
+ console.log(`[INFO] Created directory: ${baseOutputDir}`);
83
+ }
84
+
85
+ // Resolve icon component
86
+ let IconComponent = icon;
87
+ let iconIdentifier = null;
88
+
89
+ if (typeof icon === 'string') {
90
+
91
+ if (icon.includes('/')) {
92
+
93
+ // ("ai/AiFillAlert")
94
+ IconComponent = requireDynamicIcon(icon);
95
+ iconIdentifier = icon.split('/')[1];
96
+
97
+ } else {
98
+
99
+ // Icon from the iconMap
100
+ IconComponent = iconMap[icon.toLowerCase()]?.icon;
101
+ iconIdentifier = icon;
102
+ }
103
+
104
+ if (!IconComponent) {
105
+ throw new Error(`Icon "${icon}" could not be resolved`);
106
+ }
107
+
108
+ // Use identifier if iconName not specified
109
+ if (!options.iconName && iconIdentifier) {
110
+ options.iconName = iconIdentifier;
111
+ }
112
+ }
113
+
114
+ const { color = 'white', size = 24, filename = null, iconName = null } = options;
115
+
116
+ // Determine final output path
117
+ const pathStats = fs.existsSync(outputPath) ? fs.statSync(outputPath) : null;
118
+ const isDirectory = pathStats ? pathStats.isDirectory() : false;
119
+
120
+ if (isDirectory || filename) {
121
+ const baseDir = isDirectory ? outputPath : path.dirname(outputPath);
122
+
123
+ // Create directory if needed
124
+ if (!fs.existsSync(baseDir)) {
125
+ fs.mkdirSync(baseDir, { recursive: true });
126
+ }
127
+
128
+ // Generate filename if not provided
129
+ const componentName = iconName || IconComponent.name;
130
+ const finalFilename = filename || `icon-${componentName}.svg`;
131
+ outputPath = path.join(baseDir, finalFilename);
132
+ }
133
+
134
+ // Create and render the icon
135
+ const element = React.createElement(IconComponent, {
136
+ color,
137
+ size,
138
+ style: { color }
139
+ });
140
+
141
+ // Render the React element to an HTML string and clean it
142
+ const svgString = ReactDOMServer.renderToStaticMarkup(element);
143
+ const cleanedSvg = cleanSvgString(svgString, color);
144
+
145
+ // Ensure final output path exists
146
+ const finalDir = path.dirname(outputPath);
147
+
148
+ if (!fs.existsSync(finalDir)) {
149
+ fs.mkdirSync(finalDir, { recursive: true });
150
+ }
151
+
152
+ // Write to file
153
+ fs.writeFileSync(outputPath, cleanedSvg);
154
+ console.log(`[INFO] Generated SVG icon: ${outputPath}`);
155
+
156
+ return outputPath;
157
+ }
158
+
159
+ module.exports = { extractSvg, iconMap };
@@ -0,0 +1,88 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const https = require('https');
4
+
5
+
6
+ // AI GENERATED - Partially
7
+
8
+ /**
9
+ * Downloads or copies an image from a URL or local file path to a specified directory
10
+ *
11
+ * @param {string} source - The URL or local file path of the image
12
+ * @param {string} outputDir - The directory where the image should be saved
13
+ * @param {string} [filename='temp_image.png'] - The filename to use for the saved image
14
+ * @returns {Promise<string>} - A promise that resolves with the path to the saved image
15
+ */
16
+ async function downloadImage(source, outputDir, filename = 'temp_image.png') {
17
+
18
+ const tmpImagePath = path.resolve(outputDir, filename);
19
+
20
+ // Check if the source is a local file path or a URL
21
+ const isUrl = source.startsWith('http://') || source.startsWith('https://');
22
+
23
+ if (isUrl) {
24
+ console.log(`[INFO] Downloading remote image from: ${source}`);
25
+
26
+ return new Promise((resolve, reject) => {
27
+ const file = fs.createWriteStream(tmpImagePath);
28
+
29
+ https.get(source, (response) => {
30
+ if (response.statusCode !== 200) {
31
+ reject(new Error(`Failed to download image, status code: ${response.statusCode}`));
32
+ return;
33
+ }
34
+
35
+ response.pipe(file);
36
+
37
+ file.on('finish', () => {
38
+ file.close();
39
+ resolve(tmpImagePath);
40
+ });
41
+
42
+ file.on('error', (err) => {
43
+ fs.unlink(tmpImagePath, () => {});
44
+ reject(err);
45
+ });
46
+ }).on('error', (err) => {
47
+ fs.unlink(tmpImagePath, () => {});
48
+ reject(err);
49
+ });
50
+ });
51
+ } else {
52
+
53
+ // local file path
54
+ console.log(`[INFO] Copying local image from: ${source}`);
55
+
56
+ return new Promise((resolve, reject) => {
57
+
58
+ // Check if source file exists
59
+ if (!fs.existsSync(source)) {
60
+ reject(new Error(`Source file does not exist: ${source}`));
61
+ return;
62
+ }
63
+
64
+ // Create a read stream from the source file
65
+ const readStream = fs.createReadStream(source);
66
+ const writeStream = fs.createWriteStream(tmpImagePath);
67
+
68
+ // Pipe the read stream to the write stream
69
+ readStream.pipe(writeStream);
70
+
71
+ writeStream.on('finish', () => {
72
+ resolve(tmpImagePath);
73
+ });
74
+
75
+ writeStream.on('error', (err) => {
76
+ fs.unlink(tmpImagePath, () => {});
77
+ reject(err);
78
+ });
79
+
80
+ readStream.on('error', (err) => {
81
+ fs.unlink(tmpImagePath, () => {});
82
+ reject(err);
83
+ });
84
+ });
85
+ }
86
+ }
87
+
88
+ module.exports = { downloadImage };