@pixelated-tech/components 3.3.6 → 3.4.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.COMPONENTS.md +126 -0
- package/README.md +14 -7
- package/dist/components/admin/componentusage/componentAnalysis.js +144 -0
- package/dist/components/admin/componentusage/componentDiscovery.js +85 -0
- package/dist/components/admin/deploy/deployment.integration.js +163 -0
- package/dist/components/admin/site-health/google-api-auth.js +69 -0
- package/dist/components/admin/site-health/seo-constants.js +14 -0
- package/dist/components/admin/site-health/seo-metrics.config.json +265 -0
- package/dist/components/admin/site-health/site-health-accessibility.js +158 -0
- package/dist/components/admin/site-health/site-health-axe-core.integration.js +119 -0
- package/dist/components/admin/site-health/site-health-axe-core.js +53 -0
- package/dist/components/admin/site-health/site-health-cache.js +23 -0
- package/dist/components/admin/site-health/site-health-core-web-vitals.integration.js +208 -0
- package/dist/components/admin/site-health/site-health-dependency-vulnerabilities.js +38 -0
- package/dist/components/admin/site-health/site-health-github.integration.js +81 -0
- package/dist/components/admin/site-health/site-health-github.js +34 -0
- package/dist/components/admin/site-health/site-health-google-analytics.integration.js +112 -0
- package/dist/components/admin/site-health/site-health-google-analytics.js +43 -0
- package/dist/components/admin/site-health/site-health-google-search-console.integration.js +118 -0
- package/dist/components/admin/site-health/site-health-google-search-console.js +43 -0
- package/dist/components/admin/site-health/site-health-indicators.js +71 -0
- package/dist/components/admin/site-health/site-health-on-site-seo.integration.js +669 -0
- package/dist/components/admin/site-health/site-health-on-site-seo.js +204 -0
- package/dist/components/admin/site-health/site-health-overview.js +65 -0
- package/dist/components/admin/site-health/site-health-performance.js +191 -0
- package/dist/components/admin/site-health/site-health-security.integration.js +109 -0
- package/dist/components/admin/site-health/site-health-security.js +169 -0
- package/dist/components/admin/site-health/site-health-seo.js +124 -0
- package/dist/components/admin/site-health/site-health-template.js +62 -0
- package/dist/components/admin/site-health/site-health-types.js +1 -0
- package/dist/components/admin/site-health/site-health-uptime.integration.js +29 -0
- package/dist/components/admin/site-health/site-health-uptime.js +30 -0
- package/dist/components/admin/site-health/site-health.css +427 -0
- package/dist/components/admin/sites/sites.integration.js +117 -0
- package/dist/components/cms/contentful.management.js +104 -0
- package/dist/components/cms/pixelated.linkedin1.js +0 -19
- package/dist/components/shoppingcart/shipping.from.json +101 -0
- package/dist/components/shoppingcart/shipping.parcel.json +112 -0
- package/dist/components/shoppingcart/shipping.to.json +422 -0
- package/dist/components/shoppingcart/shoppingCartDiscountCodes.json +26 -0
- package/dist/components/shoppingcart/shoppingcart.components.js +3 -2
- package/dist/components/sitebuilder/config/ConfigBuilder.js +36 -140
- package/dist/components/sitebuilder/config/siteinfo-form.json +200 -0
- package/dist/components/sitebuilder/config/visualdesignform.json +244 -0
- package/dist/components/structured/buzzwordbingo.js +3 -2
- package/dist/components/utilities/functions.js +5 -2
- package/dist/data/404-data.json +128 -102
- package/dist/data/flickr.json +25 -0
- package/dist/data/form.json +368 -368
- package/dist/data/recipes.json +3251 -3251
- package/dist/data/references.json +138 -137
- package/dist/data/requestform.json +111 -0
- package/dist/data/requests.json +136 -135
- package/dist/data/resume.json +2573 -2575
- package/dist/data/routes.json +238 -238
- package/dist/data/routes2.json +141 -140
- package/dist/index.js +17 -3
- package/dist/index.server.js +37 -15
- package/dist/types/components/admin/componentusage/componentAnalysis.d.ts +35 -0
- package/dist/types/components/admin/componentusage/componentAnalysis.d.ts.map +1 -0
- package/dist/types/components/admin/componentusage/componentDiscovery.d.ts +10 -0
- package/dist/types/components/admin/componentusage/componentDiscovery.d.ts.map +1 -0
- package/dist/types/components/admin/deploy/deployment.integration.d.ts +26 -0
- package/dist/types/components/admin/deploy/deployment.integration.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/google-api-auth.d.ts +37 -0
- package/dist/types/components/admin/site-health/google-api-auth.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/seo-constants.d.ts +8 -0
- package/dist/types/components/admin/site-health/seo-constants.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-accessibility.d.ts +6 -0
- package/dist/types/components/admin/site-health/site-health-accessibility.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-axe-core.d.ts +6 -0
- package/dist/types/components/admin/site-health/site-health-axe-core.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-axe-core.integration.d.ts +63 -0
- package/dist/types/components/admin/site-health/site-health-axe-core.integration.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-cache.d.ts +12 -0
- package/dist/types/components/admin/site-health/site-health-cache.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-core-web-vitals.integration.d.ts +3 -0
- package/dist/types/components/admin/site-health/site-health-core-web-vitals.integration.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-dependency-vulnerabilities.d.ts +6 -0
- package/dist/types/components/admin/site-health/site-health-dependency-vulnerabilities.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-github.d.ts +8 -0
- package/dist/types/components/admin/site-health/site-health-github.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-github.integration.d.ts +26 -0
- package/dist/types/components/admin/site-health/site-health-github.integration.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-google-analytics.d.ts +8 -0
- package/dist/types/components/admin/site-health/site-health-google-analytics.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-google-analytics.integration.d.ts +26 -0
- package/dist/types/components/admin/site-health/site-health-google-analytics.integration.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-google-search-console.d.ts +8 -0
- package/dist/types/components/admin/site-health/site-health-google-search-console.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-google-search-console.integration.d.ts +46 -0
- package/dist/types/components/admin/site-health/site-health-google-search-console.integration.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-indicators.d.ts +73 -0
- package/dist/types/components/admin/site-health/site-health-indicators.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-on-site-seo.d.ts +4 -0
- package/dist/types/components/admin/site-health/site-health-on-site-seo.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-on-site-seo.integration.d.ts +34 -0
- package/dist/types/components/admin/site-health/site-health-on-site-seo.integration.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-overview.d.ts +6 -0
- package/dist/types/components/admin/site-health/site-health-overview.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-performance.d.ts +6 -0
- package/dist/types/components/admin/site-health/site-health-performance.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-security.d.ts +6 -0
- package/dist/types/components/admin/site-health/site-health-security.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-security.integration.d.ts +29 -0
- package/dist/types/components/admin/site-health/site-health-security.integration.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-seo.d.ts +6 -0
- package/dist/types/components/admin/site-health/site-health-seo.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-template.d.ts +12 -0
- package/dist/types/components/admin/site-health/site-health-template.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-types.d.ts +186 -0
- package/dist/types/components/admin/site-health/site-health-types.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-uptime.d.ts +6 -0
- package/dist/types/components/admin/site-health/site-health-uptime.d.ts.map +1 -0
- package/dist/types/components/admin/site-health/site-health-uptime.integration.d.ts +10 -0
- package/dist/types/components/admin/site-health/site-health-uptime.integration.d.ts.map +1 -0
- package/dist/types/components/admin/sites/sites.integration.d.ts +40 -0
- package/dist/types/components/admin/sites/sites.integration.d.ts.map +1 -0
- package/dist/types/components/cms/contentful.management.d.ts +41 -0
- package/dist/types/components/cms/contentful.management.d.ts.map +1 -1
- package/dist/types/components/cms/pixelated.linkedin1.d.ts.map +1 -1
- package/dist/types/components/sitebuilder/config/ConfigBuilder.d.ts +4 -4
- package/dist/types/components/sitebuilder/config/ConfigBuilder.d.ts.map +1 -1
- package/dist/types/components/structured/buzzwordbingo.d.ts +1 -1
- package/dist/types/components/structured/buzzwordbingo.d.ts.map +1 -1
- package/dist/types/components/structured/buzzwordbingo.words.d.ts +2 -0
- package/dist/types/components/structured/buzzwordbingo.words.d.ts.map +1 -0
- package/dist/types/components/utilities/functions.d.ts.map +1 -1
- package/dist/types/index.d.ts +17 -3
- package/dist/types/index.server.d.ts +37 -13
- package/dist/types/stories/admin/preview.d.ts +12 -0
- package/dist/types/stories/admin/preview.d.ts.map +1 -0
- package/dist/types/stories/admin/site-health.stories.d.ts +65 -0
- package/dist/types/stories/admin/site-health.stories.d.ts.map +1 -0
- package/dist/types/stories/carousel/tiles.stories.d.ts.map +1 -1
- package/dist/types/stories/structured/buzzword-bingo.stories.d.ts +1 -1
- package/dist/types/stories/structured/buzzword-bingo.stories.d.ts.map +1 -1
- package/dist/types/tests/site-health-axe-core.test.d.ts +2 -0
- package/dist/types/tests/site-health-axe-core.test.d.ts.map +1 -0
- package/dist/types/tests/site-health-cache.test.d.ts +2 -0
- package/dist/types/tests/site-health-cache.test.d.ts.map +1 -0
- package/dist/types/tests/site-health-indicators.test.d.ts +2 -0
- package/dist/types/tests/site-health-indicators.test.d.ts.map +1 -0
- package/dist/types/tests/site-health-overview.test.d.ts +2 -0
- package/dist/types/tests/site-health-overview.test.d.ts.map +1 -0
- package/dist/types/tests/site-health-template.test.d.ts +2 -0
- package/dist/types/tests/site-health-template.test.d.ts.map +1 -0
- package/dist/types/tests/sites.integration.test.d.ts +2 -0
- package/dist/types/tests/sites.integration.test.d.ts.map +1 -0
- package/package.json +14 -8
- package/dist/data/shipping.to.json +0 -422
- package/dist/data/siteinfo-form.json +0 -200
- package/dist/data/visualdesignform.json +0 -244
- package/dist/types/data/buzzwords.d.ts +0 -2
- package/dist/types/data/buzzwords.d.ts.map +0 -1
- /package/dist/{data/buzzwords.js → components/structured/buzzwordbingo.words.js} +0 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
/**
|
|
3
|
+
* On-Site SEO Analysis Integration Services
|
|
4
|
+
* Server-side utilities for performing comprehensive SEO analysis on websites
|
|
5
|
+
* Note: This makes external HTTP requests and should only be used server-side
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import puppeteer from 'puppeteer';
|
|
11
|
+
import { EXCLUDED_URL_PATTERNS, EXCLUDED_FILE_EXTENSIONS, EXCLUDED_DIRECTORY_NAMES } from './seo-constants';
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
const seoMetricsConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'seo-metrics.config.json'), 'utf8'));
|
|
15
|
+
/**
|
|
16
|
+
* Registry of data collection functions
|
|
17
|
+
*/
|
|
18
|
+
const dataCollectors = {
|
|
19
|
+
collectSemanticTagsData,
|
|
20
|
+
collectTitleTagsData,
|
|
21
|
+
collectMetaKeywordsData,
|
|
22
|
+
collectMetaDescriptionsData
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Registry of scoring functions
|
|
26
|
+
*/
|
|
27
|
+
const scorers = {
|
|
28
|
+
calculateSemanticTagsScore,
|
|
29
|
+
calculateTitleTagsScore,
|
|
30
|
+
calculateMetaKeywordsScore,
|
|
31
|
+
calculateMetaDescriptionsScore
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Data collection functions
|
|
35
|
+
*/
|
|
36
|
+
function collectSemanticTagsData(html) {
|
|
37
|
+
const hasHeader = /<header[^>]*>/i.test(html);
|
|
38
|
+
const hasFooter = /<footer[^>]*>/i.test(html);
|
|
39
|
+
const hasNav = /<nav[^>]*>/i.test(html);
|
|
40
|
+
const hasMain = /<main[^>]*>/i.test(html);
|
|
41
|
+
const hasSection = /<section[^>]*>/i.test(html);
|
|
42
|
+
const hasArticle = /<article[^>]*>/i.test(html);
|
|
43
|
+
const hasAside = /<aside[^>]*>/i.test(html);
|
|
44
|
+
const hasFigure = /<figure[^>]*>/i.test(html);
|
|
45
|
+
const hasFigcaption = /<figcaption[^>]*>/i.test(html);
|
|
46
|
+
const hasTime = /<time[^>]*>/i.test(html);
|
|
47
|
+
const hasMark = /<mark[^>]*>/i.test(html);
|
|
48
|
+
return {
|
|
49
|
+
requiredTags: [
|
|
50
|
+
{ tag: 'header', present: hasHeader },
|
|
51
|
+
{ tag: 'footer', present: hasFooter },
|
|
52
|
+
{ tag: 'nav', present: hasNav },
|
|
53
|
+
{ tag: 'main', present: hasMain },
|
|
54
|
+
{ tag: 'section', present: hasSection }
|
|
55
|
+
],
|
|
56
|
+
optionalTags: [
|
|
57
|
+
{ tag: 'article', present: hasArticle },
|
|
58
|
+
{ tag: 'aside', present: hasAside },
|
|
59
|
+
{ tag: 'figure', present: hasFigure },
|
|
60
|
+
{ tag: 'figcaption', present: hasFigcaption },
|
|
61
|
+
{ tag: 'time', present: hasTime },
|
|
62
|
+
{ tag: 'mark', present: hasMark }
|
|
63
|
+
]
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function collectTitleTagsData(html, titleMatch) {
|
|
67
|
+
return {
|
|
68
|
+
content: titleMatch ? titleMatch[1].trim() : '',
|
|
69
|
+
length: titleMatch ? titleMatch[1].trim().length : 0
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function collectMetaKeywordsData(html) {
|
|
73
|
+
const keywordsMatch = html.match(/<meta[^>]*name=["']keywords["'][^>]*content=["']([^"']*)["'][^>]*>/i);
|
|
74
|
+
const content = keywordsMatch ? keywordsMatch[1].trim() : '';
|
|
75
|
+
const length = content.length;
|
|
76
|
+
return {
|
|
77
|
+
content,
|
|
78
|
+
length
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function collectMetaDescriptionsData(html) {
|
|
82
|
+
const descriptionMatch = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["'][^>]*>/i);
|
|
83
|
+
const content = descriptionMatch ? descriptionMatch[1].trim() : '';
|
|
84
|
+
const length = content.length;
|
|
85
|
+
return {
|
|
86
|
+
content,
|
|
87
|
+
length,
|
|
88
|
+
optimal: length > 0 && length <= 160
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Generic function to analyze pattern-based metrics
|
|
93
|
+
*/
|
|
94
|
+
function analyzePatternMetric(html, metric) {
|
|
95
|
+
if (!metric.pattern) {
|
|
96
|
+
return { score: 0, displayValue: 'No pattern defined' };
|
|
97
|
+
}
|
|
98
|
+
const regex = new RegExp(metric.pattern, 'gi');
|
|
99
|
+
const matches = html.match(regex) || [];
|
|
100
|
+
const count = matches.length;
|
|
101
|
+
let score = 0;
|
|
102
|
+
let displayValue = '';
|
|
103
|
+
let details = undefined;
|
|
104
|
+
// Apply count logic
|
|
105
|
+
switch (metric.countLogic) {
|
|
106
|
+
case 'exact':
|
|
107
|
+
score = count === (metric.expectedCount || 1) ? 1 : 0;
|
|
108
|
+
break;
|
|
109
|
+
case 'min':
|
|
110
|
+
score = count >= (metric.expectedCount || 1) ? 1 : 0;
|
|
111
|
+
break;
|
|
112
|
+
case 'max':
|
|
113
|
+
score = count <= (metric.expectedCount || 1) ? 1 : 0;
|
|
114
|
+
break;
|
|
115
|
+
case 'or':
|
|
116
|
+
score = count > 0 ? 1 : 0;
|
|
117
|
+
break;
|
|
118
|
+
case 'count':
|
|
119
|
+
default:
|
|
120
|
+
score = count > 0 ? 1 : 0;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
// Apply score logic
|
|
124
|
+
if (metric.scoreLogic === 'optimal' && metric.optimalRange) {
|
|
125
|
+
const { min, max } = metric.optimalRange;
|
|
126
|
+
score = count >= min && count <= max ? 1 : 0;
|
|
127
|
+
}
|
|
128
|
+
else if (metric.scoreLogic === 'percentage') {
|
|
129
|
+
// For percentage-based scoring, we'd need additional logic
|
|
130
|
+
score = count > 0 ? 1 : 0;
|
|
131
|
+
}
|
|
132
|
+
// Generate display value
|
|
133
|
+
if (metric.displayTemplate) {
|
|
134
|
+
displayValue = metric.displayTemplate
|
|
135
|
+
.replace('{{count}}', count.toString())
|
|
136
|
+
.replace('{{matches}}', matches.length.toString());
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
displayValue = `${count} match(es) found`;
|
|
140
|
+
}
|
|
141
|
+
// Generate details based on metric type
|
|
142
|
+
if (metric.id === 'h1-tags' || metric.id === 'h2-tags') {
|
|
143
|
+
const headings = matches.map(match => {
|
|
144
|
+
const text = match.replace(/<[^>]*>/g, '').trim();
|
|
145
|
+
return { tag: metric.id.replace('-tags', ''), text };
|
|
146
|
+
});
|
|
147
|
+
details = { items: headings };
|
|
148
|
+
}
|
|
149
|
+
else if (metric.id === 'image-alt-text') {
|
|
150
|
+
// Special logic for image alt text - check each image individually
|
|
151
|
+
const images = matches.map(match => {
|
|
152
|
+
const srcMatch = match.match(/src=["']([^"']*)["']/);
|
|
153
|
+
const altMatch = match.match(/alt=["']([^"']*)["']/);
|
|
154
|
+
return {
|
|
155
|
+
src: srcMatch ? srcMatch[1] : '',
|
|
156
|
+
alt: altMatch ? altMatch[1] : null
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
const imagesWithAlt = images.filter(img => img.alt !== null).length;
|
|
160
|
+
const totalImages = images.length;
|
|
161
|
+
score = totalImages > 0 && imagesWithAlt === totalImages ? 1 : 0;
|
|
162
|
+
displayValue = `${imagesWithAlt}/${totalImages} images have alt text`;
|
|
163
|
+
details = { items: images };
|
|
164
|
+
}
|
|
165
|
+
else if (metric.id === 'canonical-urls') {
|
|
166
|
+
const canonicalMatch = matches[0]?.match(/href=["']([^"']*)["']/);
|
|
167
|
+
const canonicalUrl = canonicalMatch ? canonicalMatch[1] : null;
|
|
168
|
+
displayValue = canonicalUrl || 'No canonical URL found';
|
|
169
|
+
}
|
|
170
|
+
else if (metric.id === 'language-tags') {
|
|
171
|
+
const langMatch = matches[0]?.match(/lang=["']([^"']*)["']/);
|
|
172
|
+
const lang = langMatch ? langMatch[1] : null;
|
|
173
|
+
displayValue = lang ? `Language: ${lang}` : 'No language tag found';
|
|
174
|
+
}
|
|
175
|
+
return { score, displayValue, details };
|
|
176
|
+
}
|
|
177
|
+
function calculateSemanticTagsScore(semanticData) {
|
|
178
|
+
const requiredTagsPresent = semanticData.requiredTags.filter(tag => tag.present).length;
|
|
179
|
+
const optionalTagsPresent = semanticData.optionalTags.filter(tag => tag.present).length;
|
|
180
|
+
const totalSemanticTags = requiredTagsPresent + optionalTagsPresent;
|
|
181
|
+
return {
|
|
182
|
+
score: requiredTagsPresent >= 5 ? 1 : 0,
|
|
183
|
+
displayValue: `${requiredTagsPresent}/5 required, ${optionalTagsPresent} optional (${totalSemanticTags} total)`,
|
|
184
|
+
details: {
|
|
185
|
+
items: [
|
|
186
|
+
{ type: 'required', tags: semanticData.requiredTags },
|
|
187
|
+
{ type: 'optional', tags: semanticData.optionalTags },
|
|
188
|
+
{ type: 'summary', requiredCount: requiredTagsPresent, optionalCount: optionalTagsPresent, totalCount: totalSemanticTags }
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function calculateTitleTagsScore(titleData) {
|
|
194
|
+
const score = titleData.length > 0 && titleData.length <= 60 ? 1 : 0;
|
|
195
|
+
const displayValue = titleData.content || 'No title tag found';
|
|
196
|
+
return {
|
|
197
|
+
score,
|
|
198
|
+
displayValue,
|
|
199
|
+
details: {
|
|
200
|
+
items: [
|
|
201
|
+
{ type: 'title', content: titleData.content, length: titleData.length, optimal: titleData.length > 0 && titleData.length <= 60 }
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function calculateMetaKeywordsScore(keywordsData) {
|
|
207
|
+
const score = keywordsData.length > 0 ? 1 : 0;
|
|
208
|
+
const displayValue = keywordsData.content || 'No meta keywords found';
|
|
209
|
+
return {
|
|
210
|
+
score,
|
|
211
|
+
displayValue,
|
|
212
|
+
details: {
|
|
213
|
+
items: [
|
|
214
|
+
{ type: 'keywords', content: keywordsData.content, length: keywordsData.length, present: keywordsData.length > 0 }
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function calculateMetaDescriptionsScore(descriptionData) {
|
|
220
|
+
const score = descriptionData.optimal ? 1 : 0;
|
|
221
|
+
const displayValue = descriptionData.content || 'No meta description found';
|
|
222
|
+
return {
|
|
223
|
+
score,
|
|
224
|
+
displayValue,
|
|
225
|
+
details: {
|
|
226
|
+
items: [
|
|
227
|
+
{ type: 'description', content: descriptionData.content, length: descriptionData.length, optimal: descriptionData.optimal }
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Crawl the site to discover internal pages
|
|
234
|
+
*/
|
|
235
|
+
async function crawlSite(baseUrl, maxPages = 10) {
|
|
236
|
+
const visited = new Set();
|
|
237
|
+
const toVisit = [baseUrl];
|
|
238
|
+
const discovered = [];
|
|
239
|
+
try {
|
|
240
|
+
// Parse base URL for domain matching
|
|
241
|
+
const baseUrlObj = new URL(baseUrl);
|
|
242
|
+
const baseDomain = baseUrlObj.hostname;
|
|
243
|
+
while (toVisit.length > 0 && discovered.length < maxPages) {
|
|
244
|
+
const currentUrl = toVisit.shift();
|
|
245
|
+
if (visited.has(currentUrl))
|
|
246
|
+
continue;
|
|
247
|
+
visited.add(currentUrl);
|
|
248
|
+
discovered.push(currentUrl);
|
|
249
|
+
try {
|
|
250
|
+
const response = await fetch(currentUrl, {
|
|
251
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SEO Analysis Bot)' }
|
|
252
|
+
});
|
|
253
|
+
if (!response.ok)
|
|
254
|
+
continue;
|
|
255
|
+
const html = await response.text();
|
|
256
|
+
// Extract internal links
|
|
257
|
+
const linkRegex = /<a[^>]*href=["']([^"']*)["'][^>]*>/gi;
|
|
258
|
+
let match;
|
|
259
|
+
while ((match = linkRegex.exec(html)) !== null) {
|
|
260
|
+
try {
|
|
261
|
+
const href = match[1];
|
|
262
|
+
const absoluteUrl = new URL(href, currentUrl).toString();
|
|
263
|
+
// Only include same domain links that look like pages
|
|
264
|
+
const urlObj = new URL(absoluteUrl);
|
|
265
|
+
const pathname = urlObj.pathname.toLowerCase();
|
|
266
|
+
// Exclude common non-page directories and files
|
|
267
|
+
const isExcluded = EXCLUDED_URL_PATTERNS.some(pattern => pathname.includes(pattern)) ||
|
|
268
|
+
pathname.match(EXCLUDED_FILE_EXTENSIONS) ||
|
|
269
|
+
EXCLUDED_DIRECTORY_NAMES.some(dir => pathname.endsWith(`/${dir}`));
|
|
270
|
+
if (urlObj.hostname === baseDomain && !visited.has(absoluteUrl) && !toVisit.includes(absoluteUrl) && !isExcluded) {
|
|
271
|
+
toVisit.push(absoluteUrl);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Invalid URL, skip
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
console.warn(`Failed to crawl ${currentUrl}:`, error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
console.warn('Error during site crawling:', error);
|
|
286
|
+
}
|
|
287
|
+
return discovered.slice(0, maxPages);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Analyze a single page for on-page SEO elements using Puppeteer for full rendering
|
|
291
|
+
*/
|
|
292
|
+
async function analyzeSinglePage(url) {
|
|
293
|
+
let browser;
|
|
294
|
+
try {
|
|
295
|
+
// Reuse browser instance if available, otherwise create new one
|
|
296
|
+
browser = globalThis.__seoBrowser;
|
|
297
|
+
if (!browser || browser.isConnected() === false) {
|
|
298
|
+
browser = await puppeteer.launch({
|
|
299
|
+
headless: true,
|
|
300
|
+
args: [
|
|
301
|
+
'--no-sandbox',
|
|
302
|
+
'--disable-setuid-sandbox',
|
|
303
|
+
'--disable-dev-shm-usage',
|
|
304
|
+
'--disable-accelerated-2d-canvas',
|
|
305
|
+
'--no-first-run',
|
|
306
|
+
'--no-zygote',
|
|
307
|
+
'--disable-gpu',
|
|
308
|
+
'--disable-web-security',
|
|
309
|
+
'--disable-features=VizDisplayCompositor',
|
|
310
|
+
'--disable-extensions',
|
|
311
|
+
'--disable-plugins',
|
|
312
|
+
'--disable-default-apps',
|
|
313
|
+
'--disable-background-timer-throttling',
|
|
314
|
+
'--disable-backgrounding-occluded-windows',
|
|
315
|
+
'--disable-renderer-backgrounding'
|
|
316
|
+
]
|
|
317
|
+
});
|
|
318
|
+
globalThis.__seoBrowser = browser;
|
|
319
|
+
}
|
|
320
|
+
const page = await browser.newPage();
|
|
321
|
+
// Block unnecessary resources for faster loading while preserving SEO-relevant content
|
|
322
|
+
await page.setRequestInterception(true);
|
|
323
|
+
page.on('request', (request) => {
|
|
324
|
+
const resourceType = request.resourceType();
|
|
325
|
+
const url = request.url();
|
|
326
|
+
// Block heavy resources that slow down loading but aren't needed for HTML structure
|
|
327
|
+
if (resourceType === 'image' ||
|
|
328
|
+
resourceType === 'media' ||
|
|
329
|
+
url.includes('.jpg') ||
|
|
330
|
+
url.includes('.jpeg') ||
|
|
331
|
+
url.includes('.png') ||
|
|
332
|
+
url.includes('.gif') ||
|
|
333
|
+
url.includes('.webp') ||
|
|
334
|
+
url.includes('google-analytics.com') ||
|
|
335
|
+
url.includes('googletagmanager.com') ||
|
|
336
|
+
url.includes('facebook.com/tr') ||
|
|
337
|
+
url.includes('doubleclick.net')) {
|
|
338
|
+
request.abort();
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
request.continue();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
// Set smaller viewport for faster rendering
|
|
345
|
+
await page.setViewport({ width: 800, height: 600 });
|
|
346
|
+
// Set user agent to avoid bot detection
|
|
347
|
+
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36');
|
|
348
|
+
// Navigate to the page with faster waiting strategy
|
|
349
|
+
const response = await page.goto(url, {
|
|
350
|
+
waitUntil: 'domcontentloaded', // Wait for DOM instead of all network requests
|
|
351
|
+
timeout: 15000 // Reduced timeout
|
|
352
|
+
});
|
|
353
|
+
// Wait for H1 elements to be rendered (if any) with a short timeout
|
|
354
|
+
try {
|
|
355
|
+
await page.waitForSelector('h1', { timeout: 2000 });
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// H1 not found within timeout, continue anyway
|
|
359
|
+
}
|
|
360
|
+
// Get title and heading counts directly from DOM for speed
|
|
361
|
+
const pageData = await page.evaluate(() => {
|
|
362
|
+
return {
|
|
363
|
+
title: document.title,
|
|
364
|
+
h1Count: document.querySelectorAll('h1').length,
|
|
365
|
+
h2Count: document.querySelectorAll('h2').length,
|
|
366
|
+
h1Elements: Array.from(document.querySelectorAll('h1')).map(h1 => ({
|
|
367
|
+
text: h1.textContent?.trim() || ''
|
|
368
|
+
})),
|
|
369
|
+
h2Elements: Array.from(document.querySelectorAll('h2')).map(h2 => ({
|
|
370
|
+
text: h2.textContent?.trim() || ''
|
|
371
|
+
}))
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
// Get the rendered HTML for other pattern-based checks
|
|
375
|
+
const html = await page.content();
|
|
376
|
+
await page.close();
|
|
377
|
+
const audits = [];
|
|
378
|
+
// Process on-page metrics from configuration
|
|
379
|
+
const config = seoMetricsConfig;
|
|
380
|
+
const onPageCategory = config.categories['on-page'];
|
|
381
|
+
for (const metric of Object.values(onPageCategory.metrics)) {
|
|
382
|
+
let score = 0;
|
|
383
|
+
let displayValue = '';
|
|
384
|
+
let details = undefined;
|
|
385
|
+
// Use data collector and scorer if available
|
|
386
|
+
if (metric.dataCollector && metric.scorer) {
|
|
387
|
+
const collector = dataCollectors[metric.dataCollector];
|
|
388
|
+
const scorer = scorers[metric.scorer];
|
|
389
|
+
if (collector && scorer) {
|
|
390
|
+
const rawData = collector(html, pageData.title);
|
|
391
|
+
const result = scorer(rawData);
|
|
392
|
+
score = result.score;
|
|
393
|
+
displayValue = result.displayValue;
|
|
394
|
+
details = result.details;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
else if (metric.pattern) {
|
|
398
|
+
// Use pattern-based analysis
|
|
399
|
+
const result = analyzePatternMetric(html, metric);
|
|
400
|
+
score = result.score;
|
|
401
|
+
displayValue = result.displayValue;
|
|
402
|
+
details = result.details;
|
|
403
|
+
}
|
|
404
|
+
// Override H1 and H2 results with direct DOM counts for accuracy and speed
|
|
405
|
+
if (metric.id === 'h1-tags') {
|
|
406
|
+
score = pageData.h1Count === (metric.expectedCount || 1) ? 1 : 0;
|
|
407
|
+
displayValue = `${pageData.h1Count} H1 tag(s) found`;
|
|
408
|
+
details = { items: pageData.h1Elements.map((h1) => ({ tag: 'h1', text: h1.text })) };
|
|
409
|
+
}
|
|
410
|
+
else if (metric.id === 'h2-tags') {
|
|
411
|
+
score = pageData.h2Count > 0 ? 1 : 0;
|
|
412
|
+
displayValue = `${pageData.h2Count} H2 tag(s) found`;
|
|
413
|
+
details = { items: pageData.h2Elements.map((h2) => ({ tag: 'h2', text: h2.text })) };
|
|
414
|
+
}
|
|
415
|
+
audits.push({
|
|
416
|
+
id: metric.id,
|
|
417
|
+
title: metric.title,
|
|
418
|
+
score,
|
|
419
|
+
scoreDisplayMode: metric.scoreDisplayMode,
|
|
420
|
+
displayValue,
|
|
421
|
+
category: 'on-page',
|
|
422
|
+
details
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
url,
|
|
427
|
+
title: pageData.title,
|
|
428
|
+
statusCode: response.status(),
|
|
429
|
+
audits,
|
|
430
|
+
crawledAt: new Date().toISOString()
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
console.warn(`Failed to analyze ${url}:`, error);
|
|
435
|
+
// Return a basic analysis for failed requests
|
|
436
|
+
return {
|
|
437
|
+
url,
|
|
438
|
+
title: undefined,
|
|
439
|
+
statusCode: 0, // Indicates analysis failed
|
|
440
|
+
audits: [],
|
|
441
|
+
crawledAt: new Date().toISOString()
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
if (browser) {
|
|
446
|
+
await browser.close();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function performSiteWideAudits(baseUrl) {
|
|
451
|
+
const audits = [];
|
|
452
|
+
try {
|
|
453
|
+
// Parse base URL
|
|
454
|
+
const baseUrlObj = new URL(baseUrl);
|
|
455
|
+
const baseDomain = baseUrlObj.hostname;
|
|
456
|
+
const protocol = baseUrlObj.protocol;
|
|
457
|
+
// Process on-site metrics from configuration
|
|
458
|
+
const config = seoMetricsConfig;
|
|
459
|
+
const onSiteCategory = config.categories['on-site'];
|
|
460
|
+
for (const metric of Object.values(onSiteCategory.metrics)) {
|
|
461
|
+
let score = 0;
|
|
462
|
+
let displayValue = '';
|
|
463
|
+
// Handle metrics without custom collectors/scorers
|
|
464
|
+
switch (metric.id) {
|
|
465
|
+
case 'https':
|
|
466
|
+
score = protocol === 'https:' ? 1 : 0;
|
|
467
|
+
displayValue = score ? 'Site uses HTTPS' : 'Site does not use HTTPS';
|
|
468
|
+
break;
|
|
469
|
+
case 'url-structure': {
|
|
470
|
+
const hasQueryParams = baseUrlObj.search.length > 0;
|
|
471
|
+
score = hasQueryParams ? 0 : 1;
|
|
472
|
+
displayValue = score ? 'Clean URL structure' : 'URL contains query parameters';
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
case 'robots-txt':
|
|
476
|
+
try {
|
|
477
|
+
const robotsUrl = `${protocol}//${baseDomain}/robots.txt`;
|
|
478
|
+
const robotsResponse = await fetch(robotsUrl);
|
|
479
|
+
score = robotsResponse.ok ? 1 : 0;
|
|
480
|
+
displayValue = score ? 'Robots.txt accessible' : 'Robots.txt not found or inaccessible';
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
score = 0;
|
|
484
|
+
displayValue = 'Robots.txt not accessible';
|
|
485
|
+
}
|
|
486
|
+
break;
|
|
487
|
+
case 'sitemap-xml':
|
|
488
|
+
try {
|
|
489
|
+
const sitemapUrl = `${protocol}//${baseDomain}/sitemap.xml`;
|
|
490
|
+
const sitemapResponse = await fetch(sitemapUrl);
|
|
491
|
+
score = sitemapResponse.ok ? 1 : 0;
|
|
492
|
+
displayValue = score ? 'Sitemap.xml accessible' : 'Sitemap.xml not found or inaccessible';
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
score = 0;
|
|
496
|
+
displayValue = 'Sitemap.xml not accessible';
|
|
497
|
+
}
|
|
498
|
+
break;
|
|
499
|
+
case 'internal-linking':
|
|
500
|
+
score = 1; // Placeholder - would need full crawl analysis
|
|
501
|
+
displayValue = 'Internal links found during crawl';
|
|
502
|
+
break;
|
|
503
|
+
case 'navigation':
|
|
504
|
+
score = 1; // Placeholder - would need content analysis
|
|
505
|
+
displayValue = 'Navigation structure found';
|
|
506
|
+
break;
|
|
507
|
+
case 'broken-links':
|
|
508
|
+
score = 1; // Placeholder - would need comprehensive link checking
|
|
509
|
+
displayValue = 'No obvious broken links detected';
|
|
510
|
+
break;
|
|
511
|
+
case 'manifest-file':
|
|
512
|
+
try {
|
|
513
|
+
const manifestUrl = `${protocol}//${baseDomain}/manifest.webmanifest`;
|
|
514
|
+
const manifestResponse = await fetch(manifestUrl);
|
|
515
|
+
score = manifestResponse.ok ? 1 : 0;
|
|
516
|
+
displayValue = score ? 'Manifest.webmanifest accessible' : 'Manifest.webmanifest not found or inaccessible';
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
score = 0;
|
|
520
|
+
displayValue = 'Manifest.webmanifest not accessible';
|
|
521
|
+
}
|
|
522
|
+
break;
|
|
523
|
+
default:
|
|
524
|
+
score = 0;
|
|
525
|
+
displayValue = 'Not implemented';
|
|
526
|
+
}
|
|
527
|
+
audits.push({
|
|
528
|
+
id: metric.id,
|
|
529
|
+
title: metric.title,
|
|
530
|
+
score,
|
|
531
|
+
scoreDisplayMode: metric.scoreDisplayMode,
|
|
532
|
+
displayValue,
|
|
533
|
+
category: 'on-site'
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
console.error('Error performing site-wide audits:', error);
|
|
539
|
+
}
|
|
540
|
+
return audits;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Fetch and parse sitemap.xml to get all site URLs
|
|
544
|
+
*/
|
|
545
|
+
async function getUrlsFromSitemap(baseUrl) {
|
|
546
|
+
try {
|
|
547
|
+
const sitemapUrl = `${baseUrl}/sitemap.xml`;
|
|
548
|
+
const response = await fetch(sitemapUrl);
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
throw new Error(`Failed to fetch sitemap: ${response.status}`);
|
|
551
|
+
}
|
|
552
|
+
const xmlText = await response.text();
|
|
553
|
+
const baseUrlObj = new URL(baseUrl);
|
|
554
|
+
// Simple XML parsing to extract URLs
|
|
555
|
+
const urlRegex = /<loc>([^<]+)<\/loc>/g;
|
|
556
|
+
const urls = [];
|
|
557
|
+
let match;
|
|
558
|
+
while ((match = urlRegex.exec(xmlText)) !== null) {
|
|
559
|
+
const url = match[1].trim();
|
|
560
|
+
// Only include URLs from the same domain and that look like valid page URLs
|
|
561
|
+
try {
|
|
562
|
+
const urlObj = new URL(url);
|
|
563
|
+
if (urlObj.hostname === baseUrlObj.hostname) {
|
|
564
|
+
const pathname = urlObj.pathname.toLowerCase();
|
|
565
|
+
// Exclude common non-page directories and files
|
|
566
|
+
const isExcluded = EXCLUDED_URL_PATTERNS.some(pattern => pathname.includes(pattern)) ||
|
|
567
|
+
pathname.match(EXCLUDED_FILE_EXTENSIONS) ||
|
|
568
|
+
EXCLUDED_DIRECTORY_NAMES.some(dir => pathname.endsWith(`/${dir}`));
|
|
569
|
+
if (!isExcluded) {
|
|
570
|
+
urls.push(url);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// Invalid URL, skip
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return urls.slice(0, 20); // Limit to 20 pages to prevent excessive analysis
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
console.warn('Failed to fetch sitemap:', error);
|
|
582
|
+
return [];
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Main function to perform comprehensive on-site SEO analysis
|
|
587
|
+
*/
|
|
588
|
+
export async function performOnSiteSEOAnalysis(baseUrl) {
|
|
589
|
+
try {
|
|
590
|
+
let pagesToAnalyze = [];
|
|
591
|
+
// Try to get URLs from sitemap first
|
|
592
|
+
const sitemapUrls = await getUrlsFromSitemap(baseUrl);
|
|
593
|
+
if (sitemapUrls.length > 0) {
|
|
594
|
+
pagesToAnalyze = sitemapUrls;
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
// Fallback to crawling if sitemap not available
|
|
598
|
+
pagesToAnalyze = await crawlSite(baseUrl, 5);
|
|
599
|
+
}
|
|
600
|
+
if (pagesToAnalyze.length === 0) {
|
|
601
|
+
return {
|
|
602
|
+
site: baseUrl,
|
|
603
|
+
url: baseUrl,
|
|
604
|
+
overallScore: null,
|
|
605
|
+
pagesAnalyzed: [],
|
|
606
|
+
onSiteAudits: [],
|
|
607
|
+
totalPages: 0,
|
|
608
|
+
timestamp: new Date().toISOString(),
|
|
609
|
+
status: 'error',
|
|
610
|
+
error: 'No pages could be analyzed'
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
// Analyze each page
|
|
614
|
+
const pagesAnalyzed = [];
|
|
615
|
+
for (const pageUrl of pagesToAnalyze) {
|
|
616
|
+
try {
|
|
617
|
+
const pageAnalysis = await analyzeSinglePage(pageUrl);
|
|
618
|
+
pagesAnalyzed.push(pageAnalysis);
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
console.warn(`Failed to analyze ${pageUrl}:`, error);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// Perform site-wide audits
|
|
625
|
+
const onSiteAudits = await performSiteWideAudits(baseUrl);
|
|
626
|
+
// Calculate overall score (simplified - average of all page scores)
|
|
627
|
+
let totalScore = 0;
|
|
628
|
+
let totalAudits = 0;
|
|
629
|
+
for (const page of pagesAnalyzed) {
|
|
630
|
+
for (const audit of page.audits) {
|
|
631
|
+
if (audit.score !== null) {
|
|
632
|
+
totalScore += audit.score;
|
|
633
|
+
totalAudits++;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
for (const audit of onSiteAudits) {
|
|
638
|
+
if (audit.score !== null) {
|
|
639
|
+
totalScore += audit.score;
|
|
640
|
+
totalAudits++;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const overallScore = totalAudits > 0 ? Math.round((totalScore / totalAudits) * 100) / 100 : null;
|
|
644
|
+
return {
|
|
645
|
+
site: baseUrl,
|
|
646
|
+
url: baseUrl,
|
|
647
|
+
overallScore,
|
|
648
|
+
pagesAnalyzed,
|
|
649
|
+
onSiteAudits,
|
|
650
|
+
totalPages: pagesAnalyzed.length,
|
|
651
|
+
timestamp: new Date().toISOString(),
|
|
652
|
+
status: 'success'
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
console.error('Error performing on-site SEO analysis:', error);
|
|
657
|
+
return {
|
|
658
|
+
site: baseUrl,
|
|
659
|
+
url: baseUrl,
|
|
660
|
+
overallScore: null,
|
|
661
|
+
pagesAnalyzed: [],
|
|
662
|
+
onSiteAudits: [],
|
|
663
|
+
totalPages: 0,
|
|
664
|
+
timestamp: new Date().toISOString(),
|
|
665
|
+
status: 'error',
|
|
666
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
}
|