@viniaraujo68/plinth 0.3.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +197 -19
  2. package/dist/components/DateRangePicker.svelte +93 -57
  3. package/dist/components/DateRangePicker.svelte.d.ts +32 -15
  4. package/dist/components/ErrorDisplay.svelte +30 -16
  5. package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
  6. package/dist/components/JsonView.svelte +43 -0
  7. package/dist/components/JsonView.svelte.d.ts +4 -0
  8. package/dist/components/JsonViewFrame.svelte +654 -0
  9. package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
  10. package/dist/components/Paginator.svelte +34 -8
  11. package/dist/components/Paginator.svelte.d.ts +10 -0
  12. package/dist/components/RequestErrorDisplay.svelte +43 -0
  13. package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
  14. package/dist/components/Tabs.svelte +204 -0
  15. package/dist/components/Tabs.svelte.d.ts +76 -0
  16. package/dist/components/VirtualJsonView.svelte +67 -0
  17. package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
  18. package/dist/components/date-range.d.ts +82 -14
  19. package/dist/components/date-range.js +86 -5
  20. package/dist/components/index.d.ts +9 -2
  21. package/dist/components/index.js +15 -9
  22. package/dist/components/json-view.d.ts +70 -0
  23. package/dist/components/json-view.js +302 -0
  24. package/dist/components/request-error.d.ts +8 -0
  25. package/dist/components/request-error.js +55 -0
  26. package/dist/components/tabs.d.ts +5 -0
  27. package/dist/components/tabs.js +1 -0
  28. package/dist/http.d.ts +10 -5
  29. package/dist/http.js +34 -6
  30. package/dist/i18n/LocaleToggle.svelte +45 -0
  31. package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
  32. package/dist/i18n/i18n.svelte.d.ts +56 -0
  33. package/dist/i18n/i18n.svelte.js +94 -0
  34. package/dist/i18n/index.d.ts +3 -0
  35. package/dist/i18n/index.js +7 -0
  36. package/dist/i18n/translate.d.ts +30 -0
  37. package/dist/i18n/translate.js +38 -0
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/redirect.d.ts +40 -0
  41. package/dist/redirect.js +59 -0
  42. package/dist/routing.svelte.d.ts +1 -0
  43. package/dist/routing.svelte.js +3 -0
  44. package/dist/shell/AppShell.svelte +265 -93
  45. package/dist/shell/AppShell.svelte.d.ts +3 -2
  46. package/dist/shell/NavigationProgress.svelte +85 -0
  47. package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
  48. package/dist/shell/TopBar.svelte +191 -0
  49. package/dist/shell/TopBar.svelte.d.ts +41 -0
  50. package/dist/shell/UserAvatar.svelte +50 -0
  51. package/dist/shell/UserAvatar.svelte.d.ts +14 -0
  52. package/dist/shell/index.d.ts +2 -0
  53. package/dist/shell/index.js +7 -3
  54. package/dist/shell/initials.d.ts +6 -0
  55. package/dist/shell/initials.js +13 -0
  56. package/dist/table/DataTable.svelte +199 -9
  57. package/dist/table/DataTable.svelte.d.ts +28 -5
  58. package/dist/table/FilterBar.svelte +417 -0
  59. package/dist/table/FilterBar.svelte.d.ts +34 -0
  60. package/dist/table/FilterRuleEditor.svelte +290 -0
  61. package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
  62. package/dist/table/filter-labels.d.ts +23 -0
  63. package/dist/table/filter-labels.js +30 -0
  64. package/dist/table/filter.d.ts +27 -0
  65. package/dist/table/filter.js +279 -0
  66. package/dist/table/index.d.ts +4 -1
  67. package/dist/table/index.js +5 -2
  68. package/dist/table/types.d.ts +49 -0
  69. package/dist/toast/Toaster.svelte +7 -3
  70. package/dist/toast/index.d.ts +1 -1
  71. package/dist/toast/toast-manager.svelte.d.ts +29 -1
  72. package/dist/toast/toast-manager.svelte.js +61 -1
  73. package/dist/url-state/codec.d.ts +15 -0
  74. package/dist/url-state/codec.js +1 -0
  75. package/dist/url-state/index.d.ts +5 -0
  76. package/dist/url-state/index.js +4 -0
  77. package/dist/url-state/params-codec.d.ts +73 -0
  78. package/dist/url-state/params-codec.js +116 -0
  79. package/dist/url-state/search-params.d.ts +4 -0
  80. package/dist/url-state/search-params.js +28 -0
  81. package/dist/url-state/synced-state.svelte.d.ts +20 -0
  82. package/dist/url-state/synced-state.svelte.js +88 -0
  83. package/dist/url-state/url-store.svelte.d.ts +12 -0
  84. package/dist/url-state/url-store.svelte.js +56 -0
  85. package/dist/virtual/DataList.svelte +215 -0
  86. package/dist/virtual/DataList.svelte.d.ts +52 -0
  87. package/dist/virtual/VirtualList.svelte +150 -0
  88. package/dist/virtual/VirtualList.svelte.d.ts +42 -0
  89. package/dist/virtual/index.d.ts +7 -0
  90. package/dist/virtual/index.js +6 -0
  91. package/dist/virtual/keyed-cache.d.ts +7 -0
  92. package/dist/virtual/keyed-cache.js +20 -0
  93. package/dist/virtual/labels.d.ts +12 -0
  94. package/dist/virtual/labels.js +6 -0
  95. package/dist/virtual/paging.d.ts +5 -0
  96. package/dist/virtual/paging.js +11 -0
  97. package/dist/virtual/range.d.ts +9 -0
  98. package/dist/virtual/range.js +31 -0
  99. package/dist/virtual/size-tree.d.ts +10 -0
  100. package/dist/virtual/size-tree.js +56 -0
  101. package/dist/virtual/virtualizer.svelte.d.ts +38 -0
  102. package/dist/virtual/virtualizer.svelte.js +295 -0
  103. package/dist/virtual/windowed-data.svelte.d.ts +38 -0
  104. package/dist/virtual/windowed-data.svelte.js +139 -0
  105. package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
  106. package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
  107. package/package.json +13 -1
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>;
@@ -0,0 +1,94 @@
1
+ import { interpolate, matchLocale, pickPhrase, } from "./translate.js";
2
+ /**
3
+ * The locale an app is shown in, and the `t()` that reads from it.
4
+ *
5
+ * Build one per app, at module level, and import it wherever text is rendered: `locale` is
6
+ * `$state`, so every `t()` read in a template or a `$derived` re-renders when it changes.
7
+ *
8
+ * On the server the instance is shared by every request the process handles and there is no
9
+ * per-request place to keep a reader's language, so it renders the fallback locale and refuses to
10
+ * switch — a switch there would leak one visitor's language into the next one's page. The browser
11
+ * corrects the text during hydration, from the remembered pick or the browser's languages.
12
+ */
13
+ export class I18n {
14
+ /** The current locale. Switch it with {@link I18n.setLocale}, which also remembers the pick. */
15
+ locale = $state();
16
+ /** Every locale on offer, in the order the dictionaries were declared. */
17
+ locales;
18
+ #dictionaries;
19
+ #tags;
20
+ #fallback;
21
+ #storageKey;
22
+ constructor({ dictionaries, tags, fallback, unmatched, storageKey = null }) {
23
+ this.#dictionaries = dictionaries;
24
+ this.#tags = tags;
25
+ this.#fallback = fallback;
26
+ this.#storageKey = storageKey;
27
+ this.locales = Object.keys(dictionaries);
28
+ this.locale = this.#initial(unmatched ?? fallback);
29
+ this.#applyLang();
30
+ }
31
+ /** The BCP 47 tag of the current locale, for `Intl` formatters and `toLocaleDateString`. */
32
+ get tag() {
33
+ return this.#tags[this.locale] ?? this.#tags[this.#fallback];
34
+ }
35
+ /**
36
+ * The text for `key` in the current locale, with `{name}` placeholders filled from `params` and
37
+ * a plural form picked by `params.count`. A key the current dictionary lacks is read from the
38
+ * fallback one; a key neither has renders as itself — loud on screen, harmless otherwise.
39
+ *
40
+ * An arrow property rather than a method, so `const { t } = i18n` keeps working.
41
+ */
42
+ t = (key, params) => {
43
+ const phrase = this.#dictionaries[this.locale]?.[key] ?? this.#dictionaries[this.#fallback]?.[key];
44
+ return phrase === undefined ? key : interpolate(pickPhrase(phrase, params), params);
45
+ };
46
+ /** Whether any dictionary has `key` — for text that is optional rather than missing. */
47
+ has = (key) => key in (this.#dictionaries[this.locale] ?? {}) ||
48
+ key in (this.#dictionaries[this.#fallback] ?? {});
49
+ /** Switches to `locale` and remembers it. Ignored on the server and for a locale not on offer. */
50
+ setLocale = (locale) => {
51
+ if (typeof window === "undefined" || !this.locales.includes(locale))
52
+ return;
53
+ this.locale = locale;
54
+ this.#remember(locale);
55
+ this.#applyLang();
56
+ };
57
+ #initial(unmatched) {
58
+ if (typeof window === "undefined")
59
+ return this.#fallback;
60
+ const saved = this.#remembered();
61
+ if (saved)
62
+ return saved;
63
+ const languages = navigator.languages?.length ? navigator.languages : [navigator.language];
64
+ return matchLocale(languages.filter(Boolean), this.#tags) ?? unmatched;
65
+ }
66
+ #remembered() {
67
+ if (!this.#storageKey)
68
+ return null;
69
+ try {
70
+ const saved = localStorage.getItem(this.#storageKey);
71
+ return saved && this.locales.includes(saved) ? saved : null;
72
+ }
73
+ catch {
74
+ // Storage blocked: fall through to the browser's languages.
75
+ return null;
76
+ }
77
+ }
78
+ #remember(locale) {
79
+ if (!this.#storageKey)
80
+ return;
81
+ try {
82
+ localStorage.setItem(this.#storageKey, locale);
83
+ }
84
+ catch {
85
+ // Storage blocked, as in some private modes: the pick still holds for this visit.
86
+ }
87
+ }
88
+ #applyLang() {
89
+ if (typeof document !== "undefined")
90
+ document.documentElement.lang = this.tag;
91
+ }
92
+ }
93
+ /** An {@link I18n} for `options` — the one place an app declares its locales. */
94
+ export const createI18n = (options) => new I18n(options);
@@ -0,0 +1,3 @@
1
+ export { createI18n, I18n, type I18nOptions } from "./i18n.svelte.js";
2
+ export { default as LocaleToggle } from "./LocaleToggle.svelte";
3
+ export { interpolate, matchLocale, pickPhrase, type Dictionary, type Phrase, type TranslationParams, } from "./translate.js";
@@ -0,0 +1,7 @@
1
+ // The app's own strings in more than one language: the current locale as state, a `t()` that reads
2
+ // it, and the toggle that switches it. The library still ships no translations of its own — its
3
+ // components take every word through props — so this is for the app's text, and those props are
4
+ // where its `t()` calls go. `@viniaraujo68/plinth/i18n`.
5
+ export { createI18n, I18n } from "./i18n.svelte.js";
6
+ export { default as LocaleToggle } from "./LocaleToggle.svelte";
7
+ export { interpolate, matchLocale, pickPhrase, } from "./translate.js";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A dictionary value: plain text, or a pair picked by the `count` parameter.
3
+ *
4
+ * Two forms only, `one` for a count of exactly 1 and `other` for everything else. That is
5
+ * deliberately not CLDR: `Intl.PluralRules` files 0 under "one" in Portuguese, which would print
6
+ * "0 dia" where a Brazilian reader expects "0 dias". A language that needs more forms than two is
7
+ * the point at which a real i18n library earns its weight.
8
+ */
9
+ export type Phrase = string | {
10
+ one: string;
11
+ other: string;
12
+ };
13
+ /** One locale's strings, flat and dot-namespaced: `"nav.login": "Entrar"`. */
14
+ export type Dictionary = Readonly<Record<string, Phrase>>;
15
+ /** What a `{name}` placeholder is filled with. `count` also picks a plural form. */
16
+ export type TranslationParams = Readonly<Record<string, string | number | null | undefined>>;
17
+ /** The form of `phrase` for `params`: the text itself, or the `one`/`other` the count picks. */
18
+ export declare const pickPhrase: (phrase: Phrase, params?: TranslationParams) => string;
19
+ /**
20
+ * `text` with each `{name}` replaced by `params.name`. A placeholder with no value — absent,
21
+ * `null` or `undefined` — is left as written, so a missing parameter shows up on screen instead of
22
+ * printing "undefined" into a sentence.
23
+ */
24
+ export declare const interpolate: (text: string, params?: TranslationParams) => string;
25
+ /**
26
+ * Which of `locales` a reader's browser asks for. `languages` is `navigator.languages`, most
27
+ * preferred first: each is matched against the locale tags exactly, then by language alone, so a
28
+ * browser in `pt-PT` still lands on a `pt-BR` locale. `null` when none of them matches.
29
+ */
30
+ export declare const matchLocale: <L extends string>(languages: readonly string[], tags: Readonly<Record<L, string>>) => L | null;
@@ -0,0 +1,38 @@
1
+ /** The form of `phrase` for `params`: the text itself, or the `one`/`other` the count picks. */
2
+ export const pickPhrase = (phrase, params) => {
3
+ if (typeof phrase === "string")
4
+ return phrase;
5
+ return params?.count === 1 ? phrase.one : phrase.other;
6
+ };
7
+ /**
8
+ * `text` with each `{name}` replaced by `params.name`. A placeholder with no value — absent,
9
+ * `null` or `undefined` — is left as written, so a missing parameter shows up on screen instead of
10
+ * printing "undefined" into a sentence.
11
+ */
12
+ export const interpolate = (text, params) => {
13
+ if (!params)
14
+ return text;
15
+ return text.replace(/\{(\w+)\}/g, (placeholder, name) => {
16
+ const value = params[name];
17
+ return value === null || value === undefined ? placeholder : String(value);
18
+ });
19
+ };
20
+ /**
21
+ * Which of `locales` a reader's browser asks for. `languages` is `navigator.languages`, most
22
+ * preferred first: each is matched against the locale tags exactly, then by language alone, so a
23
+ * browser in `pt-PT` still lands on a `pt-BR` locale. `null` when none of them matches.
24
+ */
25
+ export const matchLocale = (languages, tags) => {
26
+ const entries = Object.entries(tags);
27
+ const primary = (tag) => tag.toLowerCase().split("-")[0];
28
+ for (const language of languages) {
29
+ const wanted = language.toLowerCase();
30
+ const exact = entries.find(([, tag]) => tag.toLowerCase() === wanted);
31
+ if (exact)
32
+ return exact[0];
33
+ const loose = entries.find(([, tag]) => primary(tag) === primary(wanted));
34
+ if (loose)
35
+ return loose[0];
36
+ }
37
+ return null;
38
+ };
package/dist/index.d.ts CHANGED
@@ -5,6 +5,6 @@
5
5
  * no portable way to import its own manifest, and a consumer that only wants to log a version
6
6
  * should not have to bundle one. `version.spec.ts` fails when this drifts from the manifest.
7
7
  */
8
- export declare const LIBRARY_VERSION = "0.3.1";
8
+ export declare const LIBRARY_VERSION = "0.6.0";
9
9
  export { AsyncAction, type AsyncActionStatus, type AsyncFn } from "./async-action.svelte.js";
10
10
  export { reportError, setErrorReporter, type ErrorReporter } from "./error.js";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * no portable way to import its own manifest, and a consumer that only wants to log a version
6
6
  * should not have to bundle one. `version.spec.ts` fails when this drifts from the manifest.
7
7
  */
8
- export const LIBRARY_VERSION = "0.3.1";
8
+ export const LIBRARY_VERSION = "0.6.0";
9
9
  // The root entry carries only what is not tied to one area: the error channel an app installs
10
10
  // once, and the async state machine the components are built on. Everything visual is reached
11
11
  // through a subpath — `/theme`, `/attachments`, `/components`, `/toast` — so importing a button
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The `?next=` round trip of a sign-in page: where a guard sends a visitor who is not signed in,
3
+ * and where the sign-in page may send them back to afterwards.
4
+ *
5
+ * The value arrives in a URL anyone can write, so it is attacker-controlled. Handed straight to
6
+ * `goto` or `location`, `?next=//evil.example` would send someone who just typed their password to
7
+ * another site — an open redirect, the first step of most credential phishing.
8
+ */
9
+ /**
10
+ * `value` when it is a path on this site, normalised; `null` for anything else.
11
+ *
12
+ * A leading `/` is not enough on its own: `//evil.example` and `/\evil.example` are
13
+ * protocol-relative to a browser, and the URL parser drops tabs and line breaks before it reads a
14
+ * URL at all, so `/<tab>/evil.example` turns into one of them on the way in. Rather than chase every
15
+ * spelling, the candidate is resolved the way the browser would resolve it and kept only if it
16
+ * stays on the same origin.
17
+ */
18
+ export declare const safeNext: (value: string | null | undefined) => string | null;
19
+ export interface LoginUrlOptions {
20
+ /** The sign-in page. Defaults to `/login`. */
21
+ path?: string;
22
+ /** The search parameter the return path travels in. Defaults to `next`. */
23
+ param?: string;
24
+ }
25
+ /**
26
+ * Where a guard sends a visitor who has to sign in first: the sign-in page, carrying the current
27
+ * path so it can send them back.
28
+ *
29
+ * Only the path travels. The query string is dropped on purpose: it is where share tokens and
30
+ * one-off codes live, and a sign-in URL gets logged, bookmarked and pasted far more than the page
31
+ * it came from. Nothing is carried from the site root or from the sign-in page itself, where
32
+ * "come back here" means nothing.
33
+ */
34
+ export declare const loginUrl: (current: URL | string, { path, param }?: LoginUrlOptions) => string;
35
+ /**
36
+ * `base` carrying `next` along — the link between the sign-in and the sign-up pages, so the
37
+ * return path survives a visitor who picked the wrong one first. `next` is checked again here, so
38
+ * an unchecked value cannot slip through a link built from it.
39
+ */
40
+ export declare const withNext: (base: string, next: string | null | undefined, param?: string) => string;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The `?next=` round trip of a sign-in page: where a guard sends a visitor who is not signed in,
3
+ * and where the sign-in page may send them back to afterwards.
4
+ *
5
+ * The value arrives in a URL anyone can write, so it is attacker-controlled. Handed straight to
6
+ * `goto` or `location`, `?next=//evil.example` would send someone who just typed their password to
7
+ * another site — an open redirect, the first step of most credential phishing.
8
+ */
9
+ /** A base no real link can point at, used only to let the URL parser resolve a candidate path. */
10
+ const PROBE_ORIGIN = "https://plinth.invalid";
11
+ /**
12
+ * `value` when it is a path on this site, normalised; `null` for anything else.
13
+ *
14
+ * A leading `/` is not enough on its own: `//evil.example` and `/\evil.example` are
15
+ * protocol-relative to a browser, and the URL parser drops tabs and line breaks before it reads a
16
+ * URL at all, so `/<tab>/evil.example` turns into one of them on the way in. Rather than chase every
17
+ * spelling, the candidate is resolved the way the browser would resolve it and kept only if it
18
+ * stays on the same origin.
19
+ */
20
+ export const safeNext = (value) => {
21
+ if (typeof value !== "string" || !value.startsWith("/"))
22
+ return null;
23
+ let url;
24
+ try {
25
+ url = new URL(value, PROBE_ORIGIN);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ if (url.origin !== PROBE_ORIGIN)
31
+ return null;
32
+ return `${url.pathname}${url.search}${url.hash}`;
33
+ };
34
+ /**
35
+ * Where a guard sends a visitor who has to sign in first: the sign-in page, carrying the current
36
+ * path so it can send them back.
37
+ *
38
+ * Only the path travels. The query string is dropped on purpose: it is where share tokens and
39
+ * one-off codes live, and a sign-in URL gets logged, bookmarked and pasted far more than the page
40
+ * it came from. Nothing is carried from the site root or from the sign-in page itself, where
41
+ * "come back here" means nothing.
42
+ */
43
+ export const loginUrl = (current, { path = "/login", param = "next" } = {}) => {
44
+ const pathname = typeof current === "string" ? new URL(current, PROBE_ORIGIN).pathname : current.pathname;
45
+ if (pathname === "/" || pathname === path)
46
+ return path;
47
+ return `${path}?${param}=${encodeURIComponent(pathname)}`;
48
+ };
49
+ /**
50
+ * `base` carrying `next` along — the link between the sign-in and the sign-up pages, so the
51
+ * return path survives a visitor who picked the wrong one first. `next` is checked again here, so
52
+ * an unchecked value cannot slip through a link built from it.
53
+ */
54
+ export const withNext = (base, next, param = "next") => {
55
+ const safe = safeNext(next);
56
+ if (!safe)
57
+ return base;
58
+ return `${base}${base.includes("?") ? "&" : "?"}${param}=${encodeURIComponent(safe)}`;
59
+ };
@@ -161,6 +161,7 @@ export declare class RoutingContext<Id extends string = string> {
161
161
  */
162
162
  export declare const resolvePathname: (pathname: string) => string;
163
163
  export declare const getRoutingContext: () => RoutingContext<string>, setRoutingContext: (context: RoutingContext<string>) => RoutingContext<string>;
164
+ export { loginUrl, safeNext, withNext, type LoginUrlOptions } from "./redirect.js";
164
165
  /**
165
166
  * A copy of `url` with some search parameters set and others removed — `null` or `undefined`
166
167
  * removes. It never edits the URL it was given, which is usually the page store's own and is read
@@ -189,6 +189,9 @@ export class RoutingContext {
189
189
  */
190
190
  export const resolvePathname = (pathname) => resolve(pathname);
191
191
  export const [getRoutingContext, setRoutingContext] = createContext();
192
+ // The `?next=` round trip of a sign-in page. Pure functions over strings, kept in their own module
193
+ // so they can be read and tested without a routing context.
194
+ export { loginUrl, safeNext, withNext } from "./redirect.js";
192
195
  /**
193
196
  * A copy of `url` with some search parameters set and others removed — `null` or `undefined`
194
197
  * removes. It never edits the URL it was given, which is usually the page store's own and is read