@viniaraujo68/plinth 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +361 -2
- package/dist/anchoring.d.ts +61 -0
- package/dist/anchoring.js +151 -0
- package/dist/async-action.svelte.d.ts +37 -0
- package/dist/async-action.svelte.js +64 -0
- package/dist/attachments/index.d.ts +2 -0
- package/dist/attachments/index.js +2 -0
- package/dist/attachments/sortable.d.ts +6 -0
- package/dist/attachments/sortable.js +227 -0
- package/dist/attachments/tooltip.d.ts +37 -0
- package/dist/attachments/tooltip.js +166 -0
- package/dist/components/AsyncButton.svelte +49 -0
- package/dist/components/AsyncButton.svelte.d.ts +32 -0
- package/dist/components/Combobox.svelte +380 -0
- package/dist/components/Combobox.svelte.d.ts +74 -0
- package/dist/components/Copyable.svelte +116 -0
- package/dist/components/Copyable.svelte.d.ts +37 -0
- package/dist/components/DateRangePicker.svelte +266 -0
- package/dist/components/DateRangePicker.svelte.d.ts +66 -0
- package/dist/components/Dialog.svelte +102 -0
- package/dist/components/Dialog.svelte.d.ts +38 -0
- package/dist/components/DragHandle.svelte +26 -0
- package/dist/components/DragHandle.svelte.d.ts +7 -0
- package/dist/components/Dropdown.svelte +204 -0
- package/dist/components/Dropdown.svelte.d.ts +49 -0
- package/dist/components/ErrorDisplay.svelte +59 -0
- package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
- package/dist/components/ErrorPage.svelte +119 -0
- package/dist/components/ErrorPage.svelte.d.ts +52 -0
- package/dist/components/JsonInput.svelte +106 -0
- package/dist/components/JsonInput.svelte.d.ts +40 -0
- package/dist/components/Listbox.svelte +257 -0
- package/dist/components/Listbox.svelte.d.ts +78 -0
- package/dist/components/LoadingButton.svelte +36 -0
- package/dist/components/LoadingButton.svelte.d.ts +22 -0
- package/dist/components/Logo.svelte +112 -0
- package/dist/components/Logo.svelte.d.ts +29 -0
- package/dist/components/Modal.svelte +187 -0
- package/dist/components/Modal.svelte.d.ts +62 -0
- package/dist/components/MultiSelect.svelte +256 -0
- package/dist/components/MultiSelect.svelte.d.ts +89 -0
- package/dist/components/OptionPanel.svelte +241 -0
- package/dist/components/OptionPanel.svelte.d.ts +51 -0
- package/dist/components/Paginator.svelte +205 -0
- package/dist/components/Paginator.svelte.d.ts +48 -0
- package/dist/components/RefreshButton.svelte +246 -0
- package/dist/components/RefreshButton.svelte.d.ts +46 -0
- package/dist/components/SegmentedControl.svelte +147 -0
- package/dist/components/SegmentedControl.svelte.d.ts +71 -0
- package/dist/components/Select.svelte +312 -0
- package/dist/components/Select.svelte.d.ts +68 -0
- package/dist/components/Skeleton.svelte +97 -0
- package/dist/components/Skeleton.svelte.d.ts +32 -0
- package/dist/components/Tooltip.svelte +60 -0
- package/dist/components/Tooltip.svelte.d.ts +58 -0
- package/dist/components/date-range.d.ts +101 -0
- package/dist/components/date-range.js +144 -0
- package/dist/components/index.d.ts +26 -0
- package/dist/components/index.js +38 -0
- package/dist/components/json-text.d.ts +22 -0
- package/dist/components/json-text.js +22 -0
- package/dist/components/pick.d.ts +18 -0
- package/dist/components/pick.js +18 -0
- package/dist/components/segmented.d.ts +7 -0
- package/dist/components/segmented.js +1 -0
- package/dist/components/select.d.ts +66 -0
- package/dist/components/select.js +90 -0
- package/dist/confirm/Confirmer.svelte +194 -0
- package/dist/confirm/Confirmer.svelte.d.ts +45 -0
- package/dist/confirm/challenge.d.ts +16 -0
- package/dist/confirm/challenge.js +16 -0
- package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
- package/dist/confirm/confirm-manager.svelte.js +149 -0
- package/dist/confirm/index.d.ts +3 -0
- package/dist/confirm/index.js +3 -0
- package/dist/error.d.ts +25 -0
- package/dist/error.js +39 -0
- package/dist/formatters.d.ts +95 -0
- package/dist/formatters.js +146 -0
- package/dist/http.d.ts +68 -0
- package/dist/http.js +138 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/routing.svelte.d.ts +169 -0
- package/dist/routing.svelte.js +208 -0
- package/dist/shell/AppShell.svelte +874 -0
- package/dist/shell/AppShell.svelte.d.ts +93 -0
- package/dist/shell/Breadcrumbs.svelte +78 -0
- package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
- package/dist/shell/crumbs.d.ts +23 -0
- package/dist/shell/crumbs.js +1 -0
- package/dist/shell/index.d.ts +3 -0
- package/dist/shell/index.js +6 -0
- package/dist/table/ColumnMenu.svelte +249 -0
- package/dist/table/ColumnMenu.svelte.d.ts +47 -0
- package/dist/table/DataTable.svelte +643 -0
- package/dist/table/DataTable.svelte.d.ts +153 -0
- package/dist/table/columns.d.ts +55 -0
- package/dist/table/columns.js +153 -0
- package/dist/table/index.d.ts +4 -0
- package/dist/table/index.js +8 -0
- package/dist/table/sort.d.ts +48 -0
- package/dist/table/sort.js +139 -0
- package/dist/table/types.d.ts +93 -0
- package/dist/table/types.js +1 -0
- package/dist/theme/ThemeController.svelte +54 -0
- package/dist/theme/ThemeController.svelte.d.ts +27 -0
- package/dist/theme/ThemeToggle.svelte +119 -0
- package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
- package/dist/theme/css-color.d.ts +23 -0
- package/dist/theme/css-color.js +31 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +5 -0
- package/dist/theme/theme.css +410 -0
- package/dist/theme/theme.svelte.d.ts +54 -0
- package/dist/theme/theme.svelte.js +58 -0
- package/dist/toast/Toaster.svelte +257 -0
- package/dist/toast/Toaster.svelte.d.ts +35 -0
- package/dist/toast/index.d.ts +2 -0
- package/dist/toast/index.js +2 -0
- package/dist/toast/toast-manager.svelte.d.ts +99 -0
- package/dist/toast/toast-manager.svelte.js +176 -0
- package/dist/user/ScopedComponent.svelte +47 -0
- package/dist/user/ScopedComponent.svelte.d.ts +29 -0
- package/dist/user/anonymous.d.ts +21 -0
- package/dist/user/anonymous.js +26 -0
- package/dist/user/context.d.ts +55 -0
- package/dist/user/context.js +13 -0
- package/dist/user/index.d.ts +3 -0
- package/dist/user/index.js +6 -0
- package/package.json +133 -4
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** A locale tag, or the priority list `Intl` walks when the first tag has no data. */
|
|
2
|
+
export type Locale = string | readonly string[];
|
|
3
|
+
/**
|
|
4
|
+
* What a formatter accepts as an instant: a `Date`, epoch milliseconds, or a string the platform
|
|
5
|
+
* `Date` parser understands — API payloads carry timestamps as strings far more often than as
|
|
6
|
+
* anything else.
|
|
7
|
+
*/
|
|
8
|
+
export type DateInput = Date | string | number;
|
|
9
|
+
/**
|
|
10
|
+
* What the numeric formatters accept. Strings are here because a backend that keeps money or
|
|
11
|
+
* rates in an exact decimal type serialises them as strings to avoid the float round trip.
|
|
12
|
+
* `bigint` is passed to `Intl` untouched, which is the only way it stays exact.
|
|
13
|
+
*/
|
|
14
|
+
export type NumericInput = number | bigint | string;
|
|
15
|
+
/**
|
|
16
|
+
* The three shapes the same amount takes across a UI:
|
|
17
|
+
*
|
|
18
|
+
* - `currency` — symbol and the currency's own decimals: `R$ 1.234,50`.
|
|
19
|
+
* - `plain` — the same digits without the symbol, for an input a user edits or a column whose
|
|
20
|
+
* header already names the currency.
|
|
21
|
+
* - `axis` — no decimals, for chart axis ticks and any other place where three characters of
|
|
22
|
+
* precision cost more room than they are worth.
|
|
23
|
+
*/
|
|
24
|
+
export type MoneyFormat = "currency" | "plain" | "axis";
|
|
25
|
+
/**
|
|
26
|
+
* Per-formatter `Intl` options merged over the defaults, fixed once for the whole factory.
|
|
27
|
+
*
|
|
28
|
+
* Precision and date style are app-wide decisions in the same way the locale is; making them
|
|
29
|
+
* per-call arguments instead would mean a cache keyed by whatever a caller passes.
|
|
30
|
+
*/
|
|
31
|
+
export interface FormatterOptions {
|
|
32
|
+
date?: Intl.DateTimeFormatOptions;
|
|
33
|
+
datetime?: Intl.DateTimeFormatOptions;
|
|
34
|
+
number?: Intl.NumberFormatOptions;
|
|
35
|
+
percent?: Intl.NumberFormatOptions;
|
|
36
|
+
relativeTime?: Intl.RelativeTimeFormatOptions;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Formats an instant, and passes a missing value through as `null`.
|
|
40
|
+
*
|
|
41
|
+
* The two signatures are what makes `{formatters.date(row.releasedAt) ?? "—"}` type-check without
|
|
42
|
+
* a guard at every call site: a value that cannot be null formats to a `string`, and only a
|
|
43
|
+
* nullable one widens the result.
|
|
44
|
+
*/
|
|
45
|
+
export interface DateFormatter {
|
|
46
|
+
(value: DateInput): string;
|
|
47
|
+
(value: DateInput | null | undefined): string | null;
|
|
48
|
+
}
|
|
49
|
+
/** Formats a number, and passes a missing value through as `null`. */
|
|
50
|
+
export interface NumberFormatter {
|
|
51
|
+
(value: NumericInput): string;
|
|
52
|
+
(value: NumericInput | null | undefined): string | null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Formats an amount in `code` (an ISO 4217 code: `"BRL"`, `"USD"`, `"JPY"`).
|
|
56
|
+
*
|
|
57
|
+
* The value is in whole currency units. Integer cents are an app's storage convention, not this
|
|
58
|
+
* library's: whoever holds the cents divides by 100.
|
|
59
|
+
*/
|
|
60
|
+
export interface CurrencyFormatter {
|
|
61
|
+
(value: NumericInput, code: string, format?: MoneyFormat): string;
|
|
62
|
+
(value: NumericInput | null | undefined, code: string, format?: MoneyFormat): string | null;
|
|
63
|
+
}
|
|
64
|
+
/** Formats the distance from `now` (default: the current time) to `value`. */
|
|
65
|
+
export interface RelativeTimeFormatter {
|
|
66
|
+
(value: DateInput, now?: DateInput): string;
|
|
67
|
+
(value: DateInput | null | undefined, now?: DateInput): string | null;
|
|
68
|
+
}
|
|
69
|
+
export interface Formatters {
|
|
70
|
+
date: DateFormatter;
|
|
71
|
+
datetime: DateFormatter;
|
|
72
|
+
number: NumberFormatter;
|
|
73
|
+
percent: NumberFormatter;
|
|
74
|
+
currency: CurrencyFormatter;
|
|
75
|
+
relativeTime: RelativeTimeFormatter;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Builds the six formatters for one locale.
|
|
79
|
+
*
|
|
80
|
+
* Nothing here decides the locale or the currency on its own. The app picks the locale once, at
|
|
81
|
+
* the factory; the currency code travels per call, so a single-currency app closes over one and a
|
|
82
|
+
* multi-currency one passes whatever the row holds.
|
|
83
|
+
*
|
|
84
|
+
* ```ts
|
|
85
|
+
* const fmt = createFormatters("pt-BR");
|
|
86
|
+
* fmt.currency(1234.5, "BRL"); // "R$ 1.234,50"
|
|
87
|
+
* fmt.currency(1234.5, "BRL", "axis"); // "R$ 1.235"
|
|
88
|
+
* fmt.relativeTime(sentAt); // "há 5 minutos"
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* The closure is the cache. `Intl` constructors are the expensive part — the `format` calls are
|
|
92
|
+
* cheap — so building the factory once at module scope of an app, or once per locale in a store,
|
|
93
|
+
* is the difference between resolving locale data a handful of times and doing it per row.
|
|
94
|
+
*/
|
|
95
|
+
export declare const createFormatters: (locale: Locale, options?: FormatterOptions) => Formatters;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const DEFAULT_DATE = { dateStyle: "short" };
|
|
2
|
+
const DEFAULT_DATETIME = { dateStyle: "short", timeStyle: "medium" };
|
|
3
|
+
const DEFAULT_NUMBER = {};
|
|
4
|
+
/** A ratio, not a 0-100 scale: `percent(0.125)` is `12.5%`. Two decimals is where rates stop. */
|
|
5
|
+
const DEFAULT_PERCENT = { style: "percent", maximumFractionDigits: 2 };
|
|
6
|
+
/**
|
|
7
|
+
* `auto` is what makes this a replacement for a hand-built "3 min ago" helper rather than a
|
|
8
|
+
* stiffer version of one: it spends the locale's own words where they exist — "yesterday",
|
|
9
|
+
* "last week", "now" — and falls back to the numeric phrasing everywhere else.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_RELATIVE_TIME = { numeric: "auto" };
|
|
12
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
13
|
+
/**
|
|
14
|
+
* A date-only string ("2026-08-29") parses as UTC midnight, so west of Greenwich it formats as
|
|
15
|
+
* the previous calendar day. Shifting it by the offset in effect *at that instant* — not today's
|
|
16
|
+
* offset, which would land dates on the far side of a DST change one day off — makes it local
|
|
17
|
+
* midnight, and it then formats as the day it names. Any other string already denotes an instant.
|
|
18
|
+
*/
|
|
19
|
+
const toDate = (value) => {
|
|
20
|
+
if (value instanceof Date)
|
|
21
|
+
return value;
|
|
22
|
+
if (typeof value === "number")
|
|
23
|
+
return new Date(value);
|
|
24
|
+
const parsed = new Date(value);
|
|
25
|
+
if (!DATE_ONLY.test(value))
|
|
26
|
+
return parsed;
|
|
27
|
+
return new Date(parsed.getTime() + parsed.getTimezoneOffset() * 60_000);
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* `Number`, not `parseFloat`: `parseFloat("12 apples")` is `12`, which puts a number on screen
|
|
31
|
+
* that nobody sent. A value that is not a number reaches `Intl` as `NaN` and renders as such,
|
|
32
|
+
* because an empty cell would hide the bug instead of showing it.
|
|
33
|
+
*/
|
|
34
|
+
const toNumeric = (value) => typeof value === "string" ? Number(value) : value;
|
|
35
|
+
/** An empty string is a missing value, not a zero and not an invalid date. */
|
|
36
|
+
const isMissing = (value) => value === null || value === undefined || value === "";
|
|
37
|
+
const SECOND = ["second", 1];
|
|
38
|
+
/**
|
|
39
|
+
* Seconds in each unit, largest first. Month and year are the average Gregorian ones: the unit is
|
|
40
|
+
* picked to phrase an approximation, so a February that is three days shorter changes nothing a
|
|
41
|
+
* reader could notice.
|
|
42
|
+
*/
|
|
43
|
+
const RELATIVE_UNITS = [
|
|
44
|
+
["year", 31_557_600],
|
|
45
|
+
["month", 2_629_800],
|
|
46
|
+
["week", 604_800],
|
|
47
|
+
["day", 86_400],
|
|
48
|
+
["hour", 3_600],
|
|
49
|
+
["minute", 60],
|
|
50
|
+
SECOND,
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Builds the six formatters for one locale.
|
|
54
|
+
*
|
|
55
|
+
* Nothing here decides the locale or the currency on its own. The app picks the locale once, at
|
|
56
|
+
* the factory; the currency code travels per call, so a single-currency app closes over one and a
|
|
57
|
+
* multi-currency one passes whatever the row holds.
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* const fmt = createFormatters("pt-BR");
|
|
61
|
+
* fmt.currency(1234.5, "BRL"); // "R$ 1.234,50"
|
|
62
|
+
* fmt.currency(1234.5, "BRL", "axis"); // "R$ 1.235"
|
|
63
|
+
* fmt.relativeTime(sentAt); // "há 5 minutos"
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* The closure is the cache. `Intl` constructors are the expensive part — the `format` calls are
|
|
67
|
+
* cheap — so building the factory once at module scope of an app, or once per locale in a store,
|
|
68
|
+
* is the difference between resolving locale data a handful of times and doing it per row.
|
|
69
|
+
*/
|
|
70
|
+
export const createFormatters = (locale, options = {}) => {
|
|
71
|
+
const dateFormat = new Intl.DateTimeFormat(locale, { ...DEFAULT_DATE, ...options.date });
|
|
72
|
+
const datetimeFormat = new Intl.DateTimeFormat(locale, {
|
|
73
|
+
...DEFAULT_DATETIME,
|
|
74
|
+
...options.datetime,
|
|
75
|
+
});
|
|
76
|
+
const numberFormat = new Intl.NumberFormat(locale, { ...DEFAULT_NUMBER, ...options.number });
|
|
77
|
+
const percentFormat = new Intl.NumberFormat(locale, { ...DEFAULT_PERCENT, ...options.percent });
|
|
78
|
+
const relativeTimeFormat = new Intl.RelativeTimeFormat(locale, {
|
|
79
|
+
...DEFAULT_RELATIVE_TIME,
|
|
80
|
+
...options.relativeTime,
|
|
81
|
+
});
|
|
82
|
+
// Currency is the one axis the factory cannot resolve up front, so it memoises what it meets.
|
|
83
|
+
// Bounded by the codes an app actually shows, times the three formats.
|
|
84
|
+
const moneyFormats = new Map();
|
|
85
|
+
const moneyFormat = (code, format) => {
|
|
86
|
+
const key = `${code}:${format}`;
|
|
87
|
+
const cached = moneyFormats.get(key);
|
|
88
|
+
if (cached)
|
|
89
|
+
return cached;
|
|
90
|
+
const built = buildMoneyFormat(code, format);
|
|
91
|
+
moneyFormats.set(key, built);
|
|
92
|
+
return built;
|
|
93
|
+
};
|
|
94
|
+
const buildMoneyFormat = (code, format) => {
|
|
95
|
+
const currencyOptions = { style: "currency", currency: code };
|
|
96
|
+
if (format === "currency")
|
|
97
|
+
return new Intl.NumberFormat(locale, currencyOptions);
|
|
98
|
+
if (format === "axis")
|
|
99
|
+
return new Intl.NumberFormat(locale, {
|
|
100
|
+
...currencyOptions,
|
|
101
|
+
minimumFractionDigits: 0,
|
|
102
|
+
maximumFractionDigits: 0,
|
|
103
|
+
});
|
|
104
|
+
// The symbol is dropped, but the currency still decides the digits: JPY has none, most have
|
|
105
|
+
// two, and a few have three. Asking the currency formatter what it resolved keeps that right
|
|
106
|
+
// for every code instead of hardcoding the two decimals of the currencies at hand.
|
|
107
|
+
const { minimumFractionDigits, maximumFractionDigits } = moneyFormat(code, "currency").resolvedOptions();
|
|
108
|
+
return new Intl.NumberFormat(locale, { minimumFractionDigits, maximumFractionDigits });
|
|
109
|
+
};
|
|
110
|
+
function date(value) {
|
|
111
|
+
if (isMissing(value))
|
|
112
|
+
return null;
|
|
113
|
+
return dateFormat.format(toDate(value));
|
|
114
|
+
}
|
|
115
|
+
function datetime(value) {
|
|
116
|
+
if (isMissing(value))
|
|
117
|
+
return null;
|
|
118
|
+
return datetimeFormat.format(toDate(value));
|
|
119
|
+
}
|
|
120
|
+
function number(value) {
|
|
121
|
+
if (isMissing(value))
|
|
122
|
+
return null;
|
|
123
|
+
return numberFormat.format(toNumeric(value));
|
|
124
|
+
}
|
|
125
|
+
function percent(value) {
|
|
126
|
+
if (isMissing(value))
|
|
127
|
+
return null;
|
|
128
|
+
return percentFormat.format(toNumeric(value));
|
|
129
|
+
}
|
|
130
|
+
function currency(value, code, format = "currency") {
|
|
131
|
+
if (isMissing(value))
|
|
132
|
+
return null;
|
|
133
|
+
return moneyFormat(code, format).format(toNumeric(value));
|
|
134
|
+
}
|
|
135
|
+
function relativeTime(value, now) {
|
|
136
|
+
if (isMissing(value))
|
|
137
|
+
return null;
|
|
138
|
+
const from = now === undefined ? Date.now() : toDate(now).getTime();
|
|
139
|
+
const seconds = (toDate(value).getTime() - from) / 1000;
|
|
140
|
+
const [unit, size] = RELATIVE_UNITS.find(([, span]) => Math.abs(seconds) >= span) ?? SECOND;
|
|
141
|
+
// Truncated, never rounded: an event 90 seconds old is "1 minute ago", not "2 minutes ago".
|
|
142
|
+
// Rounding up would also push 59.5 minutes into "60 minutes ago", a phrase no unit owns.
|
|
143
|
+
return relativeTimeFormat.format(Math.trunc(seconds / size), unit);
|
|
144
|
+
}
|
|
145
|
+
return { date, datetime, number, percent, currency, relativeTime };
|
|
146
|
+
};
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fetch wrapper, not a query layer. It has no cache, no polling and no request de-duplication:
|
|
3
|
+
* those belong to whatever the app already uses (TanStack Query, a `load` function, a plain
|
|
4
|
+
* `$effect`). What it does own is the part every hand-rolled client repeats — joining the base
|
|
5
|
+
* URL, sending and reading JSON, injecting auth, and turning a failed response into one typed
|
|
6
|
+
* error the whole app can catch.
|
|
7
|
+
*/
|
|
8
|
+
/** A failed HTTP response, after the body has been read and reduced to a message. */
|
|
9
|
+
export declare class ApiError extends Error {
|
|
10
|
+
readonly status: number;
|
|
11
|
+
/** Machine-readable backend error code, when the API sent one. */
|
|
12
|
+
readonly code: string | undefined;
|
|
13
|
+
constructor(status: number, message: string, code?: string);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The token for the `Authorization: Bearer` header. Async because the real sources are async:
|
|
17
|
+
* Supabase, Auth0 and Firebase all refresh an expired token inside this call.
|
|
18
|
+
*/
|
|
19
|
+
export type AuthTokenSource = () => string | Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Turns a failed response's parsed body into the message a user reads. The one genuinely
|
|
22
|
+
* app-specific piece: `{ detail }`, `{ error }` and `{ errors: [...] }` are all conventions, not
|
|
23
|
+
* standards, and localizing off an error code lives here too. Returning an empty string falls
|
|
24
|
+
* back to the built-in extraction.
|
|
25
|
+
*/
|
|
26
|
+
export type ErrorMessageParser = (status: number, body: unknown) => string;
|
|
27
|
+
export interface HttpClientOptions {
|
|
28
|
+
/** Prefix for every path: an absolute URL, or a same-origin prefix such as `/api`. */
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
/** `"include"` is the session-cookie case, where the browser holds the credential. */
|
|
31
|
+
credentials?: RequestCredentials;
|
|
32
|
+
auth?: AuthTokenSource;
|
|
33
|
+
parseError?: ErrorMessageParser;
|
|
34
|
+
}
|
|
35
|
+
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>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a client bound to one API.
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* // Session cookie, same-origin FastAPI behind a proxy.
|
|
47
|
+
* const api = createHttpClient({ baseUrl: "/api", credentials: "include" });
|
|
48
|
+
*
|
|
49
|
+
* // Bearer token from an async session, separate origin. `apiUrl` comes from whatever config
|
|
50
|
+
* // module the app already has: this package reads no environment of its own.
|
|
51
|
+
* const api = createHttpClient({
|
|
52
|
+
* baseUrl: apiUrl,
|
|
53
|
+
* auth: async () => (await supabase.auth.getSession()).data.session?.access_token ?? "",
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare const createHttpClient: ({ baseUrl, credentials, auth, parseError, }: HttpClientOptions) => HttpClient;
|
|
58
|
+
/**
|
|
59
|
+
* Message of a caught value. A `catch` binding is `unknown`, and narrowing it once here beats
|
|
60
|
+
* repeating the check at every call site; the `String(e)` fallback means a stray non-`Error`
|
|
61
|
+
* still reaches the user as text instead of `undefined`.
|
|
62
|
+
*/
|
|
63
|
+
export declare const errorMessage: (error: unknown) => string;
|
|
64
|
+
/**
|
|
65
|
+
* HTTP status of a caught value, or `0` for anything that did not come from the API — lets a
|
|
66
|
+
* caller special-case 403 or 404 without an `instanceof` dance.
|
|
67
|
+
*/
|
|
68
|
+
export declare const errorStatus: (error: unknown) => number;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fetch wrapper, not a query layer. It has no cache, no polling and no request de-duplication:
|
|
3
|
+
* those belong to whatever the app already uses (TanStack Query, a `load` function, a plain
|
|
4
|
+
* `$effect`). What it does own is the part every hand-rolled client repeats — joining the base
|
|
5
|
+
* URL, sending and reading JSON, injecting auth, and turning a failed response into one typed
|
|
6
|
+
* error the whole app can catch.
|
|
7
|
+
*/
|
|
8
|
+
/** A failed HTTP response, after the body has been read and reduced to a message. */
|
|
9
|
+
export class ApiError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
/** Machine-readable backend error code, when the API sent one. */
|
|
12
|
+
code;
|
|
13
|
+
constructor(status, message, code) {
|
|
14
|
+
super(message);
|
|
15
|
+
// Subclass names are erased by most bundlers' minifiers, and this one shows up in logs.
|
|
16
|
+
this.name = "ApiError";
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const joinUrl = (baseUrl, path) => {
|
|
22
|
+
const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
23
|
+
return path.startsWith("/") ? `${base}${path}` : `${base}/${path}`;
|
|
24
|
+
};
|
|
25
|
+
const nonEmpty = (value) => {
|
|
26
|
+
const trimmed = value?.trim();
|
|
27
|
+
return trimmed === "" ? undefined : trimmed;
|
|
28
|
+
};
|
|
29
|
+
const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
30
|
+
? value
|
|
31
|
+
: undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Best-effort message from an error body, walking the shapes the two reference backends produce:
|
|
34
|
+
* a bare string, `{ detail }` as a string or as a `{ code, message }` object, FastAPI's 422 list
|
|
35
|
+
* of `{ msg }`, and Go's `{ error }`. Anything it cannot read falls through to the status text,
|
|
36
|
+
* which is why an app with its own envelope supplies `parseError` instead of extending this.
|
|
37
|
+
*/
|
|
38
|
+
const extractMessage = (body) => {
|
|
39
|
+
if (typeof body === "string")
|
|
40
|
+
return nonEmpty(body);
|
|
41
|
+
if (Array.isArray(body))
|
|
42
|
+
return nonEmpty(body.map((entry) => extractMessage(entry) ?? "").join("; "));
|
|
43
|
+
const record = asRecord(body);
|
|
44
|
+
if (!record)
|
|
45
|
+
return undefined;
|
|
46
|
+
for (const key of ["message", "msg", "detail", "error"]) {
|
|
47
|
+
const found = extractMessage(record[key]);
|
|
48
|
+
if (found)
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
};
|
|
53
|
+
const extractCode = (body) => {
|
|
54
|
+
const record = asRecord(body);
|
|
55
|
+
const code = asRecord(record?.detail)?.code ?? record?.code;
|
|
56
|
+
return typeof code === "string" ? nonEmpty(code) : undefined;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Build a client bound to one API.
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* // Session cookie, same-origin FastAPI behind a proxy.
|
|
63
|
+
* const api = createHttpClient({ baseUrl: "/api", credentials: "include" });
|
|
64
|
+
*
|
|
65
|
+
* // Bearer token from an async session, separate origin. `apiUrl` comes from whatever config
|
|
66
|
+
* // module the app already has: this package reads no environment of its own.
|
|
67
|
+
* const api = createHttpClient({
|
|
68
|
+
* baseUrl: apiUrl,
|
|
69
|
+
* auth: async () => (await supabase.auth.getSession()).data.session?.access_token ?? "",
|
|
70
|
+
* });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export const createHttpClient = ({ baseUrl, credentials, auth, parseError, }) => {
|
|
74
|
+
const request = async (method, path, body) => {
|
|
75
|
+
const headers = {};
|
|
76
|
+
// GET and DELETE carry no body, and a `Content-Type` on them makes some CORS setups
|
|
77
|
+
// preflight a request that would otherwise be simple.
|
|
78
|
+
if (body !== undefined)
|
|
79
|
+
headers["Content-Type"] = "application/json";
|
|
80
|
+
if (auth) {
|
|
81
|
+
const token = await auth();
|
|
82
|
+
// An empty token means "not signed in yet": sending `Bearer ` would turn a 401 the app can
|
|
83
|
+
// reason about into a malformed-header 400 from the gateway.
|
|
84
|
+
if (token)
|
|
85
|
+
headers.Authorization = `Bearer ${token}`;
|
|
86
|
+
}
|
|
87
|
+
const response = await fetch(joinUrl(baseUrl, path), {
|
|
88
|
+
method,
|
|
89
|
+
headers,
|
|
90
|
+
credentials,
|
|
91
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
92
|
+
});
|
|
93
|
+
// Read as text first: 204 and most DELETEs answer with nothing at all, and `response.json()`
|
|
94
|
+
// throws on an empty body. A non-JSON body (an HTML error page from a proxy) is kept as the
|
|
95
|
+
// string it is, so the message extraction below still has something to show.
|
|
96
|
+
const text = await response.text();
|
|
97
|
+
let parsed = undefined;
|
|
98
|
+
if (text)
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(text);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
parsed = text;
|
|
104
|
+
}
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const message = nonEmpty(parseError?.(response.status, parsed)) ??
|
|
107
|
+
extractMessage(parsed) ??
|
|
108
|
+
nonEmpty(response.statusText) ??
|
|
109
|
+
`HTTP ${response.status}`;
|
|
110
|
+
throw new ApiError(response.status, message, extractCode(parsed));
|
|
111
|
+
}
|
|
112
|
+
// `undefined` for an empty body, never `null`: JSON `null` is a value a server can legitimately
|
|
113
|
+
// send, and collapsing the two would hide the difference from the caller's type.
|
|
114
|
+
return parsed;
|
|
115
|
+
};
|
|
116
|
+
// `body` is `unknown` rather than a structural JSON type on purpose: a TypeScript interface has
|
|
117
|
+
// no implicit index signature, so the app's own DTO interfaces would fail to satisfy one.
|
|
118
|
+
// A body always goes out, even when the caller passes none — a bodyless POST is a 422 against a
|
|
119
|
+
// FastAPI endpoint that declares a model, and `{}` is the harmless stand-in both references use.
|
|
120
|
+
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),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Message of a caught value. A `catch` binding is `unknown`, and narrowing it once here beats
|
|
130
|
+
* repeating the check at every call site; the `String(e)` fallback means a stray non-`Error`
|
|
131
|
+
* still reaches the user as text instead of `undefined`.
|
|
132
|
+
*/
|
|
133
|
+
export const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
134
|
+
/**
|
|
135
|
+
* HTTP status of a caught value, or `0` for anything that did not come from the API — lets a
|
|
136
|
+
* caller special-case 403 or 404 without an `instanceof` dance.
|
|
137
|
+
*/
|
|
138
|
+
export const errorStatus = (error) => error instanceof ApiError ? error.status : 0;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published version of this package.
|
|
3
|
+
*
|
|
4
|
+
* Declared as a literal instead of being read back from `package.json`: the packaged output has
|
|
5
|
+
* no portable way to import its own manifest, and a consumer that only wants to log a version
|
|
6
|
+
* should not have to bundle one. `version.spec.ts` fails when this drifts from the manifest.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LIBRARY_VERSION = "0.3.0";
|
|
9
|
+
export { AsyncAction, type AsyncActionStatus, type AsyncFn } from "./async-action.svelte.js";
|
|
10
|
+
export { reportError, setErrorReporter, type ErrorReporter } from "./error.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published version of this package.
|
|
3
|
+
*
|
|
4
|
+
* Declared as a literal instead of being read back from `package.json`: the packaged output has
|
|
5
|
+
* no portable way to import its own manifest, and a consumer that only wants to log a version
|
|
6
|
+
* should not have to bundle one. `version.spec.ts` fails when this drifts from the manifest.
|
|
7
|
+
*/
|
|
8
|
+
export const LIBRARY_VERSION = "0.3.0";
|
|
9
|
+
// The root entry carries only what is not tied to one area: the error channel an app installs
|
|
10
|
+
// once, and the async state machine the components are built on. Everything visual is reached
|
|
11
|
+
// through a subpath — `/theme`, `/attachments`, `/components`, `/toast` — so importing a button
|
|
12
|
+
// never drags the toast queue into the bundle.
|
|
13
|
+
export { AsyncAction } from "./async-action.svelte.js";
|
|
14
|
+
export { reportError, setErrorReporter } from "./error.js";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything a dynamic title is allowed to depend on.
|
|
3
|
+
*
|
|
4
|
+
* Kept as an object rather than a positional argument so a later addition — the query string,
|
|
5
|
+
* say — is not a breaking change for every title function an app already wrote.
|
|
6
|
+
*/
|
|
7
|
+
export interface TitleContext {
|
|
8
|
+
/** Params of the current match, already decoded by SvelteKit. */
|
|
9
|
+
params: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What the app attaches to one SvelteKit route id.
|
|
13
|
+
*
|
|
14
|
+
* There is no `children` field and there never will be: the tree is the filesystem, and
|
|
15
|
+
* SvelteKit has already matched it by the time this map is read. Re-declaring the hierarchy here
|
|
16
|
+
* is exactly the duplication the pattern exists to remove.
|
|
17
|
+
*
|
|
18
|
+
* An entry without a `title` is plumbing — a segment that has to exist so its descendants can
|
|
19
|
+
* resolve, but that no user should ever see. It still travels through `matched` and `children`
|
|
20
|
+
* carrying its `requiredRoles`; renderers drop it by testing `meta.title`.
|
|
21
|
+
*/
|
|
22
|
+
export interface RouteMeta {
|
|
23
|
+
title?: string | ((context: TitleContext) => string);
|
|
24
|
+
/** Icon class for nav chrome. The library never parses it — it is handed straight to markup. */
|
|
25
|
+
icon?: string;
|
|
26
|
+
/** Flat role names. Whoever owns the user context decides what a role means. */
|
|
27
|
+
requiredRoles?: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The config an app hands to {@link RoutingContext}.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* import type { RouteId } from "$app/types";
|
|
34
|
+
* import type { RoutingConfig } from "@viniaraujo68/plinth/routing";
|
|
35
|
+
*
|
|
36
|
+
* export const routes: RoutingConfig<RouteId> = {
|
|
37
|
+
* meta: { "/": { title: "Home" }, "/orders": { title: "Orders" } },
|
|
38
|
+
* pages: import.meta.glob("/src/routes/**\/+page.svelte"),
|
|
39
|
+
* };
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export interface RoutingConfig<Id extends string = string> {
|
|
43
|
+
/**
|
|
44
|
+
* Presentation and access data per route id.
|
|
45
|
+
*
|
|
46
|
+
* Key it with `RouteId` from `$app/types` and a renamed or deleted route becomes a compile
|
|
47
|
+
* error instead of a silently missing crumb.
|
|
48
|
+
*
|
|
49
|
+
* Two structural facts are read back out of this object rather than declared twice: a route
|
|
50
|
+
* id spells its own ancestry in its segments, and key insertion order — which the language
|
|
51
|
+
* guarantees for non-numeric string keys — is sibling order. That is all `children` needs.
|
|
52
|
+
*/
|
|
53
|
+
meta: Partial<Record<Id, RouteMeta>>;
|
|
54
|
+
/**
|
|
55
|
+
* The raw result of `import.meta.glob("/src/routes/**\/+page.svelte")`, whose keys say which
|
|
56
|
+
* ids answer a direct visit.
|
|
57
|
+
*
|
|
58
|
+
* It has to be spelled out at the app's own source location: Vite rewrites the literal call
|
|
59
|
+
* site at build time, so a library that tried to issue the glob on the app's behalf would
|
|
60
|
+
* glob its own `dist` and find nothing. The values are never read — only the keys — which is
|
|
61
|
+
* why the type is `unknown` and why a lazy glob costs nothing.
|
|
62
|
+
*/
|
|
63
|
+
pages: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* One route id resolved through the meta map — the single shape every navigational consumer
|
|
67
|
+
* reads, whether it came from `matched` or from `children`.
|
|
68
|
+
*/
|
|
69
|
+
export interface ResolvedRoute {
|
|
70
|
+
/** The route id, still in bracket notation. */
|
|
71
|
+
routeId: string;
|
|
72
|
+
/** Resolved title, or `""` for a plumbing entry. */
|
|
73
|
+
label: string;
|
|
74
|
+
/** Concrete pathname, params substituted and group segments dropped. Carries no base path —
|
|
75
|
+
* put it through {@link resolvePathname} to get an href. */
|
|
76
|
+
pathname: string;
|
|
77
|
+
/** Whether a real `+page.svelte` backs this id. An entry without it is a heading, not a link. */
|
|
78
|
+
navigable: boolean;
|
|
79
|
+
meta: RouteMeta;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The reactive match {@link RoutingContext} projects.
|
|
83
|
+
*
|
|
84
|
+
* Structural on purpose. The library never imports `$app/state`, so it never assumes a
|
|
85
|
+
* SvelteKit runtime: an app passes `page`, and a test passes any object with the same three
|
|
86
|
+
* fields. That is the whole seam.
|
|
87
|
+
*/
|
|
88
|
+
export interface RoutingSource {
|
|
89
|
+
readonly route: {
|
|
90
|
+
readonly id: string | null;
|
|
91
|
+
};
|
|
92
|
+
readonly params: Record<string, string>;
|
|
93
|
+
readonly url: URL;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Reactive view over SvelteKit's own match, projected through a flat metadata map.
|
|
97
|
+
*
|
|
98
|
+
* It resolves nothing by itself — no URL parsing, no pattern matching, no ranking. SvelteKit
|
|
99
|
+
* already decided which route the user is on; this class only answers "what does the app want
|
|
100
|
+
* displayed and gated for that decision, and for its ancestors".
|
|
101
|
+
*/
|
|
102
|
+
export declare class RoutingContext<Id extends string = string> {
|
|
103
|
+
private readonly config;
|
|
104
|
+
/** Every id with a real page, computed once — the filesystem cannot change while the tab is
|
|
105
|
+
* open, so this is the one part of the class that has no reason to be reactive. */
|
|
106
|
+
private readonly navigableIds;
|
|
107
|
+
/** The current URL, straight off the source. Nav chrome reads it for things the route id does
|
|
108
|
+
* not carry, such as the query string or the host. */
|
|
109
|
+
url: URL;
|
|
110
|
+
/** The matched route id, or `null` when nothing matched — a 404, or the first paint of an
|
|
111
|
+
* error page. */
|
|
112
|
+
routeId: string | null;
|
|
113
|
+
/** Decoded params of the current match. */
|
|
114
|
+
params: Record<string, string>;
|
|
115
|
+
/**
|
|
116
|
+
* The declared prefixes of the current match, root first.
|
|
117
|
+
*
|
|
118
|
+
* Every ancestor that has a meta entry is here, plumbing included, so a gate declared halfway
|
|
119
|
+
* up the tree stays reachable (`matched.findLast((r) => r.meta.requiredRoles)`). Prefixes with
|
|
120
|
+
* no entry contribute nothing and are skipped rather than faked. Empty when `routeId` is null.
|
|
121
|
+
*/
|
|
122
|
+
matched: ResolvedRoute[];
|
|
123
|
+
constructor(config: RoutingConfig<Id>, source: RoutingSource);
|
|
124
|
+
/**
|
|
125
|
+
* The direct declared children of a route id, in the order the meta map declares them.
|
|
126
|
+
*
|
|
127
|
+
* Plumbing children come back too — a caller may want to recurse past them or read their
|
|
128
|
+
* gates — so a renderer that only wants visible entries filters on `meta.title`. Labels and
|
|
129
|
+
* pathnames resolve against the current params, which only shows up when a child is itself a
|
|
130
|
+
* capture and the user is standing on it.
|
|
131
|
+
*/
|
|
132
|
+
children(routeId: string): ResolvedRoute[];
|
|
133
|
+
/**
|
|
134
|
+
* The title of any declared route id, `""` when it is plumbing or unknown.
|
|
135
|
+
*
|
|
136
|
+
* A dynamic title is evaluated against the params of the *current* match, so asking about a
|
|
137
|
+
* capture route the user is not on hands it empty strings. Reading the label off `matched` or
|
|
138
|
+
* `children` is the normal path; this is for the odd one-off lookup.
|
|
139
|
+
*/
|
|
140
|
+
label(routeId: Id): string;
|
|
141
|
+
/** The single place a runtime string is narrowed to the app's route-id union. Every lookup in
|
|
142
|
+
* this class goes through it so the cast exists once instead of at four call sites. */
|
|
143
|
+
private metaFor;
|
|
144
|
+
private resolve;
|
|
145
|
+
/**
|
|
146
|
+
* Warn once, in dev, about the two ways the map and the filesystem drift apart.
|
|
147
|
+
*
|
|
148
|
+
* Typed keys already catch the common case at compile time. What they cannot catch is a page
|
|
149
|
+
* added without a meta entry, or a plumbing entry left behind after the last route under it
|
|
150
|
+
* was deleted — neither is a type error, and both are invisible until a crumb or a nav item
|
|
151
|
+
* quietly stops appearing.
|
|
152
|
+
*/
|
|
153
|
+
private reportDrift;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Turn a pathname computed at runtime into an href that respects the configured base path.
|
|
157
|
+
*
|
|
158
|
+
* `$app/paths`' `resolve` is typed as one overload per literal route in the app, so no value
|
|
159
|
+
* produced at runtime can ever satisfy it. This is where that gets acknowledged, once, instead
|
|
160
|
+
* of a cast at every nav item and crumb.
|
|
161
|
+
*/
|
|
162
|
+
export declare const resolvePathname: (pathname: string) => string;
|
|
163
|
+
export declare const getRoutingContext: () => RoutingContext<string>, setRoutingContext: (context: RoutingContext<string>) => RoutingContext<string>;
|
|
164
|
+
/**
|
|
165
|
+
* A copy of `url` with some search parameters set and others removed — `null` or `undefined`
|
|
166
|
+
* removes. It never edits the URL it was given, which is usually the page store's own and is read
|
|
167
|
+
* by everything else on the page.
|
|
168
|
+
*/
|
|
169
|
+
export declare const withSearchParams: (url: URL, updates: Record<string, string | null | undefined>) => URL;
|