@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.
Files changed (22) hide show
  1. package/bin/create-acorn.mjs +50 -130
  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
@@ -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
+ });