@siteping/server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ [![npm version](https://img.shields.io/npm/v/@siteping/server)](https://www.npmjs.com/package/@siteping/server)
2
+ [![Docs](https://img.shields.io/badge/docs-siteping.dev-0066ff)](https://siteping.dev/docs/server)
3
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)](https://www.typescriptlang.org/)
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ # @siteping/server
6
+
7
+ The HTTP endpoint of [SitePing](https://github.com/NeosiaNexus/SitePing) over any store, for any framework — validation, auth, CORS, redaction, hooks and webhooks on the Fetch API. No Node built-in: it runs on Node, Bun, Deno and edge workers.
8
+
9
+ **[Documentation](https://siteping.dev/docs/server)**
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @siteping/server
15
+ ```
16
+
17
+ Node ≥ 20, or any runtime with the Fetch API.
18
+
19
+ ## Quick start
20
+
21
+ ```ts
22
+ // app/api/siteping/route.ts — Next.js App Router
23
+ import { createSitepingHandler } from "@siteping/server";
24
+ import { store } from "@/lib/siteping-store"; // Drizzle, memory, Prisma or your own
25
+
26
+ export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({
27
+ store,
28
+ apiKey: process.env.SITEPING_API_KEY, // Bearer auth — or `access` for your own sessions
29
+ allowedOrigins: ["https://my-site.com"], // exact-match CORS
30
+ });
31
+ ```
32
+
33
+ One handler per method, Web-standard `Request` → `Response`: mount them from Hono, Express, Remix, SvelteKit, `Bun.serve` or a Worker.
34
+
35
+ ## Documentation
36
+
37
+ Mounting recipes, every option, custom access with CSRF protection, lifecycle hooks, the full HTTP reference and webhooks: **[siteping.dev/docs/server](https://siteping.dev/docs/server)**.
38
+
39
+ ## License
40
+
41
+ [MIT](https://github.com/NeosiaNexus/SitePing/blob/main/LICENSE)
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Run `task` over every item with at most `concurrency` calls in flight and
3
+ * settle each call on its own, like `Promise.allSettled` over a bounded pool.
4
+ *
5
+ * Each call runs inside its own promise, so a task that throws synchronously
6
+ * is settled as a rejection instead of stopping the pool, and one failure
7
+ * never skips the remaining items.
8
+ *
9
+ * @param items - Inputs, processed in order.
10
+ * @param concurrency - Maximum number of simultaneous `task` calls (at least 1).
11
+ * @param task - Operation to run for each item.
12
+ * @returns The settled result of every call, in the order of `items`.
13
+ */
14
+ export declare function settleWithConcurrencyLimit<Item, Result>(items: readonly Item[], concurrency: number, task: (item: Item) => Promise<Result>): Promise<Array<PromiseSettledResult<Result>>>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Run `task` over every item with at most `concurrency` calls in flight and
3
+ * settle each call on its own, like `Promise.allSettled` over a bounded pool.
4
+ *
5
+ * Each call runs inside its own promise, so a task that throws synchronously
6
+ * is settled as a rejection instead of stopping the pool, and one failure
7
+ * never skips the remaining items.
8
+ *
9
+ * @param items - Inputs, processed in order.
10
+ * @param concurrency - Maximum number of simultaneous `task` calls (at least 1).
11
+ * @param task - Operation to run for each item.
12
+ * @returns The settled result of every call, in the order of `items`.
13
+ */
14
+ export declare function settleWithConcurrencyLimit<Item, Result>(items: readonly Item[], concurrency: number, task: (item: Item) => Promise<Result>): Promise<Array<PromiseSettledResult<Result>>>;
@@ -0,0 +1,14 @@
1
+ import type { FeedbackRecord } from "./types.cjs";
2
+ /**
3
+ * `url` resolved against `base`, when the result is an http(s) URL. A
4
+ * record's `url` is client-supplied: without the scheme allowlist, a crafted
5
+ * feedback (`javascript:…`, `data:…`) would turn into a live link.
6
+ */
7
+ export declare function parseHttpUrl(url: string, base?: string): URL | null;
8
+ /**
9
+ * Link that opens a feedback's page with the widget focused on it —
10
+ * `?<param>=<id>`, read by the widget's `deepLink` option. Relative record
11
+ * URLs (the widget stores `location.pathname` by default) resolve against
12
+ * `base`. Null when there is no safe http(s) target.
13
+ */
14
+ export declare function buildDeepLink(record: Pick<FeedbackRecord, "id" | "url">, param: string, base?: string): string | null;
@@ -0,0 +1,14 @@
1
+ import type { FeedbackRecord } from "./types.js";
2
+ /**
3
+ * `url` resolved against `base`, when the result is an http(s) URL. A
4
+ * record's `url` is client-supplied: without the scheme allowlist, a crafted
5
+ * feedback (`javascript:…`, `data:…`) would turn into a live link.
6
+ */
7
+ export declare function parseHttpUrl(url: string, base?: string): URL | null;
8
+ /**
9
+ * Link that opens a feedback's page with the widget focused on it —
10
+ * `?<param>=<id>`, read by the widget's `deepLink` option. Relative record
11
+ * URLs (the widget stores `location.pathname` by default) resolve against
12
+ * `base`. Null when there is no safe http(s) target.
13
+ */
14
+ export declare function buildDeepLink(record: Pick<FeedbackRecord, "id" | "url">, param: string, base?: string): string | null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The one email pattern every Siteping surface validates against.
3
+ *
4
+ * The widget's identity modal and the HTTP handler's schema used to disagree
5
+ * (a permissive regex on one side, an ASCII-only default on the other), so an
6
+ * address the modal accepted — and persisted in localStorage for good — came
7
+ * back as a 400 on every submission afterwards. One pattern, imported on both
8
+ * sides, makes that drift impossible.
9
+ *
10
+ * Deliberately Unicode-aware: internationalised local parts and domains
11
+ * (`françois@exemple.fr`, `user@münchen.de`, `user@हिंदी.भारत`) are real
12
+ * addresses and common in the audience this widget serves. Combining marks
13
+ * (`\p{M}`) are allowed alongside letters — scripts like Devanagari need them,
14
+ * and NFD-composed input spells `ç` as `c` + U+0327 — except as the first
15
+ * character of a domain label. Structure follows the usual rules — a local
16
+ * part of 1 to 64 characters with no leading, trailing or doubled dot,
17
+ * dot-separated domain labels of at most 63 characters that neither start nor
18
+ * end with a hyphen, and a final label of at least two characters.
19
+ */
20
+ export declare const EMAIL_PATTERN: RegExp;
21
+ /** Whether `value` is an email address Siteping accepts — see {@link EMAIL_PATTERN}. */
22
+ export declare function isValidEmail(value: string): boolean;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The one email pattern every Siteping surface validates against.
3
+ *
4
+ * The widget's identity modal and the HTTP handler's schema used to disagree
5
+ * (a permissive regex on one side, an ASCII-only default on the other), so an
6
+ * address the modal accepted — and persisted in localStorage for good — came
7
+ * back as a 400 on every submission afterwards. One pattern, imported on both
8
+ * sides, makes that drift impossible.
9
+ *
10
+ * Deliberately Unicode-aware: internationalised local parts and domains
11
+ * (`françois@exemple.fr`, `user@münchen.de`, `user@हिंदी.भारत`) are real
12
+ * addresses and common in the audience this widget serves. Combining marks
13
+ * (`\p{M}`) are allowed alongside letters — scripts like Devanagari need them,
14
+ * and NFD-composed input spells `ç` as `c` + U+0327 — except as the first
15
+ * character of a domain label. Structure follows the usual rules — a local
16
+ * part of 1 to 64 characters with no leading, trailing or doubled dot,
17
+ * dot-separated domain labels of at most 63 characters that neither start nor
18
+ * end with a hyphen, and a final label of at least two characters.
19
+ */
20
+ export declare const EMAIL_PATTERN: RegExp;
21
+ /** Whether `value` is an email address Siteping accepts — see {@link EMAIL_PATTERN}. */
22
+ export declare function isValidEmail(value: string): boolean;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Typed error hierarchy for Siteping client/server boundaries.
3
+ *
4
+ * Consumers can `instanceof`-check or read `code` / `retryable` instead of
5
+ * pattern-matching error messages. Designed to be additive on top of the
6
+ * existing store errors (`StoreNotFoundError`, `StoreDuplicateError`) which
7
+ * remain the canonical signals for server-side store implementations.
8
+ *
9
+ * Usage on the widget side (api-client.ts):
10
+ * - fetch failures / aborts / timeouts → `SitepingNetworkError` (retryable)
11
+ * - HTTP 4xx (except 401/403) → `SitepingValidationError` (not retryable)
12
+ * - HTTP 401 / 403 → `SitepingAuthError` (not retryable)
13
+ * - everything else → `SitepingError` generic
14
+ *
15
+ * Store mode (store-client.ts): a `createFeedback` that does not settle in
16
+ * time → `SitepingError` with code `"TIMEOUT"` (retryable).
17
+ *
18
+ * `retryable` is meta information surfaced to host apps that want to wire
19
+ * their own retry/queue/backoff strategy — the widget already retries
20
+ * network failures via its built-in retry queue.
21
+ */
22
+ /**
23
+ * Discriminant string carried by every `SitepingError`. Subclasses pin a
24
+ * literal value; the base class accepts a wider string so userland can
25
+ * extend the hierarchy without colliding with built-ins.
26
+ */
27
+ export type SitepingErrorCode = "NETWORK" | "VALIDATION" | "AUTH" | "SERVER" | "TIMEOUT" | (string & {});
28
+ export declare class SitepingError<TCode extends SitepingErrorCode = SitepingErrorCode> extends Error {
29
+ readonly code: TCode;
30
+ readonly retryable: boolean;
31
+ constructor(message: string, code: TCode, retryable: boolean);
32
+ }
33
+ /** Network-level failure: connection refused, DNS, CORS, timeout, abort. Retryable. */
34
+ export declare class SitepingNetworkError extends SitepingError<"NETWORK"> {
35
+ constructor(message: string);
36
+ }
37
+ /**
38
+ * Server rejected the request (4xx, not auth). Validation problem on the
39
+ * client side. `status` is the HTTP status when a response said so: a 404
40
+ * (the target is gone) and a 409 (a full thread) call for other answers
41
+ * than a 400.
42
+ */
43
+ export declare class SitepingValidationError extends SitepingError<"VALIDATION"> {
44
+ readonly status: number | undefined;
45
+ constructor(message: string, status?: number);
46
+ }
47
+ /** Server rejected auth (401 or 403). Not retryable without fresh credentials. */
48
+ export declare class SitepingAuthError extends SitepingError<"AUTH"> {
49
+ /**
50
+ * `401`: the credentials are missing or no longer work — drop a dead token
51
+ * on this one. `403`: they work, and the server's policy refuses this request.
52
+ */
53
+ readonly status: 401 | 403;
54
+ constructor(message: string, status: 401 | 403);
55
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Typed error hierarchy for Siteping client/server boundaries.
3
+ *
4
+ * Consumers can `instanceof`-check or read `code` / `retryable` instead of
5
+ * pattern-matching error messages. Designed to be additive on top of the
6
+ * existing store errors (`StoreNotFoundError`, `StoreDuplicateError`) which
7
+ * remain the canonical signals for server-side store implementations.
8
+ *
9
+ * Usage on the widget side (api-client.ts):
10
+ * - fetch failures / aborts / timeouts → `SitepingNetworkError` (retryable)
11
+ * - HTTP 4xx (except 401/403) → `SitepingValidationError` (not retryable)
12
+ * - HTTP 401 / 403 → `SitepingAuthError` (not retryable)
13
+ * - everything else → `SitepingError` generic
14
+ *
15
+ * Store mode (store-client.ts): a `createFeedback` that does not settle in
16
+ * time → `SitepingError` with code `"TIMEOUT"` (retryable).
17
+ *
18
+ * `retryable` is meta information surfaced to host apps that want to wire
19
+ * their own retry/queue/backoff strategy — the widget already retries
20
+ * network failures via its built-in retry queue.
21
+ */
22
+ /**
23
+ * Discriminant string carried by every `SitepingError`. Subclasses pin a
24
+ * literal value; the base class accepts a wider string so userland can
25
+ * extend the hierarchy without colliding with built-ins.
26
+ */
27
+ export type SitepingErrorCode = "NETWORK" | "VALIDATION" | "AUTH" | "SERVER" | "TIMEOUT" | (string & {});
28
+ export declare class SitepingError<TCode extends SitepingErrorCode = SitepingErrorCode> extends Error {
29
+ readonly code: TCode;
30
+ readonly retryable: boolean;
31
+ constructor(message: string, code: TCode, retryable: boolean);
32
+ }
33
+ /** Network-level failure: connection refused, DNS, CORS, timeout, abort. Retryable. */
34
+ export declare class SitepingNetworkError extends SitepingError<"NETWORK"> {
35
+ constructor(message: string);
36
+ }
37
+ /**
38
+ * Server rejected the request (4xx, not auth). Validation problem on the
39
+ * client side. `status` is the HTTP status when a response said so: a 404
40
+ * (the target is gone) and a 409 (a full thread) call for other answers
41
+ * than a 400.
42
+ */
43
+ export declare class SitepingValidationError extends SitepingError<"VALIDATION"> {
44
+ readonly status: number | undefined;
45
+ constructor(message: string, status?: number);
46
+ }
47
+ /** Server rejected auth (401 or 403). Not retryable without fresh credentials. */
48
+ export declare class SitepingAuthError extends SitepingError<"AUTH"> {
49
+ /**
50
+ * `401`: the credentials are missing or no longer work — drop a dead token
51
+ * on this one. `403`: they work, and the server's policy refuses this request.
52
+ */
53
+ readonly status: 401 | 403;
54
+ constructor(message: string, status: 401 | 403);
55
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared feedback-record filtering and pagination — extracted from
3
+ * `adapter-memory` and `adapter-localstorage` which previously kept two
4
+ * near-identical copies of the same logic. Any adapter that holds an
5
+ * in-memory snapshot of feedbacks can use it.
6
+ *
7
+ * A record matches when it passes every active filter (see
8
+ * {@link matchesFeedbackQuery}):
9
+ * - projectName (always required)
10
+ * - type
11
+ * - status / statuses (`statuses` bucket wins when both are set)
12
+ * - url
13
+ * - urlPattern
14
+ * - search (lowercase substring match on `message`)
15
+ *
16
+ * Pagination goes through `clampPagination`: `limit` capped at 100, `page`
17
+ * 1-based, both clamped up to 1 rather than indexing backwards from the end
18
+ * of the match set. Query adapters reuse the same helper so every store
19
+ * paginates identically.
20
+ */
21
+ import type { FeedbackQuery, FeedbackRecord } from "./types.cjs";
22
+ /** Default page size when the caller omits `query.limit`. */
23
+ export declare const DEFAULT_PAGE_LIMIT = 50;
24
+ /** Maximum allowed page size — defends against memory blow-ups on hostile callers. */
25
+ export declare const MAX_PAGE_LIMIT = 100;
26
+ export interface FilterResult {
27
+ feedbacks: FeedbackRecord[];
28
+ total: number;
29
+ }
30
+ /** Normalised pagination window — see {@link clampPagination}. */
31
+ export interface Pagination {
32
+ /** 1-based page number, at least 1. */
33
+ page: number;
34
+ /** Page size in `[1, MAX_PAGE_LIMIT]`. */
35
+ limit: number;
36
+ /** Offset of the first row: `(page - 1) * limit`. */
37
+ skip: number;
38
+ }
39
+ /**
40
+ * Normalise `page` / `limit` to the store contract: `page` is 1-based and
41
+ * clamped up to 1, `limit` defaults to 50 and is clamped into `[1, 100]`,
42
+ * non-finite values fall back to the defaults. `skip` is the derived row
43
+ * offset for query backends (`OFFSET`, Prisma `skip`).
44
+ *
45
+ * Shared by the in-memory pipeline and query adapters (`PrismaStore`) so
46
+ * every store paginates identically — the HTTP schema clamps the same way,
47
+ * but direct callers (dashboard store mode, server actions) reach the store
48
+ * without a schema in front of them.
49
+ */
50
+ export declare function clampPagination(query: Pick<FeedbackQuery, "page" | "limit">): Pagination;
51
+ /**
52
+ * Whether a {@link clampPagination} offset lies beyond any row a store can
53
+ * hold. `clampPagination` bounds `page` from below only, so a direct caller's
54
+ * huge `page` yields an offset past `Number.MAX_SAFE_INTEGER` — or `Infinity`
55
+ * — that SQL backends reject (`OFFSET` is a 64-bit integer in PostgreSQL and
56
+ * SQLite; Prisma's `skip` rejects non-integers and 64-bit overflow).
57
+ *
58
+ * Every safe integer fits a signed 64-bit offset and no table holds more rows
59
+ * than that, so query adapters answer such a page as empty — with the real
60
+ * `total` — instead of issuing the query: the same result the in-memory
61
+ * pipeline returns.
62
+ *
63
+ * @param skip - The `skip` returned by {@link clampPagination}.
64
+ * @returns `true` when no row can sit at that offset.
65
+ */
66
+ export declare function isUnreachableOffset(skip: number): boolean;
67
+ /**
68
+ * Whether one record passes every filter of `query` — the filter half of
69
+ * {@link applyFeedbackFilters}, exposed so a client holding a single record
70
+ * (e.g. the dashboard deciding whether an optimistic edit still belongs in
71
+ * its list) applies exactly the stores' semantics. Pagination is ignored.
72
+ */
73
+ export declare function matchesFeedbackQuery(record: FeedbackRecord, query: FeedbackQuery): boolean;
74
+ /**
75
+ * Apply the standard feedback filter + pagination pipeline against an
76
+ * in-memory snapshot. Used by `MemoryStore.getFeedbacks` and
77
+ * `LocalStorageStore.getFeedbacks` so the two never drift.
78
+ *
79
+ * @param items All known feedback records (already include `annotations`).
80
+ * @param query Filter and pagination options. `projectName` is required.
81
+ */
82
+ export declare function applyFeedbackFilters(items: readonly FeedbackRecord[], query: FeedbackQuery): FilterResult;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared feedback-record filtering and pagination — extracted from
3
+ * `adapter-memory` and `adapter-localstorage` which previously kept two
4
+ * near-identical copies of the same logic. Any adapter that holds an
5
+ * in-memory snapshot of feedbacks can use it.
6
+ *
7
+ * A record matches when it passes every active filter (see
8
+ * {@link matchesFeedbackQuery}):
9
+ * - projectName (always required)
10
+ * - type
11
+ * - status / statuses (`statuses` bucket wins when both are set)
12
+ * - url
13
+ * - urlPattern
14
+ * - search (lowercase substring match on `message`)
15
+ *
16
+ * Pagination goes through `clampPagination`: `limit` capped at 100, `page`
17
+ * 1-based, both clamped up to 1 rather than indexing backwards from the end
18
+ * of the match set. Query adapters reuse the same helper so every store
19
+ * paginates identically.
20
+ */
21
+ import type { FeedbackQuery, FeedbackRecord } from "./types.js";
22
+ /** Default page size when the caller omits `query.limit`. */
23
+ export declare const DEFAULT_PAGE_LIMIT = 50;
24
+ /** Maximum allowed page size — defends against memory blow-ups on hostile callers. */
25
+ export declare const MAX_PAGE_LIMIT = 100;
26
+ export interface FilterResult {
27
+ feedbacks: FeedbackRecord[];
28
+ total: number;
29
+ }
30
+ /** Normalised pagination window — see {@link clampPagination}. */
31
+ export interface Pagination {
32
+ /** 1-based page number, at least 1. */
33
+ page: number;
34
+ /** Page size in `[1, MAX_PAGE_LIMIT]`. */
35
+ limit: number;
36
+ /** Offset of the first row: `(page - 1) * limit`. */
37
+ skip: number;
38
+ }
39
+ /**
40
+ * Normalise `page` / `limit` to the store contract: `page` is 1-based and
41
+ * clamped up to 1, `limit` defaults to 50 and is clamped into `[1, 100]`,
42
+ * non-finite values fall back to the defaults. `skip` is the derived row
43
+ * offset for query backends (`OFFSET`, Prisma `skip`).
44
+ *
45
+ * Shared by the in-memory pipeline and query adapters (`PrismaStore`) so
46
+ * every store paginates identically — the HTTP schema clamps the same way,
47
+ * but direct callers (dashboard store mode, server actions) reach the store
48
+ * without a schema in front of them.
49
+ */
50
+ export declare function clampPagination(query: Pick<FeedbackQuery, "page" | "limit">): Pagination;
51
+ /**
52
+ * Whether a {@link clampPagination} offset lies beyond any row a store can
53
+ * hold. `clampPagination` bounds `page` from below only, so a direct caller's
54
+ * huge `page` yields an offset past `Number.MAX_SAFE_INTEGER` — or `Infinity`
55
+ * — that SQL backends reject (`OFFSET` is a 64-bit integer in PostgreSQL and
56
+ * SQLite; Prisma's `skip` rejects non-integers and 64-bit overflow).
57
+ *
58
+ * Every safe integer fits a signed 64-bit offset and no table holds more rows
59
+ * than that, so query adapters answer such a page as empty — with the real
60
+ * `total` — instead of issuing the query: the same result the in-memory
61
+ * pipeline returns.
62
+ *
63
+ * @param skip - The `skip` returned by {@link clampPagination}.
64
+ * @returns `true` when no row can sit at that offset.
65
+ */
66
+ export declare function isUnreachableOffset(skip: number): boolean;
67
+ /**
68
+ * Whether one record passes every filter of `query` — the filter half of
69
+ * {@link applyFeedbackFilters}, exposed so a client holding a single record
70
+ * (e.g. the dashboard deciding whether an optimistic edit still belongs in
71
+ * its list) applies exactly the stores' semantics. Pagination is ignored.
72
+ */
73
+ export declare function matchesFeedbackQuery(record: FeedbackRecord, query: FeedbackQuery): boolean;
74
+ /**
75
+ * Apply the standard feedback filter + pagination pipeline against an
76
+ * in-memory snapshot. Used by `MemoryStore.getFeedbacks` and
77
+ * `LocalStorageStore.getFeedbacks` so the two never drift.
78
+ *
79
+ * @param items All known feedback records (already include `annotations`).
80
+ * @param query Filter and pagination options. `projectName` is required.
81
+ */
82
+ export declare function applyFeedbackFilters(items: readonly FeedbackRecord[], query: FeedbackQuery): FilterResult;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Generic i18n machinery shared by the widget and the dashboard.
3
+ *
4
+ * Each package owns its `Translations` interface (their key sets differ) and
5
+ * its locale dictionaries; everything else — locale normalization, the
6
+ * custom-locale registry, lazy loading of built-ins, the translate-function
7
+ * factory, interpolation — is identical and lives here once.
8
+ *
9
+ * The `loaders` map is typed `Record<Exclude<BuiltinLocale, "en">, …>`: the
10
+ * moment a locale code is added to {@link BUILTIN_LOCALES}, every consuming
11
+ * package fails to compile until its loader entry (and therefore its
12
+ * dictionary file) exists. Adding a locale cannot silently fall back to
13
+ * English anymore.
14
+ */
15
+ import { type BuiltinLocale } from "./types.cjs";
16
+ /**
17
+ * Lazy dictionary loaders for every non-English built-in locale. Use static
18
+ * `() => import("./xx.cjs").then((m) => m.xx)` thunks — bundlers keep
19
+ * emitting one chunk per locale, and only the requested one ships.
20
+ */
21
+ export type LocaleLoaders<T> = Record<Exclude<BuiltinLocale, "en">, () => Promise<T>>;
22
+ /** Translate function over the message catalog `T`. */
23
+ export type TranslateFunction<T> = (key: keyof T & string) => string;
24
+ /** The i18n API returned by {@link createI18n}. */
25
+ export interface I18n<T> {
26
+ /**
27
+ * Create a translation function for the given locale.
28
+ *
29
+ * Locale resolution: exact language match > English fallback, per key.
30
+ * Non-English built-in locales are lazy-loaded via `loadLocale` — call
31
+ * `await loadLocale(locale)` at init if you want the UI to render in the
32
+ * target language immediately; until the dictionary lands, keys resolve
33
+ * to English.
34
+ */
35
+ createT(locale: string): TranslateFunction<T>;
36
+ /**
37
+ * Dynamically import a built-in locale and register it. Returns the
38
+ * loaded dictionary, or `null` if the locale isn't a known built-in.
39
+ * Custom locales registered via `registerLocale` bypass this loader —
40
+ * they are already in the registry (and are returned as registered, so a
41
+ * partial custom dictionary comes back partial).
42
+ */
43
+ loadLocale(locale: string): Promise<Partial<T> | null>;
44
+ /**
45
+ * Register a custom locale at runtime. Partial dictionaries are welcome —
46
+ * missing keys fall back to English per key, so overriding a single
47
+ * string never requires copying the whole catalog.
48
+ */
49
+ registerLocale(code: string, translations: Partial<T>): void;
50
+ }
51
+ /**
52
+ * `locale` as `Intl` accepts it. Locale options take any string, and a
53
+ * backend-style tag (`fr_FR` from PHP / WordPress) makes every `Intl`
54
+ * constructor and `toLocale*` call throw a RangeError: `_` reads as `-`, and
55
+ * a tag `Intl` still rejects falls back to English, as `createT` does.
56
+ * Silent, so it is safe on a render path; see `canonicalizeLocale` for the
57
+ * one-time, warning variant.
58
+ */
59
+ export declare function intlLocale(locale: string): string;
60
+ /**
61
+ * The host's locale tag as `Intl` accepts it. `SitepingLocale` takes any
62
+ * string, and PHP / WordPress hand out POSIX-style tags (`fr_FR`, `pt_BR`)
63
+ * that every `Intl` constructor rejects with a RangeError: `_` becomes `-`,
64
+ * then the tag is canonicalised (`fr-FR`). Falls back to `"en"`, with a
65
+ * warning, only when the tag is still invalid (empty, garbage) — meant to run
66
+ * once, where the config is read.
67
+ */
68
+ export declare function canonicalizeLocale(locale: string): string;
69
+ /**
70
+ * Build the i18n machinery for one message catalog.
71
+ *
72
+ * @param en — the complete English catalog, bundled synchronously as the fallback.
73
+ * @param loaders — lazy import thunks for every other built-in locale.
74
+ */
75
+ export declare function createI18n<T extends Record<keyof T & string, string>>(en: T, loaders: LocaleLoaders<T>): I18n<T>;
76
+ /**
77
+ * Interpolate `{paramName}` placeholders in a translated string with the
78
+ * values from `params`. Stringifies numbers and booleans inline so callers
79
+ * can pass `t("marker.count")` along with `{ count: 3 }` directly.
80
+ *
81
+ * Unknown placeholders are left as-is.
82
+ */
83
+ export declare function interpolate(template: string, params: Readonly<Record<string, string | number | boolean>>): string;
84
+ /** Shorthand for `interpolate(t(key), params)` — key-checked against the catalog. */
85
+ export declare function tWithParams<K extends string>(t: (key: K) => string, key: K, params: Readonly<Record<string, string | number | boolean>>): string;
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Generic i18n machinery shared by the widget and the dashboard.
3
+ *
4
+ * Each package owns its `Translations` interface (their key sets differ) and
5
+ * its locale dictionaries; everything else — locale normalization, the
6
+ * custom-locale registry, lazy loading of built-ins, the translate-function
7
+ * factory, interpolation — is identical and lives here once.
8
+ *
9
+ * The `loaders` map is typed `Record<Exclude<BuiltinLocale, "en">, …>`: the
10
+ * moment a locale code is added to {@link BUILTIN_LOCALES}, every consuming
11
+ * package fails to compile until its loader entry (and therefore its
12
+ * dictionary file) exists. Adding a locale cannot silently fall back to
13
+ * English anymore.
14
+ */
15
+ import { type BuiltinLocale } from "./types.js";
16
+ /**
17
+ * Lazy dictionary loaders for every non-English built-in locale. Use static
18
+ * `() => import("./xx.js").then((m) => m.xx)` thunks — bundlers keep
19
+ * emitting one chunk per locale, and only the requested one ships.
20
+ */
21
+ export type LocaleLoaders<T> = Record<Exclude<BuiltinLocale, "en">, () => Promise<T>>;
22
+ /** Translate function over the message catalog `T`. */
23
+ export type TranslateFunction<T> = (key: keyof T & string) => string;
24
+ /** The i18n API returned by {@link createI18n}. */
25
+ export interface I18n<T> {
26
+ /**
27
+ * Create a translation function for the given locale.
28
+ *
29
+ * Locale resolution: exact language match > English fallback, per key.
30
+ * Non-English built-in locales are lazy-loaded via `loadLocale` — call
31
+ * `await loadLocale(locale)` at init if you want the UI to render in the
32
+ * target language immediately; until the dictionary lands, keys resolve
33
+ * to English.
34
+ */
35
+ createT(locale: string): TranslateFunction<T>;
36
+ /**
37
+ * Dynamically import a built-in locale and register it. Returns the
38
+ * loaded dictionary, or `null` if the locale isn't a known built-in.
39
+ * Custom locales registered via `registerLocale` bypass this loader —
40
+ * they are already in the registry (and are returned as registered, so a
41
+ * partial custom dictionary comes back partial).
42
+ */
43
+ loadLocale(locale: string): Promise<Partial<T> | null>;
44
+ /**
45
+ * Register a custom locale at runtime. Partial dictionaries are welcome —
46
+ * missing keys fall back to English per key, so overriding a single
47
+ * string never requires copying the whole catalog.
48
+ */
49
+ registerLocale(code: string, translations: Partial<T>): void;
50
+ }
51
+ /**
52
+ * `locale` as `Intl` accepts it. Locale options take any string, and a
53
+ * backend-style tag (`fr_FR` from PHP / WordPress) makes every `Intl`
54
+ * constructor and `toLocale*` call throw a RangeError: `_` reads as `-`, and
55
+ * a tag `Intl` still rejects falls back to English, as `createT` does.
56
+ * Silent, so it is safe on a render path; see `canonicalizeLocale` for the
57
+ * one-time, warning variant.
58
+ */
59
+ export declare function intlLocale(locale: string): string;
60
+ /**
61
+ * The host's locale tag as `Intl` accepts it. `SitepingLocale` takes any
62
+ * string, and PHP / WordPress hand out POSIX-style tags (`fr_FR`, `pt_BR`)
63
+ * that every `Intl` constructor rejects with a RangeError: `_` becomes `-`,
64
+ * then the tag is canonicalised (`fr-FR`). Falls back to `"en"`, with a
65
+ * warning, only when the tag is still invalid (empty, garbage) — meant to run
66
+ * once, where the config is read.
67
+ */
68
+ export declare function canonicalizeLocale(locale: string): string;
69
+ /**
70
+ * Build the i18n machinery for one message catalog.
71
+ *
72
+ * @param en — the complete English catalog, bundled synchronously as the fallback.
73
+ * @param loaders — lazy import thunks for every other built-in locale.
74
+ */
75
+ export declare function createI18n<T extends Record<keyof T & string, string>>(en: T, loaders: LocaleLoaders<T>): I18n<T>;
76
+ /**
77
+ * Interpolate `{paramName}` placeholders in a translated string with the
78
+ * values from `params`. Stringifies numbers and booleans inline so callers
79
+ * can pass `t("marker.count")` along with `{ count: 3 }` directly.
80
+ *
81
+ * Unknown placeholders are left as-is.
82
+ */
83
+ export declare function interpolate(template: string, params: Readonly<Record<string, string | number | boolean>>): string;
84
+ /** Shorthand for `interpolate(t(key), params)` — key-checked against the catalog. */
85
+ export declare function tWithParams<K extends string>(t: (key: K) => string, key: K, params: Readonly<Record<string, string | number | boolean>>): string;