@viniaraujo68/plinth 0.3.0 → 0.5.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.
Files changed (103) hide show
  1. package/README.md +189 -19
  2. package/dist/components/ErrorDisplay.svelte +30 -16
  3. package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
  4. package/dist/components/JsonView.svelte +43 -0
  5. package/dist/components/JsonView.svelte.d.ts +4 -0
  6. package/dist/components/JsonViewFrame.svelte +654 -0
  7. package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
  8. package/dist/components/Paginator.svelte +34 -8
  9. package/dist/components/Paginator.svelte.d.ts +10 -0
  10. package/dist/components/RequestErrorDisplay.svelte +43 -0
  11. package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
  12. package/dist/components/Tabs.svelte +204 -0
  13. package/dist/components/Tabs.svelte.d.ts +76 -0
  14. package/dist/components/VirtualJsonView.svelte +67 -0
  15. package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
  16. package/dist/components/index.d.ts +8 -1
  17. package/dist/components/index.js +11 -5
  18. package/dist/components/json-view.d.ts +70 -0
  19. package/dist/components/json-view.js +302 -0
  20. package/dist/components/request-error.d.ts +8 -0
  21. package/dist/components/request-error.js +55 -0
  22. package/dist/components/tabs.d.ts +5 -0
  23. package/dist/components/tabs.js +1 -0
  24. package/dist/http.d.ts +10 -5
  25. package/dist/http.js +34 -6
  26. package/dist/i18n/LocaleToggle.svelte +45 -0
  27. package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
  28. package/dist/i18n/i18n.svelte.d.ts +56 -0
  29. package/dist/i18n/i18n.svelte.js +94 -0
  30. package/dist/i18n/index.d.ts +3 -0
  31. package/dist/i18n/index.js +7 -0
  32. package/dist/i18n/translate.d.ts +30 -0
  33. package/dist/i18n/translate.js +38 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/redirect.d.ts +40 -0
  37. package/dist/redirect.js +59 -0
  38. package/dist/routing.svelte.d.ts +1 -0
  39. package/dist/routing.svelte.js +3 -0
  40. package/dist/shell/AppShell.svelte +269 -95
  41. package/dist/shell/AppShell.svelte.d.ts +3 -2
  42. package/dist/shell/NavigationProgress.svelte +85 -0
  43. package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
  44. package/dist/shell/TopBar.svelte +191 -0
  45. package/dist/shell/TopBar.svelte.d.ts +41 -0
  46. package/dist/shell/UserAvatar.svelte +50 -0
  47. package/dist/shell/UserAvatar.svelte.d.ts +14 -0
  48. package/dist/shell/index.d.ts +2 -0
  49. package/dist/shell/index.js +7 -3
  50. package/dist/shell/initials.d.ts +6 -0
  51. package/dist/shell/initials.js +13 -0
  52. package/dist/table/DataTable.svelte +199 -9
  53. package/dist/table/DataTable.svelte.d.ts +28 -5
  54. package/dist/table/FilterBar.svelte +417 -0
  55. package/dist/table/FilterBar.svelte.d.ts +34 -0
  56. package/dist/table/FilterRuleEditor.svelte +290 -0
  57. package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
  58. package/dist/table/filter-labels.d.ts +23 -0
  59. package/dist/table/filter-labels.js +30 -0
  60. package/dist/table/filter.d.ts +27 -0
  61. package/dist/table/filter.js +279 -0
  62. package/dist/table/index.d.ts +4 -1
  63. package/dist/table/index.js +5 -2
  64. package/dist/table/types.d.ts +49 -0
  65. package/dist/toast/Toaster.svelte +7 -3
  66. package/dist/toast/index.d.ts +1 -1
  67. package/dist/toast/toast-manager.svelte.d.ts +29 -1
  68. package/dist/toast/toast-manager.svelte.js +61 -1
  69. package/dist/url-state/codec.d.ts +15 -0
  70. package/dist/url-state/codec.js +1 -0
  71. package/dist/url-state/index.d.ts +5 -0
  72. package/dist/url-state/index.js +4 -0
  73. package/dist/url-state/params-codec.d.ts +73 -0
  74. package/dist/url-state/params-codec.js +116 -0
  75. package/dist/url-state/search-params.d.ts +4 -0
  76. package/dist/url-state/search-params.js +28 -0
  77. package/dist/url-state/synced-state.svelte.d.ts +20 -0
  78. package/dist/url-state/synced-state.svelte.js +88 -0
  79. package/dist/url-state/url-store.svelte.d.ts +12 -0
  80. package/dist/url-state/url-store.svelte.js +56 -0
  81. package/dist/virtual/DataList.svelte +215 -0
  82. package/dist/virtual/DataList.svelte.d.ts +52 -0
  83. package/dist/virtual/VirtualList.svelte +150 -0
  84. package/dist/virtual/VirtualList.svelte.d.ts +42 -0
  85. package/dist/virtual/index.d.ts +7 -0
  86. package/dist/virtual/index.js +6 -0
  87. package/dist/virtual/keyed-cache.d.ts +7 -0
  88. package/dist/virtual/keyed-cache.js +20 -0
  89. package/dist/virtual/labels.d.ts +12 -0
  90. package/dist/virtual/labels.js +6 -0
  91. package/dist/virtual/paging.d.ts +5 -0
  92. package/dist/virtual/paging.js +11 -0
  93. package/dist/virtual/range.d.ts +9 -0
  94. package/dist/virtual/range.js +31 -0
  95. package/dist/virtual/size-tree.d.ts +10 -0
  96. package/dist/virtual/size-tree.js +56 -0
  97. package/dist/virtual/virtualizer.svelte.d.ts +38 -0
  98. package/dist/virtual/virtualizer.svelte.js +295 -0
  99. package/dist/virtual/windowed-data.svelte.d.ts +38 -0
  100. package/dist/virtual/windowed-data.svelte.js +139 -0
  101. package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
  102. package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
  103. package/package.json +13 -1
@@ -0,0 +1,302 @@
1
+ export const DEFAULT_JSON_VIEW_LABELS = {
2
+ region: "JSON viewer",
3
+ search: "Search",
4
+ searchField: "Search keys and values",
5
+ previousMatch: "Previous match",
6
+ nextMatch: "Next match",
7
+ closeSearch: "Close search",
8
+ matchCount: (current, total) => `${current} of ${total}`,
9
+ noMatches: "No matches",
10
+ expandAll: "Expand all",
11
+ collapseAll: "Collapse all",
12
+ copy: "Copy JSON",
13
+ copied: "Copied",
14
+ expand: "Expand",
15
+ collapse: "Collapse",
16
+ items: (count) => (count === 1 ? "1 item" : `${count} items`),
17
+ circular: "[Circular]",
18
+ empty: "Nothing to show",
19
+ };
20
+ export const JSON_VIEW_GUTTER_CH = 2;
21
+ export const JSON_VIEW_INDENT_CH = 2;
22
+ const SKIPPED = Symbol("skipped");
23
+ const escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
24
+ const unbox = (value) => {
25
+ if (value instanceof Number)
26
+ return Number(value);
27
+ if (value instanceof String)
28
+ return String(value);
29
+ if (value instanceof Boolean || value instanceof BigInt)
30
+ return value.valueOf();
31
+ return value;
32
+ };
33
+ const resolveForJson = (raw, key) => {
34
+ let value = raw;
35
+ if ((typeof value === "object" && value !== null) || typeof value === "bigint") {
36
+ const toJSON = value.toJSON;
37
+ if (typeof toJSON === "function")
38
+ value = toJSON.call(value, key);
39
+ }
40
+ value = unbox(value);
41
+ if (value === undefined || typeof value === "function" || typeof value === "symbol")
42
+ return SKIPPED;
43
+ return value;
44
+ };
45
+ const isContainer = (value) => typeof value === "object" && value !== null;
46
+ const closingBracket = (open) => (open === "[" ? "]" : "}");
47
+ const primitiveToken = (value) => {
48
+ if (value === null)
49
+ return { text: "null", type: "null" };
50
+ if (typeof value === "string")
51
+ return { text: JSON.stringify(value), type: "string" };
52
+ if (typeof value === "boolean")
53
+ return { text: String(value), type: "boolean" };
54
+ if (typeof value === "bigint")
55
+ return { text: value.toString(), type: "number" };
56
+ if (typeof value === "number")
57
+ return Number.isFinite(value)
58
+ ? { text: JSON.stringify(value), type: "number" }
59
+ : { text: "null", type: "null" };
60
+ return { text: "null", type: "null" };
61
+ };
62
+ export const flattenJson = (value, options = {}) => {
63
+ const circular = options.circular ?? DEFAULT_JSON_VIEW_LABELS.circular;
64
+ const lines = [];
65
+ const ancestors = new Set();
66
+ const childrenOf = (container) => {
67
+ if (Array.isArray(container)) {
68
+ const items = container;
69
+ return Array.from({ length: items.length }, (_, index) => {
70
+ const segment = String(index);
71
+ const resolved = resolveForJson(items[index], segment);
72
+ return { segment, raw: items[index], value: resolved === SKIPPED ? null : resolved };
73
+ });
74
+ }
75
+ const record = container;
76
+ return Object.keys(record)
77
+ .map((segment) => ({
78
+ segment,
79
+ raw: record[segment],
80
+ value: resolveForJson(record[segment], segment),
81
+ }))
82
+ .filter((child) => child.value !== SKIPPED);
83
+ };
84
+ const visit = (raw, node, pointer, name, depth, parent, comma) => {
85
+ const index = lines.length;
86
+ if (!isContainer(node)) {
87
+ const { text, type } = primitiveToken(node);
88
+ lines.push({
89
+ kind: "value",
90
+ pointer,
91
+ id: pointer,
92
+ depth,
93
+ parent,
94
+ end: index + 1,
95
+ name,
96
+ text,
97
+ type,
98
+ size: 0,
99
+ comma,
100
+ source: raw,
101
+ });
102
+ return;
103
+ }
104
+ if (ancestors.has(node)) {
105
+ lines.push({
106
+ kind: "value",
107
+ pointer,
108
+ id: pointer,
109
+ depth,
110
+ parent,
111
+ end: index + 1,
112
+ name,
113
+ text: circular,
114
+ type: "circular",
115
+ size: 0,
116
+ comma,
117
+ source: raw,
118
+ });
119
+ return;
120
+ }
121
+ const isArray = Array.isArray(node);
122
+ const bracket = isArray ? "[" : "{";
123
+ const children = childrenOf(node);
124
+ const open = {
125
+ kind: "open",
126
+ pointer,
127
+ id: pointer,
128
+ depth,
129
+ parent,
130
+ end: index + 1,
131
+ name,
132
+ text: bracket,
133
+ type: null,
134
+ size: children.length,
135
+ comma,
136
+ source: raw,
137
+ };
138
+ lines.push(open);
139
+ if (children.length === 0)
140
+ return;
141
+ ancestors.add(node);
142
+ children.forEach((child, position) => {
143
+ visit(child.raw, child.value, `${pointer}/${escapePointerSegment(child.segment)}`, isArray ? null : JSON.stringify(child.segment), depth + 1, index, position < children.length - 1);
144
+ });
145
+ ancestors.delete(node);
146
+ lines.push({
147
+ kind: "close",
148
+ pointer,
149
+ id: `~${pointer}`,
150
+ depth,
151
+ parent: index,
152
+ end: lines.length + 1,
153
+ name: null,
154
+ text: closingBracket(bracket),
155
+ type: null,
156
+ size: 0,
157
+ comma,
158
+ source: raw,
159
+ });
160
+ open.end = lines.length;
161
+ };
162
+ const root = resolveForJson(value, "");
163
+ if (root !== SKIPPED)
164
+ visit(value, root, "", null, 0, -1, false);
165
+ return lines;
166
+ };
167
+ export const isFoldable = (line) => line.kind === "open" && line.size > 0;
168
+ export const isFolded = (line, collapsed) => isFoldable(line) && collapsed.has(line.pointer);
169
+ export const visibleLines = (lines, collapsed) => {
170
+ const visible = [];
171
+ let index = 0;
172
+ while (index < lines.length) {
173
+ visible.push(index);
174
+ const line = lines[index];
175
+ index = isFolded(line, collapsed) ? line.end : index + 1;
176
+ }
177
+ return visible;
178
+ };
179
+ export const ancestorLines = (lines, index) => {
180
+ const chain = [];
181
+ for (let parent = lines[index].parent; parent !== -1; parent = lines[parent].parent)
182
+ chain.push(parent);
183
+ return chain;
184
+ };
185
+ const foldCase = (text) => {
186
+ const lower = text.toLowerCase();
187
+ if (lower.length === text.length)
188
+ return lower;
189
+ let folded = "";
190
+ for (const character of text) {
191
+ const candidate = character.toLowerCase();
192
+ folded += candidate.length === character.length ? candidate : character;
193
+ }
194
+ return folded;
195
+ };
196
+ const occurrences = (text, needle) => {
197
+ const haystack = foldCase(text);
198
+ const starts = [];
199
+ for (let start = haystack.indexOf(needle); start !== -1; start = haystack.indexOf(needle, start + needle.length))
200
+ starts.push(start);
201
+ return starts;
202
+ };
203
+ export const searchJson = (lines, query) => {
204
+ if (query === "")
205
+ return [];
206
+ const needle = foldCase(query);
207
+ const matches = [];
208
+ lines.forEach((line, index) => {
209
+ if (line.name !== null)
210
+ for (const start of occurrences(line.name, needle))
211
+ matches.push({ line: index, field: "name", start, length: query.length });
212
+ if (line.kind === "value")
213
+ for (const start of occurrences(line.text, needle))
214
+ matches.push({ line: index, field: "value", start, length: query.length });
215
+ });
216
+ return matches;
217
+ };
218
+ export const matchSegments = (text, query) => {
219
+ if (query === "")
220
+ return [{ text, start: 0, match: false }];
221
+ const segments = [];
222
+ let cursor = 0;
223
+ for (const start of occurrences(text, foldCase(query))) {
224
+ if (start > cursor)
225
+ segments.push({ text: text.slice(cursor, start), start: cursor, match: false });
226
+ segments.push({ text: text.slice(start, start + query.length), start, match: true });
227
+ cursor = start + query.length;
228
+ }
229
+ if (cursor < text.length || segments.length === 0)
230
+ segments.push({ text: text.slice(cursor), start: cursor, match: false });
231
+ return segments;
232
+ };
233
+ export const subtreeHasMatch = (matches, open, end) => {
234
+ let low = 0;
235
+ let high = matches.length;
236
+ while (low < high) {
237
+ const middle = (low + high) >> 1;
238
+ if (matches[middle].line <= open)
239
+ low = middle + 1;
240
+ else
241
+ high = middle;
242
+ }
243
+ return low < matches.length && matches[low].line < end;
244
+ };
245
+ const nameWidth = (line) => (line.name === null ? 0 : line.name.length + 2);
246
+ const leadWidth = (line) => JSON_VIEW_GUTTER_CH + line.depth * JSON_VIEW_INDENT_CH + nameWidth(line);
247
+ export const lineWidthCh = (line) => {
248
+ const comma = line.comma ? 1 : 0;
249
+ if (line.kind === "value" || line.kind === "close")
250
+ return leadWidth(line) + line.text.length + comma;
251
+ if (line.size === 0)
252
+ return leadWidth(line) + 2 + comma;
253
+ return leadWidth(line) + 3 + comma + 1 + String(line.size).length;
254
+ };
255
+ export const maxLineWidthCh = (lines) => {
256
+ let widest = 0;
257
+ for (const line of lines)
258
+ widest = Math.max(widest, lineWidthCh(line));
259
+ return widest;
260
+ };
261
+ export const matchColumnCh = (line, match) => JSON_VIEW_GUTTER_CH +
262
+ line.depth * JSON_VIEW_INDENT_CH +
263
+ (match.field === "name" ? match.start : nameWidth(line) + match.start);
264
+ export const lineText = (line) => {
265
+ const lead = line.name === null ? "" : `${line.name}: `;
266
+ const comma = line.comma ? "," : "";
267
+ if (line.kind === "value")
268
+ return lead + line.text + comma;
269
+ if (line.kind === "close")
270
+ return line.text + comma;
271
+ if (line.size === 0)
272
+ return lead + line.text + closingBracket(line.text) + comma;
273
+ return lead + line.text;
274
+ };
275
+ const NOTHING_COLLAPSED = new Set();
276
+ const comparePoints = (a, b) => a.line === b.line ? a.column - b.column : a.line - b.line;
277
+ export const copyJson = (lines, collapsed, from, to) => {
278
+ if (lines.length === 0)
279
+ return "";
280
+ const clamp = (point) => ({
281
+ line: Math.min(Math.max(point.line, 0), lines.length - 1),
282
+ column: Math.max(point.column, 0),
283
+ });
284
+ let [start, end] = comparePoints(from, to) <= 0 ? [from, to] : [to, from];
285
+ start = clamp(start);
286
+ end = clamp(end);
287
+ const startLine = lines[start.line];
288
+ if (isFolded(startLine, collapsed))
289
+ start = { line: start.line, column: Math.min(start.column, lineText(startLine).length) };
290
+ const endLine = lines[end.line];
291
+ if (isFolded(endLine, collapsed) && end.column > lineText(endLine).length)
292
+ end = { line: endLine.end - 1, column: Number.POSITIVE_INFINITY };
293
+ if (start.line === end.line)
294
+ return lineText(lines[start.line]).slice(start.column, end.column);
295
+ const parts = [lineText(startLine).slice(start.column)];
296
+ for (let index = start.line + 1; index < end.line; index += 1)
297
+ parts.push(" ".repeat(lines[index].depth * 2) + lineText(lines[index]));
298
+ const last = lines[end.line];
299
+ parts.push(end.column === 0 ? "" : " ".repeat(last.depth * 2) + lineText(last).slice(0, end.column));
300
+ return parts.join("\n");
301
+ };
302
+ export const copyAllJson = (lines) => copyJson(lines, NOTHING_COLLAPSED, { line: 0, column: 0 }, { line: lines.length - 1, column: Number.POSITIVE_INFINITY });
@@ -0,0 +1,8 @@
1
+ import type { RequestErrorKind } from "../http.js";
2
+ export interface RequestErrorCopy {
3
+ title: string;
4
+ message: string;
5
+ }
6
+ export type RequestErrorLabels = Partial<Record<RequestErrorKind, Partial<RequestErrorCopy>>>;
7
+ export declare const DEFAULT_REQUEST_ERROR_COPY: Record<RequestErrorKind, RequestErrorCopy>;
8
+ export declare const REQUEST_ERROR_ICONS: Record<RequestErrorKind, string[]>;
@@ -0,0 +1,55 @@
1
+ export const DEFAULT_REQUEST_ERROR_COPY = {
2
+ offline: {
3
+ title: "Connection problem",
4
+ message: "The request never reached the server. Check your connection and try again.",
5
+ },
6
+ unauthorized: {
7
+ title: "Sign-in required",
8
+ message: "Your session may have expired. Sign in again to continue.",
9
+ },
10
+ forbidden: {
11
+ title: "Access denied",
12
+ message: "You do not have permission to see this.",
13
+ },
14
+ "not-found": {
15
+ title: "Not found",
16
+ message: "What you asked for does not exist, or has moved.",
17
+ },
18
+ server: {
19
+ title: "Server error",
20
+ message: "The server ran into a problem. Try again in a moment.",
21
+ },
22
+ unknown: {
23
+ title: "Something went wrong",
24
+ message: "The application ran into an unexpected problem.",
25
+ },
26
+ };
27
+ export const REQUEST_ERROR_ICONS = {
28
+ offline: [
29
+ "M12 20h.01",
30
+ "M8.5 16.4a5 5 0 0 1 7 0",
31
+ "M5 12.9a10 10 0 0 1 5.2-2.7",
32
+ "M16.9 11.3a10 10 0 0 1 2.2 1.6",
33
+ "M2 8.8a15 15 0 0 1 4.2-2.6",
34
+ "M10.7 5a15 15 0 0 1 11.3 3.8",
35
+ "m2 2 20 20",
36
+ ],
37
+ unauthorized: [
38
+ "M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2",
39
+ "M7 11V7a5 5 0 0 1 10 0v4",
40
+ ],
41
+ forbidden: ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20", "m4.9 4.9 14.2 14.2"],
42
+ "not-found": ["M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16", "m21 21-4.3-4.3", "M8.5 11h5"],
43
+ server: [
44
+ "M6 10H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2",
45
+ "M6 14H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-2",
46
+ "M6 6h.01",
47
+ "M6 18h.01",
48
+ "m13 6-4 6h6l-4 6",
49
+ ],
50
+ unknown: [
51
+ "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",
52
+ "M12 9v4",
53
+ "M12 17h.01",
54
+ ],
55
+ };
@@ -0,0 +1,5 @@
1
+ /** One tab of a `Tabs` row. `id` is the value the row reports. */
2
+ export interface TabItem<Id extends string = string> {
3
+ id: Id;
4
+ label: string;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/http.d.ts CHANGED
@@ -32,12 +32,15 @@ export interface HttpClientOptions {
32
32
  auth?: AuthTokenSource;
33
33
  parseError?: ErrorMessageParser;
34
34
  }
35
+ export interface RequestOptions {
36
+ signal?: AbortSignal;
37
+ }
35
38
  export interface HttpClient {
36
- get: <T = unknown>(path: string) => Promise<T>;
37
- post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
38
- put: <T = unknown>(path: string, body?: unknown) => Promise<T>;
39
- patch: <T = unknown>(path: string, body?: unknown) => Promise<T>;
40
- del: <T = unknown>(path: string) => Promise<T>;
39
+ get: <T = unknown>(path: string, options?: RequestOptions) => Promise<T>;
40
+ post: <T = unknown>(path: string, body?: unknown, options?: RequestOptions) => Promise<T>;
41
+ put: <T = unknown>(path: string, body?: unknown, options?: RequestOptions) => Promise<T>;
42
+ patch: <T = unknown>(path: string, body?: unknown, options?: RequestOptions) => Promise<T>;
43
+ del: <T = unknown>(path: string, options?: RequestOptions) => Promise<T>;
41
44
  }
42
45
  /**
43
46
  * Build a client bound to one API.
@@ -66,3 +69,5 @@ export declare const errorMessage: (error: unknown) => string;
66
69
  * caller special-case 403 or 404 without an `instanceof` dance.
67
70
  */
68
71
  export declare const errorStatus: (error: unknown) => number;
72
+ export type RequestErrorKind = "offline" | "unauthorized" | "forbidden" | "not-found" | "server" | "unknown";
73
+ export declare const classifyError: (error: unknown, online?: boolean | undefined) => RequestErrorKind;
package/dist/http.js CHANGED
@@ -71,7 +71,7 @@ const extractCode = (body) => {
71
71
  * ```
72
72
  */
73
73
  export const createHttpClient = ({ baseUrl, credentials, auth, parseError, }) => {
74
- const request = async (method, path, body) => {
74
+ const request = async (method, path, body, options = {}) => {
75
75
  const headers = {};
76
76
  // GET and DELETE carry no body, and a `Content-Type` on them makes some CORS setups
77
77
  // preflight a request that would otherwise be simple.
@@ -89,6 +89,7 @@ export const createHttpClient = ({ baseUrl, credentials, auth, parseError, }) =>
89
89
  headers,
90
90
  credentials,
91
91
  body: body === undefined ? undefined : JSON.stringify(body),
92
+ signal: options.signal,
92
93
  });
93
94
  // Read as text first: 204 and most DELETEs answer with nothing at all, and `response.json()`
94
95
  // throws on an empty body. A non-JSON body (an HTML error page from a proxy) is kept as the
@@ -118,11 +119,11 @@ export const createHttpClient = ({ baseUrl, credentials, auth, parseError, }) =>
118
119
  // A body always goes out, even when the caller passes none — a bodyless POST is a 422 against a
119
120
  // FastAPI endpoint that declares a model, and `{}` is the harmless stand-in both references use.
120
121
  return {
121
- get: (path) => request("GET", path),
122
- post: (path, body) => request("POST", path, body ?? {}),
123
- put: (path, body) => request("PUT", path, body ?? {}),
124
- patch: (path, body) => request("PATCH", path, body ?? {}),
125
- del: (path) => request("DELETE", path),
122
+ get: (path, options) => request("GET", path, undefined, options),
123
+ post: (path, body, options) => request("POST", path, body ?? {}, options),
124
+ put: (path, body, options) => request("PUT", path, body ?? {}, options),
125
+ patch: (path, body, options) => request("PATCH", path, body ?? {}, options),
126
+ del: (path, options) => request("DELETE", path, undefined, options),
126
127
  };
127
128
  };
128
129
  /**
@@ -136,3 +137,30 @@ export const errorMessage = (error) => error instanceof Error ? error.message :
136
137
  * caller special-case 403 or 404 without an `instanceof` dance.
137
138
  */
138
139
  export const errorStatus = (error) => error instanceof ApiError ? error.status : 0;
140
+ const NETWORK_FAILURE = /failed to fetch|networkerror|load failed|fetch failed|network request failed|network connection|internet connection/i;
141
+ const statusOf = (error) => {
142
+ const status = asRecord(error)?.status;
143
+ return typeof status === "number" ? status : undefined;
144
+ };
145
+ const isNetworkFailure = (error, online) => {
146
+ const record = asRecord(error);
147
+ if (record?.name !== "TypeError")
148
+ return false;
149
+ if (online === false)
150
+ return true;
151
+ return typeof record.message === "string" && NETWORK_FAILURE.test(record.message);
152
+ };
153
+ export const classifyError = (error, online = typeof navigator === "undefined" ? undefined : navigator.onLine) => {
154
+ const status = statusOf(error);
155
+ if (status === 401)
156
+ return "unauthorized";
157
+ if (status === 403)
158
+ return "forbidden";
159
+ if (status === 404)
160
+ return "not-found";
161
+ if (status !== undefined && status >= 500 && status <= 599)
162
+ return "server";
163
+ if (status === undefined && isNetworkFailure(error, online))
164
+ return "offline";
165
+ return "unknown";
166
+ };
@@ -0,0 +1,45 @@
1
+ <script lang="ts" generics="L extends string">
2
+ import type { ClassValue } from "svelte/elements";
3
+ import SegmentedControl from "../components/SegmentedControl.svelte";
4
+ import type { I18n } from "./i18n.svelte.js";
5
+
6
+ interface Props {
7
+ /** The app's `I18n`. The toggle reads its locale and switches it; there is nothing to bind. */
8
+ i18n: I18n<L>;
9
+ /** Accessible name of the group — "Language". A prop because the library ships no translations. */
10
+ label: string;
11
+ /**
12
+ * What each button says. Defaults to the locale's id upper-cased, `PT` / `EN`, which is what a
13
+ * reader looking for their language scans for — a name in the current language would hide it
14
+ * from exactly the reader who cannot read that language.
15
+ */
16
+ labels?: Partial<Record<L, string>>;
17
+ /** `sm` fits a top bar or a sidebar footer; `md` is the size of the other segmented controls. */
18
+ size?: "sm" | "md";
19
+ class?: ClassValue;
20
+ }
21
+
22
+ let { i18n, label, labels = {}, size = "sm", class: className }: Props = $props();
23
+
24
+ const options = $derived(
25
+ i18n.locales.map((locale) => ({ id: locale, label: labels[locale] ?? locale.toUpperCase() })),
26
+ );
27
+ </script>
28
+
29
+ <!--
30
+ @component
31
+ The language switch: one button per locale the app's `I18n` offers, the current one pressed. It is a
32
+ `SegmentedControl` underneath, so it looks and behaves like every other few-way choice in the app.
33
+
34
+ ```svelte
35
+ <LocaleToggle {i18n} label={i18n.t("nav.language")} />
36
+ ```
37
+ -->
38
+ <SegmentedControl
39
+ {options}
40
+ value={i18n.locale}
41
+ {label}
42
+ {size}
43
+ class={className}
44
+ onchange={(locale) => i18n.setLocale(locale)}
45
+ />
@@ -0,0 +1,48 @@
1
+ import type { ClassValue } from "svelte/elements";
2
+ import type { I18n } from "./i18n.svelte.js";
3
+ declare function $$render<L extends string>(): {
4
+ props: {
5
+ /** The app's `I18n`. The toggle reads its locale and switches it; there is nothing to bind. */
6
+ i18n: I18n<L>;
7
+ /** Accessible name of the group — "Language". A prop because the library ships no translations. */
8
+ label: string;
9
+ /**
10
+ * What each button says. Defaults to the locale's id upper-cased, `PT` / `EN`, which is what a
11
+ * reader looking for their language scans for — a name in the current language would hide it
12
+ * from exactly the reader who cannot read that language.
13
+ */
14
+ labels?: Partial<Record<L, string>>;
15
+ /** `sm` fits a top bar or a sidebar footer; `md` is the size of the other segmented controls. */
16
+ size?: "sm" | "md";
17
+ class?: ClassValue;
18
+ };
19
+ exports: {};
20
+ bindings: "";
21
+ slots: {};
22
+ events: {};
23
+ };
24
+ declare class __sveltets_Render<L extends string> {
25
+ props(): ReturnType<typeof $$render<L>>['props'];
26
+ events(): ReturnType<typeof $$render<L>>['events'];
27
+ slots(): ReturnType<typeof $$render<L>>['slots'];
28
+ bindings(): "";
29
+ exports(): {};
30
+ }
31
+ interface $$IsomorphicComponent {
32
+ new <L extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<L>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<L>['props']>, ReturnType<__sveltets_Render<L>['events']>, ReturnType<__sveltets_Render<L>['slots']>> & {
33
+ $$bindings?: ReturnType<__sveltets_Render<L>['bindings']>;
34
+ } & ReturnType<__sveltets_Render<L>['exports']>;
35
+ <L extends string>(internal: unknown, props: ReturnType<__sveltets_Render<L>['props']> & {}): ReturnType<__sveltets_Render<L>['exports']>;
36
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
37
+ }
38
+ /**
39
+ * The language switch: one button per locale the app's `I18n` offers, the current one pressed. It is a
40
+ * `SegmentedControl` underneath, so it looks and behaves like every other few-way choice in the app.
41
+ *
42
+ * ```svelte
43
+ * <LocaleToggle {i18n} label={i18n.t("nav.language")} />
44
+ * ```
45
+ */
46
+ declare const LocaleToggle: $$IsomorphicComponent;
47
+ type LocaleToggle<L extends string> = InstanceType<typeof LocaleToggle<L>>;
48
+ export default LocaleToggle;
@@ -0,0 +1,56 @@
1
+ import { type Dictionary, type TranslationParams } from "./translate.js";
2
+ export interface I18nOptions<L extends string> {
3
+ /** Every locale's strings. The keys of this object are the locales the app offers. */
4
+ dictionaries: Readonly<Record<L, Dictionary>>;
5
+ /** The BCP 47 tag of each locale — what `Intl` and `<html lang>` are given. */
6
+ tags: Readonly<Record<L, string>>;
7
+ /**
8
+ * The app's primary locale. The server always renders it, a key missing from the current
9
+ * dictionary is looked up in it, and a reader whose browser asks for nothing on offer gets it
10
+ * unless {@link I18nOptions.unmatched} says otherwise.
11
+ */
12
+ fallback: L;
13
+ /**
14
+ * The locale for a browser whose languages match none on offer. Defaults to `fallback`; an app
15
+ * written for one country usually wants its international locale here instead, so a French
16
+ * reader lands on English rather than on Portuguese.
17
+ */
18
+ unmatched?: L;
19
+ /** Where the reader's pick is remembered across visits. `null` remembers nothing. */
20
+ storageKey?: string | null;
21
+ }
22
+ /**
23
+ * The locale an app is shown in, and the `t()` that reads from it.
24
+ *
25
+ * Build one per app, at module level, and import it wherever text is rendered: `locale` is
26
+ * `$state`, so every `t()` read in a template or a `$derived` re-renders when it changes.
27
+ *
28
+ * On the server the instance is shared by every request the process handles and there is no
29
+ * per-request place to keep a reader's language, so it renders the fallback locale and refuses to
30
+ * switch — a switch there would leak one visitor's language into the next one's page. The browser
31
+ * corrects the text during hydration, from the remembered pick or the browser's languages.
32
+ */
33
+ export declare class I18n<L extends string> {
34
+ #private;
35
+ /** The current locale. Switch it with {@link I18n.setLocale}, which also remembers the pick. */
36
+ locale: L;
37
+ /** Every locale on offer, in the order the dictionaries were declared. */
38
+ readonly locales: readonly L[];
39
+ constructor({ dictionaries, tags, fallback, unmatched, storageKey }: I18nOptions<L>);
40
+ /** The BCP 47 tag of the current locale, for `Intl` formatters and `toLocaleDateString`. */
41
+ get tag(): string;
42
+ /**
43
+ * The text for `key` in the current locale, with `{name}` placeholders filled from `params` and
44
+ * a plural form picked by `params.count`. A key the current dictionary lacks is read from the
45
+ * fallback one; a key neither has renders as itself — loud on screen, harmless otherwise.
46
+ *
47
+ * An arrow property rather than a method, so `const { t } = i18n` keeps working.
48
+ */
49
+ t: (key: string, params?: TranslationParams) => string;
50
+ /** Whether any dictionary has `key` — for text that is optional rather than missing. */
51
+ has: (key: string) => boolean;
52
+ /** Switches to `locale` and remembers it. Ignored on the server and for a locale not on offer. */
53
+ setLocale: (locale: L) => void;
54
+ }
55
+ /** An {@link I18n} for `options` — the one place an app declares its locales. */
56
+ export declare const createI18n: <L extends string>(options: I18nOptions<L>) => I18n<L>;