@majordigital/create-acorn 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/bin/create-acorn.mjs +132 -0
  2. package/package.json +2 -1
  3. package/storyblok/storyblok-api/package.json +15 -0
  4. package/storyblok/storyblok-api/src/lib/buildCache.ts +64 -0
  5. package/storyblok/storyblok-api/src/lib/storyblok/bloks.ts +24 -0
  6. package/storyblok/storyblok-api/src/lib/storyblok/fetch/config.ts +47 -0
  7. package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchBreadcrumbs.ts +125 -0
  8. package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchCv.ts +45 -0
  9. package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchGlobal.ts +48 -0
  10. package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchIcons.ts +108 -0
  11. package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchPaths.ts +164 -0
  12. package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchSitemap.ts +103 -0
  13. package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchStories.ts +105 -0
  14. package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchStory.ts +95 -0
  15. package/storyblok/storyblok-api/src/lib/storyblok/helpers.ts +111 -0
  16. package/storyblok/storyblok-api/src/lib/storyblok/index.ts +16 -0
  17. package/storyblok/storyblok-api/src/lib/storyblok/mergedRelations.ts +22 -0
  18. package/storyblok/storyblok-api/src/lib/storyblok/redirects.js +50 -0
  19. package/storyblok/storyblok-api/src/lib/storyblok/seoMetadata.ts +124 -0
  20. package/storyblok/storyblok-api/src/lib/storyblok/types.ts +48 -0
  21. package/storyblok/storyblok-api/src/lib/storyblok/utils/previewTokenValidator.ts +14 -0
  22. package/storyblok/storyblok-api/src/ui/FallbackComponent.tsx +24 -0
@@ -220,6 +220,136 @@ async function setupPrismic(projectName) {
220
220
  console.log('');
221
221
  }
222
222
 
223
+ async function setupStoryblok(projectName) {
224
+ console.log('Setting up Storyblok...');
225
+ console.log('');
226
+
227
+ const spaceId = await ask('Enter your Storyblok Space ID');
228
+ if (!spaceId || !/^\d+$/.test(spaceId)) {
229
+ console.error('Space ID must be a numeric value (e.g. 338885). You can find it in your Storyblok dashboard.');
230
+ exit(1);
231
+ }
232
+ console.log('');
233
+
234
+ // Install Storyblok dependencies
235
+ console.log('Installing Storyblok dependencies...');
236
+ await runCommand('npm', ['install', '--legacy-peer-deps',
237
+ '@storyblok/react', '@storyblok/js', 'storyblok-js-client'
238
+ ]);
239
+ await runCommand('npm', ['install', '--save-dev', '--legacy-peer-deps',
240
+ 'storyblok', 'storyblok-generate-ts'
241
+ ]);
242
+ console.log('');
243
+
244
+ // Update next.config.ts — replace Prismic image patterns with Storyblok
245
+ const nextConfigPath = join(process.cwd(), 'next.config.ts');
246
+ try {
247
+ let config = readFileSync(nextConfigPath, 'utf-8');
248
+ config = config.replace(
249
+ /remotePatterns:\s*\[[\s\S]*?\],/,
250
+ `remotePatterns: [
251
+ {
252
+ protocol: 'https',
253
+ hostname: 'a.storyblok.com',
254
+ port: '',
255
+ pathname: '/**',
256
+ },
257
+ ],`
258
+ );
259
+ writeFileSync(nextConfigPath, config);
260
+ console.log('Updated next.config.ts with Storyblok image domain.');
261
+ } catch {
262
+ console.log('Warning: Could not update next.config.ts image patterns.');
263
+ }
264
+
265
+ // Copy standardised Storyblok API layer from storyblok/storyblok-api/src/
266
+ const __dirname = dirname(fileURLToPath(import.meta.url));
267
+ const storyblokSrcDir = join(__dirname, '..', 'storyblok', 'storyblok-api', 'src');
268
+
269
+ // Copy src/lib/storyblok/ (init, bloks, fetch, helpers, types, etc.)
270
+ const targetLibStoryblok = join(process.cwd(), 'src', 'lib', 'storyblok');
271
+ cpSync(join(storyblokSrcDir, 'lib', 'storyblok'), targetLibStoryblok, { recursive: true });
272
+ console.log('Copied Storyblok API layer (lib/storyblok/).');
273
+
274
+ // Copy src/lib/buildCache.ts
275
+ cpSync(join(storyblokSrcDir, 'lib', 'buildCache.ts'), join(process.cwd(), 'src', 'lib', 'buildCache.ts'));
276
+ console.log('Copied build cache utility (lib/buildCache.ts).');
277
+
278
+ // Copy src/ui/FallbackComponent.tsx
279
+ const targetUiDir = join(process.cwd(), 'src', 'ui');
280
+ mkdirSync(targetUiDir, { recursive: true });
281
+ cpSync(join(storyblokSrcDir, 'ui', 'FallbackComponent.tsx'), join(targetUiDir, 'FallbackComponent.tsx'));
282
+ console.log('Copied FallbackComponent (ui/FallbackComponent.tsx).');
283
+
284
+ // Create StoryblokProvider
285
+ const componentsDir = join(process.cwd(), 'src', 'components');
286
+ mkdirSync(componentsDir, { recursive: true });
287
+ writeFileSync(join(componentsDir, 'StoryblokProvider.tsx'), `'use client';
288
+
289
+ import { getStoryblokApi } from '@/lib/storyblok';
290
+ import type { PropsWithChildren } from 'react';
291
+
292
+ export default function StoryblokProvider({ children }: PropsWithChildren) {
293
+ getStoryblokApi();
294
+ return children;
295
+ }
296
+ `);
297
+ console.log('Created StoryblokProvider component.');
298
+
299
+ // Update layout.tsx to wrap with StoryblokProvider
300
+ const layoutPath = join(process.cwd(), 'src', 'app', 'layout.tsx');
301
+ try {
302
+ let layout = readFileSync(layoutPath, 'utf-8');
303
+ layout = layout.replace(
304
+ "import '@/styles/globals.css';",
305
+ "import '@/styles/globals.css';\n\nimport StoryblokProvider from '@/components/StoryblokProvider';"
306
+ );
307
+ layout = layout.replace(
308
+ '<html lang="en"',
309
+ '<StoryblokProvider>\n\t\t<html lang="en"'
310
+ );
311
+ layout = layout.replace(
312
+ '</html>',
313
+ '</html>\n\t\t</StoryblokProvider>'
314
+ );
315
+ writeFileSync(layoutPath, layout);
316
+ console.log('Updated layout.tsx with StoryblokProvider.');
317
+ } catch {
318
+ console.log('Warning: Could not update layout.tsx with StoryblokProvider.');
319
+ }
320
+
321
+ // Add generate-types script to package.json
322
+ const pkgPath = join(process.cwd(), 'package.json');
323
+ try {
324
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
325
+ if (!pkg.scripts) pkg.scripts = {};
326
+ pkg.scripts['generate-types'] = `storyblok components pull --space ${spaceId} && storyblok types generate --type-suffix Storyblok --space ${spaceId}`;
327
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
328
+ console.log('Added "generate-types" script to package.json.');
329
+ } catch {
330
+ console.log('Warning: Could not update package.json with generate-types script.');
331
+ }
332
+
333
+ console.log('');
334
+ console.log('=== Storyblok Setup Complete ===');
335
+ console.log('');
336
+ console.log('=== Next Steps ===');
337
+ console.log('');
338
+ console.log(' 1. Create a Storyblok space at https://app.storyblok.com');
339
+ console.log('');
340
+ console.log(' 2. Add your preview token to .env.local:');
341
+ console.log(' STORYBLOK_PREVIEW_TOKEN=your_preview_token');
342
+ console.log('');
343
+ console.log(' 3. Register your blok components in src/lib/storyblok/bloks.ts');
344
+ console.log('');
345
+ console.log(' 4. Generate TypeScript types from your Storyblok space:');
346
+ console.log(' npm run generate-types');
347
+ console.log('');
348
+ console.log(' 5. Start your dev server:');
349
+ console.log(' npm run dev');
350
+ console.log('');
351
+ }
352
+
223
353
  function generateReadme(projectName, cms) {
224
354
  const title = projectName || 'Project Title';
225
355
 
@@ -431,6 +561,8 @@ SITE_URL=
431
561
 
432
562
  if (selection.key === 'prismic') {
433
563
  await setupPrismic(projectDir);
564
+ } else if (selection.key === 'storyblok') {
565
+ await setupStoryblok(projectDir);
434
566
  } else {
435
567
  console.log(`CMS preset ${selection.label} scaffolding is coming next.`);
436
568
  console.log('This run only confirms selection for non-Prismic options.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@majordigital/create-acorn",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Interactive scaffold for Acorn with Storyblok/Prismic/DatoCMS, TypeScript, and Tailwind.",
5
5
  "bin": {
6
6
  "create-acorn": "bin/create-acorn.mjs",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "bin",
22
22
  "template",
23
+ "storyblok",
23
24
  "README.md"
24
25
  ],
25
26
  "engines": {
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "storyblok-api",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "generate-types": "storyblok components pull --space <SPACEID> && storyblok types generate --type-suffix Storyblok --space <SPACEID>"
7
+ },
8
+ "dependencies": {
9
+ "@storyblok/js": "^4.2.8",
10
+ "@storyblok/react": "^5.4.11",
11
+ "storyblok": "^4.6.13",
12
+ "storyblok-generate-ts": "^2.2.0",
13
+ "storyblok-js-client": "^7.1.4",
14
+ }
15
+ }
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // File to store the CV (content version)
5
+ const BUILD_CV_FILE = path.join(process.cwd(), '.build-cv');
6
+
7
+ /**
8
+ * Clears the CV cache file
9
+ * Should be called at the start of each build
10
+ */
11
+ export function clearBuildCv(): void {
12
+ try {
13
+ if (fs.existsSync(BUILD_CV_FILE)) {
14
+ fs.unlinkSync(BUILD_CV_FILE);
15
+ }
16
+ } catch (_error) {
17
+ // biome-ignore lint/suspicious/noConsole: needs to warn about build CV clear issues
18
+ console.warn('Error clearing build CV:', _error);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Stores the CV (content version) for the build
24
+ */
25
+ export function storeBuildCv(cv: number): void {
26
+ try {
27
+ fs.writeFileSync(BUILD_CV_FILE, cv.toString(), 'utf8');
28
+ } catch (_error) {
29
+ // biome-ignore lint/suspicious/noConsole: needs to warn about build CV write issues
30
+ console.warn('Error writing build CV:', _error);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Returns the CV (content version) stored during the build
36
+ * Returns undefined if no CV file exists or if in draft mode
37
+ */
38
+ export function getBuildCv(): number | undefined {
39
+ if (fs.existsSync(BUILD_CV_FILE)) {
40
+ try {
41
+ const storedCv = fs.readFileSync(BUILD_CV_FILE, 'utf8').trim();
42
+
43
+ // If file is empty or being written, treat as if file doesn't exist
44
+ if (storedCv === '') {
45
+ return undefined;
46
+ }
47
+
48
+ const cv = parseInt(storedCv, 10);
49
+
50
+ if (Number.isNaN(cv)) {
51
+ // biome-ignore lint/suspicious/noConsole: needs to warn about invalid CV
52
+ console.warn('Stored CV is not a valid number, ignoring cache');
53
+ return undefined;
54
+ }
55
+
56
+ return cv;
57
+ } catch (_error) {
58
+ // If read fails (e.g., file being written), treat as cache miss
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ return undefined;
64
+ }
@@ -0,0 +1,24 @@
1
+ import type { SbReactComponentsMap } from '@storyblok/react/rsc';
2
+
3
+ const atoms = {
4
+ };
5
+
6
+ const elements = {
7
+ };
8
+
9
+ const components = {
10
+ };
11
+
12
+ const sections = {
13
+ };
14
+
15
+ const layouts = {
16
+ };
17
+
18
+ export const bloks: SbReactComponentsMap = {
19
+ ...atoms,
20
+ ...elements,
21
+ ...components,
22
+ ...sections,
23
+ ...layouts,
24
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Storyblok API config and constants for pagination, filtering, and relations.
3
+ */
4
+
5
+ // Pagination limits
6
+ export const MAX_STORIES_PER_PAGE = 100; // Storyblok getStories max
7
+ export const LINKS_PER_PAGE = 1000; // Storyblok getLinks max
8
+
9
+ // Default cache revalidation
10
+ export const DEFAULT_REVALIDATE = 60 * 60 * 8; // 8 hours
11
+
12
+ // Fields to exclude from listings for performance
13
+ export const DEFAULT_EXCLUDING_FIELDS = [
14
+ 'content.body',
15
+ 'content.blocks',
16
+ 'content.long_text',
17
+ 'content.richtext',
18
+ // Add/remove fields based on your schemas
19
+ ].join(',');
20
+
21
+ // Reserved slugs to exclude from sitemaps/routes
22
+ export const reservedSlugs = [
23
+ 'home',
24
+ 'error404',
25
+ '_not-found',
26
+ 'not-found',
27
+ 'favicon.ico',
28
+ 'robots.txt',
29
+ 'sitemap.xml',
30
+ ];
31
+
32
+ // Dynamic stories/routes to ignore in listings/sitemaps
33
+ export const ignoreDynamicStories: string[] = ['legal'];
34
+ export const ignoreDynamicRoutes: string[] = [
35
+ 'global',
36
+ 'global-settings',
37
+ 'preview',
38
+ ];
39
+ export const ignoreListingRoutes: string[][] = [];
40
+
41
+ // Relations to resolve globally for certain blocks/components
42
+ export const globalResolveRelations = [];
43
+
44
+ // Per-page settings for various content types
45
+ export const postPerPage = 20;
46
+ export const caseStudiesPerPage = 20;
47
+ export const resourcesPerPage = 20;
@@ -0,0 +1,125 @@
1
+ import { getStoryblokApi } from '@storyblok/react/rsc';
2
+ import type { ISbStoriesParams } from 'storyblok-js-client';
3
+
4
+ import { isDraftEnabled } from '@/lib/utils';
5
+
6
+ import type { ICustomSbStoryData, StoryblokServiceDefaults } from '../types';
7
+ import { DEFAULT_EXCLUDING_FIELDS, DEFAULT_REVALIDATE } from './config';
8
+
9
+ export interface BreadcrumbItemProps {
10
+ label: string;
11
+ href: string;
12
+ isActive?: boolean;
13
+ }
14
+
15
+ function labelFromSlug(slug: string): string {
16
+ const last = slug.split('/').filter(Boolean).pop() ?? slug;
17
+ return last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
18
+ }
19
+
20
+ /**
21
+ * Build breadcrumbs for a Storyblok story (cv ancestor fetch).
22
+ */
23
+ export const fetchBreadcrumbs = async ({
24
+ story,
25
+ draftMode,
26
+ }: {
27
+ story: ICustomSbStoryData;
28
+ draftMode?: StoryblokServiceDefaults['draftMode'];
29
+ }): Promise<BreadcrumbItemProps[]> => {
30
+ // Guard: no story or no slug
31
+ if (!story || !story.full_slug) return [];
32
+
33
+ const isDraft = isDraftEnabled(draftMode);
34
+
35
+ // If there is no parent, return the single current crumb
36
+ if (!story.parent_id) {
37
+ return [
38
+ {
39
+ label:
40
+ story.content?.heading ??
41
+ story.name ??
42
+ labelFromSlug(story.full_slug),
43
+ href: `/${story.full_slug}`,
44
+ isActive: true,
45
+ },
46
+ ];
47
+ }
48
+
49
+ const parts = story.full_slug.split('/').filter(Boolean);
50
+
51
+ // Top-level: only itself
52
+ if (parts.length <= 1) {
53
+ return [
54
+ {
55
+ label:
56
+ story.content?.heading ??
57
+ story.name ??
58
+ labelFromSlug(story.full_slug),
59
+ href: `/${story.full_slug}`,
60
+ isActive: true,
61
+ },
62
+ ];
63
+ }
64
+
65
+ // Parents in order: "a", "a/b", ..., "a/b/.../y"
66
+ const parentSlugs = parts
67
+ .slice(0, -1)
68
+ .map((_, i) => parts.slice(0, i + 1).join('/'));
69
+
70
+ try {
71
+ const storyblokApi = getStoryblokApi();
72
+ // Build a lean, typed params object for getStories
73
+ const params: ISbStoriesParams = {
74
+ version: isDraft ? 'draft' : 'published',
75
+ ...(isDraft ? { cv: Date.now() } : {}),
76
+ by_slugs: parentSlugs.join(','),
77
+ per_page: parentSlugs.length,
78
+ excluding_fields: DEFAULT_EXCLUDING_FIELDS,
79
+ };
80
+
81
+ const storiesRes = await storyblokApi.get('cdn/stories', params, {
82
+ next: {
83
+ revalidate: DEFAULT_REVALIDATE,
84
+ },
85
+ });
86
+
87
+ type SbStoryLite = {
88
+ full_slug: string;
89
+ name?: string;
90
+ content?: { heading?: string };
91
+ };
92
+ const items = (storiesRes?.data?.stories ?? []) as SbStoryLite[];
93
+ const bySlug = new Map(items.map(s => [s.full_slug, s]));
94
+
95
+ // Ancestors in the exact same order as parentSlugs, with safe fallbacks
96
+ const ancestors: BreadcrumbItemProps[] = parentSlugs.map(slug => {
97
+ const s = bySlug.get(slug);
98
+ const label = s?.content?.heading ?? s?.name ?? labelFromSlug(slug);
99
+ return { label, href: `/${slug}`, isActive: false };
100
+ });
101
+
102
+ const current: BreadcrumbItemProps = {
103
+ label:
104
+ story.content?.heading ??
105
+ story.name ??
106
+ labelFromSlug(story.full_slug),
107
+ href: `/${story.full_slug}`,
108
+ isActive: true,
109
+ };
110
+
111
+ return [...ancestors, current];
112
+ } catch {
113
+ // Fallback: at least return the current item
114
+ return [
115
+ {
116
+ label:
117
+ story.content?.heading ??
118
+ story.name ??
119
+ labelFromSlug(story.full_slug),
120
+ href: `/${story.full_slug}`,
121
+ isActive: true,
122
+ },
123
+ ];
124
+ }
125
+ };
@@ -0,0 +1,45 @@
1
+ import { Storyblok } from 'storyblok-js-client';
2
+
3
+ import { getBuildCv, storeBuildCv } from '@/lib/buildCache';
4
+
5
+ // Store the in-flight promise to prevent concurrent API calls
6
+ let cvPromise: Promise<number> | null = null;
7
+
8
+ export const fetchCv = async (): Promise<number> => {
9
+ // Try to get cached CV first
10
+ const cachedCv = getBuildCv();
11
+
12
+ if (cachedCv !== undefined) {
13
+ return cachedCv;
14
+ }
15
+
16
+ // If there's already a fetch in progress, wait for it
17
+ if (cvPromise !== null) {
18
+ return cvPromise;
19
+ }
20
+
21
+ // Start a new fetch
22
+ cvPromise = (async () => {
23
+ try {
24
+ // Fetch CV from Storyblok
25
+ const storyblokClient = new Storyblok({
26
+ accessToken: process.env.STORYBLOK_PREVIEW_TOKEN,
27
+ region: 'eu',
28
+ });
29
+
30
+ const cv = await storyblokClient
31
+ .get('cdn/spaces/me', { version: 'published' })
32
+ .then(x => x.data.space.version as number);
33
+
34
+ // Store CV for subsequent calls
35
+ storeBuildCv(cv);
36
+
37
+ return cv;
38
+ } finally {
39
+ // Clear the promise after completion (success or failure)
40
+ cvPromise = null;
41
+ }
42
+ })();
43
+
44
+ return cvPromise;
45
+ };
@@ -0,0 +1,48 @@
1
+ import type { ISbStoryParams } from 'storyblok-js-client';
2
+
3
+ import { getStoryblokApi } from '@/lib/storyblok';
4
+ import { isDraftEnabled } from '@/lib/utils';
5
+
6
+ import type { StoryblokServiceDefaults } from '../types';
7
+ import { fetchCv } from './fetchCv';
8
+
9
+ import type { GlobalSettingsStoryblok } from '.storyblok/types/338885/storyblok-components';
10
+
11
+ /**
12
+ * Fetch Global Settings (single story) with caching + webhook-friendly tags.
13
+ * - Draft adds `cv` to bypass CDN.
14
+ * - Tags allow precise revalidation via `/api/storyblok/revalidate`.
15
+ */
16
+ export const fetchGlobal = async ({
17
+ draftMode,
18
+ revalidate = 900, // 15m; webhook will keep this fresh
19
+ }: {
20
+ draftMode?: StoryblokServiceDefaults['draftMode'];
21
+ revalidate?: number;
22
+ }): Promise<GlobalSettingsStoryblok | null> => {
23
+ const slug = 'global-settings';
24
+
25
+ const isDraft = isDraftEnabled(draftMode);
26
+ const storyblokApi = getStoryblokApi();
27
+ const cv = isDraft ? undefined : await fetchCv();
28
+
29
+ // 2) Narrow, typed Story params (no list fields)
30
+ const params: ISbStoryParams = {
31
+ version: isDraft ? 'draft' : 'published',
32
+ cv,
33
+ resolve_relations: 'global_settings.footer_legal',
34
+ resolve_links: 'url',
35
+ };
36
+
37
+ // 3) Direct API call with revalidate
38
+ const { data } = await storyblokApi.get(`cdn/stories/${slug}`, params, {
39
+ next: {
40
+ revalidate,
41
+ },
42
+ });
43
+ const story = data?.story;
44
+ if (!story) return null;
45
+
46
+ // 4) Return content only
47
+ return story.content as GlobalSettingsStoryblok;
48
+ };
@@ -0,0 +1,108 @@
1
+ import type { StoryblokStory } from 'storyblok-generate-ts';
2
+
3
+ import { getStoryblokApi } from '@/lib/storyblok';
4
+ import snapshot from '@/snapshot/icons.json';
5
+ import type { PageIcon } from '@/types/components';
6
+
7
+ import type { ICustomSbStoriesParams } from '../types';
8
+ import {
9
+ DEFAULT_EXCLUDING_FIELDS,
10
+ DEFAULT_REVALIDATE,
11
+ MAX_STORIES_PER_PAGE,
12
+ } from './config';
13
+
14
+ import type { PageStoryblok } from '.storyblok/types/338885/storyblok-components';
15
+
16
+ /**
17
+ * Maps Storyblok icon stories to PageIcon objects for UI use.
18
+ */
19
+ export const mapIcons = (
20
+ icons: ReadonlyArray<StoryblokStory<PageStoryblok>>
21
+ ): PageIcon[] => {
22
+ if (!icons || icons.length === 0) return [];
23
+
24
+ return icons
25
+ .map(({ slug, content }) => {
26
+ if (!slug || !content) return null;
27
+
28
+ const { title, icon, icon_small } = content;
29
+ if (!icon || !icon.filename) return null;
30
+
31
+ const { id, filename, alt } = icon;
32
+ const filenameSmall = icon_small?.filename;
33
+
34
+ return {
35
+ id: String(id),
36
+ url: filename,
37
+ url_small: filenameSmall,
38
+ alt: alt || title || '',
39
+ slug,
40
+ };
41
+ })
42
+ .filter(Boolean) as PageIcon[];
43
+ };
44
+
45
+ /**
46
+ * Fetches icon stories from Storyblok and maps them to PageIcon objects.
47
+ */
48
+ export const fetchIcons = async (): Promise<PageIcon[]> => {
49
+ // 1) Production build → static snapshot (fast, zero API hits)
50
+ if (process.env.NODE_ENV === 'production') {
51
+ return snapshot as PageIcon[];
52
+ }
53
+
54
+ // 2) Filter for icons that actually have an image set
55
+ const filter_query: NonNullable<ICustomSbStoriesParams['filter_query']> = {
56
+ hide_from_search: { in: 'false' },
57
+ 'icon.filename': { is: 'not_empty' },
58
+ };
59
+
60
+ // 3) Build base params (published, with cv)
61
+ const per_page = MAX_STORIES_PER_PAGE;
62
+ const storyblokApi = getStoryblokApi();
63
+
64
+ const baseParams = {
65
+ version: 'published' as const,
66
+ content_type: 'page',
67
+ filter_query,
68
+ excluding_fields: DEFAULT_EXCLUDING_FIELDS,
69
+ per_page,
70
+ };
71
+
72
+ // 4) First page (direct API call)
73
+ const firstParams = { ...baseParams, page: 1 };
74
+ const first = await storyblokApi.get('cdn/stories', firstParams, {
75
+ next: {
76
+ revalidate: DEFAULT_REVALIDATE,
77
+ },
78
+ });
79
+
80
+ const total = first?.total ?? 0;
81
+ const firstStories = first?.data?.stories ?? [];
82
+
83
+ // 5) Remaining pages (direct API call, loop-free)
84
+ const maxPage = Math.ceil(total / per_page);
85
+ const rest =
86
+ maxPage <= 1
87
+ ? []
88
+ : await Promise.all(
89
+ Array.from({ length: maxPage - 1 }, (_, idx) => {
90
+ const pageParams = { ...baseParams, page: 2 + idx };
91
+ return storyblokApi.get('cdn/stories', pageParams, {
92
+ next: {
93
+ revalidate: DEFAULT_REVALIDATE,
94
+ },
95
+ });
96
+ })
97
+ );
98
+
99
+ // 6) Combine and map to PageIcon[]
100
+ const allStoriesRaw = [
101
+ firstStories,
102
+ ...rest.flatMap(r => r.data?.stories ?? []),
103
+ ];
104
+ const iconStories =
105
+ allStoriesRaw as unknown as StoryblokStory<PageStoryblok>[];
106
+
107
+ return mapIcons(iconStories);
108
+ };