busabase-cms-sdk 0.1.3

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.
@@ -0,0 +1,268 @@
1
+ import { l as readCmsOrFallback, r as createCmsPathHelpers, s as normalizeCmsPath } from "../routing-C3lkVAZ8.js";
2
+ import { createCachedBusabaseCms } from "../next.js";
3
+ import "server-only";
4
+ import { cache } from "react";
5
+ //#region src/integration/config.ts
6
+ /** Next data-cache `revalidate` used when an app does not override it. */
7
+ const DEFAULT_CMS_REVALIDATE_SECONDS = 300;
8
+ const trimValue = (value) => value?.trim() || null;
9
+ /**
10
+ * Pure `BUSABASE_CMS_*` env-var gate. All three of host/key/space are required: you genuinely
11
+ * cannot talk to a Busabase space without an API key, so a partial configuration is treated as
12
+ * "off" rather than as a guaranteed-failing read on every request.
13
+ *
14
+ * Re-reads the environment on every call — deliberately never memoized, unlike the client.
15
+ */
16
+ const readCmsEnvConfig = (defaultBaseSlugs, env = process.env) => {
17
+ const baseUrl = trimValue(env.BUSABASE_CMS_BASE_URL);
18
+ const apiKey = trimValue(env.BUSABASE_CMS_API_KEY);
19
+ const spaceId = trimValue(env.BUSABASE_CMS_SPACE_ID);
20
+ if (!baseUrl || !apiKey || !spaceId) return null;
21
+ return {
22
+ baseUrl,
23
+ apiKey,
24
+ spaceId,
25
+ folderId: trimValue(env.BUSABASE_CMS_FOLDER_ID),
26
+ baseSlugs: {
27
+ posts: trimValue(env.BUSABASE_CMS_POSTS_BASE_SLUG) ?? defaultBaseSlugs.posts,
28
+ pages: trimValue(env.BUSABASE_CMS_PAGES_BASE_SLUG) ?? defaultBaseSlugs.pages,
29
+ categories: trimValue(env.BUSABASE_CMS_CATEGORIES_BASE_SLUG) ?? defaultBaseSlugs.categories,
30
+ tags: trimValue(env.BUSABASE_CMS_TAGS_BASE_SLUG) ?? defaultBaseSlugs.tags
31
+ }
32
+ };
33
+ };
34
+ //#endregion
35
+ //#region src/integration/client.ts
36
+ /**
37
+ * Explicit Next data-cache key prefix. Two reasons it is never left to the SDK's default:
38
+ *
39
+ * 1. App identity — the SDK's own default prefix (see `resolveBusabaseCmsCacheKeyPrefix`) is
40
+ * derived purely from the CMS target (host / space / folder / profile / Base slugs) and
41
+ * carries no app identity at all. `cacheNamespace` (unique per app) is the first element
42
+ * here, so no two apps can ever share an entry even when they read the very same Bases.
43
+ * 2. Re-pointing safety — folder id, schema profile and all four Base slugs are part of the
44
+ * key, so changing any of them at deploy time starts from a cold cache instead of serving
45
+ * content read out of a different Base.
46
+ *
47
+ * The API key is deliberately NOT part of the key (it is a secret, and it does not change
48
+ * which records are addressed).
49
+ */
50
+ const buildCmsCacheKeyPrefix = (config, resolved) => [
51
+ "busabase-cms-sdk",
52
+ config.cacheNamespace,
53
+ resolved.baseUrl,
54
+ resolved.spaceId,
55
+ resolved.folderId ?? "no-folder",
56
+ config.schemaProfile,
57
+ resolved.baseSlugs.posts,
58
+ resolved.baseSlugs.pages,
59
+ resolved.baseSlugs.categories,
60
+ resolved.baseSlugs.tags
61
+ ];
62
+ /** `revalidateTag` targets per collection, defaulting to `<cacheNamespace>:cms-<collection>`. */
63
+ const resolveCmsCacheTags = (config) => ({
64
+ posts: config.cache?.tags?.posts ?? [`${config.cacheNamespace}:cms-posts`],
65
+ pages: config.cache?.tags?.pages ?? [`${config.cacheNamespace}:cms-pages`],
66
+ categories: config.cache?.tags?.categories ?? [`${config.cacheNamespace}:cms-categories`],
67
+ tags: config.cache?.tags?.tags ?? [`${config.cacheNamespace}:cms-tags`]
68
+ });
69
+ const createCmsClientProvider = (config) => {
70
+ const getCmsConfig = () => readCmsEnvConfig(config.baseSlugs);
71
+ /**
72
+ * When this is false, every `*OrFallback` read skips calling the CMS entirely (see the
73
+ * `readCmsOrFallback(enabled ? op : null, ...)` calls in posts/pages/taxonomy) — zero network
74
+ * attempts, identical to pure-local-MDX behavior.
75
+ */
76
+ const isBusabaseCmsEnabled = () => getCmsConfig() !== null;
77
+ let cachedCms;
78
+ const getCms = () => {
79
+ if (cachedCms !== void 0) return cachedCms;
80
+ const resolved = getCmsConfig();
81
+ if (!resolved) {
82
+ cachedCms = null;
83
+ return cachedCms;
84
+ }
85
+ cachedCms = createCachedBusabaseCms({
86
+ config: {
87
+ baseUrl: resolved.baseUrl,
88
+ apiKey: resolved.apiKey,
89
+ spaceId: resolved.spaceId
90
+ },
91
+ folderId: resolved.folderId ?? void 0,
92
+ lazyCreate: Boolean(resolved.folderId),
93
+ schemaProfile: config.schemaProfile,
94
+ baseSlugs: resolved.folderId ? void 0 : resolved.baseSlugs,
95
+ invalidRecords: config.invalidRecords,
96
+ onInvalidRecord: config.onInvalidRecord
97
+ }, {
98
+ revalidate: config.cache?.revalidate ?? 300,
99
+ tags: resolveCmsCacheTags(config),
100
+ keyPrefix: buildCmsCacheKeyPrefix(config, resolved)
101
+ });
102
+ return cachedCms;
103
+ };
104
+ const requireCms = () => {
105
+ const cms = getCms();
106
+ if (!cms) throw new Error(`[busabase-cms] ${config.appLabel}: BUSABASE_CMS_BASE_URL, BUSABASE_CMS_API_KEY and BUSABASE_CMS_SPACE_ID must all be set before reading CMS content`);
107
+ return cms;
108
+ };
109
+ return {
110
+ getCms,
111
+ requireCms,
112
+ getCmsConfig,
113
+ isBusabaseCmsEnabled
114
+ };
115
+ };
116
+ //#endregion
117
+ //#region src/integration/pages.ts
118
+ const createCmsPageReads = ({ getCms, requireCms }, appLabel) => ({
119
+ listBusabaseLandingPages: async () => requireCms().pages.list(),
120
+ getBusabaseLandingPageByPath: async (path) => requireCms().pages.getByPath(path),
121
+ listBusabaseLandingPagesOrFallback: async () => {
122
+ const cms = getCms();
123
+ return readCmsOrFallback(cms ? () => cms.pages.list() : null, [], `list ${appLabel} landing pages`);
124
+ },
125
+ getBusabaseLandingPageByPathOrFallback: async (path) => {
126
+ const cms = getCms();
127
+ return readCmsOrFallback(cms ? () => cms.pages.getByPath(path) : null, null, `get ${appLabel} landing page ${path}`);
128
+ }
129
+ });
130
+ const createCmsPageHelpers = ({ integration, generatePageMetadata }) => {
131
+ const { buildCmsPath, getBusabaseLandingPageByPathOrFallback, isCmsContentForLocale, parseCmsPath } = integration;
132
+ const getCmsPageForRequest = async (lang, path) => {
133
+ const canonicalPath = buildCmsPath(lang, path);
134
+ if (!canonicalPath) return null;
135
+ const page = await getBusabaseLandingPageByPathOrFallback(canonicalPath);
136
+ return page && isCmsContentForLocale(page, lang) ? page : null;
137
+ };
138
+ const generateCmsPageMetadata = (page, lang) => {
139
+ const parsed = parseCmsPath(page.path);
140
+ if (!parsed || parsed.locale !== lang) return {};
141
+ return generatePageMetadata({
142
+ title: page.seoTitle ?? page.title,
143
+ description: page.seoDescription ?? "",
144
+ path: parsed.pathWithoutLocale,
145
+ lang,
146
+ type: "website"
147
+ });
148
+ };
149
+ return {
150
+ getCmsPageForRequest,
151
+ generateCmsPageMetadata
152
+ };
153
+ };
154
+ //#endregion
155
+ //#region src/integration/posts.ts
156
+ const createCmsPostReads = ({ getCms, requireCms }, appLabel) => ({
157
+ listBusabaseBlogPosts: async () => requireCms().posts.list(),
158
+ getBusabaseBlogPostByPath: async (path) => requireCms().posts.getByPath(path),
159
+ listBusabaseBlogPostsOrFallback: async () => {
160
+ const cms = getCms();
161
+ return readCmsOrFallback(cms ? () => cms.posts.list() : null, [], `list ${appLabel} blog posts`);
162
+ },
163
+ getBusabaseBlogPostByPathOrFallback: async (path) => {
164
+ const cms = getCms();
165
+ return readCmsOrFallback(cms ? () => cms.posts.getByPath(path) : null, null, `get ${appLabel} blog post ${path}`);
166
+ }
167
+ });
168
+ /**
169
+ * Merge Post card sources in priority order, using canonical paths as identity — the earliest
170
+ * source to claim a path wins. Lets an app render CMS Posts and bundled MDX posts in one grid
171
+ * without a CMS post and its local original showing up twice.
172
+ */
173
+ const mergeBlogCardsByPath = (...sources) => {
174
+ const byPath = /* @__PURE__ */ new Map();
175
+ for (const source of sources) for (const post of source) {
176
+ const path = normalizeCmsPath(post.url);
177
+ if (path && !byPath.has(path)) byPath.set(path, {
178
+ ...post,
179
+ url: path
180
+ });
181
+ }
182
+ return [...byPath.values()].sort((a, b) => new Date(b.date ?? 0).getTime() - new Date(a.date ?? 0).getTime());
183
+ };
184
+ const createCmsPostResolver = ({ integration, localSource }) => {
185
+ const { buildCmsPath, getBusabaseBlogPostByPathOrFallback, isCmsContentForLocale, parseCmsPath } = integration;
186
+ const getCachedCmsPost = cache(async (locale, slugPath) => {
187
+ const path = buildCmsPath(locale, ["blog", ...slugPath.split("/").filter(Boolean)]);
188
+ const post = path ? await getBusabaseBlogPostByPathOrFallback(path) : null;
189
+ return post && isCmsContentForLocale(post, locale) ? post : null;
190
+ });
191
+ const defaultDependencies = {
192
+ getCmsPost: (locale, slugs) => getCachedCmsPost(locale, slugs.join("/")),
193
+ getLocalPost: (locale, slugs) => localSource.getPage(slugs, locale)
194
+ };
195
+ const resolveForLocale = async (locale, slugs, dependencies) => {
196
+ const cmsPost = await dependencies.getCmsPost(locale, slugs);
197
+ if (cmsPost) return {
198
+ source: "busabase",
199
+ content: cmsPost,
200
+ canonicalPath: parseCmsPath(cmsPost.path)?.pathWithoutLocale ?? `/blog/${slugs.join("/")}`
201
+ };
202
+ const localPost = dependencies.getLocalPost(locale, slugs);
203
+ if (localPost) return {
204
+ source: "local",
205
+ content: localPost,
206
+ canonicalPath: `/blog/${slugs.join("/")}`
207
+ };
208
+ return null;
209
+ };
210
+ /**
211
+ * Resolve a Post detail page at the requested locale, falling back to English when the
212
+ * requested locale has no CMS or local-MDX content at that slug — a post not yet translated
213
+ * falls back to English rather than 404ing.
214
+ */
215
+ const resolvePostPageWithDependencies = async (requestedLocale, slugPath, dependencies) => {
216
+ const slugs = slugPath.split("/").filter(Boolean);
217
+ const requested = await resolveForLocale(requestedLocale, slugs, dependencies);
218
+ const resolved = requested ?? (requestedLocale === "en" ? null : await resolveForLocale("en", slugs, dependencies));
219
+ return resolved ? {
220
+ ...resolved,
221
+ requestedLocale,
222
+ contentLocale: requested ? requestedLocale : "en",
223
+ isLocaleFallback: !requested
224
+ } : null;
225
+ };
226
+ const resolvePostPageUncached = (requestedLocale, slugPath) => resolvePostPageWithDependencies(requestedLocale, slugPath, defaultDependencies);
227
+ return {
228
+ resolvePostPage: cache(resolvePostPageUncached),
229
+ resolvePostPageWithDependencies
230
+ };
231
+ };
232
+ //#endregion
233
+ //#region src/integration/taxonomy.ts
234
+ const createCmsTaxonomyReads = ({ getCms, requireCms }, appLabel) => ({
235
+ listBusabaseCategories: async () => requireCms().categories.list(),
236
+ listBusabaseCategoriesOrFallback: async () => {
237
+ const cms = getCms();
238
+ return readCmsOrFallback(cms ? () => cms.categories.list() : null, [], `list ${appLabel} categories`);
239
+ },
240
+ listBusabaseTags: async () => requireCms().tags.list(),
241
+ listBusabaseTagsOrFallback: async () => {
242
+ const cms = getCms();
243
+ return readCmsOrFallback(cms ? () => cms.tags.list() : null, [], `list ${appLabel} tags`);
244
+ }
245
+ });
246
+ //#endregion
247
+ //#region src/integration/index.ts
248
+ const createCmsIntegration = (config) => {
249
+ const cmsPathHelpers = createCmsPathHelpers({
250
+ supportedLocales: config.supportedLocales,
251
+ defaultLocale: config.defaultLocale
252
+ });
253
+ const provider = createCmsClientProvider(config);
254
+ return {
255
+ cmsPathHelpers,
256
+ buildCmsPath: cmsPathHelpers.buildPath,
257
+ parseCmsPath: cmsPathHelpers.parsePath,
258
+ isCmsContentForLocale: cmsPathHelpers.isForLocale,
259
+ buildCmsTaxonomyArchivePath: cmsPathHelpers.buildTaxonomyArchivePath,
260
+ isBusabaseCmsEnabled: provider.isBusabaseCmsEnabled,
261
+ getCmsConfig: provider.getCmsConfig,
262
+ ...createCmsPostReads(provider, config.appLabel),
263
+ ...createCmsPageReads(provider, config.appLabel),
264
+ ...createCmsTaxonomyReads(provider, config.appLabel)
265
+ };
266
+ };
267
+ //#endregion
268
+ export { DEFAULT_CMS_REVALIDATE_SECONDS, buildCmsCacheKeyPrefix, createCmsClientProvider, createCmsIntegration, createCmsPageHelpers, createCmsPageReads, createCmsPostReads, createCmsPostResolver, createCmsTaxonomyReads, mergeBlogCardsByPath, readCmsEnvConfig, resolveCmsCacheTags };
package/dist/next.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { m as BusabaseCmsOptions, p as BusabaseCms, r as CmsPathHelpers } from "./routing-VU56uuJ0.js";
2
+ import "server-only";
3
+ import { MetadataRoute } from "next";
4
+ //#region src/next.d.ts
5
+ interface BusabaseCmsCacheOptions {
6
+ revalidate?: number | false;
7
+ tags?: {
8
+ posts?: string[];
9
+ pages?: string[];
10
+ categories?: string[];
11
+ tags?: string[];
12
+ };
13
+ keyPrefix?: string[];
14
+ }
15
+ declare const resolveBusabaseCmsCacheKeyPrefix: (options: BusabaseCmsOptions, cache: BusabaseCmsCacheOptions) => string[];
16
+ declare const createCachedBusabaseCms: (options?: BusabaseCmsOptions, cache?: BusabaseCmsCacheOptions) => BusabaseCms;
17
+ type MinimalCmsItem = {
18
+ locale: string;
19
+ path: string;
20
+ updatedAt?: string;
21
+ };
22
+ interface CmsSitemapEntryOptions<T extends MinimalCmsItem> {
23
+ changeFrequency?: MetadataRoute.Sitemap[number]["changeFrequency"];
24
+ /** Static priority, or derive one per item from its own fields and canonical path. */
25
+ priority?: number | ((item: T, canonicalPath: string) => number);
26
+ }
27
+ /** Sitemap entries for Blog Posts — only paths under the `blog` segment are included. */
28
+ declare const buildCmsBlogSitemapEntries: <T extends MinimalCmsItem>(posts: readonly T[], helpers: CmsPathHelpers, baseUrl: string, options?: CmsSitemapEntryOptions<T>, fallbackDate?: Date) => MetadataRoute.Sitemap;
29
+ /** Sitemap entries for Pages — every valid-for-locale record is included, no path filter. */
30
+ declare const buildCmsPageSitemapEntries: <T extends MinimalCmsItem>(pages: readonly T[], helpers: CmsPathHelpers, baseUrl: string, options?: CmsSitemapEntryOptions<T>, fallbackDate?: Date) => MetadataRoute.Sitemap;
31
+ /** Merge sitemap sources, keeping the first entry seen for any duplicate URL. */
32
+ declare const dedupeCmsSitemapEntries: (...sources: readonly MetadataRoute.Sitemap[]) => MetadataRoute.Sitemap;
33
+ //#endregion
34
+ export { BusabaseCmsCacheOptions, CmsSitemapEntryOptions, buildCmsBlogSitemapEntries, buildCmsPageSitemapEntries, createCachedBusabaseCms, dedupeCmsSitemapEntries, resolveBusabaseCmsCacheKeyPrefix };
package/dist/next.js ADDED
@@ -0,0 +1,107 @@
1
+ import { a as createBusabaseCms } from "./content-BXgsWQjQ.js";
2
+ import { resolveConfig } from "busabase-sdk";
3
+ import "server-only";
4
+ import { unstable_cache } from "next/cache.js";
5
+ //#region src/next.ts
6
+ const resolveBusabaseCmsCacheKeyPrefix = (options, cache) => {
7
+ if (cache.keyPrefix && cache.keyPrefix.length > 0) return cache.keyPrefix;
8
+ if (options.source || options.client) throw new Error("createCachedBusabaseCms requires cache.keyPrefix when using a custom source or client");
9
+ const config = resolveConfig(options.config);
10
+ if (config.headers || config.apiKey && !config.spaceId) throw new Error("createCachedBusabaseCms requires cache.keyPrefix when the target space cannot be represented without secrets");
11
+ const schemaProfile = options.schemaProfile ?? "standard";
12
+ return [
13
+ "busabase-cms-sdk",
14
+ config.baseUrl,
15
+ config.spaceId ?? "default-space",
16
+ ...options.folderId ? [options.folderId, schemaProfile] : [
17
+ "no-folder",
18
+ schemaProfile,
19
+ options.baseSlugs?.posts ?? "busabase-cms-posts",
20
+ options.baseSlugs?.pages ?? "busabase-cms-pages",
21
+ options.baseSlugs?.categories ?? "busabase-cms-categories",
22
+ options.baseSlugs?.tags ?? "busabase-cms-tags"
23
+ ]
24
+ ];
25
+ };
26
+ const createCachedBusabaseCms = (options = {}, cache = {}) => {
27
+ const keyPrefix = resolveBusabaseCmsCacheKeyPrefix(options, cache);
28
+ const cms = createBusabaseCms(options);
29
+ const revalidate = cache.revalidate ?? 300;
30
+ const cachedList = (list, kind, slug) => unstable_cache(list, [
31
+ ...keyPrefix,
32
+ kind,
33
+ slug
34
+ ], {
35
+ revalidate,
36
+ tags: cache.tags?.[kind] ?? [`busabase-cms:${kind}`]
37
+ });
38
+ const listPosts = cachedList(cms.posts.list, "posts", options.baseSlugs?.posts ?? "busabase-cms-posts");
39
+ const listPages = cachedList(cms.pages.list, "pages", options.baseSlugs?.pages ?? "busabase-cms-pages");
40
+ const listCategories = cachedList(cms.categories.list, "categories", options.baseSlugs?.categories ?? "busabase-cms-categories");
41
+ const listTags = cachedList(cms.tags.list, "tags", options.baseSlugs?.tags ?? "busabase-cms-tags");
42
+ const cachedGetByArg = (get, kind, slug) => unstable_cache(get, [
43
+ ...keyPrefix,
44
+ kind,
45
+ slug,
46
+ "by-arg"
47
+ ], {
48
+ revalidate,
49
+ tags: cache.tags?.[kind] ?? [`busabase-cms:${kind}`]
50
+ });
51
+ return {
52
+ posts: {
53
+ list: listPosts,
54
+ getByPath: cachedGetByArg(cms.posts.getByPath, "posts", options.baseSlugs?.posts ?? "busabase-cms-posts")
55
+ },
56
+ pages: {
57
+ list: listPages,
58
+ getByPath: cachedGetByArg(cms.pages.getByPath, "pages", options.baseSlugs?.pages ?? "busabase-cms-pages")
59
+ },
60
+ categories: {
61
+ list: listCategories,
62
+ getBySlug: cachedGetByArg(cms.categories.getBySlug, "categories", options.baseSlugs?.categories ?? "busabase-cms-categories")
63
+ },
64
+ tags: {
65
+ list: listTags,
66
+ getBySlug: cachedGetByArg(cms.tags.getBySlug, "tags", options.baseSlugs?.tags ?? "busabase-cms-tags")
67
+ }
68
+ };
69
+ };
70
+ const validDateOr = (value, fallback) => {
71
+ if (!value) return fallback;
72
+ const parsed = new Date(value);
73
+ return Number.isNaN(parsed.getTime()) ? fallback : parsed;
74
+ };
75
+ const toCmsSitemapEntries = (items, helpers, baseUrl, isMatch, { changeFrequency = "monthly", priority = .7 }, fallbackDate) => items.flatMap((item) => {
76
+ const parsed = helpers.parsePath(item.path);
77
+ if (!parsed || !isMatch(parsed.canonicalPath) || !helpers.isForLocale(item, parsed.locale)) return [];
78
+ return [{
79
+ url: `${baseUrl}${parsed.canonicalPath}`,
80
+ lastModified: validDateOr(item.updatedAt, fallbackDate),
81
+ changeFrequency,
82
+ priority: typeof priority === "function" ? priority(item, parsed.canonicalPath) : priority
83
+ }];
84
+ });
85
+ /** Sitemap entries for Blog Posts — only paths under the `blog` segment are included. */
86
+ const buildCmsBlogSitemapEntries = (posts, helpers, baseUrl, options = {}, fallbackDate = /* @__PURE__ */ new Date()) => toCmsSitemapEntries(posts, helpers, baseUrl, (canonicalPath) => helpers.isBlogPostPath(canonicalPath), {
87
+ changeFrequency: "weekly",
88
+ priority: .7,
89
+ ...options
90
+ }, fallbackDate);
91
+ /** Sitemap entries for Pages — every valid-for-locale record is included, no path filter. */
92
+ const buildCmsPageSitemapEntries = (pages, helpers, baseUrl, options = {}, fallbackDate = /* @__PURE__ */ new Date()) => toCmsSitemapEntries(pages, helpers, baseUrl, () => true, {
93
+ changeFrequency: "monthly",
94
+ priority: .8,
95
+ ...options
96
+ }, fallbackDate);
97
+ /** Merge sitemap sources, keeping the first entry seen for any duplicate URL. */
98
+ const dedupeCmsSitemapEntries = (...sources) => {
99
+ const byUrl = /* @__PURE__ */ new Map();
100
+ for (const source of sources) for (const entry of source) {
101
+ const key = entry.url.replace(/\/+$/, "") || entry.url;
102
+ if (!byUrl.has(key)) byUrl.set(key, entry);
103
+ }
104
+ return [...byUrl.values()];
105
+ };
106
+ //#endregion
107
+ export { buildCmsBlogSitemapEntries, buildCmsPageSitemapEntries, createCachedBusabaseCms, dedupeCmsSitemapEntries, resolveBusabaseCmsCacheKeyPrefix };
@@ -0,0 +1,91 @@
1
+ //#region src/fallback.ts
2
+ /**
3
+ * The "read from Busabase, fall back to bundled content on any failure" wrapper — every app
4
+ * consuming busabase-cms re-implemented this same try/catch/console.warn a couple of times
5
+ * (once for list-shaped reads, once for single-record reads). One generic version, one place
6
+ * to change the log format.
7
+ */
8
+ const readCmsOrFallback = async (operation, fallback, label) => {
9
+ if (!operation) return fallback;
10
+ try {
11
+ return await operation();
12
+ } catch (error) {
13
+ console.warn(`[busabase-cms] ${label} failed; using bundled content`, error);
14
+ return fallback;
15
+ }
16
+ };
17
+ //#endregion
18
+ //#region src/routing.ts
19
+ const normalizeSegment = (segment) => {
20
+ try {
21
+ const decoded = decodeURIComponent(segment);
22
+ if (!decoded || decoded === "." || decoded === ".." || /[\\/\0]/.test(decoded)) return null;
23
+ return decoded;
24
+ } catch {
25
+ return null;
26
+ }
27
+ };
28
+ /** Normalize a stored or requested path into a stable, decoded canonical key. */
29
+ const normalizeCmsPath = (path) => {
30
+ if (!path.startsWith("/") || path.includes("?") || path.includes("#")) return null;
31
+ const segments = [];
32
+ for (const rawSegment of path.split("/").filter(Boolean)) {
33
+ const segment = normalizeSegment(rawSegment);
34
+ if (segment === null) return null;
35
+ segments.push(segment);
36
+ }
37
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
38
+ };
39
+ /** Parse locale ownership from a canonical CMS path. The default locale is unprefixed. */
40
+ const parseCmsCanonicalPath = (path, { supportedLocales, defaultLocale = "en" }) => {
41
+ if (!supportedLocales.includes(defaultLocale)) return null;
42
+ const canonicalPath = normalizeCmsPath(path);
43
+ if (!canonicalPath || canonicalPath === "/") return null;
44
+ const segments = canonicalPath.slice(1).split("/");
45
+ const first = segments[0];
46
+ if (first === defaultLocale) return null;
47
+ const hasLocalePrefix = supportedLocales.includes(first);
48
+ const locale = hasLocalePrefix ? first : defaultLocale;
49
+ const contentSegments = hasLocalePrefix ? segments.slice(1) : segments;
50
+ if (contentSegments.length === 0) return null;
51
+ const pathWithoutLocale = `/${contentSegments.join("/")}`;
52
+ return {
53
+ canonicalPath: locale === defaultLocale ? pathWithoutLocale : `/${locale}${pathWithoutLocale}`,
54
+ locale,
55
+ pathWithoutLocale,
56
+ segments: contentSegments
57
+ };
58
+ };
59
+ const buildCmsCanonicalPath = (locale, path, options) => {
60
+ if (!options.supportedLocales.includes(locale)) return null;
61
+ const contentPath = Array.isArray(path) ? path.join("/") : path;
62
+ const localePrefix = locale === (options.defaultLocale ?? "en") ? "" : `/${locale}`;
63
+ return parseCmsCanonicalPath(`${localePrefix}/${contentPath}`, options)?.canonicalPath ?? null;
64
+ };
65
+ const isCmsContentForLocale = (item, locale, options) => {
66
+ const parsed = parseCmsCanonicalPath(item.path, options);
67
+ return item.locale === locale && parsed?.locale === locale;
68
+ };
69
+ const isCmsBlogPostPath = (path, options) => {
70
+ const parsed = parseCmsCanonicalPath(path, options);
71
+ return parsed?.segments[0] === "blog" && parsed.segments.length > 1;
72
+ };
73
+ /** Build a locale-aware archive URL for a Category or Tag record. */
74
+ const buildCmsTaxonomyArchivePath = (kind, taxonomy, options) => buildCmsCanonicalPath(taxonomy.locale, [kind, taxonomy.slug], options);
75
+ const createCmsPathHelpers = (options) => ({
76
+ options,
77
+ normalizePath: normalizeCmsPath,
78
+ parsePath: (path) => parseCmsCanonicalPath(path, options),
79
+ buildPath: (locale, path) => buildCmsCanonicalPath(locale, path, options),
80
+ isForLocale: (item, locale) => isCmsContentForLocale(item, locale, options),
81
+ isValidContent: (item) => isCmsContentForLocale(item, item.locale, options),
82
+ isBlogPostPath: (path) => isCmsBlogPostPath(path, options),
83
+ buildTaxonomyArchivePath: (kind, taxonomy) => buildCmsTaxonomyArchivePath(kind, taxonomy, options)
84
+ });
85
+ /** Select Posts related to one taxonomy record without mixing locales. */
86
+ const filterCmsPostsByTaxonomy = (posts, kind, taxonomy) => {
87
+ const relationKey = kind === "categories" ? "categoryIds" : "tagIds";
88
+ return posts.filter((post) => post.locale === taxonomy.locale && post[relationKey].includes(taxonomy.id));
89
+ };
90
+ //#endregion
91
+ export { isCmsBlogPostPath as a, parseCmsCanonicalPath as c, filterCmsPostsByTaxonomy as i, readCmsOrFallback as l, buildCmsTaxonomyArchivePath as n, isCmsContentForLocale as o, createCmsPathHelpers as r, normalizeCmsPath as s, buildCmsCanonicalPath as t };