Skip to content

Commit 2c3fbe2

Browse files
author
Nathan Booker
committed
feat: render scripts from Script Manager
1 parent 989bf97 commit 2c3fbe2

7 files changed

Lines changed: 206 additions & 1 deletion

File tree

.changeset/wise-ads-drive.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst-core": minor
3+
---
4+
5+
Add support for Scripts API/Script Manager scripts rendering via next/script

core/app/[locale]/layout.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { revalidate } from '~/client/revalidate-target';
1919
import { WebAnalyticsFragment } from '~/components/analytics/fragment';
2020
import { AnalyticsProvider } from '~/components/analytics/provider';
2121
import { ContainerQueryPolyfill } from '~/components/polyfills/container-query';
22+
import { ScriptManagerScripts, ScriptsFragment } from '~/components/scripts';
2223
import { routing } from '~/i18n/routing';
2324
import { getToastNotification } from '~/lib/server-toast';
2425

@@ -35,13 +36,16 @@ const RootLayoutMetadataQuery = graphql(
3536
}
3637
...WebAnalyticsFragment
3738
}
39+
content {
40+
...ScriptsFragment
41+
}
3842
}
3943
channel {
4044
entityId
4145
}
4246
}
4347
`,
44-
[WebAnalyticsFragment],
48+
[WebAnalyticsFragment, ScriptsFragment],
4549
);
4650

4751
const fetchRootLayoutMetadata = cache(async () => {
@@ -109,6 +113,13 @@ export default async function RootLayout({ params, children }: Props) {
109113

110114
return (
111115
<html className={clsx(fonts.map((f) => f.variable))} lang={locale}>
116+
<head>
117+
<ScriptManagerScripts
118+
location="head"
119+
scripts={data.site.content.scripts}
120+
strategy="afterInteractive"
121+
/>
122+
</head>
112123
<body className="flex min-h-screen flex-col">
113124
<NextIntlClientProvider>
114125
<NuqsAdapter>
@@ -124,6 +135,11 @@ export default async function RootLayout({ params, children }: Props) {
124135
</NextIntlClientProvider>
125136
<VercelComponents />
126137
<ContainerQueryPolyfill />
138+
<ScriptManagerScripts
139+
location="footer"
140+
scripts={data.site.content.scripts}
141+
strategy="lazyOnload"
142+
/>
127143
</body>
128144
</html>
129145
);

core/client/graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const graphql = initGraphQLTada<{
88
DateTime: string;
99
Long: number;
1010
BigDecimal: number;
11+
UUID: string;
1112
};
1213
disableMasking: true;
1314
}>();

core/components/scripts/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Script Components
2+
3+
This directory contains components for rendering BigCommerce scripts using Next.js Script component.
4+
5+
## Components
6+
7+
Both `HeaderScripts` and `FooterScripts` are exported from a single `scripts.tsx` file that follows DRY principles by sharing common logic through an internal `ScriptRenderer` component.
8+
9+
### HeaderScripts
10+
Renders scripts that should be placed in the `<head>` tag with `strategy="afterInteractive"`.
11+
12+
### FooterScripts
13+
Renders scripts that should be placed before the closing `</body>` tag with `strategy="lazyOnload"` for better performance.
14+
15+
## Usage
16+
17+
```tsx
18+
import { HeaderScripts, FooterScripts } from '~/components/scripts';
19+
20+
// In your layout or page component:
21+
<HeaderScripts scripts={data.site.content.scripts} />
22+
<FooterScripts scripts={data.site.content.scripts} />
23+
```
24+
25+
## Architecture
26+
27+
The components use a shared `ScriptRenderer` component that accepts:
28+
- `scripts`: The GraphQL scripts data
29+
- `location`: 'head' or 'footer'
30+
- `strategy`: 'afterInteractive' or 'lazyOnload'
31+
32+
This ensures consistent behavior and reduces code duplication between header and footer scripts.
33+
34+
## Script Processing
35+
36+
The components handle both types of BigCommerce scripts:
37+
38+
### External Scripts (SrcScript)
39+
```tsx
40+
<Script
41+
id="bc-script-123"
42+
src="https://example.com/script.js"
43+
strategy="afterInteractive"
44+
integrity="sha256-abc123..."
45+
/>
46+
```
47+
48+
### Inline Scripts (InlineScript)
49+
Inline scripts are rendered using `dangerouslySetInnerHTML` for reliable execution:
50+
```tsx
51+
<Script
52+
id="bc-script-456"
53+
strategy="lazyOnload"
54+
dangerouslySetInnerHTML={{ __html: scriptContent }}
55+
/>
56+
```
57+
58+
## Filtering Logic
59+
60+
Scripts are filtered by:
61+
- **Location**: BigCommerce API returns `'HEAD'` or `'FOOTER'` (uppercase)
62+
- **Visibility**: Only renders scripts with `'ALL_PAGES'` or `'STOREFRONT'` visibility
63+
64+
## Performance
65+
66+
- Header scripts use `afterInteractive` strategy for critical functionality
67+
- Footer scripts use `lazyOnload` strategy for better performance
68+
- Script IDs are generated using the `entityId` from the BigCommerce API
69+
- Integrity hashes are included when available for security
70+
- Scripts are efficiently filtered and rendered based on location and visibility
71+
72+
## GraphQL Integration
73+
74+
Uses the `ScriptsFragment` to fetch script data:
75+
```graphql
76+
fragment ScriptsFragment on Content {
77+
scripts(first: 50, filters: { visibilities: [ALL_PAGES, STOREFRONT] }) {
78+
edges {
79+
node {
80+
integrityHashes { hash }
81+
entityId
82+
consentCategory
83+
location
84+
visibility
85+
... on InlineScript { scriptTag }
86+
... on SrcScript { src }
87+
}
88+
}
89+
}
90+
}
91+
```
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { graphql } from '~/client/graphql';
2+
3+
export const ScriptsFragment = graphql(`
4+
fragment ScriptsFragment on Content {
5+
scripts(first: 50, filters: { visibilities: [ALL_PAGES, STOREFRONT] }) {
6+
edges {
7+
node {
8+
__typename
9+
integrityHashes {
10+
hash
11+
}
12+
entityId
13+
consentCategory
14+
location
15+
visibility
16+
... on InlineScript {
17+
scriptTag
18+
}
19+
... on SrcScript {
20+
src
21+
}
22+
}
23+
}
24+
}
25+
}
26+
`);

core/components/scripts/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ScriptManagerScripts } from './scripts';
2+
export { ScriptsFragment } from './fragment';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client';
2+
import Script from 'next/script';
3+
import { ComponentProps } from 'react';
4+
5+
import { FragmentOf } from '~/client/graphql';
6+
7+
import { ScriptsFragment } from './fragment';
8+
9+
type ScriptsData = FragmentOf<typeof ScriptsFragment>;
10+
11+
interface ScriptRendererProps {
12+
scripts: ScriptsData['scripts'] | null;
13+
location: 'head' | 'footer';
14+
strategy: ComponentProps<typeof Script>['strategy'];
15+
}
16+
17+
export const ScriptManagerScripts = ({ scripts, location, strategy }: ScriptRendererProps) => {
18+
if (!scripts?.edges) return null;
19+
20+
const scriptNodes = removeEdgesAndNodes(scripts);
21+
const locationScripts = scriptNodes.filter(
22+
(script) => script.location.toLowerCase() === location,
23+
);
24+
const filteredScripts = locationScripts.filter(
25+
// only render scripts that are visible on all pages or storefront
26+
(script) => script.visibility === 'ALL_PAGES' || script.visibility === 'STOREFRONT',
27+
);
28+
29+
return (
30+
<>
31+
{filteredScripts.map((script) => {
32+
const scriptProps: ComponentProps<typeof Script> = {
33+
strategy,
34+
};
35+
36+
// Handle external scripts (SrcScript)
37+
if (script.__typename === 'SrcScript' && script.src) {
38+
scriptProps.src = script.src;
39+
40+
// Add integrity hashes if provided
41+
if (script.integrityHashes.length > 0) {
42+
scriptProps.integrity = script.integrityHashes.map((hashObj) => hashObj.hash).join(' ');
43+
scriptProps.crossOrigin = 'anonymous';
44+
}
45+
}
46+
47+
// Handle inline scripts (InlineScript)
48+
if (script.__typename === 'InlineScript' && script.scriptTag) {
49+
const scriptMatch = /<script[^>]*>([\s\S]*?)<\/script>/i.exec(script.scriptTag);
50+
51+
if (scriptMatch?.[1]) {
52+
scriptProps.dangerouslySetInnerHTML = {
53+
__html: scriptMatch[1],
54+
};
55+
}
56+
}
57+
58+
scriptProps.id = `bc-script-${script.entityId}`;
59+
60+
return <Script key={scriptProps.id} {...scriptProps} />;
61+
})}
62+
</>
63+
);
64+
};

0 commit comments

Comments
 (0)