storyloco 1.16.0 → 2.0.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/README.md CHANGED
@@ -24,7 +24,7 @@ A collection of slick Storyblok field plugins built with Svelte 5, TypeScript, a
24
24
  ### Storyblok Client
25
25
 
26
26
  - **🎯 StoryblokClient** - Reactive wrapper for Storyblok with Svelte 5 integration
27
- - **🔧 Utilities** - Re-exported `editable`, `Block`, and `richtext` functions
27
+ - **🔧 Utilities** - `attrs` for wiring a block to the visual editor, plus re-exported `editable`, `Block`, and `richtext` functions
28
28
 
29
29
  ### Shared Components
30
30
 
@@ -68,18 +68,20 @@ import type { Video, Heading, Link, Input, Asset, SEO } from 'storyloco'
68
68
  import { StoryblokClient } from 'storyloco'
69
69
  import { PUBLIC_STORYBLOK_ACCESS_TOKEN } from '$env/static/public'
70
70
 
71
- // Create client (defaults to $lib/blocks/*.svelte) - init() called automatically
72
- export const client = new StoryblokClient(PUBLIC_STORYBLOK_ACCESS_TOKEN)
71
+ export const client = new StoryblokClient({
72
+ token: PUBLIC_STORYBLOK_ACCESS_TOKEN,
73
+ relations: ['shared.items'],
74
+ components: import.meta.glob(['$blocks/**/*.svelte', '$templates/*.svelte'])
75
+ })
76
+ ```
73
77
 
74
- // With custom component globs
75
- export const client = new StoryblokClient(
76
- PUBLIC_STORYBLOK_ACCESS_TOKEN,
77
- import.meta.glob(['$blocks/*.svelte', '$templates/*.svelte'])
78
- )
78
+ Everything is configured here rather than by subclassing, and `init()` runs on its own — the
79
+ API methods await it, so you never call it on the server. `components` takes the result of
80
+ `import.meta.glob`: Vite has to see the literal pattern to rewrite it, so the glob has to stay
81
+ at your call site rather than move into the client.
79
82
 
80
- // Single glob pattern
81
- export const client = new StoryblokClient(PUBLIC_STORYBLOK_ACCESS_TOKEN, import.meta.glob('$blocks/*.svelte'))
82
- ```
83
+ `client.handle_error` is on the instance, so `.catch(client.handle_error)` works without
84
+ wrapping anything.
83
85
 
84
86
  #### 2. Use in your Svelte components
85
87
 
@@ -108,7 +110,7 @@ export const client = new StoryblokClient(PUBLIC_STORYBLOK_ACCESS_TOKEN, import.
108
110
 
109
111
  ```svelte
110
112
  <script lang="ts">
111
- import { editable } from 'storyloco'
113
+ import { attrs } from 'storyloco'
112
114
 
113
115
  // This is auto-generated by the Storyblok Schema plugin
114
116
  import type { Blok, Hero } from '$lib/components.schema.js'
@@ -120,12 +122,78 @@ export const client = new StoryblokClient(PUBLIC_STORYBLOK_ACCESS_TOKEN, import.
120
122
  let { blok } = $props()
121
123
  </script>
122
124
 
123
- <section use:editable={blok}>
125
+ <section {...attrs(blok)}>
124
126
  <h1>{blok.headline}</h1>
125
127
  <p>{blok.subheadline}</p>
126
128
  </section>
127
129
  ```
128
130
 
131
+ `attrs` makes the block editable in the visual editor and gives it a stable `id` — the
132
+ block's `anchor` field when it has one, so sections can be linked to, otherwise its `_uid`.
133
+
134
+ Prefer it to `use:editable`. Being an attachment rather than an action it spreads, so the
135
+ element keeps its own attributes, and you can wrap it to add project-specific ones in one
136
+ place rather than repeating them in every block:
137
+
138
+ ```typescript
139
+ // Mapping section fields onto CSS custom properties is left to you — the field names and
140
+ // the design tokens are yours. Wrap `attrs` rather than reaching past it:
141
+ //
142
+ // export function section<T extends SectionFields>(blok: Blok<T>) {
143
+ // let style = ''
144
+ // if (blok.theme) style += `--theme: var(--c-${blok.theme});`
145
+ // if (blok.pad_top) style += `--pt: var(--s-${blok.pad_top});`
146
+ // return { ...attrs(blok), style }
147
+ // }
148
+ ```
149
+
150
+ `editable` is still exported for anything `attrs` doesn't cover.
151
+
152
+ ### Read Content on the Server
153
+
154
+ `storyloco/server` binds a client to the current request, so reads carry the version from
155
+ the `version` hook and the resolve options you'd otherwise repeat everywhere. Server-only,
156
+ since it reads the active request.
157
+
158
+ **`$lib/storyblok.server.ts`:**
159
+
160
+ ```typescript
161
+ import { setup } from 'storyloco/server'
162
+ import { client } from './storyblok.js'
163
+
164
+ export const storyblok = setup(client)
165
+ ```
166
+
167
+ Then anywhere on the server:
168
+
169
+ ```typescript
170
+ import { storyblok } from '$lib/storyblok.server.js'
171
+
172
+ // A single story, with the client's `relations` resolved
173
+ await storyblok.story('layout')
174
+
175
+ // A list. `total` is the unpaged count, for "showing n of m"
176
+ const { stories, total } = await storyblok.stories({ content_type: 'blog', per_page: 2 })
177
+
178
+ // The link tree, flattened — Storyblok returns it keyed by uuid
179
+ const links = await storyblok.links({ starts_with: 'blog/' })
180
+ ```
181
+
182
+ Your params are spread last, so any default can be overridden:
183
+
184
+ ```typescript
185
+ await storyblok.story('layout', { resolve_assets: 0 })
186
+ ```
187
+
188
+ It's callable too, for anything the methods don't cover:
189
+
190
+ ```typescript
191
+ const { client, version } = storyblok()
192
+ await client.get('cdn/datasource_entries', { version })
193
+ ```
194
+
195
+ Errors go through `handle_error`, so a missing story is a 404 and anything else a 500.
196
+
129
197
  ### Use Shared Components
130
198
 
131
199
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyloco",
3
- "version": "1.16.0",
3
+ "version": "2.0.0",
4
4
  "description": "A collection of slick Storyblok field plugins built with Svelte 5, TypeScript, and Tailwind CSS",
5
5
  "keywords": [
6
6
  "storyblok",
@@ -32,7 +32,8 @@
32
32
  "packages/ticketsource/types.ts",
33
33
  "packages/input/types.ts",
34
34
  "packages/time/types.ts",
35
- "packages/seo/types.ts"
35
+ "packages/seo/types.ts",
36
+ "packages/hubspot_form/types.ts"
36
37
  ],
37
38
  "exports": {
38
39
  ".": {
@@ -78,28 +79,38 @@
78
79
  "types": "./packages/vite/dist/hooks.d.ts",
79
80
  "import": "./packages/vite/dist/hooks.js"
80
81
  },
82
+ "./server": {
83
+ "types": "./packages/vite/dist/server.d.ts",
84
+ "import": "./packages/vite/dist/server.js"
85
+ },
81
86
  "./time": {
82
87
  "types": "./packages/time/types.ts",
83
88
  "svelte": "./packages/time/src/app.svelte.ts"
84
89
  },
85
90
  "./seo": {
86
91
  "types": "./packages/seo/types.ts"
92
+ },
93
+ "./hubspot_form": {
94
+ "types": "./packages/hubspot_form/types.ts",
95
+ "svelte": "./packages/hubspot_form/src/app.svelte.ts"
87
96
  }
88
97
  },
89
98
  "devDependencies": {
90
- "@types/node": "24.10.0",
91
- "@typescript-eslint/eslint-plugin": "8.46.3",
92
- "@typescript-eslint/parser": "8.46.3",
93
- "eslint": "9.39.1",
94
- "kitto": "1.0.0-rc.17",
95
- "oxfmt": "0.55.0",
96
- "typescript": "5.9.3",
97
- "vite": "7.2.2"
99
+ "@types/node": "26.1.1",
100
+ "@typescript-eslint/eslint-plugin": "8.65.0",
101
+ "@typescript-eslint/parser": "8.65.0",
102
+ "eslint": "10.7.0",
103
+ "kitto": "1.6.0",
104
+ "oxfmt": "0.59.0",
105
+ "typescript": "6.0.3",
106
+ "vite": "8.1.5"
98
107
  },
99
108
  "peerDependencies": {
100
- "@storyblok/svelte": "5.2.14",
101
- "@storyblok/field-plugin": "^1.6.0",
102
- "svelte": "^5.0.0"
109
+ "@storyblok/svelte": "7.2.2",
110
+ "@storyblok/field-plugin": "^1.7.1",
111
+ "svelte": "^5.0.0",
112
+ "@sveltejs/kit": "^2.0.0",
113
+ "storyblok-js-client": "^7.0.0"
103
114
  },
104
115
  "scripts": {
105
116
  "add-plugin": "npx @storyblok/field-plugin-cli@latest add --dir \"./packages\" --structure monorepo",
@@ -112,7 +123,7 @@
112
123
  "packages/*"
113
124
  ],
114
125
  "dependencies": {
115
- "ora": "9.0.0",
116
- "path-to-regexp": "8.3.0"
126
+ "ora": "9.4.1",
127
+ "path-to-regexp": "8.4.2"
117
128
  }
118
129
  }
@@ -0,0 +1,4 @@
1
+ export interface HubspotForm {
2
+ id: string
3
+ name: string
4
+ }
@@ -18,5 +18,4 @@
18
18
  className
19
19
  )}
20
20
  bind:value
21
- {...restProps}
22
- ></textarea>
21
+ {...restProps}></textarea>
@@ -1 +1,9 @@
1
+ import type { Handle } from "@sveltejs/kit";
1
2
  export { handle_redirects as redirects } from "./redirects/handle.js";
3
+ /**
4
+ * Put the Storyblok content version on `event.locals.version` — draft while developing and
5
+ * inside the visual editor (`?_storyblok`), published everywhere else.
6
+ *
7
+ * Declare it on `App.Locals` as `version: import("storyloco").Version`.
8
+ */
9
+ export declare const version: Handle;
@@ -1 +1,12 @@
1
+ import { dev } from "$app/environment";
1
2
  export { handle_redirects as redirects } from "./redirects/handle.js";
3
+ /**
4
+ * Put the Storyblok content version on `event.locals.version` — draft while developing and
5
+ * inside the visual editor (`?_storyblok`), published everywhere else.
6
+ *
7
+ * Declare it on `App.Locals` as `version: import("storyloco").Version`.
8
+ */
9
+ export const version = async ({ event, resolve }) => {
10
+ event.locals.version = dev || event.url.searchParams.has("_storyblok") ? "draft" : "published";
11
+ return await resolve(event);
12
+ };
@@ -0,0 +1,51 @@
1
+ import type { ISbStoriesParams, ISbStoryData, ISbStoryParams } from "@storyblok/svelte";
2
+ import type { ISbLink, ISbLinksParams } from "storyblok-js-client";
3
+ import { type StoryblokClient, type Version } from "./storyblok.svelte.js";
4
+ /** The content version for the current request, as set by the `version` hook. */
5
+ export declare function version(): Version;
6
+ /**
7
+ * Bind a client to the current request.
8
+ *
9
+ * Returns a callable: call it for the client and version to reach anything the methods
10
+ * don't cover, or use `.story` / `.stories` for the common reads. Those carry the
11
+ * request's version and the resolve options a Storyblok read almost always wants; the
12
+ * caller's params are spread last, so every default can be overridden. `resolve_links`
13
+ * is on because `href` depends on it.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * // $lib/storyblok.server.ts
18
+ * export const storyblok = setup(client)
19
+ *
20
+ * // anywhere on the server
21
+ * await storyblok.story("layout")
22
+ * await storyblok.stories<Blog>({ content_type: "blog", per_page: 2 })
23
+ *
24
+ * const { client, version } = storyblok()
25
+ * await client.get("cdn/links", { version })
26
+ * ```
27
+ */
28
+ export declare function setup(client: StoryblokClient): (() => {
29
+ client: StoryblokClient;
30
+ version: Version;
31
+ }) & {
32
+ /**
33
+ * A single story by full slug. Resolves the client's `relations`, unlike `stories`.
34
+ *
35
+ * `T` is the story's content type; it defaults to the SDK's permissive one, so an
36
+ * unparameterised call behaves as before.
37
+ */
38
+ story<T = import("storyblok-js-client").ISbComponentType<string> & {
39
+ [index: string]: any;
40
+ }>(slug: string, params?: ISbStoryParams): Promise<ISbStoryData<T>>;
41
+ /**
42
+ * The link tree as a flat array — Storyblok returns it keyed by uuid, which is
43
+ * almost never what you want. Filtering and ordering are left to the caller.
44
+ */
45
+ links(params?: ISbLinksParams): Promise<ISbLink[]>;
46
+ /** A list of stories. `total` is the unpaged count, for "showing n of m". */
47
+ stories<T>(params?: ISbStoriesParams): Promise<{
48
+ stories: Array<ISbStoryData<T>>;
49
+ total: number;
50
+ }>;
51
+ };
@@ -0,0 +1,76 @@
1
+ import { getRequestEvent } from "$app/server";
2
+ import { handle_error } from "./storyblok.svelte.js";
3
+ /** The content version for the current request, as set by the `version` hook. */
4
+ export function version() {
5
+ return getRequestEvent().locals.version;
6
+ }
7
+ /**
8
+ * Bind a client to the current request.
9
+ *
10
+ * Returns a callable: call it for the client and version to reach anything the methods
11
+ * don't cover, or use `.story` / `.stories` for the common reads. Those carry the
12
+ * request's version and the resolve options a Storyblok read almost always wants; the
13
+ * caller's params are spread last, so every default can be overridden. `resolve_links`
14
+ * is on because `href` depends on it.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // $lib/storyblok.server.ts
19
+ * export const storyblok = setup(client)
20
+ *
21
+ * // anywhere on the server
22
+ * await storyblok.story("layout")
23
+ * await storyblok.stories<Blog>({ content_type: "blog", per_page: 2 })
24
+ *
25
+ * const { client, version } = storyblok()
26
+ * await client.get("cdn/links", { version })
27
+ * ```
28
+ */
29
+ export function setup(client) {
30
+ const context = () => ({ client, version: version() });
31
+ return Object.assign(context, {
32
+ /**
33
+ * A single story by full slug. Resolves the client's `relations`, unlike `stories`.
34
+ *
35
+ * `T` is the story's content type; it defaults to the SDK's permissive one, so an
36
+ * unparameterised call behaves as before.
37
+ */
38
+ async story(slug, params = {}) {
39
+ const response = await client
40
+ .get(`cdn/stories/${slug}`, {
41
+ version: version(),
42
+ resolve_links: "url",
43
+ resolve_relations: client.relations,
44
+ resolve_assets: 1,
45
+ ...params,
46
+ })
47
+ .catch(handle_error);
48
+ return response.data.story;
49
+ },
50
+ /**
51
+ * The link tree as a flat array — Storyblok returns it keyed by uuid, which is
52
+ * almost never what you want. Filtering and ordering are left to the caller.
53
+ */
54
+ async links(params = {}) {
55
+ const response = await client
56
+ .get("cdn/links", { version: version(), per_page: 1000, ...params })
57
+ .catch(handle_error);
58
+ return Object.values(response.data.links ?? {});
59
+ },
60
+ /** A list of stories. `total` is the unpaged count, for "showing n of m". */
61
+ async stories(params = {}) {
62
+ const response = await client
63
+ .get("cdn/stories", {
64
+ version: version(),
65
+ resolve_links: "url",
66
+ resolve_assets: 1,
67
+ ...params,
68
+ })
69
+ .catch(handle_error);
70
+ return {
71
+ stories: response.data.stories,
72
+ total: response.total ?? 0,
73
+ };
74
+ },
75
+ });
76
+ }
@@ -1,4 +1,4 @@
1
- import { renderRichText as richtext, StoryblokComponent as Block, storyblokEditable as editable, useStoryblokApi, type ISbStoryData } from "@storyblok/svelte";
1
+ import { renderRichText as richtext, StoryblokComponent as Block, storyblokEditable as editable, useStoryblokApi, type ISbStoryData, type SbBlokData } from "@storyblok/svelte";
2
2
  import type { Component } from "svelte";
3
3
  import { SvelteMap } from "svelte/reactivity";
4
4
  /**
@@ -25,6 +25,25 @@ export interface Storyblok<T extends ISbStoryData> {
25
25
  }
26
26
  /** Type for dynamic component imports */
27
27
  type Import<T = unknown> = () => Promise<T>;
28
+ /** Everything a client needs, so a project doesn't have to subclass to configure one. */
29
+ export interface ClientOptions {
30
+ /** Your Storyblok access token */
31
+ token: string;
32
+ /**
33
+ * The block and template components to register.
34
+ *
35
+ * Call `import.meta.glob` at your own call site and pass the result — Vite has to see
36
+ * the literal pattern to rewrite it, so the glob can't live in here.
37
+ */
38
+ components?: Record<string, Import>;
39
+ /** Relation fields to resolve on every request, e.g. `["shared.items"]` */
40
+ relations?: Array<string>;
41
+ /**
42
+ * Content types that render through another component, e.g. `{ news: "blog" }` when
43
+ * news posts reuse the blog template. Applied by `template`.
44
+ */
45
+ aliases?: Record<string, string>;
46
+ }
28
47
  /** Type alias for the storyblok api instance */
29
48
  type API = ReturnType<typeof useStoryblokApi>;
30
49
  /**
@@ -37,12 +56,21 @@ export declare class StoryblokClient {
37
56
  components: SvelteMap<string, Component<{}, {}, string>>;
38
57
  /** Array of relation field names to resolve when fetching stories */
39
58
  relations: Array<string>;
59
+ /** Content types that render through another component's template */
60
+ aliases: Record<string, string>;
40
61
  /**
41
62
  * Check if the current URL is in Storyblok preview mode
42
63
  * @param url - The URL to check
43
64
  * @returns True if in preview mode
44
65
  */
45
66
  is_preview(url: URL): boolean;
67
+ /**
68
+ * Resolve a story to its component by `component` name.
69
+ *
70
+ * Falls back when the name isn't registered, so a content type added in Storyblok
71
+ * before its component exists renders as a generic page rather than blanking.
72
+ */
73
+ template(story: ISbStoryData | null, fallback?: string): Component | null | undefined;
46
74
  /** Fetch a single story or collection of stories */
47
75
  get: API["get"];
48
76
  /** Fetch all stories matching criteria (handles pagination automatically) */
@@ -55,20 +83,33 @@ export declare class StoryblokClient {
55
83
  put: API["put"];
56
84
  /** Make a DELETE request to the Storyblok API */
57
85
  delete: API["delete"];
86
+ /** The standalone `handle_error`, on the instance so `.catch(client.handle_error)` works */
87
+ handle_error: typeof handle_error;
58
88
  /**
59
- * Create a new StoryblokClient instance
60
- * @param access_token - Your Storyblok access token
61
- * @param component_imports - Map of component paths to their import functions
89
+ * Configured here rather than by subclassing, so setup is one expression.
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * export const client = new StoryblokClient({
94
+ * token: PUBLIC_STORYBLOK_ACCESS_TOKEN,
95
+ * relations: ["shared.items"],
96
+ * components: import.meta.glob(["$blocks/**\/*.svelte", "$templates/*.svelte"]),
97
+ * })
98
+ * ```
62
99
  */
63
- constructor(access_token: string, component_imports?: Record<string, Import>);
100
+ constructor({ token, components, relations, aliases }: ClientOptions);
64
101
  /**
65
- * Initialize the client by loading components and setting up the API
66
- * This method is idempotent and can be called multiple times safely
102
+ * Load the components and set up the SDK.
103
+ *
104
+ * Safe to call any number of times, including concurrently — the first call starts the
105
+ * work and every later one awaits that same promise. Server callers never need it; the
106
+ * API methods await it themselves. The browser does, since `components` must be
107
+ * populated before the first render.
67
108
  */
68
109
  init(): Promise<void>;
69
110
  /** Storyblok editable function for making content editable in preview mode */
70
- editable: (node: HTMLElement, value: import("@storyblok/svelte").SbBlokData) => {
71
- update: (value: import("@storyblok/svelte").SbBlokData) => void;
111
+ editable: (node: HTMLElement, value: SbBlokData) => {
112
+ update: (value: SbBlokData) => void;
72
113
  };
73
114
  /**
74
115
  * Connect a story to Storyblok's live preview system for real-time updates
@@ -84,5 +125,80 @@ export declare class StoryblokClient {
84
125
  */
85
126
  connect<T extends ISbStoryData>(current: T): Storyblok<T>;
86
127
  }
128
+ /** The Storyblok content version to read. `event.locals.version` from `storyloco/hooks`. */
129
+ export type Version = "draft" | "published";
130
+ /**
131
+ * Richtext, with `attrs` typed so an embedded blok's fields are reachable — the CLI types
132
+ * it as `Record<string, unknown>`. `Blocks` is your generated block union.
133
+ */
134
+ export interface Richtext<Blocks = unknown> {
135
+ type: string;
136
+ text?: string;
137
+ attrs?: {
138
+ body: Blocks;
139
+ };
140
+ content?: Array<Richtext<Blocks>>;
141
+ marks?: Array<Richtext<Blocks>>;
142
+ }
143
+ /** Adds the `story.url` that `resolve_links: "url"` injects but the CLI doesn't declare. */
144
+ export type Resolved<T extends {
145
+ story?: unknown;
146
+ }> = T & {
147
+ story?: NonNullable<T["story"]> & {
148
+ url?: string;
149
+ };
150
+ };
151
+ /** Structural, so a generated `StoryblokMultilink` satisfies it. */
152
+ interface Link {
153
+ linktype?: string;
154
+ url?: string;
155
+ cached_url?: string;
156
+ story?: {
157
+ url?: string;
158
+ [key: string]: unknown;
159
+ };
160
+ }
161
+ /**
162
+ * Resolve a multilink to an href. Story links become root-relative, everything else passes
163
+ * through. Prefers `story.url` over `cached_url`, which goes stale when a target moves —
164
+ * so pair with `resolve_links: "url"`.
165
+ */
166
+ export declare function href(link?: Link): string | undefined;
167
+ /**
168
+ * Turn a Storyblok error into a SvelteKit one, for `.catch(handle_error)`.
169
+ *
170
+ * A missing story is a 404; anything else is a 500, since the CMS being unreachable isn't
171
+ * something to show a visitor a Storyblok status code for.
172
+ */
173
+ export declare function handle_error(err: unknown): never;
174
+ /** Declaring these keeps `SbBlokData`'s index signature from widening every access. */
175
+ interface BlokFields {
176
+ anchor?: string;
177
+ }
178
+ /**
179
+ * Spread onto a block's root element to wire it up for the visual editor.
180
+ *
181
+ * Prefer this to `use:editable`: being an attachment it spreads, so the element keeps its
182
+ * own attributes and a project can wrap it. Also sets a stable `id` from the block's
183
+ * `anchor`, falling back to `_uid`.
184
+ *
185
+ * @example
186
+ * ```svelte
187
+ * <script lang="ts">
188
+ * import { attrs } from "storyloco"
189
+ * import type { Blok, Hero } from "./components.schema.js"
190
+ *
191
+ * let { blok }: { blok: Blok<Hero> } = $props()
192
+ * </script>
193
+ *
194
+ * <section {...attrs(blok)}>
195
+ * <h1>{blok.headline}</h1>
196
+ * </section>
197
+ * ```
198
+ */
199
+ export declare function attrs<T extends BlokFields>(blok: SbBlokData & T): {
200
+ [x: symbol]: (node: HTMLElement) => void;
201
+ id: string | undefined;
202
+ };
87
203
  /** Re-export commonly used Storyblok utilities */
88
204
  export { editable, Block, richtext };
@@ -1,5 +1,7 @@
1
1
  import { storyblokInit, apiPlugin, renderRichText as richtext, useStoryblokBridge, StoryblokComponent as Block, storyblokEditable as editable, useStoryblokApi, } from "@storyblok/svelte";
2
2
  import { SvelteMap } from "svelte/reactivity";
3
+ import { createAttachmentKey as attach } from "svelte/attachments";
4
+ import { error } from "@sveltejs/kit";
3
5
  /**
4
6
  * Main Storyblok client class that handles component registration, API calls, and story bridging
5
7
  * Provides a unified interface for working with Storyblok in Svelte applications
@@ -7,10 +9,12 @@ import { SvelteMap } from "svelte/reactivity";
7
9
  export class StoryblokClient {
8
10
  /** Map of registered Svelte components by name */
9
11
  components = new SvelteMap();
10
- /** Internal flag to prevent multiple initializations */
11
- #initialized = false;
12
+ /** The in-flight or settled setup, shared by every caller of `init` */
13
+ #ready;
12
14
  /** Array of relation field names to resolve when fetching stories */
13
15
  relations = [];
16
+ /** Content types that render through another component's template */
17
+ aliases = {};
14
18
  /**
15
19
  * Check if the current URL is in Storyblok preview mode
16
20
  * @param url - The URL to check
@@ -19,27 +23,74 @@ export class StoryblokClient {
19
23
  is_preview(url) {
20
24
  return url.searchParams.has("_storyblok");
21
25
  }
26
+ /**
27
+ * Resolve a story to its component by `component` name.
28
+ *
29
+ * Falls back when the name isn't registered, so a content type added in Storyblok
30
+ * before its component exists renders as a generic page rather than blanking.
31
+ */
32
+ template(story, fallback = "page") {
33
+ if (!story)
34
+ return null;
35
+ const name = story.content.component || fallback;
36
+ return this.components.get(this.aliases[name] ?? name) ?? this.components.get(fallback);
37
+ }
22
38
  /** Storyblok access token for API authentication */
23
39
  #access_token;
24
40
  /** Map of component import functions */
25
41
  #component_imports;
42
+ /** Every API method below goes through here, so callers never `await init()` first. */
43
+ async #api() {
44
+ await this.init();
45
+ return useStoryblokApi();
46
+ }
47
+ /** Fetch a single story or collection of stories */
48
+ get = async (...args) => (await this.#api()).get(...args);
49
+ /** Fetch all stories matching criteria (handles pagination automatically) */
50
+ getAll = async (...args) => (await this.#api()).getAll(...args);
51
+ /** Fetch a specific story by slug */
52
+ getStory = async (...args) => (await this.#api()).getStory(...args);
53
+ /** Make a POST request to the Storyblok API */
54
+ post = async (...args) => (await this.#api()).post(...args);
55
+ /** Make a PUT request to the Storyblok API */
56
+ put = async (...args) => (await this.#api()).put(...args);
57
+ /** Make a DELETE request to the Storyblok API */
58
+ delete = async (...args) => (await this.#api()).delete(...args);
59
+ /** The standalone `handle_error`, on the instance so `.catch(client.handle_error)` works */
60
+ handle_error = handle_error;
26
61
  /**
27
- * Create a new StoryblokClient instance
28
- * @param access_token - Your Storyblok access token
29
- * @param component_imports - Map of component paths to their import functions
62
+ * Configured here rather than by subclassing, so setup is one expression.
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * export const client = new StoryblokClient({
67
+ * token: PUBLIC_STORYBLOK_ACCESS_TOKEN,
68
+ * relations: ["shared.items"],
69
+ * components: import.meta.glob(["$blocks/**\/*.svelte", "$templates/*.svelte"]),
70
+ * })
71
+ * ```
30
72
  */
31
- constructor(access_token, component_imports = import.meta.glob("$lib/blocks/*.svelte")) {
32
- this.#access_token = access_token;
33
- this.#component_imports = component_imports;
34
- this.init();
73
+ constructor({ token, components = {}, relations = [], aliases = {} }) {
74
+ this.#access_token = token;
75
+ this.#component_imports = components;
76
+ this.relations = relations;
77
+ this.aliases = aliases;
78
+ // Eager, but handled so a failure surfaces to whoever awaits `init` rather than
79
+ // as an unhandled rejection
80
+ this.init().catch(() => { });
35
81
  }
36
82
  /**
37
- * Initialize the client by loading components and setting up the API
38
- * This method is idempotent and can be called multiple times safely
83
+ * Load the components and set up the SDK.
84
+ *
85
+ * Safe to call any number of times, including concurrently — the first call starts the
86
+ * work and every later one awaits that same promise. Server callers never need it; the
87
+ * API methods await it themselves. The browser does, since `components` must be
88
+ * populated before the first render.
39
89
  */
40
- async init() {
41
- if (this.#initialized)
42
- return;
90
+ init() {
91
+ return (this.#ready ??= this.#setup());
92
+ }
93
+ async #setup() {
43
94
  // Load all components from the provided imports
44
95
  for (const [path, fn] of Object.entries(this.#component_imports)) {
45
96
  const name = path.split("/").pop()?.replace(".svelte", "") ?? "";
@@ -54,15 +105,6 @@ export class StoryblokClient {
54
105
  use: [apiPlugin],
55
106
  components: Object.fromEntries(this.components),
56
107
  });
57
- // Bind API methods to this instance
58
- const api = useStoryblokApi();
59
- this.get = api.get.bind(api);
60
- this.getAll = api.getAll.bind(api);
61
- this.getStory = api.getStory.bind(api);
62
- this.post = api.post?.bind(api);
63
- this.put = api.put?.bind(api);
64
- this.delete = api.delete?.bind(api);
65
- this.#initialized = true;
66
108
  }
67
109
  /** Storyblok editable function for making content editable in preview mode */
68
110
  editable = editable;
@@ -95,5 +137,73 @@ export class StoryblokClient {
95
137
  };
96
138
  }
97
139
  }
140
+ /**
141
+ * Resolve a multilink to an href. Story links become root-relative, everything else passes
142
+ * through. Prefers `story.url` over `cached_url`, which goes stale when a target moves —
143
+ * so pair with `resolve_links: "url"`.
144
+ */
145
+ export function href(link) {
146
+ if (!link)
147
+ return undefined;
148
+ return link.linktype === "story"
149
+ ? `/${link.story?.url || link.cached_url}`
150
+ : link.url || link.cached_url;
151
+ }
152
+ /**
153
+ * Turn a Storyblok error into a SvelteKit one, for `.catch(handle_error)`.
154
+ *
155
+ * A missing story is a 404; anything else is a 500, since the CMS being unreachable isn't
156
+ * something to show a visitor a Storyblok status code for.
157
+ */
158
+ export function handle_error(err) {
159
+ console.error(err);
160
+ const status = typeof err === "object" && err !== null && "status" in err ? err.status : undefined;
161
+ if (status === 404)
162
+ error(404, "Story not found");
163
+ error(500, "Internal server error");
164
+ }
165
+ /**
166
+ * Spread onto a block's root element to wire it up for the visual editor.
167
+ *
168
+ * Prefer this to `use:editable`: being an attachment it spreads, so the element keeps its
169
+ * own attributes and a project can wrap it. Also sets a stable `id` from the block's
170
+ * `anchor`, falling back to `_uid`.
171
+ *
172
+ * @example
173
+ * ```svelte
174
+ * <script lang="ts">
175
+ * import { attrs } from "storyloco"
176
+ * import type { Blok, Hero } from "./components.schema.js"
177
+ *
178
+ * let { blok }: { blok: Blok<Hero> } = $props()
179
+ * </script>
180
+ *
181
+ * <section {...attrs(blok)}>
182
+ * <h1>{blok.headline}</h1>
183
+ * </section>
184
+ * ```
185
+ */
186
+ export function attrs(blok) {
187
+ return {
188
+ [attach()](node) {
189
+ editable(node, blok);
190
+ },
191
+ id: blok.anchor || blok._uid,
192
+ };
193
+ }
194
+ // Map section fields onto CSS custom properties by wrapping `attrs`, rather than reaching
195
+ // past it — the field names and design tokens are yours:
196
+ //
197
+ // interface SectionFields extends BlokFields {
198
+ // theme?: number | string
199
+ // pad_top?: number | string
200
+ // }
201
+ //
202
+ // export function section<T extends SectionFields>(blok: Blok<T>) {
203
+ // let style = ""
204
+ // if (blok.theme) style += `--theme: var(--c-${blok.theme});`
205
+ // if (blok.pad_top) style += `--pt: var(--s-${blok.pad_top});`
206
+ // return { ...attrs(blok), style }
207
+ // }
98
208
  /** Re-export commonly used Storyblok utilities */
99
209
  export { editable, Block, richtext };
@@ -1,25 +0,0 @@
1
- import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
2
- import Content from './dropdown-menu-content.svelte';
3
- import Group from './dropdown-menu-group.svelte';
4
- import Item from './dropdown-menu-item.svelte';
5
- import Label from './dropdown-menu-label.svelte';
6
- import RadioGroup from './dropdown-menu-radio-group.svelte';
7
- import RadioItem from './dropdown-menu-radio-item.svelte';
8
- import Separator from './dropdown-menu-separator.svelte';
9
- import Shortcut from './dropdown-menu-shortcut.svelte';
10
- import Trigger from './dropdown-menu-trigger.svelte';
11
- import SubContent from './dropdown-menu-sub-content.svelte';
12
- import SubTrigger from './dropdown-menu-sub-trigger.svelte';
13
- import GroupHeading from './dropdown-menu-group-heading.svelte';
14
- declare const Sub: import("svelte").Component<import("bits-ui").ContextMenuSubPropsWithoutHTML, {}, "open">;
15
- declare const Root: import("svelte").Component<{
16
- open?: boolean;
17
- onOpenChange?: import("bits-ui/dist/internal/types").OnChangeFn<boolean>;
18
- onOpenChangeComplete?: import("bits-ui/dist/internal/types").OnChangeFn<boolean>;
19
- dir?: import("bits-ui").Direction;
20
- } & {
21
- children?: import("svelte").Snippet | undefined;
22
- } & {
23
- _internal_variant?: "context-menu" | "dropdown-menu" | "menubar";
24
- }, {}, "open">;
25
- export { CheckboxItem, Content, Root as DropdownMenu, CheckboxItem as DropdownMenuCheckboxItem, Content as DropdownMenuContent, Group as DropdownMenuGroup, Item as DropdownMenuItem, Label as DropdownMenuLabel, RadioGroup as DropdownMenuRadioGroup, RadioItem as DropdownMenuRadioItem, Separator as DropdownMenuSeparator, Shortcut as DropdownMenuShortcut, Sub as DropdownMenuSub, SubContent as DropdownMenuSubContent, SubTrigger as DropdownMenuSubTrigger, Trigger as DropdownMenuTrigger, GroupHeading as DropdownMenuGroupHeading, Group, GroupHeading, Item, Label, RadioGroup, RadioItem, Root, Separator, Shortcut, Sub, SubContent, SubTrigger, Trigger, };