@pixelated-tech/components 3.8.1 → 3.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/components/admin/site-health/site-health-axe-core.integration.js +135 -20
  2. package/dist/components/admin/site-health/site-health-axe-core.integration.test.js +79 -0
  3. package/dist/components/admin/site-health/site-health-axe-core.js +35 -0
  4. package/dist/components/admin/site-health/site-health-core-web-vitals.integration.js +5 -3
  5. package/dist/components/admin/site-health/site-health-core-web-vitals.integration.test.js +33 -0
  6. package/dist/components/admin/site-health/site-health-template.js +3 -1
  7. package/dist/components/config/config.validators.js +67 -0
  8. package/dist/components/general/google.reviews.components.js +1 -2
  9. package/dist/components/general/metadata.functions.js +15 -1
  10. package/dist/components/general/proxy-csp-listener.js +20 -0
  11. package/dist/components/general/proxy-handler.js +4 -2
  12. package/dist/components/general/sitemap.js +51 -29
  13. package/dist/components/shoppingcart/ebay.components.js +114 -8
  14. package/dist/components/shoppingcart/ebay.functions.js +49 -32
  15. package/dist/components/sitebuilder/config/ConfigEngine.js +5 -2
  16. package/dist/components/sitebuilder/config/google-fonts.js +5 -3
  17. package/dist/components/sitebuilder/page/lib/pageStorageLocal.js +2 -1
  18. package/dist/config/pixelated.config.json +83 -70
  19. package/dist/index.adminclient.js +1 -0
  20. package/dist/index.js +2 -0
  21. package/dist/index.server.js +1 -0
  22. package/dist/types/components/admin/site-health/site-health-axe-core.d.ts.map +1 -1
  23. package/dist/types/components/admin/site-health/site-health-axe-core.integration.d.ts +2 -0
  24. package/dist/types/components/admin/site-health/site-health-axe-core.integration.d.ts.map +1 -1
  25. package/dist/types/components/admin/site-health/site-health-axe-core.integration.test.d.ts +2 -0
  26. package/dist/types/components/admin/site-health/site-health-axe-core.integration.test.d.ts.map +1 -0
  27. package/dist/types/components/admin/site-health/site-health-core-web-vitals.integration.d.ts +1 -0
  28. package/dist/types/components/admin/site-health/site-health-core-web-vitals.integration.d.ts.map +1 -1
  29. package/dist/types/components/admin/site-health/site-health-core-web-vitals.integration.test.d.ts +2 -0
  30. package/dist/types/components/admin/site-health/site-health-core-web-vitals.integration.test.d.ts.map +1 -0
  31. package/dist/types/components/admin/site-health/site-health-template.d.ts.map +1 -1
  32. package/dist/types/components/config/config.types.d.ts +32 -11
  33. package/dist/types/components/config/config.types.d.ts.map +1 -1
  34. package/dist/types/components/config/config.validators.d.ts +17 -0
  35. package/dist/types/components/config/config.validators.d.ts.map +1 -0
  36. package/dist/types/components/general/google.reviews.components.d.ts.map +1 -1
  37. package/dist/types/components/general/metadata.functions.d.ts +2 -8
  38. package/dist/types/components/general/metadata.functions.d.ts.map +1 -1
  39. package/dist/types/components/general/proxy-csp-listener.d.ts +15 -0
  40. package/dist/types/components/general/proxy-csp-listener.d.ts.map +1 -0
  41. package/dist/types/components/general/proxy-handler.d.ts.map +1 -1
  42. package/dist/types/components/general/schema-localbusiness.d.ts.map +1 -1
  43. package/dist/types/components/general/schema-website.d.ts.map +1 -1
  44. package/dist/types/components/general/sitemap.d.ts +1 -0
  45. package/dist/types/components/general/sitemap.d.ts.map +1 -1
  46. package/dist/types/components/shoppingcart/ebay.components.d.ts +9 -0
  47. package/dist/types/components/shoppingcart/ebay.components.d.ts.map +1 -1
  48. package/dist/types/components/shoppingcart/ebay.functions.d.ts +12 -28
  49. package/dist/types/components/shoppingcart/ebay.functions.d.ts.map +1 -1
  50. package/dist/types/components/sitebuilder/config/ConfigBuilder.d.ts +135 -0
  51. package/dist/types/components/sitebuilder/config/ConfigBuilder.d.ts.map +1 -1
  52. package/dist/types/components/sitebuilder/config/ConfigEngine.d.ts +3 -2
  53. package/dist/types/components/sitebuilder/config/ConfigEngine.d.ts.map +1 -1
  54. package/dist/types/components/sitebuilder/config/google-fonts.d.ts +1 -1
  55. package/dist/types/components/sitebuilder/config/google-fonts.d.ts.map +1 -1
  56. package/dist/types/components/sitebuilder/page/lib/pageStorageLocal.d.ts.map +1 -1
  57. package/dist/types/index.adminclient.d.ts +1 -0
  58. package/dist/types/index.d.ts +2 -0
  59. package/dist/types/index.server.d.ts +1 -0
  60. package/dist/types/stories/{seo/seo.404.stories.d.ts → general/404.stories.d.ts} +1 -1
  61. package/dist/types/stories/general/404.stories.d.ts.map +1 -0
  62. package/dist/types/stories/general/accordion.stories.d.ts +0 -1
  63. package/dist/types/stories/general/accordion.stories.d.ts.map +1 -1
  64. package/dist/types/stories/general/buzzword-bingo.stories.d.ts.map +1 -0
  65. package/dist/types/stories/general/callout.many.stories.d.ts.map +1 -0
  66. package/dist/types/stories/{callout → general}/callout.stories.d.ts.map +1 -1
  67. package/dist/types/stories/general/carousel-hero.stories.d.ts.map +1 -0
  68. package/dist/types/stories/general/carousel-reviews.stories.d.ts.map +1 -0
  69. package/dist/types/stories/general/carousel-workportfolio.stories.d.ts.map +1 -0
  70. package/dist/types/stories/general/carousel.stories.d.ts.map +1 -0
  71. package/dist/types/stories/general/contentful.item.stories.d.ts.map +1 -0
  72. package/dist/types/stories/general/contentful.items.stories.d.ts.map +1 -0
  73. package/dist/types/stories/general/contentful.stories.d.ts.map +1 -0
  74. package/dist/types/stories/{seo/seo.faq-accordion.stories.d.ts → general/faq-accordion.stories.d.ts} +1 -1
  75. package/dist/types/stories/general/faq-accordion.stories.d.ts.map +1 -0
  76. package/dist/types/stories/{cms → general}/google.reviews.stories.d.ts +1 -1
  77. package/dist/types/stories/general/google.reviews.stories.d.ts.map +1 -0
  78. package/dist/types/stories/{seo/seo.googleanalytics.stories.d.ts → general/googleanalytics.stories.d.ts} +1 -1
  79. package/dist/types/stories/general/googleanalytics.stories.d.ts.map +1 -0
  80. package/dist/types/stories/{seo/seo.googlesearch.stories.d.ts → general/googlesearch.stories.d.ts} +1 -1
  81. package/dist/types/stories/general/googlesearch.stories.d.ts.map +1 -0
  82. package/dist/types/stories/general/gravatar.stories.d.ts.map +1 -0
  83. package/dist/types/stories/{cms → general}/instagram.stories.d.ts +2 -2
  84. package/dist/types/stories/general/instagram.stories.d.ts.map +1 -0
  85. package/dist/types/stories/general/layout.stories.d.ts +9 -9
  86. package/dist/types/stories/{structured → general}/markdown.stories.d.ts +1 -1
  87. package/dist/types/stories/general/markdown.stories.d.ts.map +1 -0
  88. package/dist/types/stories/{menu → general}/menu-accordion.stories.d.ts +2 -2
  89. package/dist/types/stories/general/menu-accordion.stories.d.ts.map +1 -0
  90. package/dist/types/stories/{menu → general}/menu-expando.stories.d.ts +1 -1
  91. package/dist/types/stories/general/menu-expando.stories.d.ts.map +1 -0
  92. package/dist/types/stories/{menu → general}/menu-simple.stories.d.ts +1 -1
  93. package/dist/types/stories/general/menu-simple.stories.d.ts.map +1 -0
  94. package/dist/types/stories/{seo/seo.metadata.stories.d.ts → general/metadata.stories.d.ts} +1 -1
  95. package/dist/types/stories/general/metadata.stories.d.ts.map +1 -0
  96. package/dist/types/stories/general/nerdjoke.stories.d.ts.map +1 -0
  97. package/dist/types/stories/{structured → general}/recipe.stories.d.ts +1 -1
  98. package/dist/types/stories/general/recipe.stories.d.ts.map +1 -0
  99. package/dist/types/stories/{structured → general}/resume.stories.d.ts +1 -1
  100. package/dist/types/stories/{structured → general}/resume.stories.d.ts.map +1 -1
  101. package/dist/types/stories/{seo/seo.schema.stories.d.ts → general/schema.stories.d.ts} +1 -1
  102. package/dist/types/stories/general/schema.stories.d.ts.map +1 -0
  103. package/dist/types/stories/{seo/seo.sitemap.stories.d.ts → general/sitemap.stories.d.ts} +1 -1
  104. package/dist/types/stories/general/sitemap.stories.d.ts.map +1 -0
  105. package/dist/types/stories/general/socialcard.stories.d.ts.map +1 -0
  106. package/dist/types/stories/general/tiles.stories.d.ts.map +1 -0
  107. package/dist/types/stories/general/timeline.stories.d.ts.map +1 -0
  108. package/dist/types/stories/general/wordpress.stories.d.ts.map +1 -0
  109. package/dist/types/stories/shoppingcart/ebay.stories.d.ts +16 -0
  110. package/dist/types/stories/shoppingcart/ebay.stories.d.ts.map +1 -0
  111. package/dist/types/stories/sitebuilder/compoundfontselector.stories.d.ts +2 -2
  112. package/dist/types/stories/sitebuilder/compoundfontselector.stories.d.ts.map +1 -1
  113. package/dist/types/test/test-utils.d.ts +3 -0
  114. package/dist/types/test/test-utils.d.ts.map +1 -1
  115. package/dist/types/tests/config.validators.test.d.ts +2 -0
  116. package/dist/types/tests/config.validators.test.d.ts.map +1 -0
  117. package/package.json +5 -5
  118. package/dist/types/stories/callout/callout.many.stories.d.ts.map +0 -1
  119. package/dist/types/stories/carousel/carousel-hero.stories.d.ts.map +0 -1
  120. package/dist/types/stories/carousel/carousel-reviews.stories.d.ts.map +0 -1
  121. package/dist/types/stories/carousel/carousel-workportfolio.stories.d.ts.map +0 -1
  122. package/dist/types/stories/carousel/carousel.stories.d.ts.map +0 -1
  123. package/dist/types/stories/carousel/tiles.stories.d.ts.map +0 -1
  124. package/dist/types/stories/cms/contentful.item.stories.d.ts.map +0 -1
  125. package/dist/types/stories/cms/contentful.items.stories.d.ts.map +0 -1
  126. package/dist/types/stories/cms/contentful.stories.d.ts.map +0 -1
  127. package/dist/types/stories/cms/google.reviews.stories.d.ts.map +0 -1
  128. package/dist/types/stories/cms/gravatar.stories.d.ts.map +0 -1
  129. package/dist/types/stories/cms/instagram.stories.d.ts.map +0 -1
  130. package/dist/types/stories/cms/wordpress.stories.d.ts.map +0 -1
  131. package/dist/types/stories/menu/menu-accordion.stories.d.ts.map +0 -1
  132. package/dist/types/stories/menu/menu-expando.stories.d.ts.map +0 -1
  133. package/dist/types/stories/menu/menu-simple.stories.d.ts.map +0 -1
  134. package/dist/types/stories/nerdjoke.stories.d.ts.map +0 -1
  135. package/dist/types/stories/seo/seo.404.stories.d.ts.map +0 -1
  136. package/dist/types/stories/seo/seo.faq-accordion.stories.d.ts.map +0 -1
  137. package/dist/types/stories/seo/seo.googleanalytics.stories.d.ts.map +0 -1
  138. package/dist/types/stories/seo/seo.googlesearch.stories.d.ts.map +0 -1
  139. package/dist/types/stories/seo/seo.metadata.stories.d.ts.map +0 -1
  140. package/dist/types/stories/seo/seo.schema.stories.d.ts.map +0 -1
  141. package/dist/types/stories/seo/seo.sitemap.stories.d.ts.map +0 -1
  142. package/dist/types/stories/structured/buzzword-bingo.stories.d.ts.map +0 -1
  143. package/dist/types/stories/structured/markdown.stories.d.ts.map +0 -1
  144. package/dist/types/stories/structured/recipe.stories.d.ts.map +0 -1
  145. package/dist/types/stories/structured/socialcard.stories.d.ts.map +0 -1
  146. package/dist/types/stories/structured/timeline.stories.d.ts.map +0 -1
  147. /package/dist/types/stories/{structured → general}/buzzword-bingo.stories.d.ts +0 -0
  148. /package/dist/types/stories/{callout → general}/callout.many.stories.d.ts +0 -0
  149. /package/dist/types/stories/{callout → general}/callout.stories.d.ts +0 -0
  150. /package/dist/types/stories/{carousel → general}/carousel-hero.stories.d.ts +0 -0
  151. /package/dist/types/stories/{carousel → general}/carousel-reviews.stories.d.ts +0 -0
  152. /package/dist/types/stories/{carousel → general}/carousel-workportfolio.stories.d.ts +0 -0
  153. /package/dist/types/stories/{carousel → general}/carousel.stories.d.ts +0 -0
  154. /package/dist/types/stories/{cms → general}/contentful.item.stories.d.ts +0 -0
  155. /package/dist/types/stories/{cms → general}/contentful.items.stories.d.ts +0 -0
  156. /package/dist/types/stories/{cms → general}/contentful.stories.d.ts +0 -0
  157. /package/dist/types/stories/{cms → general}/gravatar.stories.d.ts +0 -0
  158. /package/dist/types/stories/{nerdjoke.stories.d.ts → general/nerdjoke.stories.d.ts} +0 -0
  159. /package/dist/types/stories/{structured → general}/socialcard.stories.d.ts +0 -0
  160. /package/dist/types/stories/{carousel → general}/tiles.stories.d.ts +0 -0
  161. /package/dist/types/stories/{structured → general}/timeline.stories.d.ts +0 -0
  162. /package/dist/types/stories/{cms → general}/wordpress.stories.d.ts +0 -0
@@ -1,9 +1,12 @@
1
1
  "use server";
2
2
  import puppeteer from 'puppeteer';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ const debug = false;
3
6
  export async function performAxeCoreAnalysis(url) {
4
7
  try {
5
8
  // Run axe-core analysis
6
- const axeResult = await runAxeCoreAnalysis(url);
9
+ const { result: axeResult, injectionSource } = await runAxeCoreAnalysis(url);
7
10
  // Calculate summary
8
11
  const summary = {
9
12
  violations: axeResult.violations.length,
@@ -22,6 +25,7 @@ export async function performAxeCoreAnalysis(url) {
22
25
  summary,
23
26
  timestamp: new Date().toISOString(),
24
27
  status: 'success',
28
+ injectionSource: injectionSource,
25
29
  };
26
30
  }
27
31
  catch (error) {
@@ -80,6 +84,38 @@ async function runAxeCoreAnalysis(url) {
80
84
  const page = await browser.newPage();
81
85
  // Set viewport for consistent results
82
86
  await page.setViewport({ width: 1280, height: 720 });
87
+ // Capture console messages from the page for debugging
88
+ if (debug) {
89
+ page.on('console', msg => {
90
+ try {
91
+ console.info('PAGE CONSOLE:', msg.text());
92
+ }
93
+ catch (e) {
94
+ console.warn('PAGE CONSOLE (error reading):', e);
95
+ }
96
+ });
97
+ // Capture failed requests (esp. script loads) and successful script responses
98
+ page.on('requestfailed', req => {
99
+ try {
100
+ if (req.resourceType && req.resourceType() === 'script') {
101
+ console.warn('PAGE REQUEST FAILED:', req.url(), req.failure()?.errorText);
102
+ }
103
+ }
104
+ catch (e) {
105
+ // ignore
106
+ }
107
+ });
108
+ page.on('response', resp => {
109
+ try {
110
+ if (resp.request && resp.request().resourceType() === 'script') {
111
+ console.info('PAGE SCRIPT RESPONSE:', resp.url(), resp.status());
112
+ }
113
+ }
114
+ catch (e) {
115
+ // ignore
116
+ }
117
+ });
118
+ }
83
119
  // Set user agent to avoid bot detection
84
120
  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');
85
121
  // Navigate to the page with timeout
@@ -89,27 +125,106 @@ async function runAxeCoreAnalysis(url) {
89
125
  });
90
126
  // Wait a bit for dynamic content to load
91
127
  await new Promise(resolve => setTimeout(resolve, 2000));
92
- // Inject axe-core by adding the script tag
93
- await page.addScriptTag({
94
- url: 'https://cdn.jsdelivr.net/npm/axe-core@4.8.2/axe.min.js'
95
- });
96
- // Wait a bit for axe to load
97
- await new Promise(resolve => setTimeout(resolve, 1000));
98
- // Run axe-core analysis
99
- const result = await page.evaluate(async () => {
100
- // Check if axe is available
101
- if (typeof window.axe === 'undefined') {
102
- throw new Error('axe-core not loaded');
128
+ // Try to inject axe-core via CDN first; if that fails (network restrictions), fall back to several local strategies
129
+ let injectionSource = 'none';
130
+ try {
131
+ await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/axe-core/axe.min.js' });
132
+ // Wait a bit for axe to load
133
+ await new Promise(resolve => setTimeout(resolve, 1000));
134
+ injectionSource = 'cdn';
135
+ }
136
+ catch (err) {
137
+ let injected = false;
138
+ // Try common local node_modules locations relative to process.cwd() and __dirname
139
+ const possiblePaths = [
140
+ path.join(process.cwd(), 'node_modules', 'axe-core', 'axe.min.js'),
141
+ path.join(process.cwd(), '..', 'node_modules', 'axe-core', 'axe.min.js'),
142
+ path.join(__dirname, '..', '..', 'node_modules', 'axe-core', 'axe.min.js')
143
+ ];
144
+ for (const p of possiblePaths) {
145
+ try {
146
+ if (fs.existsSync(p)) {
147
+ const fileSrc = fs.readFileSync(p, 'utf8');
148
+ await page.addScriptTag({ content: fileSrc });
149
+ injected = true;
150
+ injectionSource = 'local-inline';
151
+ break;
152
+ }
153
+ }
154
+ catch (e) {
155
+ // ignore local file read errors
156
+ }
157
+ }
158
+ // Last resort: require.resolve
159
+ if (!injected) {
160
+ try {
161
+ const axePath = require.resolve('axe-core/axe.min.js');
162
+ const axeSrc = fs.readFileSync(axePath, 'utf8');
163
+ await page.addScriptTag({ content: axeSrc });
164
+ injected = true;
165
+ injectionSource = 'require-resolve';
166
+ }
167
+ catch (e) {
168
+ // ignore
169
+ }
170
+ }
171
+ if (!injected) {
172
+ throw new Error('Could not load axe-core via CDN or local inline injection');
103
173
  }
104
- // Run axe with all rules enabled
105
- const axeResults = await window.axe.run(document, {
106
- rules: {}, // Run all rules
107
- runOnly: undefined, // Don't restrict to specific rule sets
108
- reporter: 'v2'
174
+ }
175
+ // Run axe-core analysis (poll across frames for availability after injection)
176
+ // Wait up to 10s total for window.axe to appear (check every 200ms across frames)
177
+ const timeoutMs = 10000;
178
+ const intervalMs = 200;
179
+ const start = Date.now();
180
+ let axeResults = null;
181
+ let frameWithAxe = null;
182
+ while (!frameWithAxe && Date.now() - start < timeoutMs) {
183
+ const frames = page.frames();
184
+ for (const f of frames) {
185
+ try {
186
+ const hasAxe = await f.evaluate(() => typeof window.axe !== 'undefined').catch(() => false);
187
+ if (hasAxe) {
188
+ frameWithAxe = f;
189
+ break;
190
+ }
191
+ }
192
+ catch (e) {
193
+ // ignore frame evaluation errors
194
+ }
195
+ }
196
+ if (!frameWithAxe)
197
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
198
+ }
199
+ if (!frameWithAxe) {
200
+ // Collect some debug info to help identify why axe didn't attach
201
+ try {
202
+ const scripts = await page.evaluate(() => Array.from(document.querySelectorAll('script')).map(s => (s.src || (s.innerText || '').slice(0, 200))));
203
+ const csp = await page.evaluate(() => document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute('content') || null);
204
+ const pageDiag = await page.evaluate(() => ({ hasAxe: typeof window.axe !== 'undefined', axeKeys: Object.keys(window).filter(k => /axe/i.test(k)), windowHasAxeRun: typeof window.axe?.run === 'function' }));
205
+ }
206
+ catch (e) {
207
+ // ignore diagnostic errors
208
+ }
209
+ // Diagnostic: no axe found in any frame
210
+ throw new Error('axe-core not loaded');
211
+ }
212
+ // Run axe in the frame that has it
213
+ try {
214
+ axeResults = await frameWithAxe.evaluate(async () => {
215
+ return await window.axe.run(document, {
216
+ rules: {},
217
+ runOnly: undefined,
218
+ reporter: 'v2'
219
+ });
109
220
  });
110
- return axeResults;
111
- });
112
- return result;
221
+ }
222
+ catch (e) {
223
+ if (debug)
224
+ console.error('Axe run failed:', e);
225
+ throw e;
226
+ }
227
+ return { result: axeResults, injectionSource };
113
228
  }
114
229
  finally {
115
230
  if (browser) {
@@ -0,0 +1,79 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ // We'll mock puppeteer and fs before importing the module under test to avoid ESM spy limitations
3
+ describe('performAxeCoreAnalysis (CDN blocked -> local-inline fallback)', () => {
4
+ beforeEach(async () => {
5
+ vi.resetModules();
6
+ // Mock puppeteer browser and page
7
+ const page = {
8
+ setViewport: vi.fn().mockResolvedValue(undefined),
9
+ on: vi.fn().mockReturnValue(undefined),
10
+ setUserAgent: vi.fn().mockResolvedValue(undefined),
11
+ goto: vi.fn().mockResolvedValue(undefined),
12
+ addScriptTag: vi.fn().mockImplementation(async (opts) => {
13
+ if (opts && opts.url && opts.url.includes('cdn.jsdelivr')) {
14
+ // Simulate CDN blocked
15
+ throw new Error('CDN blocked');
16
+ }
17
+ // Otherwise pretend inline injection succeeded
18
+ return Promise.resolve(undefined);
19
+ }),
20
+ frames: vi.fn().mockReturnValue([{
21
+ evaluate: vi.fn().mockImplementation(async (fn) => {
22
+ const fnStr = fn.toString();
23
+ if (fnStr.includes('typeof (window as any).axe') || fnStr.includes('typeof window.axe')) {
24
+ return true; // axe is present after inline injection
25
+ }
26
+ if (fnStr.includes('axe.run') || fnStr.includes('window.axe.run')) {
27
+ // return a minimal axe result shape
28
+ return {
29
+ violations: [],
30
+ passes: [],
31
+ incomplete: [],
32
+ inapplicable: [],
33
+ testEngine: { name: 'axe-core', version: 'test' },
34
+ testRunner: { name: 'mock' },
35
+ testEnvironment: { userAgent: 'mock', windowWidth: 1280, windowHeight: 720 },
36
+ timestamp: new Date().toISOString(),
37
+ url: 'http://example'
38
+ };
39
+ }
40
+ return null;
41
+ })
42
+ }])
43
+ };
44
+ const browser = {
45
+ newPage: vi.fn().mockResolvedValue(page),
46
+ close: vi.fn().mockResolvedValue(undefined)
47
+ };
48
+ // Mock puppeteer before importing the module to avoid ESM spy issues
49
+ vi.doMock('puppeteer', async (importOriginal) => {
50
+ // Provide a minimal mock that exposes launch as both default and named
51
+ return {
52
+ default: { launch: () => Promise.resolve(browser) },
53
+ launch: () => Promise.resolve(browser)
54
+ };
55
+ });
56
+ // Mock fs before importing the module (provide both default and named exports for interop)
57
+ vi.doMock('fs', () => ({
58
+ existsSync: () => true,
59
+ readFileSync: () => '/* fake axe content */',
60
+ default: {
61
+ existsSync: () => true,
62
+ readFileSync: () => '/* fake axe content */'
63
+ }
64
+ }));
65
+ });
66
+ afterEach(() => {
67
+ vi.restoreAllMocks();
68
+ vi.clearAllMocks();
69
+ vi.resetModules();
70
+ });
71
+ it('falls back to local inline injection when CDN is blocked and reports injectionSource "local-inline"', async () => {
72
+ const { performAxeCoreAnalysis } = await import('./site-health-axe-core.integration');
73
+ const url = 'http://example.local';
74
+ const res = await performAxeCoreAnalysis(url);
75
+ expect(res).toBeDefined();
76
+ expect(res.status).toBe('success');
77
+ expect(res.injectionSource).toBe('local-inline');
78
+ });
79
+ });
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import React from 'react';
3
4
  import PropTypes from 'prop-types';
4
5
  import { SiteHealthTemplate } from './site-health-template';
5
6
  import { getImpactIndicator, getIncompleteIndicator, getPassingIndicator } from './site-health-indicators';
@@ -10,6 +11,40 @@ export function SiteHealthAxeCore({ siteName }) {
10
11
  const getImpactColor = (impact) => {
11
12
  return getImpactIndicator(impact).color;
12
13
  };
14
+ // Ensure axe-core is available on the admin page so self-analysis and debugging are possible
15
+ React.useEffect(() => {
16
+ if (typeof window === 'undefined')
17
+ return;
18
+ if (window.axe)
19
+ return; // already loaded
20
+ const cdnSrc = 'https://cdn.jsdelivr.net/npm/axe-core/axe.min.js';
21
+ const apiFallback = '/api/axe-core';
22
+ function injectScript(src) {
23
+ const script = document.createElement('script');
24
+ script.src = src;
25
+ script.async = false; // preserve execution order
26
+ script.onload = () => console.info('axe-core loaded from', src);
27
+ script.onerror = async () => {
28
+ console.warn('Failed to load axe-core from', src);
29
+ if (src !== apiFallback) {
30
+ // Try fallback to local API that serves the bundle
31
+ injectScript(apiFallback);
32
+ }
33
+ };
34
+ document.head.appendChild(script);
35
+ }
36
+ // Try CDN first, then API fallback
37
+ try {
38
+ injectScript(cdnSrc);
39
+ }
40
+ catch (e) {
41
+ console.warn('Could not inject axe-core script:', e);
42
+ injectScript(apiFallback);
43
+ }
44
+ return () => {
45
+ // no cleanup: scripts persist on the page
46
+ };
47
+ }, []);
13
48
  const getImpactIcon = (impact) => {
14
49
  return getImpactIndicator(impact).icon;
15
50
  };
@@ -1,4 +1,5 @@
1
1
  "use server";
2
+ import { getFullPixelatedConfig } from '../../config/config';
2
3
  const psiCache = new Map();
3
4
  const CACHE_TTL_SUCCESS = 60 * 60 * 1000; // 1 hour for successful results
4
5
  const CACHE_TTL_ERROR = 5 * 60 * 1000; // 5 minutes for error results
@@ -83,10 +84,11 @@ export async function performCoreWebVitalsAnalysis(url, siteName, useCache = tru
83
84
  return errorResult;
84
85
  }
85
86
  }
86
- async function fetchPSIData(url) {
87
- const apiKey = process.env.GOOGLE_API_KEY;
87
+ export async function fetchPSIData(url) {
88
+ // Require the API key from the unified pixelated.config.json. No environment fallback.
89
+ const apiKey = getFullPixelatedConfig()?.google?.api_key;
88
90
  if (!apiKey) {
89
- throw new Error('GOOGLE_API_KEY environment variable is not set');
91
+ throw new Error('Google API key is not set; set google.api_key in pixelated.config.json');
90
92
  }
91
93
  const psiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&key=${apiKey}&strategy=mobile&category=performance&category=accessibility&category=best-practices&category=seo`;
92
94
  const fetchWithRetry = async (url, maxRetries = 2) => {
@@ -0,0 +1,33 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as configModule from '../../config/config';
3
+ import { fetchPSIData } from './site-health-core-web-vitals.integration';
4
+ import { mockConfig } from '../../../test/config.mock';
5
+ // Use the test harness mock config derived from src/config/pixelated.config.json
6
+ describe('fetchPSIData', () => {
7
+ let originalFetch;
8
+ let getFullConfigSpy;
9
+ beforeEach(() => {
10
+ originalFetch = globalThis.fetch;
11
+ globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ lighthouseResult: { audits: { someAudit: {} }, categories: {} } }) });
12
+ // Ensure server-side code sees the standard test harness config by default
13
+ getFullConfigSpy = vi.spyOn(configModule, 'getFullPixelatedConfig').mockReturnValue(mockConfig);
14
+ });
15
+ afterEach(() => {
16
+ globalThis.fetch = originalFetch;
17
+ getFullConfigSpy?.mockRestore();
18
+ vi.restoreAllMocks();
19
+ });
20
+ it('uses API key from pixelated.config.json', async () => {
21
+ const apiKey = mockConfig?.google?.api_key;
22
+ expect(apiKey).toBeDefined();
23
+ const url = 'https://example.com';
24
+ await fetchPSIData(url);
25
+ expect(globalThis.fetch).toHaveBeenCalled();
26
+ const calledUrl = globalThis.fetch.mock.calls[0][0];
27
+ expect(calledUrl).toContain(`key=${apiKey}`);
28
+ });
29
+ it('throws when api key is missing from config', async () => {
30
+ getFullConfigSpy.mockReturnValue({});
31
+ await expect(fetchPSIData('https://example.com')).rejects.toThrow('Google API key is not set');
32
+ });
33
+ });
@@ -76,7 +76,9 @@ export function SiteHealthTemplate(props) {
76
76
  // Check for cache control from URL query parameters
77
77
  const urlParams = new URLSearchParams(window.location.search);
78
78
  const cacheParam = urlParams.get('cache');
79
- const useCache = typedProps.enableCacheControl ?? true ? (cacheParam !== 'false') : true;
79
+ // Correctly compute useCache: if enableCacheControl is enabled (default true), honor cacheParam; if disabled, caching is off
80
+ const enableCacheControl = (typeof typedProps.enableCacheControl === 'boolean') ? typedProps.enableCacheControl : true;
81
+ const useCache = enableCacheControl ? (String(cacheParam).toLowerCase() !== 'false') : false;
80
82
  const result = await fetchFromEndpoint(useCache);
81
83
  setData(result);
82
84
  setError(null);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Assert that an object is a valid SiteInfo with required fields.
3
+ * Throws with a clear message if validation fails.
4
+ */
5
+ export function assertSiteInfo(v) {
6
+ const missing = [];
7
+ if (!v || typeof v !== 'object') {
8
+ throw new Error('Invalid routes.json: siteInfo is missing or not an object');
9
+ }
10
+ ['name', 'url', 'description'].forEach(k => {
11
+ if (!v[k] || typeof v[k] !== 'string' || String(v[k]).trim() === '')
12
+ missing.push(k);
13
+ });
14
+ if (missing.length) {
15
+ throw new Error(`Invalid routes.json: siteInfo missing required fields: ${missing.join(', ')}`);
16
+ }
17
+ }
18
+ /**
19
+ * Basic validation for a routes object/array. Ensures the structure looks like
20
+ * a routes data blob and contains at least one named route.
21
+ */
22
+ export function assertRoutes(routes) {
23
+ if (!routes || (typeof routes !== 'object' && !Array.isArray(routes))) {
24
+ throw new Error('Invalid routes.json: routes is missing or not an object/array');
25
+ }
26
+ const found = (function findAnyNamed(obj) {
27
+ if (!obj || typeof obj !== 'object')
28
+ return false;
29
+ if (Array.isArray(obj))
30
+ return obj.some(item => findAnyNamed(item));
31
+ if (obj.name && typeof obj.name === 'string')
32
+ return true;
33
+ for (const k of Object.keys(obj)) {
34
+ if (findAnyNamed(obj[k]))
35
+ return true;
36
+ }
37
+ return false;
38
+ })(routes);
39
+ if (!found) {
40
+ throw new Error('Invalid routes.json: expected at least one route entry with a `name` property');
41
+ }
42
+ }
43
+ /**
44
+ * Basic validation for visualdesign tokens. Ensures it's an object and contains
45
+ * at least the common tokens we care about (e.g., colors or fonts may be optional)
46
+ */
47
+ export function assertVisualDesign(v) {
48
+ if (v === undefined || v === null) {
49
+ throw new Error('Invalid routes.json: visualdesign is missing');
50
+ }
51
+ if (typeof v !== 'object' || Array.isArray(v)) {
52
+ throw new Error('Invalid routes.json: visualdesign must be an object');
53
+ }
54
+ const keys = Object.keys(v || {});
55
+ if (keys.length === 0) {
56
+ throw new Error('Invalid routes.json: visualdesign must contain at least one token');
57
+ }
58
+ for (const k of keys) {
59
+ const val = v[k];
60
+ // Accept simple string tokens or objects with a string `value` property
61
+ if (typeof val === 'string')
62
+ continue;
63
+ if (val && typeof val === 'object' && typeof val.value === 'string')
64
+ continue;
65
+ throw new Error(`Invalid routes.json: visualdesign token '${k}' has an invalid value`);
66
+ }
67
+ }
@@ -6,7 +6,6 @@ import { SmartImage } from './smartimage';
6
6
  import { getGoogleReviewsByPlaceId } from './google.reviews.functions';
7
7
  import { usePixelatedConfig } from '../config/config.client';
8
8
  import './google.reviews.css';
9
- const GOOGLE_MAPS_API_KEY = 'AIzaSyBJVi0O9Ir9imRgINLZbojTifatX-Z4aUs';
10
9
  GoogleReviewsCard.propTypes = {
11
10
  placeId: PropTypes.string.isRequired,
12
11
  language: PropTypes.string,
@@ -20,7 +19,7 @@ export function GoogleReviewsCard(props) {
20
19
  const [reviews, setReviews] = useState([]);
21
20
  const [loading, setLoading] = useState(true);
22
21
  const [error, setError] = useState(null);
23
- const apiKey = props.apiKey || config?.googleMaps?.apiKey || GOOGLE_MAPS_API_KEY;
22
+ const apiKey = props.apiKey || config?.googleMaps?.apiKey || '';
24
23
  const proxyBase = props.proxyBase || config?.global?.proxyUrl || undefined;
25
24
  useEffect(() => {
26
25
  (async () => {
@@ -65,6 +65,8 @@ export function getAllRoutes(routes, key) {
65
65
  return result;
66
66
  }
67
67
  export const getMetadata = (routes, key = "name", value = "Home") => {
68
+ // Validate the routes blob early to fail fast if invalid
69
+ assertRoutes(routes);
68
70
  const foundObject = getRouteByKey(routes, key, value);
69
71
  if (foundObject) {
70
72
  const metadata = {
@@ -105,8 +107,20 @@ export function getAccordionMenuData(myRoutes) {
105
107
  });
106
108
  return menuItems;
107
109
  }
110
+ import { assertSiteInfo, assertRoutes } from '../config/config.validators';
108
111
  export function generateMetaTags(props) {
109
112
  const { title, description, keywords, origin, url, site_name: prop_site_name, email: prop_email, image: prop_image, image_height: prop_image_height, image_width: prop_image_width, favicon: prop_favicon, siteInfo } = props;
113
+ const safeOrigin = typeof origin === 'string' && origin.length > 0 ? origin : '';
114
+ const safeUrl = typeof url === 'string' && url.length > 0 ? url : '';
115
+ let newOrigin;
116
+ try {
117
+ newOrigin = safeOrigin ? new URL(safeOrigin).hostname : undefined;
118
+ }
119
+ catch {
120
+ newOrigin = undefined;
121
+ }
122
+ // Validate siteInfo strictly so downstream sites must provide the contract
123
+ assertSiteInfo(siteInfo);
110
124
  // Use props if provided, otherwise fall back to siteInfo
111
125
  const site_name = prop_site_name || siteInfo?.name;
112
126
  const email = prop_email || siteInfo?.email;
@@ -114,5 +128,5 @@ export function generateMetaTags(props) {
114
128
  const image_height = prop_image_height || siteInfo?.image_height;
115
129
  const image_width = prop_image_width || siteInfo?.image_width;
116
130
  const favicon = prop_favicon || siteInfo?.favicon;
117
- return (_jsxs(_Fragment, { children: [_jsx("title", { children: title }), _jsx("meta", { charSet: "UTF-8" }), _jsx("meta", { httpEquiv: "content-type", content: "text/html; charset=UTF-8" }), _jsx("meta", { httpEquiv: 'Expires', content: '0' }), _jsx("meta", { httpEquiv: 'Pragma', content: 'no-cache' }), _jsx("meta", { httpEquiv: 'Cache-Control', content: 'no-cache' }), _jsx("meta", { name: "application-name", content: site_name }), _jsx("meta", { name: "author", content: site_name + ", " + email }), _jsx("meta", { name: 'copyright', content: site_name }), _jsx("meta", { name: "creator", content: site_name }), _jsx("meta", { name: "description", content: description }), _jsx("meta", { name: "keywords", content: keywords }), _jsx("meta", { name: 'language', content: 'EN' }), _jsx("meta", { name: 'owner', content: site_name }), _jsx("meta", { name: "publisher", content: site_name }), _jsx("meta", { name: 'rating', content: 'General' }), _jsx("meta", { name: 'reply-to', content: email }), _jsx("meta", { name: "robots", content: "index, follow" }), _jsx("meta", { name: 'url', content: url }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0, shrink-to-fit=no" }), _jsx("meta", { property: "og:description", content: description }), _jsx("meta", { property: 'og:email', content: email }), _jsx("meta", { property: "og:image", content: image }), _jsx("meta", { property: "og:image:height", content: image_height }), _jsx("meta", { property: "og:image:width", content: image_width }), _jsx("meta", { property: "og:locale", content: "en_US" }), _jsx("meta", { property: "og:site_name", content: site_name }), _jsx("meta", { property: "og:title", content: title }), _jsx("meta", { property: "og:type", content: "website" }), _jsx("meta", { property: "og:url", content: url }), _jsx("meta", { itemProp: "name", content: site_name }), _jsx("meta", { itemProp: "url", content: url }), _jsx("meta", { itemProp: "description", content: description }), _jsx("meta", { itemProp: "thumbnailUrl", content: image }), _jsx("meta", { property: "twitter:domain", content: new URL(origin).hostname }), _jsx("meta", { property: "twitter:url", content: url }), _jsx("meta", { name: "twitter:card", content: "summary_large_image" }), _jsx("meta", { name: "twitter:creator", content: site_name }), _jsx("meta", { name: "twitter:description", content: description }), _jsx("meta", { name: "twitter:image", content: image }), _jsx("meta", { name: "twitter:image:height", content: image_height }), _jsx("meta", { name: "twitter:image:width", content: image_width }), _jsx("meta", { name: "twitter:title", content: title }), _jsx("link", { rel: "author", href: origin }), _jsx("link", { rel: "canonical", href: url }), _jsx("link", { rel: "icon", type: "image/x-icon", href: favicon }), _jsx("link", { rel: "shortcut icon", type: "image/x-icon", href: favicon }), _jsx("link", { rel: "manifest", href: "/manifest.webmanifest" }), _jsx("link", { rel: "preconnect", href: "https://images.ctfassets.net/" }), _jsx("link", { rel: "preconnect", href: "https://res.cloudinary.com/" }), _jsx("link", { rel: "preconnect", href: "https://farm2.static.flickr.com" }), _jsx("link", { rel: "preconnect", href: "https://farm6.static.flickr.com" }), _jsx("link", { rel: "preconnect", href: "https://farm8.static.flickr.com" }), _jsx("link", { rel: "preconnect", href: "https://farm66.static.flickr.com" })] }));
131
+ return (_jsxs(_Fragment, { children: [_jsx("title", { children: title }), _jsx("meta", { charSet: "UTF-8" }), _jsx("meta", { httpEquiv: "content-type", content: "text/html; charset=UTF-8" }), _jsx("meta", { httpEquiv: 'Expires', content: '0' }), _jsx("meta", { httpEquiv: 'Pragma', content: 'no-cache' }), _jsx("meta", { httpEquiv: 'Cache-Control', content: 'no-cache' }), _jsx("meta", { name: "application-name", content: site_name }), _jsx("meta", { name: "author", content: site_name + ", " + email }), _jsx("meta", { name: 'copyright', content: site_name }), _jsx("meta", { name: "creator", content: site_name }), _jsx("meta", { name: "description", content: description }), _jsx("meta", { name: "keywords", content: keywords }), _jsx("meta", { name: 'language', content: 'EN' }), _jsx("meta", { name: 'owner', content: site_name }), _jsx("meta", { name: "publisher", content: site_name }), _jsx("meta", { name: 'rating', content: 'General' }), _jsx("meta", { name: 'reply-to', content: email }), _jsx("meta", { name: "robots", content: "index, follow" }), _jsx("meta", { name: 'url', content: url }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0, shrink-to-fit=no" }), _jsx("meta", { property: "og:description", content: description }), _jsx("meta", { property: 'og:email', content: email }), _jsx("meta", { property: "og:image", content: image }), _jsx("meta", { property: "og:image:height", content: image_height != null ? String(image_height) : undefined }), _jsx("meta", { property: "og:image:width", content: image_width != null ? String(image_width) : undefined }), _jsx("meta", { property: "og:locale", content: "en_US" }), _jsx("meta", { property: "og:site_name", content: site_name }), _jsx("meta", { property: "og:title", content: title }), _jsx("meta", { property: "og:type", content: "website" }), _jsx("meta", { property: "og:url", content: url }), _jsx("meta", { itemProp: "name", content: site_name }), _jsx("meta", { itemProp: "url", content: url }), _jsx("meta", { itemProp: "description", content: description }), _jsx("meta", { itemProp: "thumbnailUrl", content: image }), _jsx("meta", { property: "twitter:domain", content: newOrigin }), _jsx("meta", { property: "twitter:url", content: url }), _jsx("meta", { name: "twitter:card", content: "summary_large_image" }), _jsx("meta", { name: "twitter:creator", content: site_name }), _jsx("meta", { name: "twitter:description", content: description }), _jsx("meta", { name: "twitter:image", content: image }), _jsx("meta", { name: "twitter:image:height", content: image_height != null ? String(image_height) : undefined }), _jsx("meta", { name: "twitter:image:width", content: image_width != null ? String(image_width) : undefined }), _jsx("meta", { name: "twitter:title", content: title }), _jsx("link", { rel: "author", href: newOrigin }), _jsx("link", { rel: "canonical", href: url }), _jsx("link", { rel: "icon", type: "image/x-icon", href: favicon }), _jsx("link", { rel: "shortcut icon", type: "image/x-icon", href: favicon }), _jsx("link", { rel: "manifest", href: "/manifest.webmanifest" }), _jsx("link", { rel: "preconnect", href: "https://images.ctfassets.net/" }), _jsx("link", { rel: "preconnect", href: "https://res.cloudinary.com/" }), _jsx("link", { rel: "preconnect", href: "https://farm2.static.flickr.com" }), _jsx("link", { rel: "preconnect", href: "https://farm6.static.flickr.com" }), _jsx("link", { rel: "preconnect", href: "https://farm8.static.flickr.com" }), _jsx("link", { rel: "preconnect", href: "https://farm66.static.flickr.com" })] }));
118
132
  }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+ const debug = false;
3
+ export function attachProxyCspListener(onCsp) {
4
+ const handler = (e) => {
5
+ const info = {
6
+ blockedURI: e.blockedURI,
7
+ violatedDirective: e.violatedDirective,
8
+ originalPolicy: e.originalPolicy,
9
+ sourceFile: e?.sourceFile,
10
+ disposition: e.disposition,
11
+ referrer: e.referrer,
12
+ };
13
+ const defaultHandler = (i) => { if (debug)
14
+ console.warn('CSP violation', i); };
15
+ (onCsp ?? defaultHandler)(info);
16
+ };
17
+ window.addEventListener('securitypolicyviolation', handler);
18
+ // Return a cleanup function so callers can detach the listener
19
+ return () => window.removeEventListener('securitypolicyviolation', handler);
20
+ }
@@ -41,10 +41,12 @@ export function handlePixelatedProxy(req) {
41
41
  response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
42
42
  // Content Security Policy (CSP)
43
43
  // Includes all discovered domains in the workspace: HubSpot, Gravatar, Flickr, Contentful, Cloudinary, eBay, and Google Analytics + Search.
44
+ const scriptSrc = "'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com https://*.googletagmanager.com https://*.hs-scripts.com https://*.hs-analytics.net https://*.hsforms.net https://*.hscollectedforms.net https://*.hs-banner.com https://*.google.com https://*.doubleclick.net https://*.googleadservices.com https://*.adtrafficquality.google https://*.hsappstatic.net https://assets.calendly.com https://cdn.jsdelivr.net";
44
45
  const csp = [
45
46
  "default-src 'self'",
46
- "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com https://*.googletagmanager.com https://*.hs-scripts.com https://*.hs-analytics.net https://*.hsforms.net https://*.hscollectedforms.net https://*.hs-banner.com https://*.google.com https://*.doubleclick.net https://*.googleadservices.com https://*.adtrafficquality.google https://*.hsappstatic.net https://assets.calendly.com",
47
- "connect-src 'self' https: https://*.hubspot.com https://proxy.pixelated.tech https://sendmail.pixelated.tech https://*.google-analytics.com https://*.analytics.google.com",
47
+ `script-src ${scriptSrc}`,
48
+ `script-src-elem ${scriptSrc}`,
49
+ "connect-src 'self' https: https://*.hubspot.com https://proxy.pixelated.tech https://sendmail.pixelated.tech https://*.google-analytics.com https://*.analytics.google.com https://cdn.jsdelivr.net",
48
50
  "img-src 'self' data: https: https://*.gravatar.com https://*.staticflickr.com https://*.ctfassets.net https://res.cloudinary.com https://*.ebayimg.com",
49
51
  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://*.google.com",
50
52
  "font-src 'self' data: https://fonts.gstatic.com",