storyloco 2.3.1 → 2.6.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
@@ -16,6 +16,7 @@ A collection of slick Storyblok field plugins built with Svelte 5, TypeScript, a
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
18
  - **💷 Currency** - Amount input with a currency selector; configure allowed codes via the `currencies` and `default_currency` options
19
+ - **📅 Dates** - List of dates, each with an optional time, end date and end time, plus a timezone picker; configure via the `min`, `max` and `auto_sort` options
19
20
 
20
21
  ### Vite Plugins
21
22
 
@@ -59,6 +60,35 @@ import type { SEO } from 'storyloco/seo'
59
60
  import type { Video, Heading, Link, Input, Asset, SEO } from 'storyloco'
60
61
  ```
61
62
 
63
+ ### Write Currency Values
64
+
65
+ Map spreadsheet cells or plain numbers to the currency field's stored shape, e.g. for a Management API import:
66
+
67
+ ```ts
68
+ import { to_currency } from 'storyloco/currency/utils'
69
+
70
+ to_currency('£1,234.50') // currency defaults to GBP
71
+ // { plugin: 'uiloco-currency', currency: 'GBP', amount: 1234.5, formatted: '£1,234.50' }
72
+ to_currency('', 'GBP') // null
73
+ to_currency('POA', 'GBP') // throws, as do negatives and too many decimal places
74
+ ```
75
+
76
+ `format_currency(amount, code)` gives the same `formatted` string the plugin writes.
77
+
78
+ ### Display Dates
79
+
80
+ Timed entries carry UTC instants, so the site formats them natively in the field's zone. All-day entries are dates, not moments: use `start_date`/`end_date` as they are.
81
+
82
+ ```ts
83
+ import type { DateEntry, Dates } from 'storyloco/dates'
84
+
85
+ const show = ({ timezone }: Dates, entry: DateEntry) =>
86
+ entry.utc_start
87
+ ? new Intl.DateTimeFormat('en-GB', { timeZone: timezone, dateStyle: 'medium', timeStyle: 'short' })
88
+ .formatRange(new Date(entry.utc_start), new Date(entry.utc_end ?? entry.utc_start))
89
+ : [entry.start_date, entry.end_date].filter(Boolean).join(' – ')
90
+ ```
91
+
62
92
  ### Use Storyblok Client
63
93
 
64
94
  #### 1. Create your Storyblok client
@@ -84,6 +114,19 @@ at your call site rather than move into the client.
84
114
  `client.handle_error` is on the instance, so `.catch(client.handle_error)` works without
85
115
  wrapping anything.
86
116
 
117
+ Errors carry only a `message` by default. If your `App.Error` needs more, pass `error` to shape
118
+ the body of every 404 and 500 storyloco raises — it's typed against your own `App.Error`:
119
+
120
+ ```typescript
121
+ export const client = new StoryblokClient({
122
+ token: PUBLIC_STORYBLOK_ACCESS_TOKEN,
123
+ error: (status) => ({
124
+ message: status === 404 ? 'Story not found' : 'Storyblok request failed',
125
+ code: status === 404 ? 'NOT_FOUND' : 'STORYBLOK'
126
+ })
127
+ })
128
+ ```
129
+
87
130
  #### 2. Use in your Svelte components
88
131
 
89
132
  **`+page.svelte`:**
@@ -222,7 +265,7 @@ const { client, version } = storyblok()
222
265
  await client.get('cdn/datasource_entries', { version })
223
266
  ```
224
267
 
225
- Errors go through `handle_error`, so a missing story is a 404 and anything else a 500.
268
+ Errors go through `client.handle_error`, so a missing story is a 404 and anything else a 500, shaped by the client's `error` option.
226
269
 
227
270
  ### Use Shared Components
228
271
 
@@ -289,6 +332,7 @@ export interface StoryblokCustomPlugins {
289
332
  }
290
333
  'loco-time': string
291
334
  'loco-currency': import('storyloco/currency').Currency
335
+ 'loco-dates': import('storyloco/dates').Dates
292
336
  'loco-asset': import('storyloco/asset').Asset
293
337
  }
294
338
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyloco",
3
- "version": "2.3.1",
3
+ "version": "2.6.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",
@@ -29,12 +29,15 @@
29
29
  "packages/theme/types.ts",
30
30
  "packages/shared/dist",
31
31
  "packages/vite/dist",
32
+ "packages/vite/src/lib/currency.ts",
33
+ "!packages/vite/dist/**/*.test.*",
32
34
  "packages/ticketsource/types.ts",
33
35
  "packages/input/types.ts",
34
36
  "packages/time/types.ts",
35
37
  "packages/seo/types.ts",
36
38
  "packages/hubspot_form/types.ts",
37
- "packages/currency/types.ts"
39
+ "packages/currency/types.ts",
40
+ "packages/dates/types.ts"
38
41
  ],
39
42
  "exports": {
40
43
  ".": {
@@ -98,6 +101,14 @@
98
101
  "./currency": {
99
102
  "types": "./packages/currency/types.ts",
100
103
  "svelte": "./packages/currency/src/app.svelte.ts"
104
+ },
105
+ "./currency/utils": {
106
+ "types": "./packages/vite/dist/currency.d.ts",
107
+ "import": "./packages/vite/dist/currency.js"
108
+ },
109
+ "./dates": {
110
+ "types": "./packages/dates/types.ts",
111
+ "svelte": "./packages/dates/src/app.svelte.ts"
101
112
  }
102
113
  },
103
114
  "devDependencies": {
@@ -108,7 +119,7 @@
108
119
  "kitto": "1.6.0",
109
120
  "oxfmt": "0.59.0",
110
121
  "typescript": "6.0.3",
111
- "vite": "8.1.5"
122
+ "vite": "7.2.2"
112
123
  },
113
124
  "peerDependencies": {
114
125
  "@storyblok/svelte": "7.2.2",
@@ -1,11 +1 @@
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
- }
1
+ export type { Currency, CurrencyField } from '../vite/src/lib/currency.js'
@@ -0,0 +1,20 @@
1
+ export interface DateEntry {
2
+ /** `YYYY-MM-DD`, wall-clock in the field's `timezone` */
3
+ start_date: string
4
+ /** `HH:mm`; `null` makes this an all-day entry */
5
+ start_time: string | null
6
+ /** `YYYY-MM-DD`; `null` means the same day as `start_date` */
7
+ end_date: string | null
8
+ /** `HH:mm`; only set when `start_time` is */
9
+ end_time: string | null
10
+ /** the start as an ISO instant, e.g. `2026-09-15T10:00:00.000Z`; `null` for all-day entries, which are dates, not moments */
11
+ utc_start: string | null
12
+ /** the end as an ISO instant; `null` without an `end_time` */
13
+ utc_end: string | null
14
+ }
15
+
16
+ export interface Dates {
17
+ /** IANA zone the editor picked, e.g. `Europe/London` */
18
+ timezone: string
19
+ dates: DateEntry[]
20
+ }
@@ -0,0 +1,27 @@
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
+ }
12
+ /** The field's value as Storyblok stores it, `plugin` key included — what a Management API write needs. */
13
+ export type CurrencyField<Code extends string = Uppercase<string>> = Currency<Code> & {
14
+ plugin: "uiloco-currency";
15
+ };
16
+ /** `amount` as the plugin displays it, e.g. `£1,234.50`. The plugin formats with this, so the two can't drift. */
17
+ export declare function format_currency(amount: number, currency?: string): string;
18
+ /**
19
+ * A spreadsheet cell or plain number as the `uiloco-currency` field's value:
20
+ * `to_currency('£1,234.50', 'GBP')` → `{ plugin, currency: 'GBP', amount: 1234.5, formatted: '£1,234.50' }`.
21
+ *
22
+ * Takes what a CSV export of money looks like — a leading symbol or code, thousands
23
+ * commas — and throws on anything else rather than guessing: negatives, `POA`, a
24
+ * decimal comma, or more places than the currency has (`1.005` in GBP). An import
25
+ * should stop on a bad row, not write a wrong price. Blank returns `null`.
26
+ */
27
+ export declare function to_currency<Code extends Uppercase<string> = "GBP">(value: string | number | null | undefined, currency?: Code): CurrencyField<Code> | null;
@@ -0,0 +1,45 @@
1
+ /** `amount` as the plugin displays it, e.g. `£1,234.50`. The plugin formats with this, so the two can't drift. */
2
+ export function format_currency(amount, currency = "GBP") {
3
+ return new Intl.NumberFormat("en", {
4
+ style: "currency",
5
+ currency,
6
+ currencyDisplay: "narrowSymbol",
7
+ }).format(amount);
8
+ }
9
+ function minor_digits(currency) {
10
+ return (new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions()
11
+ .maximumFractionDigits ?? 2);
12
+ }
13
+ /**
14
+ * A spreadsheet cell or plain number as the `uiloco-currency` field's value:
15
+ * `to_currency('£1,234.50', 'GBP')` → `{ plugin, currency: 'GBP', amount: 1234.5, formatted: '£1,234.50' }`.
16
+ *
17
+ * Takes what a CSV export of money looks like — a leading symbol or code, thousands
18
+ * commas — and throws on anything else rather than guessing: negatives, `POA`, a
19
+ * decimal comma, or more places than the currency has (`1.005` in GBP). An import
20
+ * should stop on a bad row, not write a wrong price. Blank returns `null`.
21
+ */
22
+ export function to_currency(value, currency = "GBP") {
23
+ if (value === null || value === undefined)
24
+ return null;
25
+ const input = String(value).trim();
26
+ if (input === "")
27
+ return null;
28
+ const raw = input
29
+ .replace(/^[^\d\s.,-]{1,3}\s?/, "")
30
+ .replace(/\s?[A-Z]{3}$/, "")
31
+ .replace(/,(?=\d{3}(?:\D|$))/g, "");
32
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(raw);
33
+ if (!match)
34
+ throw new RangeError(`Not a ${currency} amount: ${JSON.stringify(value)}`);
35
+ if ((match[2]?.length ?? 0) > minor_digits(currency)) {
36
+ throw new RangeError(`${JSON.stringify(value)} has more decimal places than ${currency} allows`);
37
+ }
38
+ const amount = Number(raw);
39
+ return {
40
+ plugin: "uiloco-currency",
41
+ currency,
42
+ amount,
43
+ formatted: format_currency(amount, currency),
44
+ };
45
+ }
@@ -1,6 +1,6 @@
1
1
  import { getRequestEvent } from "$app/server";
2
2
  import { error } from "@sveltejs/kit";
3
- import { handle_error, } from "./storyblok.svelte.js";
3
+ import {} from "./storyblok.svelte.js";
4
4
  function is_not_found(err) {
5
5
  return typeof err === "object" && err !== null && "status" in err && err.status === 404;
6
6
  }
@@ -52,7 +52,7 @@ export function setup(client) {
52
52
  .catch((err) => {
53
53
  if (is_not_found(err))
54
54
  return null;
55
- return handle_error(err);
55
+ return client.handle_error(err);
56
56
  });
57
57
  return (response?.data.story ?? null);
58
58
  }
@@ -60,7 +60,7 @@ export function setup(client) {
60
60
  async function story(slug, params = {}) {
61
61
  const found = await find(slug, params);
62
62
  if (!found)
63
- error(404, "Story not found");
63
+ error(404, client.error(404));
64
64
  return found;
65
65
  }
66
66
  async function stories(params = {}) {
@@ -71,7 +71,7 @@ export function setup(client) {
71
71
  resolve_assets: 1,
72
72
  ...params,
73
73
  })
74
- .catch(handle_error);
74
+ .catch(client.handle_error);
75
75
  return { stories: response.data.stories, total: response.total ?? 0 };
76
76
  }
77
77
  async function all(params = {}) {
@@ -83,7 +83,7 @@ export function setup(client) {
83
83
  per_page: 100,
84
84
  ...params,
85
85
  })
86
- .catch(handle_error);
86
+ .catch(client.handle_error);
87
87
  }
88
88
  return Object.assign(context, {
89
89
  story,
@@ -97,7 +97,7 @@ export function setup(client) {
97
97
  async links(params = {}) {
98
98
  const response = await client
99
99
  .get("cdn/links", { version: version(), per_page: 1000, ...params })
100
- .catch(handle_error);
100
+ .catch(client.handle_error);
101
101
  return Object.values(response.data.links ?? {});
102
102
  },
103
103
  });
@@ -43,7 +43,15 @@ export interface ClientOptions {
43
43
  * news posts reuse the blog template. Applied by `template`.
44
44
  */
45
45
  aliases?: Record<string, string>;
46
+ /**
47
+ * The body of the errors storyloco raises, for projects whose `App.Error` needs more
48
+ * than a message — e.g. `(status) => ({ message: "Not found", code: "NOT_FOUND" })`.
49
+ * `cause` is the Storyblok error, when there was one.
50
+ */
51
+ error?: ErrorBody;
46
52
  }
53
+ /** Shapes the body of a 404 (missing story) or 500 (anything else Storyblok threw) */
54
+ export type ErrorBody = (status: 404 | 500, cause?: unknown) => App.Error;
47
55
  /** Type alias for the storyblok api instance */
48
56
  type API = ReturnType<typeof useStoryblokApi>;
49
57
  /**
@@ -83,8 +91,10 @@ export declare class StoryblokClient {
83
91
  put: API["put"];
84
92
  /** Make a DELETE request to the Storyblok API */
85
93
  delete: API["delete"];
86
- /** The standalone `handle_error`, on the instance so `.catch(client.handle_error)` works */
87
- handle_error: typeof handle_error;
94
+ /** Shapes the body of every error storyloco raises; see `ClientOptions.error` */
95
+ error: ErrorBody;
96
+ /** `handle_error` with this client's `error` shape, bound so `.catch(client.handle_error)` works */
97
+ handle_error: (err: unknown) => never;
88
98
  /**
89
99
  * Configured here rather than by subclassing, so setup is one expression.
90
100
  *
@@ -97,7 +107,7 @@ export declare class StoryblokClient {
97
107
  * })
98
108
  * ```
99
109
  */
100
- constructor({ token, components, relations, aliases }: ClientOptions);
110
+ constructor({ token, components, relations, aliases, error, }: ClientOptions);
101
111
  /**
102
112
  * Load the components and set up the SDK.
103
113
  *
@@ -215,9 +225,10 @@ export declare function href(link?: Link): string | undefined;
215
225
  * Turn a Storyblok error into a SvelteKit one, for `.catch(handle_error)`.
216
226
  *
217
227
  * A missing story is a 404; anything else is a 500, since the CMS being unreachable isn't
218
- * something to show a visitor a Storyblok status code for.
228
+ * something to show a visitor a Storyblok status code for. `body` shapes the error; prefer
229
+ * `client.handle_error`, which passes the client's own.
219
230
  */
220
- export declare function handle_error(err: unknown): never;
231
+ export declare function handle_error(err: unknown, body?: ErrorBody): never;
221
232
  /** Declaring these keeps `SbBlokData`'s index signature from widening every access. */
222
233
  interface BlokFields {
223
234
  anchor?: string;
@@ -2,6 +2,9 @@ import { storyblokInit, apiPlugin, renderRichText as richtext, useStoryblokBridg
2
2
  import { SvelteMap } from "svelte/reactivity";
3
3
  import { createAttachmentKey as attach } from "svelte/attachments";
4
4
  import { error } from "@sveltejs/kit";
5
+ const default_error = (status) => ({
6
+ message: status === 404 ? "Story not found" : "Internal server error",
7
+ });
5
8
  /**
6
9
  * Main Storyblok client class that handles component registration, API calls, and story bridging
7
10
  * Provides a unified interface for working with Storyblok in Svelte applications
@@ -56,8 +59,10 @@ export class StoryblokClient {
56
59
  put = async (...args) => (await this.#api()).put(...args);
57
60
  /** Make a DELETE request to the Storyblok API */
58
61
  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;
62
+ /** Shapes the body of every error storyloco raises; see `ClientOptions.error` */
63
+ error = default_error;
64
+ /** `handle_error` with this client's `error` shape, bound so `.catch(client.handle_error)` works */
65
+ handle_error = (err) => handle_error(err, this.error);
61
66
  /**
62
67
  * Configured here rather than by subclassing, so setup is one expression.
63
68
  *
@@ -70,11 +75,12 @@ export class StoryblokClient {
70
75
  * })
71
76
  * ```
72
77
  */
73
- constructor({ token, components = {}, relations = [], aliases = {} }) {
78
+ constructor({ token, components = {}, relations = [], aliases = {}, error = default_error, }) {
74
79
  this.#access_token = token;
75
80
  this.#component_imports = components;
76
81
  this.relations = relations;
77
82
  this.aliases = aliases;
83
+ this.error = error;
78
84
  // Eager, but handled so a failure surfaces to whoever awaits `init` rather than
79
85
  // as an unhandled rejection
80
86
  this.init().catch(() => { });
@@ -166,14 +172,15 @@ export function href(link) {
166
172
  * Turn a Storyblok error into a SvelteKit one, for `.catch(handle_error)`.
167
173
  *
168
174
  * A missing story is a 404; anything else is a 500, since the CMS being unreachable isn't
169
- * something to show a visitor a Storyblok status code for.
175
+ * something to show a visitor a Storyblok status code for. `body` shapes the error; prefer
176
+ * `client.handle_error`, which passes the client's own.
170
177
  */
171
- export function handle_error(err) {
178
+ export function handle_error(err, body = default_error) {
172
179
  console.error(err);
173
180
  const status = typeof err === "object" && err !== null && "status" in err ? err.status : undefined;
174
181
  if (status === 404)
175
- error(404, "Story not found");
176
- error(500, "Internal server error");
182
+ error(404, body(404, err));
183
+ error(500, body(500, err));
177
184
  }
178
185
  /**
179
186
  * Spread onto a block's root element to wire it up for the visual editor.
@@ -0,0 +1,68 @@
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
+ }
12
+
13
+ /** The field's value as Storyblok stores it, `plugin` key included — what a Management API write needs. */
14
+ export type CurrencyField<Code extends string = Uppercase<string>> = Currency<Code> & {
15
+ plugin: "uiloco-currency"
16
+ }
17
+
18
+ /** `amount` as the plugin displays it, e.g. `£1,234.50`. The plugin formats with this, so the two can't drift. */
19
+ export function format_currency(amount: number, currency: string = "GBP"): string {
20
+ return new Intl.NumberFormat("en", {
21
+ style: "currency",
22
+ currency,
23
+ currencyDisplay: "narrowSymbol",
24
+ }).format(amount)
25
+ }
26
+
27
+ function minor_digits(currency: string): number {
28
+ return (
29
+ new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions()
30
+ .maximumFractionDigits ?? 2
31
+ )
32
+ }
33
+
34
+ /**
35
+ * A spreadsheet cell or plain number as the `uiloco-currency` field's value:
36
+ * `to_currency('£1,234.50', 'GBP')` → `{ plugin, currency: 'GBP', amount: 1234.5, formatted: '£1,234.50' }`.
37
+ *
38
+ * Takes what a CSV export of money looks like — a leading symbol or code, thousands
39
+ * commas — and throws on anything else rather than guessing: negatives, `POA`, a
40
+ * decimal comma, or more places than the currency has (`1.005` in GBP). An import
41
+ * should stop on a bad row, not write a wrong price. Blank returns `null`.
42
+ */
43
+ export function to_currency<Code extends Uppercase<string> = "GBP">(
44
+ value: string | number | null | undefined,
45
+ currency: Code = "GBP" as Code
46
+ ): CurrencyField<Code> | null {
47
+ if (value === null || value === undefined) return null
48
+ const input = String(value).trim()
49
+ if (input === "") return null
50
+
51
+ const raw = input
52
+ .replace(/^[^\d\s.,-]{1,3}\s?/, "")
53
+ .replace(/\s?[A-Z]{3}$/, "")
54
+ .replace(/,(?=\d{3}(?:\D|$))/g, "")
55
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(raw)
56
+ if (!match) throw new RangeError(`Not a ${currency} amount: ${JSON.stringify(value)}`)
57
+ if ((match[2]?.length ?? 0) > minor_digits(currency)) {
58
+ throw new RangeError(`${JSON.stringify(value)} has more decimal places than ${currency} allows`)
59
+ }
60
+
61
+ const amount = Number(raw)
62
+ return {
63
+ plugin: "uiloco-currency",
64
+ currency,
65
+ amount,
66
+ formatted: format_currency(amount, currency),
67
+ }
68
+ }