@majordigital/create-acorn 1.3.0 → 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 +50 -130
- 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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { ISbLinksParams } from 'storyblok-js-client';
|
|
2
|
+
|
|
3
|
+
import { getStoryblokApi } from '@/lib/storyblok';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ICustomSbStoriesParams,
|
|
7
|
+
Paths,
|
|
8
|
+
StoryblokLinkObject,
|
|
9
|
+
} from '../types';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_EXCLUDING_FIELDS,
|
|
12
|
+
DEFAULT_REVALIDATE,
|
|
13
|
+
ignoreDynamicRoutes,
|
|
14
|
+
ignoreDynamicStories,
|
|
15
|
+
ignoreListingRoutes,
|
|
16
|
+
LINKS_PER_PAGE,
|
|
17
|
+
reservedSlugs,
|
|
18
|
+
} from './config';
|
|
19
|
+
import { fetchStories as fetchStoriesList } from './fetchStories';
|
|
20
|
+
|
|
21
|
+
// --- Types local to this module
|
|
22
|
+
type LinksResponse = {
|
|
23
|
+
total: number;
|
|
24
|
+
data: { links: Record<string, StoryblokLinkObject> };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isStoryblokLinkObject(v: unknown): v is StoryblokLinkObject {
|
|
28
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
29
|
+
const obj = v as Record<string, unknown>;
|
|
30
|
+
return typeof obj.id === 'number' && typeof obj.slug === 'string';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asLinksMap(src: unknown): Record<string, StoryblokLinkObject> {
|
|
34
|
+
if (typeof src !== 'object' || src === null) return {};
|
|
35
|
+
const obj = src as Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
return Object.entries(obj)
|
|
38
|
+
.filter(([, v]) => isStoryblokLinkObject(v))
|
|
39
|
+
.reduce<Record<string, StoryblokLinkObject>>((acc, [k, v]) => {
|
|
40
|
+
acc[k] = v as StoryblokLinkObject;
|
|
41
|
+
return acc;
|
|
42
|
+
}, {});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const getPaths = async (): Promise<Record<string, StoryblokLinkObject>> => {
|
|
46
|
+
const storyblokApi = getStoryblokApi();
|
|
47
|
+
const params: ISbLinksParams = {
|
|
48
|
+
version: 'published',
|
|
49
|
+
per_page: LINKS_PER_PAGE,
|
|
50
|
+
page: 1,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// First page (direct API call)
|
|
54
|
+
const firstPage = (await storyblokApi.get('cdn/links', params, {
|
|
55
|
+
next: {
|
|
56
|
+
revalidate: DEFAULT_REVALIDATE,
|
|
57
|
+
},
|
|
58
|
+
})) as LinksResponse;
|
|
59
|
+
|
|
60
|
+
const total = firstPage?.total ?? 0;
|
|
61
|
+
const effectivePerPage = params.per_page ?? LINKS_PER_PAGE;
|
|
62
|
+
|
|
63
|
+
// If everything fits, just return this page’s links
|
|
64
|
+
if (total <= effectivePerPage) {
|
|
65
|
+
return asLinksMap(firstPage.data.links);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fetch remaining pages in parallel (direct API call per page) — loop-free
|
|
69
|
+
const maxPage = Math.ceil(total / effectivePerPage);
|
|
70
|
+
const otherPages =
|
|
71
|
+
maxPage <= 1
|
|
72
|
+
? []
|
|
73
|
+
: await Promise.all(
|
|
74
|
+
Array.from({ length: maxPage - 1 }, (_, idx) => {
|
|
75
|
+
const pageParams: ISbLinksParams = {
|
|
76
|
+
...params,
|
|
77
|
+
page: 2 + idx,
|
|
78
|
+
};
|
|
79
|
+
return storyblokApi.get('cdn/links', pageParams, {
|
|
80
|
+
next: {
|
|
81
|
+
revalidate: DEFAULT_REVALIDATE,
|
|
82
|
+
},
|
|
83
|
+
}) as Promise<LinksResponse>;
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Merge all pages into a single map — loop-free
|
|
88
|
+
const allLinksMap: Record<string, StoryblokLinkObject> = [
|
|
89
|
+
firstPage,
|
|
90
|
+
...otherPages,
|
|
91
|
+
]
|
|
92
|
+
.map(res => asLinksMap(res.data.links))
|
|
93
|
+
.reduce<Record<string, StoryblokLinkObject>>(
|
|
94
|
+
(acc, cur) => ({ ...acc, ...cur }),
|
|
95
|
+
{}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return allLinksMap;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const filterPaths = (
|
|
102
|
+
linksMap: Record<string, StoryblokLinkObject>
|
|
103
|
+
): Paths[] => {
|
|
104
|
+
const items: StoryblokLinkObject[] = Object.values(linksMap);
|
|
105
|
+
|
|
106
|
+
return items
|
|
107
|
+
.filter(link => !link.is_folder)
|
|
108
|
+
.filter(link => link.published)
|
|
109
|
+
.filter(link => {
|
|
110
|
+
// ignore reserved slugs and routes configured to be ignored
|
|
111
|
+
if (reservedSlugs.includes(link.slug)) return false;
|
|
112
|
+
if (ignoreDynamicRoutes.some(r => link.slug.startsWith(`${r}/`)))
|
|
113
|
+
return false;
|
|
114
|
+
if (
|
|
115
|
+
ignoreListingRoutes.some(parts => link.slug === parts.join('/'))
|
|
116
|
+
)
|
|
117
|
+
return false;
|
|
118
|
+
return true;
|
|
119
|
+
})
|
|
120
|
+
.filter(
|
|
121
|
+
link =>
|
|
122
|
+
!ignoreDynamicStories.includes(link.slug.split('/')[0] || '')
|
|
123
|
+
)
|
|
124
|
+
.map(link => ({ slug: link.slug.split('/') }));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const fetchPaths = async (): Promise<Paths[]> => {
|
|
128
|
+
const linksMap = await getPaths();
|
|
129
|
+
return filterPaths(linksMap).sort((a, b) =>
|
|
130
|
+
a.slug.join('/').localeCompare(b.slug.join('/'))
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Build /{slug}/page/2..N for list pages with minimal cost
|
|
135
|
+
export const fetchPaginationPaths = async ({
|
|
136
|
+
slug,
|
|
137
|
+
content_type,
|
|
138
|
+
limit,
|
|
139
|
+
}: {
|
|
140
|
+
slug: string;
|
|
141
|
+
content_type: ICustomSbStoriesParams['content_type'];
|
|
142
|
+
limit: number; // posts per page
|
|
143
|
+
}): Promise<Paths[]> => {
|
|
144
|
+
// Ask only for the *total* cheaply (per_page: 1, excluding heavy fields)
|
|
145
|
+
const config: ICustomSbStoriesParams = {
|
|
146
|
+
content_type,
|
|
147
|
+
per_page: 1,
|
|
148
|
+
limit: 1, // keep our fetchStories cap low; we only need total
|
|
149
|
+
page: 1,
|
|
150
|
+
filter_query: { hide_from_search: { is: 'false' } },
|
|
151
|
+
sort_by: 'content.date:desc',
|
|
152
|
+
excluding_fields: DEFAULT_EXCLUDING_FIELDS,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const stories = await fetchStoriesList({ config });
|
|
156
|
+
const totalItems = stories?.total ?? 0;
|
|
157
|
+
const totalPages = totalItems > 0 ? Math.ceil(totalItems / limit) : 1;
|
|
158
|
+
|
|
159
|
+
if (totalPages <= 1) return [];
|
|
160
|
+
|
|
161
|
+
return Array.from({ length: totalPages - 1 }).map((_, i) => ({
|
|
162
|
+
slug: [slug, 'page', String(i + 2)],
|
|
163
|
+
}));
|
|
164
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
|
|
3
|
+
import { siteConfig } from '@/lib/config';
|
|
4
|
+
import { getStoryblokApi } from '@/lib/storyblok';
|
|
5
|
+
|
|
6
|
+
import type { StoryblokLinkObject } from '../types';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_REVALIDATE,
|
|
9
|
+
ignoreDynamicRoutes,
|
|
10
|
+
LINKS_PER_PAGE,
|
|
11
|
+
reservedSlugs,
|
|
12
|
+
} from './config';
|
|
13
|
+
|
|
14
|
+
// Response shape from Storyblok Links API
|
|
15
|
+
type LinksResponse = {
|
|
16
|
+
total: number;
|
|
17
|
+
data: { links: Record<string, StoryblokLinkObject> };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function asLinksMap(src: unknown): Record<string, StoryblokLinkObject> {
|
|
21
|
+
if (typeof src !== 'object' || src === null) return {};
|
|
22
|
+
const obj = src as Record<string, unknown>;
|
|
23
|
+
return Object.entries(obj)
|
|
24
|
+
.filter(([, v]) => {
|
|
25
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
26
|
+
const o = v as Record<string, unknown>;
|
|
27
|
+
return typeof o.id === 'number' && typeof o.slug === 'string';
|
|
28
|
+
})
|
|
29
|
+
.reduce<Record<string, StoryblokLinkObject>>((acc, [k, v]) => {
|
|
30
|
+
acc[k] = v as StoryblokLinkObject;
|
|
31
|
+
return acc;
|
|
32
|
+
}, {});
|
|
33
|
+
}
|
|
34
|
+
export const fetchSitemap = async (): Promise<MetadataRoute.Sitemap> => {
|
|
35
|
+
const storyblokApi = getStoryblokApi();
|
|
36
|
+
|
|
37
|
+
// 1) First page (direct API call)
|
|
38
|
+
const params = {
|
|
39
|
+
version: 'published',
|
|
40
|
+
per_page: LINKS_PER_PAGE,
|
|
41
|
+
page: 1,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const first = (await storyblokApi.get('cdn/links', params, {
|
|
45
|
+
next: {
|
|
46
|
+
revalidate: DEFAULT_REVALIDATE,
|
|
47
|
+
},
|
|
48
|
+
})) as LinksResponse;
|
|
49
|
+
|
|
50
|
+
const total = first?.total ?? 0;
|
|
51
|
+
const perPage = params.per_page ?? LINKS_PER_PAGE;
|
|
52
|
+
|
|
53
|
+
// 2) Paginate (direct API call per page)
|
|
54
|
+
const maxPage = Math.ceil(total / perPage);
|
|
55
|
+
const rest: LinksResponse[] =
|
|
56
|
+
maxPage <= 1
|
|
57
|
+
? []
|
|
58
|
+
: await Promise.all(
|
|
59
|
+
Array.from({ length: maxPage - 1 }, (_, idx) => {
|
|
60
|
+
const pageParams = { ...params, page: 2 + idx };
|
|
61
|
+
return storyblokApi.get('cdn/links', pageParams, {
|
|
62
|
+
next: {
|
|
63
|
+
revalidate: DEFAULT_REVALIDATE,
|
|
64
|
+
},
|
|
65
|
+
}) as Promise<LinksResponse>;
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// 3) Merge all link maps
|
|
70
|
+
const linksMap: Record<string, StoryblokLinkObject> = [first, ...rest]
|
|
71
|
+
.map(r => asLinksMap(r.data.links))
|
|
72
|
+
.reduce<Record<string, StoryblokLinkObject>>(
|
|
73
|
+
(acc, cur) => ({ ...acc, ...cur }),
|
|
74
|
+
{}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// 4) Filter & map to sitemap entries
|
|
78
|
+
const baseUrl = siteConfig.url.replace(/\/+$/, '');
|
|
79
|
+
|
|
80
|
+
const entries: MetadataRoute.Sitemap = Object.values(linksMap)
|
|
81
|
+
.filter(l => !l.is_folder)
|
|
82
|
+
.filter(l => l.published)
|
|
83
|
+
.filter(l => {
|
|
84
|
+
if (reservedSlugs.includes(l.slug)) return false;
|
|
85
|
+
if (ignoreDynamicRoutes.some(r => l.slug.startsWith(`${r}/`)))
|
|
86
|
+
return false;
|
|
87
|
+
return true;
|
|
88
|
+
})
|
|
89
|
+
.map(link => {
|
|
90
|
+
const isHome = link.slug === '' || link.slug === 'home';
|
|
91
|
+
const url = isHome ? `${baseUrl}/` : `${baseUrl}/${link.slug}`;
|
|
92
|
+
return {
|
|
93
|
+
url,
|
|
94
|
+
lastModified: new Date(),
|
|
95
|
+
changeFrequency: isHome ? 'daily' : 'weekly',
|
|
96
|
+
priority: isHome ? 1.0 : 0.7,
|
|
97
|
+
} as MetadataRoute.Sitemap[number];
|
|
98
|
+
})
|
|
99
|
+
// 5) Deterministic ordering
|
|
100
|
+
.sort((a, b) => a.url.localeCompare(b.url));
|
|
101
|
+
|
|
102
|
+
return entries;
|
|
103
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getStoryblokApi } from '@storyblok/react/rsc';
|
|
2
|
+
import type { ISbStories } from 'storyblok-js-client';
|
|
3
|
+
|
|
4
|
+
import { isDraftEnabled } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ICustomSbStoriesParams,
|
|
8
|
+
StoryblokServiceDefaults,
|
|
9
|
+
} from '../types';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_EXCLUDING_FIELDS,
|
|
12
|
+
DEFAULT_REVALIDATE,
|
|
13
|
+
MAX_STORIES_PER_PAGE,
|
|
14
|
+
} from './config';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch a paginated list of Storyblok stories (cached).
|
|
18
|
+
*/
|
|
19
|
+
export const fetchStories = async ({
|
|
20
|
+
config,
|
|
21
|
+
draftMode,
|
|
22
|
+
}: {
|
|
23
|
+
config: ICustomSbStoriesParams;
|
|
24
|
+
draftMode?: StoryblokServiceDefaults['draftMode'];
|
|
25
|
+
}): Promise<ISbStories | null> => {
|
|
26
|
+
const isDraft = isDraftEnabled(draftMode);
|
|
27
|
+
const cv = isDraft ? Date.now() : undefined;
|
|
28
|
+
|
|
29
|
+
// 1) per_page + cap (min 1, max 100)
|
|
30
|
+
const requested = config.per_page ?? MAX_STORIES_PER_PAGE;
|
|
31
|
+
const limitCap =
|
|
32
|
+
typeof config.limit === 'number'
|
|
33
|
+
? Math.min(config.limit, MAX_STORIES_PER_PAGE)
|
|
34
|
+
: MAX_STORIES_PER_PAGE;
|
|
35
|
+
const per_page = Math.max(
|
|
36
|
+
1,
|
|
37
|
+
Math.min(requested, limitCap, MAX_STORIES_PER_PAGE)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const { limit, ...configWithoutLimit } = config;
|
|
41
|
+
const pageStart = configWithoutLimit.page ?? 1;
|
|
42
|
+
|
|
43
|
+
// Base params passed to Storyblok
|
|
44
|
+
const baseParams = {
|
|
45
|
+
version: (isDraft ? 'draft' : 'published') as 'draft' | 'published',
|
|
46
|
+
cv,
|
|
47
|
+
...configWithoutLimit,
|
|
48
|
+
excluding_fields: config.excluding_fields ?? DEFAULT_EXCLUDING_FIELDS,
|
|
49
|
+
per_page,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 2) First page (direct API call)
|
|
53
|
+
const storyblokApi = getStoryblokApi();
|
|
54
|
+
const firstParams = { ...baseParams, page: pageStart };
|
|
55
|
+
const firstPage = await storyblokApi.getStories(firstParams, {
|
|
56
|
+
next: {
|
|
57
|
+
revalidate: DEFAULT_REVALIDATE,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const total = firstPage?.total ?? 0;
|
|
62
|
+
const firstBatch = firstPage?.data?.stories?.length ?? 0;
|
|
63
|
+
|
|
64
|
+
// 3) If all stories fit in the first page, slice and return (preserve real total)
|
|
65
|
+
const cap = typeof limit === 'number' ? Math.min(limit, total) : total;
|
|
66
|
+
if (cap <= firstBatch) {
|
|
67
|
+
const sliced = (firstPage.data.stories || []).slice(0, cap);
|
|
68
|
+
return {
|
|
69
|
+
...firstPage,
|
|
70
|
+
data: { ...firstPage.data, stories: sliced },
|
|
71
|
+
total, // keep Storyblok's real total
|
|
72
|
+
} as ISbStories;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4) Remaining pages in parallel (cached per page) — loop-free
|
|
76
|
+
const maxPage = Math.ceil(cap / per_page);
|
|
77
|
+
const pageCount = Math.max(0, maxPage - pageStart);
|
|
78
|
+
|
|
79
|
+
const pagePromises: Array<Promise<ISbStories>> =
|
|
80
|
+
pageCount === 0
|
|
81
|
+
? []
|
|
82
|
+
: Array.from({ length: pageCount }, (_, idx) => {
|
|
83
|
+
const page = pageStart + 1 + idx;
|
|
84
|
+
const pageParams = { ...baseParams, page };
|
|
85
|
+
return storyblokApi.getStories(pageParams, {
|
|
86
|
+
next: {
|
|
87
|
+
revalidate: DEFAULT_REVALIDATE,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const otherPages = await Promise.all(pagePromises);
|
|
93
|
+
|
|
94
|
+
// 5) Combine and hard-cap to `limit` (preserve real total)
|
|
95
|
+
const combined = [
|
|
96
|
+
...(firstPage.data.stories || []),
|
|
97
|
+
...otherPages.flatMap(p => p.data.stories || []),
|
|
98
|
+
].slice(0, cap);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
...firstPage,
|
|
102
|
+
data: { ...firstPage.data, stories: combined },
|
|
103
|
+
total, // keep Storyblok's real total
|
|
104
|
+
} as ISbStories;
|
|
105
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { getStoryblokApi } from '@storyblok/react/rsc';
|
|
2
|
+
import type { ISbStoryParams } from 'storyblok-js-client';
|
|
3
|
+
|
|
4
|
+
import { isDraftEnabled } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
import { mergeResolveRelations } from '../mergedRelations';
|
|
7
|
+
import type {
|
|
8
|
+
ICustomSbStoriesParams,
|
|
9
|
+
ICustomSbStoryData,
|
|
10
|
+
StoryblokServiceDefaults,
|
|
11
|
+
} from '../types';
|
|
12
|
+
import { globalResolveRelations } from './config';
|
|
13
|
+
import { fetchBreadcrumbs } from './fetchBreadcrumbs';
|
|
14
|
+
import { fetchCv } from './fetchCv';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetches a single Storyblok story by slug, with optional config and draft mode.
|
|
18
|
+
* Uses cache version (cv) for published content and revalidate for Next.js caching.
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} params - Parameters for fetching the story.
|
|
21
|
+
* @param {string} params.slug - The story slug to fetch.
|
|
22
|
+
* @param {Partial<ICustomSbStoriesParams>} [params.config] - Optional Storyblok API config (language, resolve_relations, etc).
|
|
23
|
+
* @param {StoryblokServiceDefaults['draftMode']} [params.draftMode] - Whether to fetch draft content.
|
|
24
|
+
* @param {boolean} [params.resolveRelations=true] - Whether to merge and resolve relations for the story.
|
|
25
|
+
* @param {number} [params.revalidate=300] - Next.js cache revalidate time (seconds).
|
|
26
|
+
* @returns {Promise<ICustomSbStoryData|null>} The story data with breadcrumbs, or null if not found or error.
|
|
27
|
+
*/
|
|
28
|
+
export const fetchStory = async ({
|
|
29
|
+
slug,
|
|
30
|
+
config,
|
|
31
|
+
draftMode,
|
|
32
|
+
resolveRelations = true,
|
|
33
|
+
revalidate = 300,
|
|
34
|
+
}: {
|
|
35
|
+
slug: string;
|
|
36
|
+
config?: Partial<ICustomSbStoriesParams>;
|
|
37
|
+
draftMode?: StoryblokServiceDefaults['draftMode'];
|
|
38
|
+
resolveRelations?: boolean;
|
|
39
|
+
revalidate?: number;
|
|
40
|
+
}): Promise<ICustomSbStoryData | null> => {
|
|
41
|
+
try {
|
|
42
|
+
const isDraft = isDraftEnabled(draftMode);
|
|
43
|
+
const cv = isDraft ? undefined : await fetchCv();
|
|
44
|
+
|
|
45
|
+
// Merge caller relations with global ones (for single story only)
|
|
46
|
+
const mergedRelations = mergeResolveRelations(
|
|
47
|
+
resolveRelations,
|
|
48
|
+
config?.resolve_relations,
|
|
49
|
+
globalResolveRelations
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Narrow to story params only (avoid leaking list params into getStory)
|
|
53
|
+
const resolveLinks =
|
|
54
|
+
(config?.resolve_links as
|
|
55
|
+
| 'url'
|
|
56
|
+
| 'story'
|
|
57
|
+
| '0'
|
|
58
|
+
| '1'
|
|
59
|
+
| 'link'
|
|
60
|
+
| undefined) ?? 'url';
|
|
61
|
+
|
|
62
|
+
const params: ISbStoryParams = {
|
|
63
|
+
version: isDraft ? 'draft' : 'published',
|
|
64
|
+
cv,
|
|
65
|
+
...(mergedRelations ? { resolve_relations: mergedRelations } : {}),
|
|
66
|
+
resolve_links: resolveLinks,
|
|
67
|
+
...(config?.language ? { language: config.language } : {}),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const storyblokApi = getStoryblokApi();
|
|
71
|
+
const { data } = await storyblokApi.getStory(slug, params, {
|
|
72
|
+
next: {
|
|
73
|
+
revalidate,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const story = data?.story as ICustomSbStoryData | undefined;
|
|
78
|
+
if (!story) return null;
|
|
79
|
+
|
|
80
|
+
// Fetch breadcrumbs (already lightweight + typed)
|
|
81
|
+
const breadcrumbs = await fetchBreadcrumbs({
|
|
82
|
+
story,
|
|
83
|
+
draftMode: isDraft,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
...story,
|
|
88
|
+
breadcrumbs: breadcrumbs ?? [],
|
|
89
|
+
};
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// biome-ignore lint/suspicious/noConsole: needs to log errors
|
|
92
|
+
console.error('Error fetching story:', error);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { decodeHTML } from 'entities';
|
|
2
|
+
import type { ISbStoryData } from 'storyblok-js-client';
|
|
3
|
+
import { render } from 'storyblok-rich-text-react-renderer';
|
|
4
|
+
|
|
5
|
+
import type { DateFormat } from '@/lib/storyblok/types';
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
StoryblokAsset,
|
|
9
|
+
StoryblokMultilink,
|
|
10
|
+
StoryblokRichtext,
|
|
11
|
+
} from '.storyblok/types/storyblok';
|
|
12
|
+
|
|
13
|
+
export const getSlugFromParams = <T extends string[] | string>(slug?: T) => {
|
|
14
|
+
const path = (slug && Array.isArray(slug) && slug.join('/')) || 'home';
|
|
15
|
+
|
|
16
|
+
return path;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const isSbAssetEmpty = (asset: StoryblokAsset | undefined) => {
|
|
20
|
+
if (!asset) return false;
|
|
21
|
+
|
|
22
|
+
return asset.filename === '' || asset.filename === null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const sbText = (richtext: StoryblokRichtext | undefined): string => {
|
|
26
|
+
if (!richtext) return '';
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
richtext &&
|
|
30
|
+
render(richtext, {
|
|
31
|
+
textResolver: text => decodeHTML(text),
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const getSbUrl = (link: StoryblokMultilink | undefined): string => {
|
|
37
|
+
if (!link) return '';
|
|
38
|
+
|
|
39
|
+
if (!link.url && !link.cached_url) return '';
|
|
40
|
+
|
|
41
|
+
const anchorID = link.anchor ? `#${link.anchor}` : '';
|
|
42
|
+
const href = link?.url || `/${link?.cached_url}`;
|
|
43
|
+
|
|
44
|
+
return anchorID || href;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const checkSbRelations = (items: unknown[]): ISbStoryData[] => {
|
|
48
|
+
return items.filter(
|
|
49
|
+
(item): item is ISbStoryData =>
|
|
50
|
+
typeof item === 'object' &&
|
|
51
|
+
item !== null &&
|
|
52
|
+
'id' in item &&
|
|
53
|
+
'name' in item
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function formatSbDate(input: string, style: DateFormat) {
|
|
58
|
+
const date = new Date(input.replace(' ', 'T'));
|
|
59
|
+
|
|
60
|
+
const fullMonthNames = [
|
|
61
|
+
'January',
|
|
62
|
+
'February',
|
|
63
|
+
'March',
|
|
64
|
+
'April',
|
|
65
|
+
'May',
|
|
66
|
+
'June',
|
|
67
|
+
'July',
|
|
68
|
+
'August',
|
|
69
|
+
'September',
|
|
70
|
+
'October',
|
|
71
|
+
'November',
|
|
72
|
+
'December',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const getDaySuffix = (day: number) => {
|
|
76
|
+
if (day >= 11 && day <= 13) return 'th';
|
|
77
|
+
switch (day % 10) {
|
|
78
|
+
case 1:
|
|
79
|
+
return 'st';
|
|
80
|
+
case 2:
|
|
81
|
+
return 'nd';
|
|
82
|
+
case 3:
|
|
83
|
+
return 'rd';
|
|
84
|
+
default:
|
|
85
|
+
return 'th';
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const day = date.getDate();
|
|
90
|
+
const monthIndex = date.getMonth();
|
|
91
|
+
const year = date.getFullYear();
|
|
92
|
+
|
|
93
|
+
const month =
|
|
94
|
+
style === 'monthDay'
|
|
95
|
+
? (fullMonthNames[monthIndex]?.slice(0, 3) ?? '')
|
|
96
|
+
: (fullMonthNames[monthIndex] ?? '');
|
|
97
|
+
|
|
98
|
+
switch (style) {
|
|
99
|
+
case 'monthDay':
|
|
100
|
+
// e.g., "Aug 25"
|
|
101
|
+
return `${month} ${day}`;
|
|
102
|
+
case 'monthYear':
|
|
103
|
+
// e.g., "August 2003"
|
|
104
|
+
return `${month} ${year}`;
|
|
105
|
+
case 'dayMonthYear':
|
|
106
|
+
// e.g., "25th August, 2003"
|
|
107
|
+
return `${day}${getDaySuffix(day)} ${month}, ${year}`;
|
|
108
|
+
default:
|
|
109
|
+
return date.toDateString(); // fallback
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { apiPlugin, storyblokInit } from '@storyblok/react/rsc';
|
|
2
|
+
|
|
3
|
+
import StoryblokFallback from '@/ui/FallbackComponent';
|
|
4
|
+
|
|
5
|
+
import { bloks } from './bloks';
|
|
6
|
+
|
|
7
|
+
export const getStoryblokApi = storyblokInit({
|
|
8
|
+
accessToken: process.env.STORYBLOK_PREVIEW_TOKEN,
|
|
9
|
+
use: [apiPlugin],
|
|
10
|
+
components: bloks,
|
|
11
|
+
apiOptions: {
|
|
12
|
+
region: 'eu',
|
|
13
|
+
},
|
|
14
|
+
enableFallbackComponent: true,
|
|
15
|
+
customFallbackComponent: StoryblokFallback,
|
|
16
|
+
});
|