@brandfine/client 0.1.0
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/CHANGELOG.md +21 -0
- package/README.md +41 -0
- package/dist/cache/index.cjs +16 -0
- package/dist/cache/index.cjs.map +1 -0
- package/dist/cache/index.d.cts +61 -0
- package/dist/cache/index.d.ts +61 -0
- package/dist/cache/index.js +3 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/chunk-DHQHUIFO.js +95 -0
- package/dist/chunk-DHQHUIFO.js.map +1 -0
- package/dist/chunk-KHHMR2NX.cjs +75 -0
- package/dist/chunk-KHHMR2NX.cjs.map +1 -0
- package/dist/chunk-MTBSSTTG.js +101 -0
- package/dist/chunk-MTBSSTTG.js.map +1 -0
- package/dist/chunk-OKIEA3AD.cjs +107 -0
- package/dist/chunk-OKIEA3AD.cjs.map +1 -0
- package/dist/chunk-QQLAYITF.js +71 -0
- package/dist/chunk-QQLAYITF.js.map +1 -0
- package/dist/chunk-XJFKL2HU.cjs +98 -0
- package/dist/chunk-XJFKL2HU.cjs.map +1 -0
- package/dist/index-_XBy9Y81.d.cts +262 -0
- package/dist/index-_XBy9Y81.d.ts +262 -0
- package/dist/index.cjs +159 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +107 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.js +115 -0
- package/dist/index.js.map +1 -0
- package/dist/resolvers/index.cjs +28 -0
- package/dist/resolvers/index.cjs.map +1 -0
- package/dist/resolvers/index.d.cts +1 -0
- package/dist/resolvers/index.d.ts +1 -0
- package/dist/resolvers/index.js +3 -0
- package/dist/resolvers/index.js.map +1 -0
- package/dist/webhook/index.cjs +20 -0
- package/dist/webhook/index.cjs.map +1 -0
- package/dist/webhook/index.d.cts +117 -0
- package/dist/webhook/index.d.ts +117 -0
- package/dist/webhook/index.js +3 -0
- package/dist/webhook/index.js.map +1 -0
- package/package.json +82 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @brandfine/client
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8627156: Initial public release.
|
|
8
|
+
|
|
9
|
+
**`@brandfine/client`**
|
|
10
|
+
|
|
11
|
+
Framework-agnostic SDK for the Brandfine CMS. Includes:
|
|
12
|
+
- `createBrandfineClient` — typed HTTP client with `posts`, `categories`, `workspace`, `navigations` namespaces. Supports custom `fetch` injection for tests / edge runtimes.
|
|
13
|
+
- `createCache` / `createKeyedCache` — server-side caches with stale-while-revalidate, explicit-invalidation-blocks-fresh-fetch, and stale-while-error semantics.
|
|
14
|
+
- `resolveNavigation` — turns the API's navigation shape into a per-locale render-ready tree. Supports custom `urlForPost` for non-standard URL patterns.
|
|
15
|
+
- `pickLocale`, `localizePath`, `stripLocalePrefix`, `isLocale` — pure locale helpers.
|
|
16
|
+
- `createBrandfineWebhookHandler` — framework-agnostic webhook handler (`Request → Response`), works with Astro, Next App Router, Remix, Bun, Cloudflare Workers.
|
|
17
|
+
- `BrandfineApiError` — structured error class with status, body, and URL for non-2xx responses.
|
|
18
|
+
|
|
19
|
+
**`@brandfine/client-astro`**
|
|
20
|
+
|
|
21
|
+
Astro adapter for `@brandfine/client`. Provides `createBrandfineWebhookRoute` (wraps the core handler in Astro's `APIRoute` shape) and `readBrandfineEnv` (handles the `import.meta.env` ↔ `process.env` dual-source quirk).
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @brandfine/client
|
|
2
|
+
|
|
3
|
+
Typed HTTP client, server-side caches, locale + navigation resolvers, and webhook helpers for landing pages consuming the Brandfine CMS.
|
|
4
|
+
|
|
5
|
+
> **Status:** early development (`0.x.y`). API may change in minor bumps before `1.0`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @brandfine/client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Available on public npm with [provenance](https://docs.npmjs.com/generating-provenance-statements) — installs are verified against this repo's CI runs.
|
|
14
|
+
|
|
15
|
+
## Subpath exports
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createBrandfineClient } from '@brandfine/client'
|
|
19
|
+
import { createCache, createLocaleCache } from '@brandfine/client/cache'
|
|
20
|
+
import { resolveNavigation, localizePath } from '@brandfine/client/resolvers'
|
|
21
|
+
import { verifyWebhookSecret, parseWebhookPayload } from '@brandfine/client/webhook'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Pick the import path that scopes to what you actually use — tree-shaking does the rest, but subpath imports keep consumer bundle analysis honest.
|
|
25
|
+
|
|
26
|
+
## Roadmap
|
|
27
|
+
|
|
28
|
+
| Module | Status |
|
|
29
|
+
|---|---|
|
|
30
|
+
| Scaffold + build pipeline | ✅ Phase 1 |
|
|
31
|
+
| `createBrandfineClient` | 🔜 Phase 1 |
|
|
32
|
+
| Cache primitives | 🔜 Phase 1 |
|
|
33
|
+
| Navigation resolver | 🔜 Phase 1 |
|
|
34
|
+
| Webhook core | 🔜 Phase 1 |
|
|
35
|
+
| Astro adapter (`@brandfine/client-astro`) | 🔜 Phase 1 |
|
|
36
|
+
| Content-cache factories | 🔜 Phase 2 |
|
|
37
|
+
| React hooks (`@brandfine/client-react`) | 🔜 Phase 2 |
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var chunkXJFKL2HU_cjs = require('../chunk-XJFKL2HU.cjs');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Object.defineProperty(exports, "createCache", {
|
|
8
|
+
enumerable: true,
|
|
9
|
+
get: function () { return chunkXJFKL2HU_cjs.createCache; }
|
|
10
|
+
});
|
|
11
|
+
Object.defineProperty(exports, "createKeyedCache", {
|
|
12
|
+
enumerable: true,
|
|
13
|
+
get: function () { return chunkXJFKL2HU_cjs.createKeyedCache; }
|
|
14
|
+
});
|
|
15
|
+
//# sourceMappingURL=index.cjs.map
|
|
16
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.cjs"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-slot in-memory cache. Used for one-off resources like
|
|
3
|
+
* the workspace metadata that don't vary by any key.
|
|
4
|
+
*
|
|
5
|
+
* The keyed version (`createKeyedCache`) shares the same load
|
|
6
|
+
* semantics — see `./internal.ts` for the canonical description.
|
|
7
|
+
*/
|
|
8
|
+
type CacheOptions<T> = {
|
|
9
|
+
/** Identifier used in error logs. Convention: kebab-case scope
|
|
10
|
+
* (e.g. `'workspace-cache'`, `'navigations-cache'`). */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Milliseconds to consider cached data fresh. After this,
|
|
13
|
+
* reads serve stale data + kick a background refresh. */
|
|
14
|
+
ttl: number;
|
|
15
|
+
/** Async fetcher. Throws on cold-start failures propagate to
|
|
16
|
+
* the caller; with cached data, the entry's logger is invoked
|
|
17
|
+
* and the stale value is served. */
|
|
18
|
+
fetch: () => Promise<T>;
|
|
19
|
+
/** Override the default `console.warn` error logger. Common
|
|
20
|
+
* patterns: pipe to a structured logger, swallow in test
|
|
21
|
+
* environments. */
|
|
22
|
+
onError?: (err: unknown) => void;
|
|
23
|
+
};
|
|
24
|
+
type Cache<T> = {
|
|
25
|
+
/** Read-through: returns fresh or stale data per the SWR
|
|
26
|
+
* semantics. See `./internal.ts` for the full rule set. */
|
|
27
|
+
get: () => Promise<T>;
|
|
28
|
+
/** Mark the entry dirty. The next `get()` will block on a
|
|
29
|
+
* fresh fetch instead of serving stale + revalidating. Used
|
|
30
|
+
* for webhook-driven invalidation where "the value just
|
|
31
|
+
* changed; don't serve old data for one more request". */
|
|
32
|
+
invalidate: () => void;
|
|
33
|
+
};
|
|
34
|
+
declare function createCache<T>(opts: CacheOptions<T>): Cache<T>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Keyed in-memory cache. One entry per string key — used for
|
|
38
|
+
* per-locale caches (`'en'` vs `'pt'`), per-navigation caches
|
|
39
|
+
* (`'header'` vs `'footer'`), or any other dimension where the
|
|
40
|
+
* same fetcher returns different data for different inputs.
|
|
41
|
+
*
|
|
42
|
+
* Load semantics match `createCache` — see `./internal.ts`.
|
|
43
|
+
*/
|
|
44
|
+
type KeyedCacheOptions<T> = {
|
|
45
|
+
label: string;
|
|
46
|
+
ttl: number;
|
|
47
|
+
/** Async fetcher, receives the cache key. */
|
|
48
|
+
fetch: (key: string) => Promise<T>;
|
|
49
|
+
/** Override the default `console.warn` error logger. The key
|
|
50
|
+
* is passed through so consumers can attribute errors. */
|
|
51
|
+
onError?: (err: unknown, key: string) => void;
|
|
52
|
+
};
|
|
53
|
+
type KeyedCache<T> = {
|
|
54
|
+
get: (key: string) => Promise<T>;
|
|
55
|
+
/** Without `key`, marks every entry dirty (used for webhooks
|
|
56
|
+
* that don't carry a key — invalidate everything to be safe). */
|
|
57
|
+
invalidate: (key?: string) => void;
|
|
58
|
+
};
|
|
59
|
+
declare function createKeyedCache<T>(opts: KeyedCacheOptions<T>): KeyedCache<T>;
|
|
60
|
+
|
|
61
|
+
export { type Cache, type CacheOptions, type KeyedCache, type KeyedCacheOptions, createCache, createKeyedCache };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-slot in-memory cache. Used for one-off resources like
|
|
3
|
+
* the workspace metadata that don't vary by any key.
|
|
4
|
+
*
|
|
5
|
+
* The keyed version (`createKeyedCache`) shares the same load
|
|
6
|
+
* semantics — see `./internal.ts` for the canonical description.
|
|
7
|
+
*/
|
|
8
|
+
type CacheOptions<T> = {
|
|
9
|
+
/** Identifier used in error logs. Convention: kebab-case scope
|
|
10
|
+
* (e.g. `'workspace-cache'`, `'navigations-cache'`). */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Milliseconds to consider cached data fresh. After this,
|
|
13
|
+
* reads serve stale data + kick a background refresh. */
|
|
14
|
+
ttl: number;
|
|
15
|
+
/** Async fetcher. Throws on cold-start failures propagate to
|
|
16
|
+
* the caller; with cached data, the entry's logger is invoked
|
|
17
|
+
* and the stale value is served. */
|
|
18
|
+
fetch: () => Promise<T>;
|
|
19
|
+
/** Override the default `console.warn` error logger. Common
|
|
20
|
+
* patterns: pipe to a structured logger, swallow in test
|
|
21
|
+
* environments. */
|
|
22
|
+
onError?: (err: unknown) => void;
|
|
23
|
+
};
|
|
24
|
+
type Cache<T> = {
|
|
25
|
+
/** Read-through: returns fresh or stale data per the SWR
|
|
26
|
+
* semantics. See `./internal.ts` for the full rule set. */
|
|
27
|
+
get: () => Promise<T>;
|
|
28
|
+
/** Mark the entry dirty. The next `get()` will block on a
|
|
29
|
+
* fresh fetch instead of serving stale + revalidating. Used
|
|
30
|
+
* for webhook-driven invalidation where "the value just
|
|
31
|
+
* changed; don't serve old data for one more request". */
|
|
32
|
+
invalidate: () => void;
|
|
33
|
+
};
|
|
34
|
+
declare function createCache<T>(opts: CacheOptions<T>): Cache<T>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Keyed in-memory cache. One entry per string key — used for
|
|
38
|
+
* per-locale caches (`'en'` vs `'pt'`), per-navigation caches
|
|
39
|
+
* (`'header'` vs `'footer'`), or any other dimension where the
|
|
40
|
+
* same fetcher returns different data for different inputs.
|
|
41
|
+
*
|
|
42
|
+
* Load semantics match `createCache` — see `./internal.ts`.
|
|
43
|
+
*/
|
|
44
|
+
type KeyedCacheOptions<T> = {
|
|
45
|
+
label: string;
|
|
46
|
+
ttl: number;
|
|
47
|
+
/** Async fetcher, receives the cache key. */
|
|
48
|
+
fetch: (key: string) => Promise<T>;
|
|
49
|
+
/** Override the default `console.warn` error logger. The key
|
|
50
|
+
* is passed through so consumers can attribute errors. */
|
|
51
|
+
onError?: (err: unknown, key: string) => void;
|
|
52
|
+
};
|
|
53
|
+
type KeyedCache<T> = {
|
|
54
|
+
get: (key: string) => Promise<T>;
|
|
55
|
+
/** Without `key`, marks every entry dirty (used for webhooks
|
|
56
|
+
* that don't carry a key — invalidate everything to be safe). */
|
|
57
|
+
invalidate: (key?: string) => void;
|
|
58
|
+
};
|
|
59
|
+
declare function createKeyedCache<T>(opts: KeyedCacheOptions<T>): KeyedCache<T>;
|
|
60
|
+
|
|
61
|
+
export { type Cache, type CacheOptions, type KeyedCache, type KeyedCacheOptions, createCache, createKeyedCache };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/cache/internal.ts
|
|
2
|
+
function createEntry() {
|
|
3
|
+
return { data: void 0, cachedAt: 0, inFlight: null, dirty: false };
|
|
4
|
+
}
|
|
5
|
+
function refreshEntry(entry, opts) {
|
|
6
|
+
if (entry.inFlight) return entry.inFlight;
|
|
7
|
+
entry.inFlight = (async () => {
|
|
8
|
+
try {
|
|
9
|
+
const data = await opts.fetch();
|
|
10
|
+
entry.data = data;
|
|
11
|
+
entry.cachedAt = Date.now();
|
|
12
|
+
entry.dirty = false;
|
|
13
|
+
return data;
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (entry.cachedAt > 0 && entry.data !== void 0) {
|
|
16
|
+
opts.onError(err);
|
|
17
|
+
return entry.data;
|
|
18
|
+
}
|
|
19
|
+
throw err;
|
|
20
|
+
} finally {
|
|
21
|
+
entry.inFlight = null;
|
|
22
|
+
}
|
|
23
|
+
})();
|
|
24
|
+
return entry.inFlight;
|
|
25
|
+
}
|
|
26
|
+
async function loadEntry(entry, opts) {
|
|
27
|
+
const hasCachedData = entry.cachedAt > 0 && entry.data !== void 0;
|
|
28
|
+
const fresh = !entry.dirty && Date.now() - entry.cachedAt < opts.ttl;
|
|
29
|
+
if (fresh && hasCachedData) return entry.data;
|
|
30
|
+
if (entry.dirty || !hasCachedData) {
|
|
31
|
+
return refreshEntry(entry, opts);
|
|
32
|
+
}
|
|
33
|
+
void refreshEntry(entry, opts).catch(() => {
|
|
34
|
+
});
|
|
35
|
+
return entry.data;
|
|
36
|
+
}
|
|
37
|
+
function defaultOnError(label) {
|
|
38
|
+
return (err) => {
|
|
39
|
+
console.warn(`[${label}] refetch failed, serving stale cache:`, err);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/cache/primitive.ts
|
|
44
|
+
function createCache(opts) {
|
|
45
|
+
const entry = createEntry();
|
|
46
|
+
const internalOpts = {
|
|
47
|
+
label: opts.label,
|
|
48
|
+
ttl: opts.ttl,
|
|
49
|
+
fetch: opts.fetch,
|
|
50
|
+
onError: opts.onError ?? defaultOnError(opts.label)
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
get: () => loadEntry(entry, internalOpts),
|
|
54
|
+
invalidate: () => {
|
|
55
|
+
entry.dirty = true;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/cache/keyed.ts
|
|
61
|
+
function createKeyedCache(opts) {
|
|
62
|
+
const entries = /* @__PURE__ */ new Map();
|
|
63
|
+
function entryFor(key) {
|
|
64
|
+
let e = entries.get(key);
|
|
65
|
+
if (!e) {
|
|
66
|
+
e = createEntry();
|
|
67
|
+
entries.set(key, e);
|
|
68
|
+
}
|
|
69
|
+
return e;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
get(key) {
|
|
73
|
+
const entry = entryFor(key);
|
|
74
|
+
const fallbackLogger = defaultOnError(`${opts.label}:${key}`);
|
|
75
|
+
return loadEntry(entry, {
|
|
76
|
+
label: `${opts.label}:${key}`,
|
|
77
|
+
ttl: opts.ttl,
|
|
78
|
+
fetch: () => opts.fetch(key),
|
|
79
|
+
onError: opts.onError ? (err) => opts.onError(err, key) : fallbackLogger
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
invalidate(key) {
|
|
83
|
+
if (key) {
|
|
84
|
+
const e = entries.get(key);
|
|
85
|
+
if (e) e.dirty = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const e of entries.values()) e.dirty = true;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { createCache, createKeyedCache };
|
|
94
|
+
//# sourceMappingURL=chunk-DHQHUIFO.js.map
|
|
95
|
+
//# sourceMappingURL=chunk-DHQHUIFO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cache/internal.ts","../src/cache/primitive.ts","../src/cache/keyed.ts"],"names":[],"mappings":"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","file":"chunk-DHQHUIFO.js","sourcesContent":["/**\n * Shared internals for the single-slot and keyed caches.\n *\n * Encodes the cache's invariants in one place so the two public\n * primitives (`createCache`, `createKeyedCache`) stay thin\n * wrappers. The semantics are:\n *\n * - Fresh (not dirty, within TTL) → serve cached value.\n * - Explicit invalidation OR cold start (no data yet)\n * → BLOCK on a fresh fetch. The invalidation contract is\n * \"the cache lies, re-read now\"; stale-while-revalidate\n * here would force consumers into a two-refresh dance.\n * - TTL-expired with cached data → stale-while-revalidate.\n * The visitor sees old data for one request; a background\n * refresh runs and the next request gets fresh.\n * - Refetch error with cached data → log via onError, serve\n * stale (\"stale-while-error\"). A momentary upstream outage\n * shouldn't 500 the consumer site.\n * - Concurrent calls during an in-flight fetch dedupe — they\n * await the same promise.\n */\n\nexport type CacheEntry<T> = {\n data: T | undefined\n cachedAt: number\n inFlight: Promise<T> | null\n dirty: boolean\n}\n\nexport function createEntry<T>(): CacheEntry<T> {\n return { data: undefined, cachedAt: 0, inFlight: null, dirty: false }\n}\n\nexport type InternalLoadOptions<T> = {\n label: string\n ttl: number\n fetch: () => Promise<T>\n onError: (err: unknown) => void\n}\n\n/**\n * Run the refresh path on an entry. Dedupes concurrent callers\n * via the entry's `inFlight` promise — a key reason this lives\n * in shared internal rather than being inlined.\n */\nexport function refreshEntry<T>(\n entry: CacheEntry<T>,\n opts: InternalLoadOptions<T>,\n): Promise<T> {\n if (entry.inFlight) return entry.inFlight\n entry.inFlight = (async () => {\n try {\n const data = await opts.fetch()\n entry.data = data\n entry.cachedAt = Date.now()\n entry.dirty = false\n return data\n } catch (err) {\n // Stale-while-error: if we have cached data, log and keep\n // serving it. The next refresh attempt will retry.\n if (entry.cachedAt > 0 && entry.data !== undefined) {\n opts.onError(err)\n return entry.data\n }\n throw err\n } finally {\n entry.inFlight = null\n }\n })()\n return entry.inFlight\n}\n\nexport async function loadEntry<T>(\n entry: CacheEntry<T>,\n opts: InternalLoadOptions<T>,\n): Promise<T> {\n const hasCachedData = entry.cachedAt > 0 && entry.data !== undefined\n const fresh = !entry.dirty && Date.now() - entry.cachedAt < opts.ttl\n\n if (fresh && hasCachedData) return entry.data as T\n\n // Explicit invalidation (e.g. publish webhook) and cold start\n // both block on a fresh fetch. The contract for `invalidate()`\n // is \"the cache lies; re-read now\" — serving stale here would\n // make webhook-driven freshness a two-refresh UX.\n if (entry.dirty || !hasCachedData) {\n return refreshEntry(entry, opts)\n }\n\n // TTL-expired with cached data — stale-while-revalidate.\n void refreshEntry(entry, opts).catch(() => {\n // Already routed through opts.onError inside refreshEntry.\n })\n return entry.data as T\n}\n\n/**\n * Default error logger. Consumers can override via `onError` in\n * the cache options. We deliberately use `console.warn` (not\n * `console.error`) — a single failed refresh while we serve stale\n * is a warning condition, not an error.\n */\nexport function defaultOnError(label: string) {\n return (err: unknown) => {\n \n console.warn(`[${label}] refetch failed, serving stale cache:`, err)\n }\n}\n","/**\n * Single-slot in-memory cache. Used for one-off resources like\n * the workspace metadata that don't vary by any key.\n *\n * The keyed version (`createKeyedCache`) shares the same load\n * semantics — see `./internal.ts` for the canonical description.\n */\n\nimport {\n createEntry,\n defaultOnError,\n loadEntry,\n type CacheEntry,\n} from './internal'\n\nexport type CacheOptions<T> = {\n /** Identifier used in error logs. Convention: kebab-case scope\n * (e.g. `'workspace-cache'`, `'navigations-cache'`). */\n label: string\n /** Milliseconds to consider cached data fresh. After this,\n * reads serve stale data + kick a background refresh. */\n ttl: number\n /** Async fetcher. Throws on cold-start failures propagate to\n * the caller; with cached data, the entry's logger is invoked\n * and the stale value is served. */\n fetch: () => Promise<T>\n /** Override the default `console.warn` error logger. Common\n * patterns: pipe to a structured logger, swallow in test\n * environments. */\n onError?: (err: unknown) => void\n}\n\nexport type Cache<T> = {\n /** Read-through: returns fresh or stale data per the SWR\n * semantics. See `./internal.ts` for the full rule set. */\n get: () => Promise<T>\n /** Mark the entry dirty. The next `get()` will block on a\n * fresh fetch instead of serving stale + revalidating. Used\n * for webhook-driven invalidation where \"the value just\n * changed; don't serve old data for one more request\". */\n invalidate: () => void\n}\n\nexport function createCache<T>(opts: CacheOptions<T>): Cache<T> {\n const entry: CacheEntry<T> = createEntry<T>()\n const internalOpts = {\n label: opts.label,\n ttl: opts.ttl,\n fetch: opts.fetch,\n onError: opts.onError ?? defaultOnError(opts.label),\n }\n\n return {\n get: () => loadEntry(entry, internalOpts),\n invalidate: () => {\n entry.dirty = true\n },\n }\n}\n","/**\n * Keyed in-memory cache. One entry per string key — used for\n * per-locale caches (`'en'` vs `'pt'`), per-navigation caches\n * (`'header'` vs `'footer'`), or any other dimension where the\n * same fetcher returns different data for different inputs.\n *\n * Load semantics match `createCache` — see `./internal.ts`.\n */\n\nimport {\n createEntry,\n defaultOnError,\n loadEntry,\n type CacheEntry,\n} from './internal'\n\nexport type KeyedCacheOptions<T> = {\n label: string\n ttl: number\n /** Async fetcher, receives the cache key. */\n fetch: (key: string) => Promise<T>\n /** Override the default `console.warn` error logger. The key\n * is passed through so consumers can attribute errors. */\n onError?: (err: unknown, key: string) => void\n}\n\nexport type KeyedCache<T> = {\n get: (key: string) => Promise<T>\n /** Without `key`, marks every entry dirty (used for webhooks\n * that don't carry a key — invalidate everything to be safe). */\n invalidate: (key?: string) => void\n}\n\nexport function createKeyedCache<T>(opts: KeyedCacheOptions<T>): KeyedCache<T> {\n const entries = new Map<string, CacheEntry<T>>()\n\n function entryFor(key: string): CacheEntry<T> {\n let e = entries.get(key)\n if (!e) {\n e = createEntry<T>()\n entries.set(key, e)\n }\n return e\n }\n\n return {\n get(key: string): Promise<T> {\n const entry = entryFor(key)\n const fallbackLogger = defaultOnError(`${opts.label}:${key}`)\n return loadEntry(entry, {\n label: `${opts.label}:${key}`,\n ttl: opts.ttl,\n fetch: () => opts.fetch(key),\n onError: opts.onError\n ? (err) => opts.onError!(err, key)\n : fallbackLogger,\n })\n },\n invalidate(key?: string) {\n if (key) {\n const e = entries.get(key)\n if (e) e.dirty = true\n return\n }\n for (const e of entries.values()) e.dirty = true\n },\n }\n}\n"]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/webhook/payload.ts
|
|
4
|
+
async function parseWebhookPayload(request) {
|
|
5
|
+
let raw;
|
|
6
|
+
try {
|
|
7
|
+
raw = await request.json();
|
|
8
|
+
} catch {
|
|
9
|
+
throw new Error("webhook body is not valid JSON");
|
|
10
|
+
}
|
|
11
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
12
|
+
throw new Error("webhook body must be a JSON object");
|
|
13
|
+
}
|
|
14
|
+
const body = raw;
|
|
15
|
+
if (typeof body["workspaceId"] !== "string") {
|
|
16
|
+
throw new Error("webhook body missing `workspaceId`");
|
|
17
|
+
}
|
|
18
|
+
return body;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/webhook/verify.ts
|
|
22
|
+
function verifyWebhookSecret(provided, expected) {
|
|
23
|
+
if (!expected) return false;
|
|
24
|
+
if (provided.length !== expected.length) return false;
|
|
25
|
+
let mismatch = 0;
|
|
26
|
+
for (let i = 0; i < provided.length; i++) {
|
|
27
|
+
mismatch |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
28
|
+
}
|
|
29
|
+
return mismatch === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/webhook/handler.ts
|
|
33
|
+
function createBrandfineWebhookHandler(opts) {
|
|
34
|
+
const onError = opts.onError ?? ((err) => {
|
|
35
|
+
console.error("[brandfine-webhook] onEvent failed:", err);
|
|
36
|
+
});
|
|
37
|
+
return async (request) => {
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
const provided = url.searchParams.get("secret") ?? "";
|
|
40
|
+
if (!verifyWebhookSecret(provided, opts.secret)) {
|
|
41
|
+
return json({ ok: false }, 401);
|
|
42
|
+
}
|
|
43
|
+
let payload;
|
|
44
|
+
try {
|
|
45
|
+
payload = await parseWebhookPayload(request);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return json(
|
|
48
|
+
{
|
|
49
|
+
ok: false,
|
|
50
|
+
error: err instanceof Error ? err.message : "invalid payload"
|
|
51
|
+
},
|
|
52
|
+
400
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await opts.onEvent(payload);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
onError(err);
|
|
59
|
+
return json({ ok: false }, 500);
|
|
60
|
+
}
|
|
61
|
+
return json({ ok: true, event: payload.event }, 200);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function json(body, status) {
|
|
65
|
+
return new Response(JSON.stringify(body), {
|
|
66
|
+
status,
|
|
67
|
+
headers: { "Content-Type": "application/json" }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
exports.createBrandfineWebhookHandler = createBrandfineWebhookHandler;
|
|
72
|
+
exports.parseWebhookPayload = parseWebhookPayload;
|
|
73
|
+
exports.verifyWebhookSecret = verifyWebhookSecret;
|
|
74
|
+
//# sourceMappingURL=chunk-KHHMR2NX.cjs.map
|
|
75
|
+
//# sourceMappingURL=chunk-KHHMR2NX.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/webhook/payload.ts","../src/webhook/verify.ts","../src/webhook/handler.ts"],"names":[],"mappings":"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","file":"chunk-KHHMR2NX.cjs","sourcesContent":["/**\n * Webhook payload types + parser.\n *\n * The cms POSTs a small JSON envelope every time content changes.\n * The discriminator is `event` — a string we recognise (the\n * literal union below) or arbitrary new strings for\n * forward-compatibility. The `& {}` trick keeps autocomplete on\n * the known events while still typing the field as `string`.\n */\n\n/** Known event types emitted by the cms today. Add to this union\n * as new events ship — consumers who switch on `event` get\n * exhaustiveness checks. */\nexport type BrandfineWebhookEvent =\n | 'post.published'\n | 'post.unpublished'\n | 'navigation.created'\n | 'navigation.updated'\n | 'navigation.deleted'\n | 'navigation.items.replaced'\n\n/**\n * Envelope shape. Optional event-specific fields (`postId`,\n * `navigationId`, …) are typed as optional so consumers can\n * narrow with `if (payload.event === 'post.published' && payload.postId)`.\n *\n * Unknown event types still parse into the same shape so handlers\n * never throw on new server-side events. Forward-compatible by\n * default — the cms can add events and the package keeps working.\n */\nexport type BrandfineWebhookPayload = {\n /** Recognised event or any new event string. */\n event: BrandfineWebhookEvent | (string & {})\n workspaceId: string\n /** ISO timestamp of when the event was generated, server-side. */\n at?: string\n\n // ── post-related ─────────────────────────────────────────────\n postId?: string\n slug?: string\n title?: string\n publishedAt?: string\n\n // ── navigation-related ───────────────────────────────────────\n navigationId?: string\n /** Kebab-case nav identifier (e.g. `'header'`). Null when the\n * cms emits the event without a specific key (rare). */\n key?: string | null\n}\n\n/**\n * Read + parse a webhook request body. Tolerant — accepts any\n * JSON object with at minimum a `workspaceId` string, since the\n * `event` field may be missing or unknown and we don't want to\n * reject those (we want consumers to be able to inspect the\n * payload and decide).\n *\n * Throws on:\n * - non-JSON body\n * - non-object root (string, array, null)\n * - missing `workspaceId`\n *\n * Callers in the framework adapter layer turn these into 400\n * responses — the cms doesn't retry on 400, which is correct\n * since malformed bodies aren't transient.\n */\nexport async function parseWebhookPayload(\n request: Request,\n): Promise<BrandfineWebhookPayload> {\n let raw: unknown\n try {\n raw = await request.json()\n } catch {\n throw new Error('webhook body is not valid JSON')\n }\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {\n throw new Error('webhook body must be a JSON object')\n }\n const body = raw as Record<string, unknown>\n if (typeof body['workspaceId'] !== 'string') {\n throw new Error('webhook body missing `workspaceId`')\n }\n return body as unknown as BrandfineWebhookPayload\n}\n","/**\n * Constant-time secret comparison.\n *\n * The webhook receiver compares the URL `?secret=` query param\n * against the shared secret. Naive `===` leaks information via\n * timing — early-exit on first byte mismatch means an attacker\n * can brute-force the secret one byte at a time.\n *\n * This implementation XORs every byte and ORs the results, so\n * the loop runs in O(n) regardless of where the mismatch is.\n *\n * Note on length: we early-return on length mismatch. The\n * `expected` length is set by configuration (not secret data),\n * so leaking the length is acceptable in practice — and skipping\n * the loop entirely beats the cost of running it for a guaranteed\n * mismatch. Cryptographic-grade primitives (e.g. WebCrypto's\n * `timingSafeEqual` on Buffers) require fixed-length inputs.\n */\nexport function verifyWebhookSecret(\n provided: string,\n expected: string,\n): boolean {\n if (!expected) return false\n if (provided.length !== expected.length) return false\n let mismatch = 0\n for (let i = 0; i < provided.length; i++) {\n mismatch |= provided.charCodeAt(i) ^ expected.charCodeAt(i)\n }\n return mismatch === 0\n}\n","/**\n * Framework-agnostic webhook handler.\n *\n * `createBrandfineWebhookHandler` takes a config and returns a\n * `(request: Request) => Promise<Response>` — the standardised\n * Web Fetch API shape that Astro, Next App Router, Remix,\n * SvelteKit, Bun, and Cloudflare Workers all share.\n *\n * Framework adapters (`@brandfine/client-astro`) re-export this\n * with the framework's route convention applied — e.g. exposing\n * `POST = createBrandfineWebhookHandler(...)` directly.\n *\n * Response codes:\n * - 401 — secret mismatch or no secret configured. The cms\n * does not retry on 401, so a misconfigured consumer fails\n * loudly rather than racking up retries.\n * - 400 — body is missing / malformed. Also not retried.\n * - 500 — `onEvent` callback threw. The cms retries with\n * backoff; the consumer's `onError` (if set) gets the\n * original error.\n * - 200 — accepted. Body echoes `event` for debugging.\n */\n\nimport {\n parseWebhookPayload,\n type BrandfineWebhookPayload,\n} from './payload'\nimport { verifyWebhookSecret } from './verify'\n\nexport type BrandfineWebhookHandlerOptions = {\n /** Shared secret. Compared against the `?secret=` query param\n * on every request (constant-time). When empty, every request\n * is rejected with 401 — consumers shouldn't construct a\n * handler without a secret. */\n secret: string\n /** Called once the request is authenticated and parsed. Wire\n * cache invalidations here. May be async; the handler waits\n * for it before responding so the cms's \"delivered\" status\n * reflects whether the consumer actually processed the event. */\n onEvent: (payload: BrandfineWebhookPayload) => void | Promise<void>\n /** Optional error logger for failures inside `onEvent`. The\n * package's default falls back to `console.error` — override\n * to route into a structured logger. */\n onError?: (err: unknown) => void\n}\n\nexport function createBrandfineWebhookHandler(\n opts: BrandfineWebhookHandlerOptions,\n): (request: Request) => Promise<Response> {\n const onError =\n opts.onError ??\n ((err: unknown) => {\n \n console.error('[brandfine-webhook] onEvent failed:', err)\n })\n\n return async (request: Request): Promise<Response> => {\n // 1. Verify the shared secret. Surfacing 401 also when the\n // secret isn't configured catches misconfigured deploys.\n const url = new URL(request.url)\n const provided = url.searchParams.get('secret') ?? ''\n if (!verifyWebhookSecret(provided, opts.secret)) {\n return json({ ok: false }, 401)\n }\n\n // 2. Parse the body. Bad bodies → 400 (no retry).\n let payload: BrandfineWebhookPayload\n try {\n payload = await parseWebhookPayload(request)\n } catch (err) {\n return json(\n {\n ok: false,\n error: err instanceof Error ? err.message : 'invalid payload',\n },\n 400,\n )\n }\n\n // 3. Run the consumer's handler. Errors here are transient —\n // return 500 so the cms retries with backoff.\n try {\n await opts.onEvent(payload)\n } catch (err) {\n onError(err)\n return json({ ok: false }, 500)\n }\n\n return json({ ok: true, event: payload.event }, 200)\n }\n}\n\nfunction json(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n"]}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/resolvers/locale.ts
|
|
2
|
+
function isLocale(value, locales) {
|
|
3
|
+
return typeof value === "string" && locales.includes(value);
|
|
4
|
+
}
|
|
5
|
+
function pickLocale(input, opts) {
|
|
6
|
+
return isLocale(input, opts.locales) ? input : opts.defaultLocale;
|
|
7
|
+
}
|
|
8
|
+
function localizePath(path, locale, opts) {
|
|
9
|
+
if (locale === opts.defaultLocale) return path;
|
|
10
|
+
if (path === "/") return `/${locale}`;
|
|
11
|
+
return `/${locale}${path.startsWith("/") ? path : `/${path}`}`;
|
|
12
|
+
}
|
|
13
|
+
function stripLocalePrefix(pathname, opts) {
|
|
14
|
+
for (const locale of opts.locales) {
|
|
15
|
+
if (locale === opts.defaultLocale) continue;
|
|
16
|
+
const prefix = `/${locale}`;
|
|
17
|
+
if (pathname === prefix || pathname === `${prefix}/`) return "/";
|
|
18
|
+
if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
|
|
19
|
+
}
|
|
20
|
+
return pathname;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/resolvers/navigation.ts
|
|
24
|
+
function resolveNavigation(nav, locale, opts) {
|
|
25
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const item of nav.items) {
|
|
27
|
+
if (!item.parentId) continue;
|
|
28
|
+
const arr = childrenByParent.get(item.parentId) ?? [];
|
|
29
|
+
arr.push(item);
|
|
30
|
+
childrenByParent.set(item.parentId, arr);
|
|
31
|
+
}
|
|
32
|
+
const topLevel = nav.items.filter((i) => i.parentId === null);
|
|
33
|
+
return {
|
|
34
|
+
key: nav.key,
|
|
35
|
+
name: nav.name,
|
|
36
|
+
items: topLevel.filter((item) => !item.hiddenLocales.includes(locale)).map(
|
|
37
|
+
(item) => hydrate(
|
|
38
|
+
item,
|
|
39
|
+
(childrenByParent.get(item.id) ?? []).filter(
|
|
40
|
+
(c) => !c.hiddenLocales.includes(locale)
|
|
41
|
+
),
|
|
42
|
+
locale,
|
|
43
|
+
opts
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function hydrate(item, children, locale, opts) {
|
|
49
|
+
return {
|
|
50
|
+
href: resolveHref(item, locale, opts),
|
|
51
|
+
label: resolveLabel(item, locale, opts.defaultLocale),
|
|
52
|
+
type: item.type,
|
|
53
|
+
children: children.map((c) => hydrate(c, [], locale, opts))
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function resolveHref(item, locale, opts) {
|
|
57
|
+
if (item.type === "HEADING") return null;
|
|
58
|
+
if (item.type === "CUSTOM_URL") {
|
|
59
|
+
const url = item.customUrl ?? "";
|
|
60
|
+
if (!url) return null;
|
|
61
|
+
if (/^([a-z][a-z0-9+.-]*:)|^\/\//i.test(url)) return url;
|
|
62
|
+
return localizePath(
|
|
63
|
+
url.startsWith("/") ? url : `/${url}`,
|
|
64
|
+
locale,
|
|
65
|
+
{ defaultLocale: opts.defaultLocale }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const post = item.post;
|
|
69
|
+
if (!post) return null;
|
|
70
|
+
const sibling = post.locales.find((s) => s.locale === locale) ?? post.locales.find((s) => s.locale === opts.defaultLocale) ?? post.locales[0];
|
|
71
|
+
if (!sibling) return null;
|
|
72
|
+
const linkLocale = post.locales.some((s) => s.locale === locale) ? locale : opts.defaultLocale;
|
|
73
|
+
if (opts.urlForPost) {
|
|
74
|
+
return opts.urlForPost({
|
|
75
|
+
post,
|
|
76
|
+
sibling,
|
|
77
|
+
locale: linkLocale,
|
|
78
|
+
defaultLocale: opts.defaultLocale
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (!post.postTypeSlug) return null;
|
|
82
|
+
return localizePath(
|
|
83
|
+
`/${post.postTypeSlug}/${sibling.slug}`,
|
|
84
|
+
linkLocale,
|
|
85
|
+
{ defaultLocale: opts.defaultLocale }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
function resolveLabel(item, locale, defaultLocale) {
|
|
89
|
+
const labels = item.labels ?? {};
|
|
90
|
+
const override = labels[locale]?.trim() || labels[defaultLocale]?.trim() || Object.values(labels).map((v) => v?.trim()).find((v) => Boolean(v)) || "";
|
|
91
|
+
if (override) return override;
|
|
92
|
+
if (item.type === "POST" && item.post) {
|
|
93
|
+
const sibling = item.post.locales.find((s) => s.locale === locale) ?? item.post.locales.find((s) => s.locale === defaultLocale) ?? item.post.locales[0];
|
|
94
|
+
if (sibling) return sibling.title;
|
|
95
|
+
}
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { isLocale, localizePath, pickLocale, resolveNavigation, stripLocalePrefix };
|
|
100
|
+
//# sourceMappingURL=chunk-MTBSSTTG.js.map
|
|
101
|
+
//# sourceMappingURL=chunk-MTBSSTTG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/resolvers/locale.ts","../src/resolvers/navigation.ts"],"names":[],"mappings":";AAmCO,SAAS,QAAA,CACd,OACA,OAAA,EACiB;AACjB,EAAA,OAAO,OAAO,KAAA,KAAU,QAAA,IAAY,OAAA,CAAQ,SAAS,KAAK,CAAA;AAC5D;AAOO,SAAS,UAAA,CAAW,OAAgB,IAAA,EAA6B;AACtE,EAAA,OAAO,SAAS,KAAA,EAAO,IAAA,CAAK,OAAO,CAAA,GAAI,QAAQ,IAAA,CAAK,aAAA;AACtD;AAcO,SAAS,YAAA,CACd,IAAA,EACA,MAAA,EACA,IAAA,EACQ;AACR,EAAA,IAAI,MAAA,KAAW,IAAA,CAAK,aAAA,EAAe,OAAO,IAAA;AAC1C,EAAA,IAAI,IAAA,KAAS,GAAA,EAAK,OAAO,CAAA,CAAA,EAAI,MAAM,CAAA,CAAA;AACnC,EAAA,OAAO,CAAA,CAAA,EAAI,MAAM,CAAA,EAAG,IAAA,CAAK,UAAA,CAAW,GAAG,CAAA,GAAI,IAAA,GAAO,CAAA,CAAA,EAAI,IAAI,CAAA,CAAE,CAAA,CAAA;AAC9D;AAcO,SAAS,iBAAA,CACd,UACA,IAAA,EACQ;AACR,EAAA,KAAA,MAAW,MAAA,IAAU,KAAK,OAAA,EAAS;AACjC,IAAA,IAAI,MAAA,KAAW,KAAK,aAAA,EAAe;AACnC,IAAA,MAAM,MAAA,GAAS,IAAI,MAAM,CAAA,CAAA;AACzB,IAAA,IAAI,aAAa,MAAA,IAAU,QAAA,KAAa,CAAA,EAAG,MAAM,KAAK,OAAO,GAAA;AAC7D,IAAA,IAAI,QAAA,CAAS,UAAA,CAAW,CAAA,EAAG,MAAM,CAAA,CAAA,CAAG,GAAG,OAAO,QAAA,CAAS,KAAA,CAAM,MAAA,CAAO,MAAM,CAAA;AAAA,EAC5E;AACA,EAAA,OAAO,QAAA;AACT;;;ACjCO,SAAS,iBAAA,CACd,GAAA,EACA,MAAA,EACA,IAAA,EACa;AACb,EAAA,MAAM,gBAAA,uBAAuB,GAAA,EAAgC;AAC7D,EAAA,KAAA,MAAW,IAAA,IAAQ,IAAI,KAAA,EAAO;AAC5B,IAAA,IAAI,CAAC,KAAK,QAAA,EAAU;AACpB,IAAA,MAAM,MAAM,gBAAA,CAAiB,GAAA,CAAI,IAAA,CAAK,QAAQ,KAAK,EAAC;AACpD,IAAA,GAAA,CAAI,KAAK,IAAI,CAAA;AACb,IAAA,gBAAA,CAAiB,GAAA,CAAI,IAAA,CAAK,QAAA,EAAU,GAAG,CAAA;AAAA,EACzC;AAGA,EAAA,MAAM,QAAA,GAAW,IAAI,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,aAAa,IAAI,CAAA;AAE5D,EAAA,OAAO;AAAA,IACL,KAAK,GAAA,CAAI,GAAA;AAAA,IACT,MAAM,GAAA,CAAI,IAAA;AAAA,IACV,KAAA,EAAO,QAAA,CACJ,MAAA,CAAO,CAAC,IAAA,KAAS,CAAC,IAAA,CAAK,aAAA,CAAc,QAAA,CAAS,MAAM,CAAC,CAAA,CACrD,GAAA;AAAA,MAAI,CAAC,IAAA,KACJ,OAAA;AAAA,QACE,IAAA;AAAA,QAAA,CACC,iBAAiB,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA,IAAK,EAAC,EAAG,MAAA;AAAA,UACpC,CAAC,CAAA,KAAM,CAAC,CAAA,CAAE,aAAA,CAAc,SAAS,MAAM;AAAA,SACzC;AAAA,QACA,MAAA;AAAA,QACA;AAAA;AACF;AACF,GACJ;AACF;AAEA,SAAS,OAAA,CACP,IAAA,EACA,QAAA,EACA,MAAA,EACA,IAAA,EACiB;AACjB,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,WAAA,CAAY,IAAA,EAAM,MAAA,EAAQ,IAAI,CAAA;AAAA,IACpC,KAAA,EAAO,YAAA,CAAa,IAAA,EAAM,MAAA,EAAQ,KAAK,aAAa,CAAA;AAAA,IACpD,MAAM,IAAA,CAAK,IAAA;AAAA,IACX,QAAA,EAAU,QAAA,CAAS,GAAA,CAAI,CAAC,CAAA,KAAM,OAAA,CAAQ,CAAA,EAAG,EAAC,EAAG,MAAA,EAAQ,IAAI,CAAC;AAAA,GAC5D;AACF;AAEA,SAAS,WAAA,CACP,IAAA,EACA,MAAA,EACA,IAAA,EACe;AACf,EAAA,IAAI,IAAA,CAAK,IAAA,KAAS,SAAA,EAAW,OAAO,IAAA;AAEpC,EAAA,IAAI,IAAA,CAAK,SAAS,YAAA,EAAc;AAC9B,IAAA,MAAM,GAAA,GAAM,KAAK,SAAA,IAAa,EAAA;AAC9B,IAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AAIjB,IAAA,IAAI,8BAAA,CAA+B,IAAA,CAAK,GAAG,CAAA,EAAG,OAAO,GAAA;AACrD,IAAA,OAAO,YAAA;AAAA,MACL,IAAI,UAAA,CAAW,GAAG,CAAA,GAAI,GAAA,GAAM,IAAI,GAAG,CAAA,CAAA;AAAA,MACnC,MAAA;AAAA,MACA,EAAE,aAAA,EAAe,IAAA,CAAK,aAAA;AAAc,KACtC;AAAA,EACF;AAGA,EAAA,MAAM,OAAO,IAAA,CAAK,IAAA;AAClB,EAAA,IAAI,CAAC,MAAM,OAAO,IAAA;AAKlB,EAAA,MAAM,OAAA,GACJ,KAAK,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,MAAA,KAAW,MAAM,CAAA,IAC5C,IAAA,CAAK,QAAQ,IAAA,CAAK,CAAC,MAAM,CAAA,CAAE,MAAA,KAAW,KAAK,aAAa,CAAA,IACxD,IAAA,CAAK,OAAA,CAAQ,CAAC,CAAA;AAChB,EAAA,IAAI,CAAC,SAAS,OAAO,IAAA;AAMrB,EAAA,MAAM,UAAA,GAAa,IAAA,CAAK,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,MAAA,KAAW,MAAM,CAAA,GAC3D,MAAA,GACA,IAAA,CAAK,aAAA;AAET,EAAA,IAAI,KAAK,UAAA,EAAY;AACnB,IAAA,OAAO,KAAK,UAAA,CAAW;AAAA,MACrB,IAAA;AAAA,MACA,OAAA;AAAA,MACA,MAAA,EAAQ,UAAA;AAAA,MACR,eAAe,IAAA,CAAK;AAAA,KACrB,CAAA;AAAA,EACH;AAKA,EAAA,IAAI,CAAC,IAAA,CAAK,YAAA,EAAc,OAAO,IAAA;AAC/B,EAAA,OAAO,YAAA;AAAA,IACL,CAAA,CAAA,EAAI,IAAA,CAAK,YAAY,CAAA,CAAA,EAAI,QAAQ,IAAI,CAAA,CAAA;AAAA,IACrC,UAAA;AAAA,IACA,EAAE,aAAA,EAAe,IAAA,CAAK,aAAA;AAAc,GACtC;AACF;AAEA,SAAS,YAAA,CACP,IAAA,EACA,MAAA,EACA,aAAA,EACQ;AACR,EAAA,MAAM,MAAA,GAAS,IAAA,CAAK,MAAA,IAAU,EAAC;AAI/B,EAAA,MAAM,QAAA,GACJ,MAAA,CAAO,MAAM,CAAA,EAAG,IAAA,EAAK,IACrB,MAAA,CAAO,aAAa,CAAA,EAAG,IAAA,EAAK,IAC5B,MAAA,CAAO,MAAA,CAAO,MAAM,CAAA,CACjB,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,EAAG,IAAA,EAAM,CAAA,CACpB,IAAA,CAAK,CAAC,CAAA,KAAM,OAAA,CAAQ,CAAC,CAAC,CAAA,IACzB,EAAA;AACF,EAAA,IAAI,UAAU,OAAO,QAAA;AAErB,EAAA,IAAI,IAAA,CAAK,IAAA,KAAS,MAAA,IAAU,IAAA,CAAK,IAAA,EAAM;AACrC,IAAA,MAAM,OAAA,GACJ,IAAA,CAAK,IAAA,CAAK,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,MAAA,KAAW,MAAM,CAAA,IACjD,IAAA,CAAK,KAAK,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,MAAA,KAAW,aAAa,CAAA,IACxD,IAAA,CAAK,IAAA,CAAK,OAAA,CAAQ,CAAC,CAAA;AACrB,IAAA,IAAI,OAAA,SAAgB,OAAA,CAAQ,KAAA;AAAA,EAC9B;AACA,EAAA,OAAO,EAAA;AACT","file":"chunk-MTBSSTTG.js","sourcesContent":["/**\n * Locale helpers.\n *\n * Pure functions over BCP47 locale codes. Olavisa's in-tree\n * version reads `LOCALES` and `DEFAULT_LOCALE` from module-level\n * constants — the package takes them as options instead so a\n * single SDK instance can serve callers with different locale\n * sets (multi-tenant, preview environments, tests).\n *\n * The conventional URL shape these helpers assume:\n * - The `defaultLocale` is served at the bare URL: `/about`\n * - Other locales get a path prefix: `/pt/about`, `/es/about`\n * - `pickLocale` falls back to `defaultLocale` for unknown\n * inputs. Don't throw — `Astro.currentLocale` is `string |\n * undefined`, and callers shouldn't have to defend against\n * every framework's quirk.\n */\n\nexport type LocaleOptions = {\n /** Locales the consumer serves, in any order. The list is used\n * for membership checks (`isLocale`), prefix detection\n * (`stripLocalePrefix`), and as the codomain of `pickLocale`. */\n locales: readonly string[]\n /** The locale served at bare URLs. Must appear in `locales`. */\n defaultLocale: string\n}\n\n/**\n * Type guard. Narrows `unknown` inputs to a known locale so the\n * caller can use them without further coercion.\n *\n * if (isLocale(value, ['en', 'pt'])) {\n * // value: string (known to be 'en' | 'pt' at runtime)\n * }\n */\nexport function isLocale(\n value: unknown,\n locales: readonly string[],\n): value is string {\n return typeof value === 'string' && locales.includes(value)\n}\n\n/**\n * Coerce arbitrary input into a known locale, falling back to\n * `defaultLocale` for unknowns. Convenient at the consumer's\n * framework boundary (Astro.currentLocale, request headers, etc.).\n */\nexport function pickLocale(input: unknown, opts: LocaleOptions): string {\n return isLocale(input, opts.locales) ? input : opts.defaultLocale\n}\n\n/**\n * Convert a canonical (default-locale) path into the locale-prefixed\n * variant for `locale`. The default locale's URLs are bare; every\n * other locale prefixes with `/<locale>`.\n *\n * localizePath('/services/uk-eta', 'en', { defaultLocale: 'en' })\n * → '/services/uk-eta'\n * localizePath('/services/uk-eta', 'pt', { defaultLocale: 'en' })\n * → '/pt/services/uk-eta'\n * localizePath('/', 'pt', { defaultLocale: 'en' })\n * → '/pt'\n */\nexport function localizePath(\n path: string,\n locale: string,\n opts: { defaultLocale: string },\n): string {\n if (locale === opts.defaultLocale) return path\n if (path === '/') return `/${locale}`\n return `/${locale}${path.startsWith('/') ? path : `/${path}`}`\n}\n\n/**\n * Strip a non-default locale prefix off a pathname. The inverse\n * of `localizePath` — useful for normalising back to a canonical\n * path before re-localising for a different locale (the language\n * switcher's main job).\n *\n * stripLocalePrefix('/pt/about', ...) → '/about'\n * stripLocalePrefix('/about', ...) → '/about' (already canonical)\n * stripLocalePrefix('/pt', ...) → '/'\n * stripLocalePrefix('/en/foo', { defaultLocale: 'en', … })\n * → '/en/foo' (en is default — no prefix to strip)\n */\nexport function stripLocalePrefix(\n pathname: string,\n opts: LocaleOptions,\n): string {\n for (const locale of opts.locales) {\n if (locale === opts.defaultLocale) continue\n const prefix = `/${locale}`\n if (pathname === prefix || pathname === `${prefix}/`) return '/'\n if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length)\n }\n return pathname\n}\n","/**\n * Navigation resolver.\n *\n * Turns a `BrandfineNavigation` (locale-agnostic shape from the\n * external API) into a `HydratedNav` ready for a specific locale —\n * URLs computed, labels picked, hidden items dropped, children\n * grouped under their parent. Header / Footer components consume\n * the hydrated form directly.\n *\n * The semantics encoded here come from olavisa but are\n * generalised:\n * - POST items resolve URLs via `post.locales`. If the active\n * locale has no translation, fall back to the default-locale\n * URL. Consumers can override the URL pattern entirely via\n * `urlForPost`.\n * - CUSTOM_URL items: paths (start with `/`) run through\n * `localizePath`; anything with a scheme (https://, mailto:)\n * passes through unchanged.\n * - Labels: per-locale override → default-locale label → any\n * populated label (last-resort, covers single-locale fills) →\n * for POST items, the post's per-locale title.\n * - `hiddenLocales` drops the item entirely for that locale.\n */\n\nimport type {\n BrandfineNavItem,\n BrandfineNavPost,\n BrandfineNavigation,\n} from '../types'\nimport { localizePath } from './locale'\n\nexport type HydratedNavItem = {\n /** Final URL, ready to render in an `<a href>`. `null` for\n * HEADING items (label-only) and for POST items whose post\n * has been deleted (orphan). */\n href: string | null\n label: string\n type: 'CUSTOM_URL' | 'POST' | 'HEADING'\n children: HydratedNavItem[]\n}\n\nexport type HydratedNav = {\n key: string\n name: string\n items: HydratedNavItem[]\n}\n\nexport type ResolveNavigationOptions = {\n defaultLocale: string\n /** Override the URL pattern for POST items. Default:\n * `localizePath('/${postTypeSlug}/${sibling.slug}', linkLocale)`.\n *\n * Use when a consumer routes posts under a non-default prefix\n * (e.g. `/blog/<slug>` instead of `/posts/<slug>`), or wants\n * to omit the locale prefix for specific post types. */\n urlForPost?: (args: {\n post: BrandfineNavPost\n sibling: BrandfineNavPost['locales'][number]\n locale: string\n defaultLocale: string\n }) => string | null\n}\n\nexport function resolveNavigation(\n nav: BrandfineNavigation,\n locale: string,\n opts: ResolveNavigationOptions,\n): HydratedNav {\n const childrenByParent = new Map<string, BrandfineNavItem[]>()\n for (const item of nav.items) {\n if (!item.parentId) continue\n const arr = childrenByParent.get(item.parentId) ?? []\n arr.push(item)\n childrenByParent.set(item.parentId, arr)\n }\n // Array-position dictates sibling order within a parent —\n // matches the cms editor's render contract.\n const topLevel = nav.items.filter((i) => i.parentId === null)\n\n return {\n key: nav.key,\n name: nav.name,\n items: topLevel\n .filter((item) => !item.hiddenLocales.includes(locale))\n .map((item) =>\n hydrate(\n item,\n (childrenByParent.get(item.id) ?? []).filter(\n (c) => !c.hiddenLocales.includes(locale),\n ),\n locale,\n opts,\n ),\n ),\n }\n}\n\nfunction hydrate(\n item: BrandfineNavItem,\n children: BrandfineNavItem[],\n locale: string,\n opts: ResolveNavigationOptions,\n): HydratedNavItem {\n return {\n href: resolveHref(item, locale, opts),\n label: resolveLabel(item, locale, opts.defaultLocale),\n type: item.type,\n children: children.map((c) => hydrate(c, [], locale, opts)),\n }\n}\n\nfunction resolveHref(\n item: BrandfineNavItem,\n locale: string,\n opts: ResolveNavigationOptions,\n): string | null {\n if (item.type === 'HEADING') return null\n\n if (item.type === 'CUSTOM_URL') {\n const url = item.customUrl ?? ''\n if (!url) return null\n // External (scheme:) and protocol-relative URLs pass through;\n // `localizePath` would mangle them. Anything else is treated\n // as a path on the consumer site.\n if (/^([a-z][a-z0-9+.-]*:)|^\\/\\//i.test(url)) return url\n return localizePath(\n url.startsWith('/') ? url : `/${url}`,\n locale,\n { defaultLocale: opts.defaultLocale },\n )\n }\n\n // POST\n const post = item.post\n if (!post) return null\n\n // Pick the sibling for the active locale, falling back to the\n // default-locale sibling, then any sibling. If none exist, the\n // post has no published translations.\n const sibling =\n post.locales.find((s) => s.locale === locale) ??\n post.locales.find((s) => s.locale === opts.defaultLocale) ??\n post.locales[0]\n if (!sibling) return null\n\n // The URL prefix the user navigates to — `locale` when this\n // post has a translation in `locale`, else `defaultLocale` so\n // we land on the default-locale page (Astro's i18n rewrite or\n // similar serves the right content under the right URL).\n const linkLocale = post.locales.some((s) => s.locale === locale)\n ? locale\n : opts.defaultLocale\n\n if (opts.urlForPost) {\n return opts.urlForPost({\n post,\n sibling,\n locale: linkLocale,\n defaultLocale: opts.defaultLocale,\n })\n }\n\n // Default convention: `/<postTypeSlug>/<sibling.slug>`.\n // Orphan posts (postType deleted) have no path — return null so\n // the consumer can render an unlinked label or skip the item.\n if (!post.postTypeSlug) return null\n return localizePath(\n `/${post.postTypeSlug}/${sibling.slug}`,\n linkLocale,\n { defaultLocale: opts.defaultLocale },\n )\n}\n\nfunction resolveLabel(\n item: BrandfineNavItem,\n locale: string,\n defaultLocale: string,\n): string {\n const labels = item.labels ?? {}\n // Active locale override → default-locale label → any other\n // populated label (last-resort, useful when editors only fill\n // one locale). Empty strings are treated as \"not set\".\n const override =\n labels[locale]?.trim() ||\n labels[defaultLocale]?.trim() ||\n Object.values(labels)\n .map((v) => v?.trim())\n .find((v) => Boolean(v)) ||\n ''\n if (override) return override\n\n if (item.type === 'POST' && item.post) {\n const sibling =\n item.post.locales.find((s) => s.locale === locale) ??\n item.post.locales.find((s) => s.locale === defaultLocale) ??\n item.post.locales[0]\n if (sibling) return sibling.title\n }\n return ''\n}\n"]}
|