@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.
- package/bin/create-acorn.mjs +132 -0
- package/package.json +2 -1
- package/storyblok/storyblok-api/package.json +15 -0
- package/storyblok/storyblok-api/src/lib/buildCache.ts +64 -0
- package/storyblok/storyblok-api/src/lib/storyblok/bloks.ts +24 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/config.ts +47 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchBreadcrumbs.ts +125 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchCv.ts +45 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchGlobal.ts +48 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchIcons.ts +108 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchPaths.ts +164 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchSitemap.ts +103 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchStories.ts +105 -0
- package/storyblok/storyblok-api/src/lib/storyblok/fetch/fetchStory.ts +95 -0
- package/storyblok/storyblok-api/src/lib/storyblok/helpers.ts +111 -0
- package/storyblok/storyblok-api/src/lib/storyblok/index.ts +16 -0
- package/storyblok/storyblok-api/src/lib/storyblok/mergedRelations.ts +22 -0
- package/storyblok/storyblok-api/src/lib/storyblok/redirects.js +50 -0
- package/storyblok/storyblok-api/src/lib/storyblok/seoMetadata.ts +124 -0
- package/storyblok/storyblok-api/src/lib/storyblok/types.ts +48 -0
- package/storyblok/storyblok-api/src/lib/storyblok/utils/previewTokenValidator.ts +14 -0
- package/storyblok/storyblok-api/src/ui/FallbackComponent.tsx +24 -0
package/bin/create-acorn.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
};
|