storyloco 2.7.2 → 2.8.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
@@ -393,10 +393,12 @@ export const handle = handle_redirects
393
393
  The plugin will:
394
394
 
395
395
  - Fetch redirect entries from your Storyblok datasource
396
- - Generate a redirects map during build
396
+ - Bake them into the build as a fallback
397
397
  - Support exact matches and wildcard patterns
398
398
  - Handle both internal and external redirects
399
399
 
400
+ Redirects stay live without a rebuild: the handle fetches the datasource on a server's first request, then serves from memory and refreshes in the background once the list is a minute old. If Storyblok can't be reached, it keeps the last list it had, or the one baked in at build.
401
+
400
402
  ## 🔧 Development
401
403
 
402
404
  ### Prerequisites
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyloco",
3
- "version": "2.7.2",
3
+ "version": "2.8.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",
@@ -133,7 +133,8 @@
133
133
  z-index: 1;
134
134
  }
135
135
 
136
- & input {
136
+ /* the :not() matches the general input rule's, so this outranks it wherever it sits */
137
+ & input:not([type='checkbox'], [type='radio']) {
137
138
  flex: 1;
138
139
  min-inline-size: 0;
139
140
  padding-inline-start: 34px;
@@ -0,0 +1,7 @@
1
+ /** A redirect as `[from, to]`, the way the datasource stores it (`name` → `value`). */
2
+ export type Redirect = [string, string];
3
+ /**
4
+ * Every entry in a Storyblok datasource, as `[from, to]` pairs. A missing datasource is no
5
+ * redirects; any other failure throws, so a caller holding redirects can keep them.
6
+ */
7
+ export declare function fetch_redirects(datasource: string, token: string): Promise<Array<Redirect>>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Every entry in a Storyblok datasource, as `[from, to]` pairs. A missing datasource is no
3
+ * redirects; any other failure throws, so a caller holding redirects can keep them.
4
+ */
5
+ export async function fetch_redirects(datasource, token) {
6
+ const per_page = 100;
7
+ const cv = Date.now().toString();
8
+ const entries = [];
9
+ for (let page = 1;; page++) {
10
+ const url = new URL("https://api.storyblok.com/v2/cdn/datasource_entries");
11
+ url.search = new URLSearchParams({
12
+ datasource,
13
+ token,
14
+ cv,
15
+ per_page: String(per_page),
16
+ page: String(page),
17
+ }).toString();
18
+ const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
19
+ if (response.status === 404)
20
+ return [];
21
+ if (!response.ok)
22
+ throw new Error(`Storyblok datasource "${datasource}" → ${response.status}`);
23
+ const { datasource_entries = [] } = (await response.json());
24
+ entries.push(...datasource_entries);
25
+ const total = Number(response.headers.get("total"));
26
+ if (datasource_entries.length < per_page || (total && entries.length >= total))
27
+ break;
28
+ }
29
+ const pairs = entries.filter((e) => e.name && e.value).map((e) => [e.name, e.value]);
30
+ return [...new Map(pairs)];
31
+ }
@@ -1,27 +1,8 @@
1
1
  import { match, compile } from "path-to-regexp";
2
2
  import { Logger } from "../shared/logger.js";
3
+ import { fetch_redirects } from "./fetch.js";
3
4
  const name = "vite-storyblok-redirects";
4
5
  const logger = new Logger(name);
5
- // Lazy load the redirects map to avoid import errors during dev
6
- let redirects_map = null;
7
- async function load_redirects_map() {
8
- if (redirects_map)
9
- return redirects_map;
10
- try {
11
- // import from virtual module
12
- // @ts-expect-error - virtual module resolved at runtime by vite plugin
13
- const module = await import("virtual:storyblok-redirects");
14
- const redirects = module.redirects;
15
- redirects_map = new Map(redirects);
16
- return redirects_map;
17
- }
18
- catch (err) {
19
- logger.warn(`Failed to load redirects from virtual module, using empty redirects map`);
20
- logger.warn(String(err));
21
- redirects_map = new Map();
22
- return redirects_map;
23
- }
24
- }
25
6
  /** Normalise to a leading-slash path. Empty → '/' */
26
7
  function normalize_path(pathname) {
27
8
  if (!pathname)
@@ -43,32 +24,62 @@ function to_dst_pattern(glob) {
43
24
  let i = 1;
44
25
  return normalize_path(glob).replaceAll("*", () => `:w${i++}`);
45
26
  }
46
- /** Precompiled wildcard rules generated from the redirects map. */
47
- let wildcard_rules = [];
48
- /** Exact redirect lookups, with and without leading slash variants. */
49
- let exact_map = new Map();
50
- let rules_initialized = false;
51
- async function initialize_rules() {
52
- if (rules_initialized)
53
- return;
54
- const map = await load_redirects_map();
55
- wildcard_rules = [];
56
- exact_map = new Map();
57
- for (const [key, value] of map.entries()) {
27
+ function build_rules(redirects) {
28
+ const rules = { exact: new Map(), wildcards: [] };
29
+ for (const [key, value] of redirects) {
58
30
  if (key.includes("*")) {
59
- const src_pattern = to_src_pattern(key);
60
- const dst_pattern = to_dst_pattern(value);
61
- wildcard_rules.push({
62
- matcher: match(src_pattern, { decode: decodeURIComponent, end: true }),
63
- builder: compile(dst_pattern, { encode: (x) => x }),
64
- });
31
+ try {
32
+ rules.wildcards.push({
33
+ matcher: match(to_src_pattern(key), { decode: decodeURIComponent, end: true }),
34
+ builder: compile(to_dst_pattern(value), { encode: (x) => x }),
35
+ });
36
+ }
37
+ catch (err) {
38
+ logger.warn(`Skipping redirect ${key} -> ${value}: ${err}`);
39
+ }
65
40
  }
66
41
  else {
67
- exact_map.set(normalize_path(key), value);
68
- exact_map.set(key.startsWith("/") ? key.slice(1) : key, value);
42
+ rules.exact.set(normalize_path(key), value);
43
+ rules.exact.set(key.startsWith("/") ? key.slice(1) : key, value);
69
44
  }
70
45
  }
71
- rules_initialized = true;
46
+ return rules;
47
+ }
48
+ /** How long a fetched list is used before it's refreshed in the background. */
49
+ const MAX_AGE = 60_000;
50
+ let rules;
51
+ let refresh;
52
+ let fetched_at = 0;
53
+ /**
54
+ * The current rules. The first request waits for the live datasource, falling back to the list baked
55
+ * in at build; after that, a list older than MAX_AGE keeps serving while a fresh one loads, and a
56
+ * failed refresh keeps the last good list.
57
+ */
58
+ async function current_rules() {
59
+ rules ??= (async () => {
60
+ let config;
61
+ try {
62
+ // @ts-expect-error - virtual module resolved at runtime by vite plugin
63
+ config = await import("virtual:storyblok-redirects");
64
+ }
65
+ catch (err) {
66
+ logger.warn(`Failed to load redirects from virtual module, using no redirects: ${err}`);
67
+ return build_rules([]);
68
+ }
69
+ refresh = config.token ? () => fetch_redirects(config.datasource, config.token) : undefined;
70
+ fetched_at = Date.now();
71
+ return build_rules((await refresh?.().catch(warn)) ?? config.redirects);
72
+ })();
73
+ if (refresh && Date.now() - fetched_at > MAX_AGE) {
74
+ fetched_at = Date.now();
75
+ refresh()
76
+ .then((redirects) => (rules = Promise.resolve(build_rules(redirects))))
77
+ .catch(warn);
78
+ }
79
+ return rules;
80
+ }
81
+ function warn(err) {
82
+ logger.warn(`Couldn't refresh redirects, keeping the current ones: ${err}`);
72
83
  }
73
84
  /**
74
85
  * Resolve a redirect for the given pathname.
@@ -77,15 +88,15 @@ async function initialize_rules() {
77
88
  * Self-redirects are ignored.
78
89
  */
79
90
  async function resolve_redirect(url) {
80
- await initialize_rules();
91
+ const { exact, wildcards } = await current_rules();
81
92
  try {
82
93
  const source = normalize_path(url.pathname + (url.search || ""));
83
94
  const source_plain = source.includes("?") ? source.split("?")[0] : source;
84
- const lookup = (key) => exact_map.get(key) ?? exact_map.get(key.startsWith("/") ? key.slice(1) : key);
95
+ const lookup = (key) => exact.get(key) ?? exact.get(key.startsWith("/") ? key.slice(1) : key);
85
96
  // Prefer exact including query, then plain path
86
97
  let target = lookup(source) ?? lookup(source_plain) ?? null;
87
98
  if (!target) {
88
- for (const { matcher, builder } of wildcard_rules) {
99
+ for (const { matcher, builder } of wildcards) {
89
100
  const m = matcher(source_plain);
90
101
  if (!m)
91
102
  continue;
@@ -5,7 +5,8 @@ interface Options {
5
5
  }
6
6
  /**
7
7
  * @remarks
8
- * Vite plugin that automatically generates TypeScript redirects from Storyblok datasource entries
8
+ * Vite plugin that bakes a Storyblok datasource's redirects into the build, along with what
9
+ * `handle_redirects` needs to keep them live: it refreshes them from Storyblok every minute.
9
10
  *
10
11
  * @param [options] - The options for the plugin.
11
12
  * @param [options.datasource] - The Storyblok datasource name to fetch redirects from. Defaults to 'redirects'.
@@ -1,48 +1,12 @@
1
1
  import { loadEnv } from "vite";
2
2
  import { Logger } from "../shared/logger.js";
3
+ import { fetch_redirects } from "./fetch.js";
3
4
  const name = "vite-storyblok-redirects";
4
5
  const logger = new Logger(name);
5
- async function get_redirects(datasource = "redirects", public_storyblok_access_token) {
6
- const env_token = loadEnv("", "", "").PUBLIC_STORYBLOK_ACCESS_TOKEN;
7
- const token = public_storyblok_access_token ?? env_token;
8
- const per_page = 100;
9
- let page = 1;
10
- const all_entries = [];
11
- const cache_version = Date.now().toString();
12
- let total = null;
13
- while (true) {
14
- const url = new URL("https://api.storyblok.com/v2/cdn/datasource_entries");
15
- url.searchParams.set("datasource", datasource);
16
- url.searchParams.set("token", token);
17
- url.searchParams.set("cv", cache_version);
18
- url.searchParams.set("per_page", String(per_page));
19
- url.searchParams.set("page", String(page));
20
- const request = await fetch(url);
21
- if (request.status === 404) {
22
- return { datasource_entries: [] };
23
- }
24
- // capture total from headers once
25
- if (total === null) {
26
- const total_header = request.headers.get("total");
27
- total = total_header ? Number(total_header) : null;
28
- }
29
- const { datasource_entries } = (await request.json());
30
- all_entries.push(...(datasource_entries ?? []));
31
- // stop conditions:
32
- // - if we know total, stop when we've collected that many
33
- // - otherwise, stop when a page returns fewer than per_page
34
- if ((total !== null && all_entries.length >= total) ||
35
- !datasource_entries ||
36
- datasource_entries.length < per_page) {
37
- break;
38
- }
39
- page += 1;
40
- }
41
- return { datasource_entries: all_entries };
42
- }
43
6
  /**
44
7
  * @remarks
45
- * Vite plugin that automatically generates TypeScript redirects from Storyblok datasource entries
8
+ * Vite plugin that bakes a Storyblok datasource's redirects into the build, along with what
9
+ * `handle_redirects` needs to keep them live: it refreshes them from Storyblok every minute.
46
10
  *
47
11
  * @param [options] - The options for the plugin.
48
12
  * @param [options.datasource] - The Storyblok datasource name to fetch redirects from. Defaults to 'redirects'.
@@ -92,7 +56,12 @@ async function get_redirects(datasource = "redirects", public_storyblok_access_t
92
56
  * ```
93
57
  */
94
58
  export default function redirects({ datasource = "redirects", public_storyblok_access_token, } = {}) {
95
- let redirects_data = [];
59
+ const token = public_storyblok_access_token ?? loadEnv("", "", "").PUBLIC_STORYBLOK_ACCESS_TOKEN ?? "";
60
+ let baked;
61
+ const load_redirects = () => (baked ??= fetch_redirects(datasource, token).catch((err) => {
62
+ logger.fail(`Error generating redirects: ${err instanceof Error ? err.message : err}`);
63
+ return [];
64
+ }));
96
65
  return {
97
66
  name,
98
67
  resolveId(id) {
@@ -100,43 +69,22 @@ export default function redirects({ datasource = "redirects", public_storyblok_a
100
69
  return id;
101
70
  },
102
71
  async load(id) {
103
- if (id === "virtual:storyblok-redirects") {
104
- try {
105
- const { datasource_entries } = await get_redirects(datasource, public_storyblok_access_token);
106
- redirects_data = Array.from(new Map(datasource_entries
107
- .filter((e) => Boolean(e?.name) && Boolean(e?.value))
108
- .map((e) => [e.name, e.value])).entries());
109
- return `export const redirects = ${JSON.stringify(redirects_data, null, 2)}`;
110
- }
111
- catch (err) {
112
- const message = err instanceof Error && typeof err.message === "string" ? err.message : "Unknown error";
113
- logger.fail(`Error generating redirects: ${message}`);
114
- return `export const redirects = []`;
115
- }
116
- }
72
+ if (id !== "virtual:storyblok-redirects")
73
+ return;
74
+ return [
75
+ `export const redirects = ${JSON.stringify(await load_redirects(), null, 2)}`,
76
+ `export const datasource = ${JSON.stringify(datasource)}`,
77
+ `export const token = ${JSON.stringify(token)}`,
78
+ ].join("\n");
117
79
  },
118
80
  async buildStart() {
119
- // preload redirects for build
120
- try {
121
- const { datasource_entries } = await get_redirects(datasource, public_storyblok_access_token);
122
- redirects_data = Array.from(new Map(datasource_entries
123
- .filter((e) => Boolean(e?.name) && Boolean(e?.value))
124
- .map((e) => [e.name, e.value])).entries());
125
- logger.start("Generating redirects");
126
- logger.succeed("Redirects virtual module generated");
127
- if (redirects_data.length === 0) {
128
- logger.info("No redirects configured");
129
- }
130
- else {
131
- for (const [from, to] of redirects_data) {
132
- logger.info(`${from} -> ${to}`);
133
- }
134
- }
135
- }
136
- catch (err) {
137
- const message = err instanceof Error && typeof err.message === "string" ? err.message : "Unknown error";
138
- logger.fail(`Error generating redirects: ${message}`);
139
- }
81
+ baked = undefined;
82
+ const redirects = await load_redirects();
83
+ logger.succeed(`Redirects virtual module generated`);
84
+ if (redirects.length === 0)
85
+ logger.info("No redirects configured");
86
+ for (const [from, to] of redirects)
87
+ logger.info(`${from} -> ${to}`);
140
88
  },
141
89
  };
142
90
  }