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 +3 -1
- package/package.json +1 -1
- package/packages/shared/dist/plugin.css +2 -1
- package/packages/vite/dist/redirects/fetch.d.ts +7 -0
- package/packages/vite/dist/redirects/fetch.js +31 -0
- package/packages/vite/dist/redirects/handle.js +55 -44
- package/packages/vite/dist/redirects/plugin.d.ts +2 -1
- package/packages/vite/dist/redirects/plugin.js +23 -75
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
|
-
-
|
|
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
|
@@ -133,7 +133,8 @@
|
|
|
133
133
|
z-index: 1;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
42
|
+
rules.exact.set(normalize_path(key), value);
|
|
43
|
+
rules.exact.set(key.startsWith("/") ? key.slice(1) : key, value);
|
|
69
44
|
}
|
|
70
45
|
}
|
|
71
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
logger.
|
|
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
|
}
|