storyloco 2.1.0 → 2.3.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
@@ -15,6 +15,7 @@ A collection of slick Storyblok field plugins built with Svelte 5, TypeScript, a
15
15
  - **🏗️ Plans** - Floor plan manager with drag-and-drop sorting for types, floors, dimensions, and rooms
16
16
  - **📝 Input** - Comprehensive form input field with support for all HTML input types, checkboxes, radio buttons, selects, and textareas
17
17
  - **⏰ Time** - Simple time input field with step control
18
+ - **💷 Currency** - Amount input with a currency selector; configure allowed codes via the `currencies` and `default_currency` options
18
19
 
19
20
  ### Vite Plugins
20
21
 
@@ -175,16 +176,45 @@ await storyblok.story('layout')
175
176
  // A list. `total` is the unpaged count, for "showing n of m"
176
177
  const { stories, total } = await storyblok.stories({ content_type: 'blog', per_page: 2 })
177
178
 
179
+ // Everything that matches, paged through the API's 100-per-page cap
180
+ const products = await storyblok.all({ content_type: 'product' })
181
+
178
182
  // The link tree, flattened — Storyblok returns it keyed by uuid
179
183
  const links = await storyblok.links({ starts_with: 'blog/' })
180
184
  ```
181
185
 
186
+ Reads with a `content_type` are typed from your generated schema: the plugin registers
187
+ the space's content types with storyloco, so `stories({ content_type: 'product' })` yields
188
+ product stories and `content_type: 'product,category'` a union of both — no type argument
189
+ needed. Until a schema has been generated, or for a name it doesn't know, the content falls
190
+ back to the SDK's permissive type. An explicit type argument always wins.
191
+
182
192
  Your params are spread last, so any default can be overridden:
183
193
 
184
194
  ```typescript
185
195
  await storyblok.story('layout', { resolve_assets: 0 })
186
196
  ```
187
197
 
198
+ `story` treats a missing story as a 404. Where a slug is allowed not to exist, `find` returns
199
+ `null` instead — the miss is then neither raised nor logged:
200
+
201
+ ```typescript
202
+ const product = await storyblok.find<Product>(`products/${slug}`)
203
+ if (!product) error(404, 'Product not found')
204
+ ```
205
+
206
+ The content type can be a union. Each member becomes its own story type, so `is_component`
207
+ can narrow the result — checking `story.content.component` yourself narrows only `content`,
208
+ not the story:
209
+
210
+ ```typescript
211
+ import { is_component } from 'storyloco'
212
+
213
+ const story = await storyblok.find<Product | Category>(`products/${slug}`)
214
+ if (is_component(story, 'product')) story.content.sku // ISbStoryData<Product>
215
+ if (is_component(story, 'category')) story.content.description // ISbStoryData<Category>
216
+ ```
217
+
188
218
  It's callable too, for anything the methods don't cover:
189
219
 
190
220
  ```typescript
@@ -258,6 +288,7 @@ export interface StoryblokCustomPlugins {
258
288
  twitter_description: string
259
289
  }
260
290
  'loco-time': string
291
+ 'loco-currency': import('storyloco/currency').Currency
261
292
  'loco-asset': import('storyloco/asset').Asset
262
293
  }
263
294
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyloco",
3
- "version": "2.1.0",
3
+ "version": "2.3.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",
@@ -33,7 +33,8 @@
33
33
  "packages/input/types.ts",
34
34
  "packages/time/types.ts",
35
35
  "packages/seo/types.ts",
36
- "packages/hubspot_form/types.ts"
36
+ "packages/hubspot_form/types.ts",
37
+ "packages/currency/types.ts"
37
38
  ],
38
39
  "exports": {
39
40
  ".": {
@@ -93,6 +94,10 @@
93
94
  "./hubspot_form": {
94
95
  "types": "./packages/hubspot_form/types.ts",
95
96
  "svelte": "./packages/hubspot_form/src/app.svelte.ts"
97
+ },
98
+ "./currency": {
99
+ "types": "./packages/currency/types.ts",
100
+ "svelte": "./packages/currency/src/app.svelte.ts"
96
101
  }
97
102
  },
98
103
  "devDependencies": {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Narrow `Code` to the codes configured in the field's `currencies` option,
3
+ * e.g. `Currency<'GBP' | 'USD'>`.
4
+ */
5
+ export interface Currency<Code extends string = Uppercase<string>> {
6
+ /** ISO 4217 code, e.g. `GBP` */
7
+ currency: Code
8
+ amount: number | null
9
+ /** `amount` formatted for display, e.g. `£130,000.00`; `null` when there's no amount */
10
+ formatted: string | null
11
+ }
@@ -218,6 +218,11 @@ import type { ISbStoryData } from '@storyblok/js';`, "");
218
218
  }
219
219
 
220
220
  export type ContentType = ContentTypes[keyof ContentTypes]
221
+
222
+ /** Lets storyloco type reads by \`content_type\` without a type argument. */
223
+ declare module "storyloco" {
224
+ interface Registry extends ContentTypes {}
225
+ }
221
226
  `;
222
227
  let content = component_types.replace(`// This file was generated by the storyblok CLI.
223
228
  // DO NOT MODIFY THIS FILE BY HAND.`, header);
@@ -1,6 +1,6 @@
1
- import type { ISbStoriesParams, ISbStoryData, ISbStoryParams } from "@storyblok/svelte";
1
+ import type { ISbStoriesParams, ISbStoryParams } from "@storyblok/svelte";
2
2
  import type { ISbLink, ISbLinksParams } from "storyblok-js-client";
3
- import { type StoryblokClient, type Version } from "./storyblok.svelte.js";
3
+ import { type ContentFor, type Story, type StoryblokClient, type Version } from "./storyblok.svelte.js";
4
4
  /** The content version for the current request, as set by the `version` hook. */
5
5
  export declare function version(): Version;
6
6
  /**
@@ -19,7 +19,10 @@ export declare function version(): Version;
19
19
  *
20
20
  * // anywhere on the server
21
21
  * await storyblok.story("layout")
22
- * await storyblok.stories<Blog>({ content_type: "blog", per_page: 2 })
22
+ * await storyblok.find<Product>(`products/${slug}`) // null when missing
23
+ * await storyblok.find<Product | Category>(slug) // a union of stories; narrow with is_component
24
+ * await storyblok.stories({ content_type: "blog", per_page: 2 }) // typed from the generated schema
25
+ * await storyblok.all<Product>({ content_type: "product" }) // every page
23
26
  *
24
27
  * const { client, version } = storyblok()
25
28
  * await client.get("cdn/links", { version })
@@ -29,23 +32,37 @@ export declare function setup(client: StoryblokClient): (() => {
29
32
  client: StoryblokClient;
30
33
  version: Version;
31
34
  }) & {
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> & {
35
+ story: <T = import("storyblok-js-client").ISbComponentType<string> & {
36
+ [index: string]: any;
37
+ }>(slug: string, params?: ISbStoryParams) => Promise<Story<T>>;
38
+ find: <T = import("storyblok-js-client").ISbComponentType<string> & {
39
39
  [index: string]: any;
40
- }>(slug: string, params?: ISbStoryParams): Promise<ISbStoryData<T>>;
40
+ }>(slug: string, params?: ISbStoryParams) => Promise<Story<T> | null>;
41
+ stories: {
42
+ <K extends string>(params: ISbStoriesParams & {
43
+ content_type: K;
44
+ }): Promise<{
45
+ stories: Story<ContentFor<K>>[];
46
+ total: number;
47
+ }>;
48
+ <T = import("storyblok-js-client").ISbComponentType<string> & {
49
+ [index: string]: any;
50
+ }>(params?: ISbStoriesParams): Promise<{
51
+ stories: Story<T>[];
52
+ total: number;
53
+ }>;
54
+ };
55
+ all: {
56
+ <K extends string>(params: ISbStoriesParams & {
57
+ content_type: K;
58
+ }): Promise<Array<Story<ContentFor<K>>>>;
59
+ <T = import("storyblok-js-client").ISbComponentType<string> & {
60
+ [index: string]: any;
61
+ }>(params?: ISbStoriesParams): Promise<Array<Story<T>>>;
62
+ };
41
63
  /**
42
64
  * The link tree as a flat array — Storyblok returns it keyed by uuid, which is
43
65
  * almost never what you want. Filtering and ordering are left to the caller.
44
66
  */
45
67
  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
68
  };
@@ -1,5 +1,9 @@
1
1
  import { getRequestEvent } from "$app/server";
2
- import { handle_error } from "./storyblok.svelte.js";
2
+ import { error } from "@sveltejs/kit";
3
+ import { handle_error, } from "./storyblok.svelte.js";
4
+ function is_not_found(err) {
5
+ return typeof err === "object" && err !== null && "status" in err && err.status === 404;
6
+ }
3
7
  /** The content version for the current request, as set by the `version` hook. */
4
8
  export function version() {
5
9
  return getRequestEvent().locals.version;
@@ -20,7 +24,10 @@ export function version() {
20
24
  *
21
25
  * // anywhere on the server
22
26
  * await storyblok.story("layout")
23
- * await storyblok.stories<Blog>({ content_type: "blog", per_page: 2 })
27
+ * await storyblok.find<Product>(`products/${slug}`) // null when missing
28
+ * await storyblok.find<Product | Category>(slug) // a union of stories; narrow with is_component
29
+ * await storyblok.stories({ content_type: "blog", per_page: 2 }) // typed from the generated schema
30
+ * await storyblok.all<Product>({ content_type: "product" }) // every page
24
31
  *
25
32
  * const { client, version } = storyblok()
26
33
  * await client.get("cdn/links", { version })
@@ -28,25 +35,61 @@ export function version() {
28
35
  */
29
36
  export function setup(client) {
30
37
  const context = () => ({ client, version: version() });
38
+ /**
39
+ * A single story by full slug, or `null` when there isn't one. Resolves the client's
40
+ * `relations`, unlike `stories`. For slugs that are allowed not to exist — a miss is
41
+ * routine, so it's neither raised nor logged. `T` is the story's content type.
42
+ */
43
+ async function find(slug, params = {}) {
44
+ const response = await client
45
+ .get(`cdn/stories/${slug}`, {
46
+ version: version(),
47
+ resolve_links: "url",
48
+ resolve_relations: client.relations,
49
+ resolve_assets: 1,
50
+ ...params,
51
+ })
52
+ .catch((err) => {
53
+ if (is_not_found(err))
54
+ return null;
55
+ return handle_error(err);
56
+ });
57
+ return (response?.data.story ?? null);
58
+ }
59
+ /** A single story by full slug; a missing one is a 404. `find` for stories that may not exist. */
60
+ async function story(slug, params = {}) {
61
+ const found = await find(slug, params);
62
+ if (!found)
63
+ error(404, "Story not found");
64
+ return found;
65
+ }
66
+ async function stories(params = {}) {
67
+ const response = await client
68
+ .get("cdn/stories", {
69
+ version: version(),
70
+ resolve_links: "url",
71
+ resolve_assets: 1,
72
+ ...params,
73
+ })
74
+ .catch(handle_error);
75
+ return { stories: response.data.stories, total: response.total ?? 0 };
76
+ }
77
+ async function all(params = {}) {
78
+ return client
79
+ .getAll("cdn/stories", {
80
+ version: version(),
81
+ resolve_links: "url",
82
+ resolve_assets: 1,
83
+ per_page: 100,
84
+ ...params,
85
+ })
86
+ .catch(handle_error);
87
+ }
31
88
  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
- },
89
+ story,
90
+ find,
91
+ stories,
92
+ all,
50
93
  /**
51
94
  * The link tree as a flat array — Storyblok returns it keyed by uuid, which is
52
95
  * almost never what you want. Filtering and ordering are left to the caller.
@@ -57,20 +100,5 @@ export function setup(client) {
57
100
  .catch(handle_error);
58
101
  return Object.values(response.data.links ?? {});
59
102
  },
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
103
  });
76
104
  }
@@ -140,6 +140,46 @@ export interface Richtext<Blocks = unknown> {
140
140
  content?: Array<Richtext<Blocks>>;
141
141
  marks?: Array<Richtext<Blocks>>;
142
142
  }
143
+ /**
144
+ * Content types by component name, for narrowing reads by `content_type`. Empty here;
145
+ * the schema plugin's generated file merges the space's `ContentTypes` into it, so
146
+ * `storyblok.stories({ content_type: "product" })` is typed as products without a type
147
+ * argument. Until a schema is generated every name falls back to the SDK's content type.
148
+ */
149
+ export interface Registry {
150
+ }
151
+ /** The SDK's permissive content type: any component, any fields. */
152
+ export type Content = ISbStoryData["content"];
153
+ /** Each name in a comma-separated `content_type`. */
154
+ type Names<S extends string> = S extends `${infer A},${infer B}` ? A | Names<B> : S;
155
+ /**
156
+ * The content behind a `content_type` param. Registered names map to their generated
157
+ * types (a list to their union); anything else is the permissive `Content`.
158
+ */
159
+ export type ContentFor<K extends string> = [Names<K>] extends [keyof Registry] ? Registry[Names<K>] : Content;
160
+ /**
161
+ * A story per member of `T`, so a union of content types is a union of stories —
162
+ * `Story<Product | Category>` is `ISbStoryData<Product> | ISbStoryData<Category>`, which
163
+ * `is_component` can narrow. `ISbStoryData<Product | Category>` can't be.
164
+ */
165
+ export type Story<T> = T extends unknown ? ISbStoryData<T> : never;
166
+ /**
167
+ * Narrow a story (or a union of them) by its content's `component`. TypeScript won't
168
+ * narrow the story through the nested discriminant on its own.
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * const story = await storyblok.find<Product | Category>(`products/${slug}`)
173
+ * if (is_component(story, "product")) story.content.sku // ISbStoryData<Product>
174
+ * ```
175
+ */
176
+ export declare function is_component<S extends ISbStoryData<{
177
+ component?: string;
178
+ }>, K extends string>(story: S | null | undefined, component: K): story is Extract<S, {
179
+ content: {
180
+ component?: K;
181
+ };
182
+ }>;
143
183
  /** Adds the `story.url` that `resolve_links: "url"` injects but the CLI doesn't declare. */
144
184
  export type Resolved<T extends {
145
185
  story?: unknown;
@@ -137,6 +137,19 @@ export class StoryblokClient {
137
137
  };
138
138
  }
139
139
  }
140
+ /**
141
+ * Narrow a story (or a union of them) by its content's `component`. TypeScript won't
142
+ * narrow the story through the nested discriminant on its own.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * const story = await storyblok.find<Product | Category>(`products/${slug}`)
147
+ * if (is_component(story, "product")) story.content.sku // ISbStoryData<Product>
148
+ * ```
149
+ */
150
+ export function is_component(story, component) {
151
+ return story?.content.component === component;
152
+ }
140
153
  /**
141
154
  * Resolve a multilink to an href. Story links become root-relative, everything else passes
142
155
  * through. Prefers `story.url` over `cached_url`, which goes stale when a target moves —