@pithy-sh/ui-react 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/LICENSE +21 -0
- package/package.json +47 -0
- package/src/templates.ts +147 -0
- package/src/testing/virtualAuth.ts +12 -0
- package/src/testing/virtualI18n.ts +16 -0
- package/src/testing/virtualPayments.ts +5 -0
- package/src/testing/virtualTurnstile.ts +5 -0
- package/templates/client-env.d.ts +299 -0
- package/templates/index.html +14 -0
- package/templates/src/client.test.tsx +98 -0
- package/templates/src/client.tsx +51 -0
- package/templates/src/payments.tsx +147 -0
- package/templates/src/pithy-config.tsx +93 -0
- package/templates/src/pithy-locale.test.tsx +140 -0
- package/templates/src/pithy-locale.tsx +134 -0
- package/templates/src/pithy-screens.css +379 -0
- package/templates/src/router.test.tsx +63 -0
- package/templates/src/router.tsx +618 -0
- package/templates/src/routes/app/home.bare.tsx +96 -0
- package/templates/src/routes/app/home.tsx +41 -0
- package/templates/src/routes/pithy/callback.tsx +42 -0
- package/templates/src/routes/pithy/otp.tsx +127 -0
- package/templates/src/routes/pithy/paywall.tsx +160 -0
- package/templates/src/routes/pithy/pricing.tsx +312 -0
- package/templates/src/routes/pithy/sign-in.test.tsx +116 -0
- package/templates/src/routes/pithy/sign-in.tsx +470 -0
- package/templates/src/routes/pithy/subscription.tsx +183 -0
- package/templates/src/session.tsx +78 -0
- package/templates/src/styles.css +53 -0
- package/templates/src/turnstile.test.tsx +119 -0
- package/templates/src/turnstile.tsx +105 -0
- package/templates/tsconfig.client.json +28 -0
- package/templates/tsconfig.node.json +22 -0
- package/templates/vite.config.ts +45 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, type ReactNode } from "react";
|
|
4
|
+
import { expect, test, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **The app mounts into a node it creates, so nothing in `index.html` can rename it out from under it.**
|
|
8
|
+
*
|
|
9
|
+
* This file used to find its mount point with `document.getElementById("root")` and render inside
|
|
10
|
+
* `if (container) { … }`, against a `<div id="root">` declared in `index.html`. Two strings, nothing
|
|
11
|
+
* comparing them. Renaming the div — an ordinary edit to your own HTML — produced an empty document
|
|
12
|
+
* with a 200: no throw, no log, a clean build and a green suite. The failure mode hardest to attribute,
|
|
13
|
+
* because the first three things anyone suspects are their own code, their build and their Worker, and
|
|
14
|
+
* none of them is wrong (#394).
|
|
15
|
+
*
|
|
16
|
+
* ## What this proves, and how it goes red
|
|
17
|
+
*
|
|
18
|
+
* The document is given a mount node with a **deliberately wrong id** before `client.tsx` is imported —
|
|
19
|
+
* exactly the document an adopter who renamed the div would have. Code that looks an id up finds
|
|
20
|
+
* nothing and renders nothing, and this goes red; only code that creates its own node can pass. The id
|
|
21
|
+
* is invented here and reachable from nowhere else, which is what a canary is for: asserting `"root"`
|
|
22
|
+
* would have passed against the very edit this catches.
|
|
23
|
+
*
|
|
24
|
+
* `./router` is stubbed for the same reason `src/turnstile.test.tsx` stubs `./pithy-config` — the real
|
|
25
|
+
* one resolves every screen, and those read `virtual:pithy/*` modules that only a Vite build serves.
|
|
26
|
+
* Mounting is what is under test here, not routing.
|
|
27
|
+
*
|
|
28
|
+
* ## And the app is mounted **under a translator**, which is the second thing this file proves
|
|
29
|
+
*
|
|
30
|
+
* `src/pithy-locale.tsx` has its own gate, and that gate mounts `<PithyLocale>` itself — so it proves
|
|
31
|
+
* the component works and never that anything renders it. Deleting the element and its import from
|
|
32
|
+
* this file left the whole repository green: an app with no translator over it, every screen in
|
|
33
|
+
* English under whatever `lang` was negotiated, and not one test anywhere going red. That is the very
|
|
34
|
+
* defect the seeded-gate convention exists to stop, one level up — the file that was broken is the
|
|
35
|
+
* file with no gate on it.
|
|
36
|
+
*
|
|
37
|
+
* So `./pithy-locale` is stubbed with a wrapper that marks the subtree it draws, and the app has to be
|
|
38
|
+
* found **inside** it. A stub rather than the real component on purpose: with nothing composed the real
|
|
39
|
+
* one renders its children untouched, which is indistinguishable from not being rendered at all — the
|
|
40
|
+
* assertion could not exist. What language it then mounts is `pithy-locale.test.tsx`'s subject, and
|
|
41
|
+
* this file does not restate it.
|
|
42
|
+
*
|
|
43
|
+
* **And `./pithy-config` is stubbed too, which is not a detail.** `src/pithy-locale.tsx` reads the i18n
|
|
44
|
+
* projection through it, so a `client.tsx` that reached the real module would reach a `virtual:pithy/*`
|
|
45
|
+
* one — and a seeded gate that needs one of those runs in this repository, where the kit\'s own Vitest
|
|
46
|
+
* config aliases them, and nowhere else. It would fail in every adopter\'s repository, on the first
|
|
47
|
+
* `vitest run` after scaffolding, over a file they had not touched. The stub above keeps that module
|
|
48
|
+
* out of the graph today; this one is what keeps this file green if it is ever narrowed. The projection
|
|
49
|
+
* is stubbed disabled, which is the shape a project that never composed `i18n` projects.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** The id the document's mount node carries. Not the one anything renders into, on purpose. */
|
|
53
|
+
const CANARY_ID = "pithy-gate-canary-not-the-mount-id";
|
|
54
|
+
|
|
55
|
+
/** What the stubbed router draws. Finding it in the document is the whole assertion. */
|
|
56
|
+
const MOUNTED = "pithy-gate-canary-mounted";
|
|
57
|
+
|
|
58
|
+
/** The attribute the stubbed provider marks its subtree with. Invented here, like every canary above. */
|
|
59
|
+
const WRAPPED = "data-pithy-gate-locale";
|
|
60
|
+
|
|
61
|
+
vi.mock("./router", () => ({ Router: () => MOUNTED }));
|
|
62
|
+
|
|
63
|
+
vi.mock("./pithy-locale", () => ({
|
|
64
|
+
PithyLocale: ({ children }: { children: ReactNode }) => <div {...{ [WRAPPED]: "" }}>{children}</div>,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
vi.mock("./pithy-config", () => ({ i18nConfig: { enabled: false } }));
|
|
68
|
+
|
|
69
|
+
// React refuses to run `act` unless the environment says it is a test one.
|
|
70
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
71
|
+
|
|
72
|
+
test("the app mounts even though no node in the document carries the id it once looked for", async () => {
|
|
73
|
+
// The canary must not have drifted onto the real value: an id of "root" would pass against the bug.
|
|
74
|
+
expect(CANARY_ID).not.toBe("root");
|
|
75
|
+
|
|
76
|
+
document.body.innerHTML = `<div id="${CANARY_ID}"></div>`;
|
|
77
|
+
|
|
78
|
+
// Imported inside the case, because importing it is what mounts.
|
|
79
|
+
await act(async () => {
|
|
80
|
+
await import("./client");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(document.body.textContent, "nothing rendered — client.tsx did not mount into a node of its own").toContain(
|
|
84
|
+
MOUNTED,
|
|
85
|
+
);
|
|
86
|
+
// And it did not mount into the adopter's element. The node it renders into is its own.
|
|
87
|
+
expect(document.getElementById(CANARY_ID)?.textContent).toBe("");
|
|
88
|
+
|
|
89
|
+
// The app is under a translator, and this is the only place that is true of. Delete `<PithyLocale>`
|
|
90
|
+
// from `client.tsx` and every screen renders English under whatever language the document declares —
|
|
91
|
+
// with nothing else in the repository noticing, which is how it got shipped once already.
|
|
92
|
+
const wrapped = document.querySelector(`[${WRAPPED}]`);
|
|
93
|
+
expect(wrapped, "client.tsx renders no <PithyLocale> — the app mounts with no translator over it").not.toBeNull();
|
|
94
|
+
// Inside it, not beside it. A provider rendered as a sibling of the app translates nothing.
|
|
95
|
+
expect(wrapped?.textContent, "the app is not inside <PithyLocale> — nothing under it reads a catalog").toContain(
|
|
96
|
+
MOUNTED,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { PithyLocale } from "./pithy-locale";
|
|
4
|
+
import { Router } from "./router";
|
|
5
|
+
// Yours, and Pithy's. `styles.css` is written once and then belongs to you; `pithy-screens.css` carries
|
|
6
|
+
// the classes Pithy's own screens render, in a cascade layer, so anything you write here wins over it.
|
|
7
|
+
// Pithy's screens import it themselves as well — that is what keeps them styled when they are added to
|
|
8
|
+
// a project whose `client.tsx` was written before they were.
|
|
9
|
+
import "./styles.css";
|
|
10
|
+
import "./pithy-screens.css";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The mount node, created here rather than found in `index.html`.
|
|
14
|
+
*
|
|
15
|
+
* **This is the whole of the mount contract, and it is one statement.** It used to be two: a
|
|
16
|
+
* `<div id="root">` in `index.html` and a `getElementById("root")` here, with an `if (container)`
|
|
17
|
+
* around the render. Renaming that div — an ordinary thing to do to your own HTML — made the app
|
|
18
|
+
* render nothing, throw nothing and log nothing: an empty document with a 200, a clean build and a
|
|
19
|
+
* green suite (#394). The guard was the defect; deleting it alone would have made the same rename an
|
|
20
|
+
* immediate error, and not having the second string at all means there is no rename to make.
|
|
21
|
+
*
|
|
22
|
+
* The id is still set, so `#root` works as a styling hook. Nothing reads it.
|
|
23
|
+
*
|
|
24
|
+
* Put whatever you like in `index.html`'s `<body>` — a splash, a `<noscript>` — and the app mounts
|
|
25
|
+
* after it. If you need it somewhere else in the document, append it there; this is ordinary DOM code,
|
|
26
|
+
* not a convention. The `<script type="module">` that loads this file is deferred, so `document.body`
|
|
27
|
+
* exists by the time this runs.
|
|
28
|
+
*/
|
|
29
|
+
const container = document.body.appendChild(document.createElement("div"));
|
|
30
|
+
container.id = "root";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The app, in one language.
|
|
34
|
+
*
|
|
35
|
+
* `<PithyLocale>` is the whole of the i18n wiring, and it is one component rather than a call here
|
|
36
|
+
* because `lang` on the document and the words on the page have to come from a **single** resolved
|
|
37
|
+
* locale. They used to come from two places: this file negotiated a locale and set `lang` from it, and
|
|
38
|
+
* nothing mounted a translator at all — so a Spanish reader met `<html lang="es">` over a page rendering
|
|
39
|
+
* English, which is worse for a screen reader than never having negotiated. `src/pithy-locale.tsx` holds
|
|
40
|
+
* the argument in full.
|
|
41
|
+
*
|
|
42
|
+
* With no i18n capability composed it renders its children untouched, and every screen reads the English
|
|
43
|
+
* it was scaffolded with. Your own catalogs go on it: `<PithyLocale messages={{ fr: … }}>`.
|
|
44
|
+
*/
|
|
45
|
+
createRoot(container).render(
|
|
46
|
+
<StrictMode>
|
|
47
|
+
<PithyLocale>
|
|
48
|
+
<Router />
|
|
49
|
+
</PithyLocale>
|
|
50
|
+
</StrictMode>,
|
|
51
|
+
);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
2
|
+
import { getEntitlements, type PaymentsFailure } from "@pithy-sh/payments/src/client/api";
|
|
3
|
+
import type { PriceVisitor } from "@pithy-sh/payments/src/pricing/location";
|
|
4
|
+
import { fetchPriceVisitor, type PriceVisitorOptions } from "@pithy-sh/payments/src/pricing/visitor";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { paymentsConfig } from "./pithy-config";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The thin bridge between the narrowed projection and the package's headless hooks.
|
|
10
|
+
*
|
|
11
|
+
* Two things live here and nothing else. {@link paymentsClient} binds every call to this project's own
|
|
12
|
+
* base path, once, so a screen never repeats it — and because it is a module constant it is a stable
|
|
13
|
+
* reference, which keeps the hooks' effects from re-running on every render.
|
|
14
|
+
*
|
|
15
|
+
* {@link holdsEntitlement} is the data half of the router's entitlement guard. The guard is a **UX
|
|
16
|
+
* affordance, never a security boundary**: the server's `requireEntitlement()` is the boundary, and this
|
|
17
|
+
* exists so a visitor without `pro` lands on the paywall instead of watching a screen 403. Anything this
|
|
18
|
+
* answers wrongly costs a redirect, never access.
|
|
19
|
+
*
|
|
20
|
+
* Everything the purchase flow actually does — the calls, the redirect-and-return dance, the error
|
|
21
|
+
* mapping — lives in `@pithy-sh/payments`, not here. That split is deliberate: this file is written once
|
|
22
|
+
* and is yours from then on, and store rules move. A paywall frozen in an adopter's repo is one Pithy
|
|
23
|
+
* cannot fix; a paywall that calls the package's hooks upgrades with a minor release.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Where the payments routes are, bound once. Pass it to every hook. */
|
|
27
|
+
export const paymentsClient = { basePath: paymentsConfig.basePath };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The class Paddle renders an inline checkout into.
|
|
31
|
+
*
|
|
32
|
+
* A class name, not an id — that is Paddle's `frameTarget` contract. The container is rendered only when
|
|
33
|
+
* the handoff asks for it, from `paddle.checkout` in your config: switch that to `inline` and the form
|
|
34
|
+
* appears in the page instead of over it, with no edit to any screen.
|
|
35
|
+
*
|
|
36
|
+
* **It is here because two screens sell.** The paywall and the pricing page each declared their own copy
|
|
37
|
+
* of this string, and the class is the one thing about it you are meant to act on — `.pithy-checkout` is
|
|
38
|
+
* an adopter hook, deliberately styled by no stylesheet Pithy ships, so that the frame lands where your
|
|
39
|
+
* layout wants it. A styling instruction against two names that agree today is one that styles half your
|
|
40
|
+
* checkouts the day somebody renames one (#391, item E).
|
|
41
|
+
*
|
|
42
|
+
* That is also why the unstyled report never mentions it: it has no rule anywhere on purpose, and a
|
|
43
|
+
* class read out of an identifier is not a literal that report can see. Both facts are intended.
|
|
44
|
+
*/
|
|
45
|
+
export const CHECKOUT_FRAME = "pithy-checkout";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What Paddle.js starts with, or null when this project has no Paddle rail.
|
|
49
|
+
*
|
|
50
|
+
* Null rather than absent, because every hook that takes it takes null and reads it as "nothing to
|
|
51
|
+
* load". That is what keeps a pricing screen free of a conditional hook call, and it is why the rail
|
|
52
|
+
* being switched off is an empty state rather than an error about a provider nobody asked for.
|
|
53
|
+
*
|
|
54
|
+
* **The client token is the only credential in this bundle, and it belongs here.** Paddle publishes it
|
|
55
|
+
* for exactly this — a browser opens a checkout with it. The API key and the webhook signing secret are
|
|
56
|
+
* in the secrets store and are not expressible in a projection.
|
|
57
|
+
*/
|
|
58
|
+
export const paddleSetup = paymentsConfig.paddle;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a refusal from the store reads as, in the reader's language.
|
|
62
|
+
*
|
|
63
|
+
* **A `PaymentsFailure` carries a namespaced `code`, and the code is the catalog key.** Every screen
|
|
64
|
+
* here used to render `failure.message` — the sentence the server put on the wire, which is English and
|
|
65
|
+
* is only ever English, because `message` is written at the throw site in the one language a throw site
|
|
66
|
+
* has. So a reader on an otherwise-Spanish page met English the moment anything went wrong, which is
|
|
67
|
+
* the moment copy matters most and the moment nobody tests.
|
|
68
|
+
*
|
|
69
|
+
* This is the contract `docs/I18N.md` § *Errors* states, and the reason it is `maybe` rather than `t`:
|
|
70
|
+
* `t` is total, so a code no catalog covers would render as the code itself — `payments/product_not_found`
|
|
71
|
+
* on a buyer's screen, in place of the English sentence the server took care to send. `maybe` answers
|
|
72
|
+
* null on a miss, and the `??` is what makes the server's own words the floor.
|
|
73
|
+
*
|
|
74
|
+
* So a project that never composed `i18n` renders exactly what it always did, byte for byte: the baked
|
|
75
|
+
* translator holds only the screen's own English, no key matches a `payments/…` code, and the message
|
|
76
|
+
* comes straight through. The `client/…` sentinels this package mints for a browser that is offline or
|
|
77
|
+
* an answer it cannot read take the same path, and their English is likewise unchanged.
|
|
78
|
+
*
|
|
79
|
+
* It lives here, with `CHECKOUT_FRAME`, for the same reason that does: three screens render a failure,
|
|
80
|
+
* and three copies of one lookup is two screens that keep saying it in English the day somebody fixes
|
|
81
|
+
* the third.
|
|
82
|
+
*/
|
|
83
|
+
export function failureText(t: Translator, failure: PaymentsFailure): string {
|
|
84
|
+
// `params` and not just the code: `interpolate` leaves an unsupplied placeholder exactly as written,
|
|
85
|
+
// so a translated sentence that names one would render `{board}` on the screen — worse than the
|
|
86
|
+
// English it replaced. `docs/I18N.md` states the contract as `t.maybe(code, params) ?? message`.
|
|
87
|
+
return t.maybe(failure.code, failure.params) ?? failure.message;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether the visitor holds `key` right now.
|
|
92
|
+
*
|
|
93
|
+
* "The visitor" is shorthand for whoever the server says this session holds entitlements for — the person
|
|
94
|
+
* under a project that bills users, the organization they are acting for under one that bills
|
|
95
|
+
* organizations. Nothing here names either, and nothing here should: the read carries the session and the
|
|
96
|
+
* server decides the holder, so no argument this file could pass would be one it was entitled to choose.
|
|
97
|
+
*
|
|
98
|
+
* `true` when payments is not composed, which is the same direction the session guard takes when there is
|
|
99
|
+
* no auth: a guard that arrives with a capability must not lock a screen when the capability is gone. The
|
|
100
|
+
* server is what protects the feature.
|
|
101
|
+
*
|
|
102
|
+
* A read that fails answers `false` — this is a route guard, so it is a lock, and a lock fails shut. The
|
|
103
|
+
* choice is written here rather than inherited from the reader: a caller that *named* the visitor's plan
|
|
104
|
+
* would need the opposite, and `getEntitlements` hands both callers the same honest answer.
|
|
105
|
+
*/
|
|
106
|
+
export async function holdsEntitlement(key: string): Promise<boolean> {
|
|
107
|
+
if (!paymentsConfig.enabled) return true;
|
|
108
|
+
const held = await getEntitlements(paymentsClient);
|
|
109
|
+
return held.ok && held.value.some((entitlement) => entitlement.key === key && entitlement.granted);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Who Paddle prices this visitor as, once there is a session to ask about.
|
|
114
|
+
*
|
|
115
|
+
* The customer it answers is the store customer for whoever the session holds purchases for — a person, or
|
|
116
|
+
* the organization they buy on behalf of — read on the server from that session and never named here.
|
|
117
|
+
*
|
|
118
|
+
* **Two renders, and that is the design rather than a flicker to hide.** A pricing screen paints before
|
|
119
|
+
* the session resolves, so the first figure is the IP estimate — labelled `Estimated.`, because it is —
|
|
120
|
+
* and the second is the price the checkout will charge, from the billing address on file. Every checkout
|
|
121
|
+
* on the web recalculates when the address arrives; the only thing that makes it a broken promise is a
|
|
122
|
+
* first figure that did not admit what it was.
|
|
123
|
+
*
|
|
124
|
+
* `ask` is false for a stranger and while the session is still being read, so a marketing page makes no
|
|
125
|
+
* round trip for an answer known in advance to be "nobody".
|
|
126
|
+
*
|
|
127
|
+
* The reading and the guarding live in `@pithy-sh/payments`, not here — this file is yours from the day
|
|
128
|
+
* it is written, and where a customer id comes from is not a thing to freeze into your repository. A
|
|
129
|
+
* failed read answers null, which quotes from the IP and says so.
|
|
130
|
+
*/
|
|
131
|
+
export function usePriceVisitor(ask: boolean, options?: PriceVisitorOptions): PriceVisitor | null {
|
|
132
|
+
const [visitor, setVisitor] = useState<PriceVisitor | null>(null);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!ask || !paymentsConfig.enabled) return;
|
|
136
|
+
let live = true;
|
|
137
|
+
void fetchPriceVisitor(options).then((answer) => {
|
|
138
|
+
if (live) setVisitor(answer);
|
|
139
|
+
});
|
|
140
|
+
return () => {
|
|
141
|
+
live = false;
|
|
142
|
+
};
|
|
143
|
+
// `options` is `paymentsClient`, a module constant, so this asks once per session resolution.
|
|
144
|
+
}, [ask, options]);
|
|
145
|
+
|
|
146
|
+
return visitor;
|
|
147
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import authModule from "virtual:pithy/auth";
|
|
2
|
+
import i18nModule from "virtual:pithy/i18n";
|
|
3
|
+
import paymentsModule from "virtual:pithy/payments";
|
|
4
|
+
import turnstileModule from "virtual:pithy/turnstile";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The composed capabilities' client-safe config, narrowed once for the whole client.
|
|
8
|
+
*
|
|
9
|
+
* `virtual:pithy/<capability>` is a union discriminated on `enabled`, because a capability that is
|
|
10
|
+
* not composed projects `{ enabled: false }` and nothing else — that is what makes importing an
|
|
11
|
+
* absent capability safe instead of a build error. Narrowing at every use would put a guard in front
|
|
12
|
+
* of every field read, so it happens here instead, once.
|
|
13
|
+
*
|
|
14
|
+
* The `enabled: false` branch carries the capability's own defaults rather than `undefined`, so a
|
|
15
|
+
* screen reads a field without a guard. Those defaults are not a fallback anyone should rely on:
|
|
16
|
+
* `pithy ui add --auth` refuses a Worker that does not compose `auth`, so on a scaffolded project the
|
|
17
|
+
* enabled branch is always the live one. They exist for the case where the capability is removed from
|
|
18
|
+
* `pithy.config.ts` *after* the scaffold — the screens keep compiling, and `enabled` is there so one
|
|
19
|
+
* can bail cleanly rather than render a form that posts nowhere.
|
|
20
|
+
*/
|
|
21
|
+
export const authConfig = authModule.enabled
|
|
22
|
+
? authModule
|
|
23
|
+
: {
|
|
24
|
+
enabled: false as const,
|
|
25
|
+
basePath: "/auth",
|
|
26
|
+
providers: { google: false, apple: false, facebook: false, github: false },
|
|
27
|
+
otpLength: 6,
|
|
28
|
+
signUpEnabled: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Turnstile's projection, narrowed the same way. `enabled` is false unless a login widget resolved. */
|
|
32
|
+
export const turnstileConfig = turnstileModule.enabled
|
|
33
|
+
? turnstileModule
|
|
34
|
+
: {
|
|
35
|
+
enabled: false as const,
|
|
36
|
+
sitekey: "",
|
|
37
|
+
// Blank for the same reason the sitekey is: a widget that cannot render has no action to solve
|
|
38
|
+
// for, and a plausible-looking default here would be a second copy of a string the enabled branch
|
|
39
|
+
// is the only statement of (#377).
|
|
40
|
+
action: "",
|
|
41
|
+
mode: "visible" as const,
|
|
42
|
+
token: { field: "cf-turnstile-response", header: null },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Payments' projection, narrowed the same way. `enabled` is false when the capability is not composed, and
|
|
47
|
+
* also when it is composed with an empty catalog — a paywall with nothing on it is nothing to render, so
|
|
48
|
+
* the two read alike and a screen branches once.
|
|
49
|
+
*
|
|
50
|
+
* Only the browser-safe half of the catalog is here. Apple's issuer id, Google's service-account
|
|
51
|
+
* credentials and Stripe's secret and signing keys live in the secrets store and never enter a projection.
|
|
52
|
+
*/
|
|
53
|
+
export const paymentsConfig = paymentsModule.enabled
|
|
54
|
+
? paymentsModule
|
|
55
|
+
: {
|
|
56
|
+
enabled: false as const,
|
|
57
|
+
environment: "dev",
|
|
58
|
+
rails: { apple: false, google: false, stripe: false, lemonSqueezy: false, paddle: false },
|
|
59
|
+
basePath: "/payments",
|
|
60
|
+
paddle: null as { clientToken: string; environment: "sandbox" | "production"; checkout: string } | null,
|
|
61
|
+
products: [] as {
|
|
62
|
+
id: string;
|
|
63
|
+
type: "consumable" | "non_consumable" | "subscription";
|
|
64
|
+
entitlements: string[];
|
|
65
|
+
name: string;
|
|
66
|
+
skus: { stripe: string | null; lemonSqueezy: string | null; paddle: string | null };
|
|
67
|
+
}[],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The i18n capability's projection, narrowed the same way. `enabled` is false unless the capability is
|
|
72
|
+
* composed, and then every screen renders the English it was scaffolded with — which is the whole of
|
|
73
|
+
* what makes this capability optional.
|
|
74
|
+
*
|
|
75
|
+
* **Locale metadata only. Never catalogs.** The projection is inlined into the main chunk, so a catalog
|
|
76
|
+
* carried here would be downloaded by every reader in every language before the first paint. Catalogs
|
|
77
|
+
* arrive by dynamic import, one chunk per locale, from `@pithy-sh/i18n`.
|
|
78
|
+
*
|
|
79
|
+
* The disabled branch is the kit's own defaults, for the reason the three above carry theirs: a screen
|
|
80
|
+
* reads a field without a guard. `en` is not a claim that this project speaks English — it is what the
|
|
81
|
+
* templates are written in, and it is what `t()` falls back to when nothing negotiated.
|
|
82
|
+
*/
|
|
83
|
+
export const i18nConfig = i18nModule.enabled
|
|
84
|
+
? i18nModule
|
|
85
|
+
: {
|
|
86
|
+
enabled: false as const,
|
|
87
|
+
supportedLocales: ["en"],
|
|
88
|
+
defaultLocale: "en",
|
|
89
|
+
queryParam: "lang",
|
|
90
|
+
storageKey: "pithy.locale",
|
|
91
|
+
browserResolvers: ["query", "account", "storage", "navigator", "server", "default"],
|
|
92
|
+
exceptions: {} as Record<string, string>,
|
|
93
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { KIT_CATALOGS } from "@pithy-sh/i18n/src/catalogs/kit";
|
|
4
|
+
import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
|
|
5
|
+
import { act, type ReactNode } from "react";
|
|
6
|
+
import { createRoot } from "react-dom/client";
|
|
7
|
+
import { expect, test, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* **The document may not declare a language the page does not speak.**
|
|
11
|
+
*
|
|
12
|
+
* `client.tsx` negotiated a locale and put it on `<html lang>`, and nothing mounted a translator behind
|
|
13
|
+
* it. A project composing `i18n` therefore served `<html lang="es">` over a page rendering the English
|
|
14
|
+
* every screen bakes — and that is worse than leaving `lang="en"` alone, because assistive technology
|
|
15
|
+
* believes the attribute: a screen reader switches voice and pronounces English with Spanish phonetics.
|
|
16
|
+
*
|
|
17
|
+
* `src/pithy-locale.tsx` is what makes the two one statement, and this is the gate that keeps them one
|
|
18
|
+
* after the file is yours. Both halves are asserted from a single mount, which is the whole point — a
|
|
19
|
+
* test that checked them separately would pass on a page where each was independently reasonable.
|
|
20
|
+
*
|
|
21
|
+
* ## What this proves, and how it goes red
|
|
22
|
+
*
|
|
23
|
+
* The projection is mocked to negotiate a locale the kit has a real translation for, and a probe screen
|
|
24
|
+
* is rendered underneath with **invented** English baked into it. The probe's rendered text must be the
|
|
25
|
+
* kit's Spanish and not the English it carries, and `document.documentElement.lang` must be the same
|
|
26
|
+
* locale that produced those words. Delete the provider and the probe renders its baked English; delete
|
|
27
|
+
* the negotiation and `lang` never moves. Either way this goes red.
|
|
28
|
+
*
|
|
29
|
+
* The invented English is what makes the first assertion mean anything: a plausible sentence could
|
|
30
|
+
* coincide with a catalog entry, and then a page that mounted no provider at all would still pass.
|
|
31
|
+
*
|
|
32
|
+
* `./pithy-config` is mocked for the reason `src/turnstile.test.tsx` and `src/routes/pithy/sign-in.test.tsx`
|
|
33
|
+
* mock it — it imports the `virtual:pithy/*` modules, which only a Vite build serves. Mocked here it is
|
|
34
|
+
* also the input: this is the one gate whose subject is what the projection says.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The locale the mocked projection negotiates. Not `en`, on purpose — see below. */
|
|
38
|
+
const CANARY_LOCALE = "es";
|
|
39
|
+
|
|
40
|
+
/** The key the probe renders. Base-group copy: `router.tsx` says it while a route loads. */
|
|
41
|
+
const KEY = "app/loading";
|
|
42
|
+
|
|
43
|
+
/** What the probe bakes for {@link KEY}. Invented here, so no catalog can answer with it by accident. */
|
|
44
|
+
const CANARY_ENGLISH = "pithy-gate-canary-untranslated";
|
|
45
|
+
|
|
46
|
+
vi.mock("./pithy-config", () => ({
|
|
47
|
+
i18nConfig: {
|
|
48
|
+
enabled: true,
|
|
49
|
+
supportedLocales: [CANARY_LOCALE],
|
|
50
|
+
defaultLocale: CANARY_LOCALE,
|
|
51
|
+
queryParam: "lang",
|
|
52
|
+
storageKey: "pithy.locale",
|
|
53
|
+
// Only the link that cannot read a global, so this case is about the wiring and not about whatever
|
|
54
|
+
// a test environment happens to have in `localStorage` or on the URL.
|
|
55
|
+
browserResolvers: ["default"],
|
|
56
|
+
exceptions: {},
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// React refuses to run `act` unless the environment says it is a test one.
|
|
61
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How long the catalog gets to land, and how long each poll waits.
|
|
65
|
+
*
|
|
66
|
+
* **A deadline in milliseconds, waited on — not a fixed number of turns, and not no wait at all.** The
|
|
67
|
+
* catalog arrives by dynamic `import()`, so how long it takes is the machine's answer and not this
|
|
68
|
+
* file's: a cold import plus its transform under a loaded CI runner is orders of magnitude slower than
|
|
69
|
+
* a warm one, and this file is copied into every adopter's repository, where the first `vitest run`
|
|
70
|
+
* after scaffolding is the coldest run that will ever happen there. A gate that reds on a file the
|
|
71
|
+
* adopter never wrote is a gate they delete.
|
|
72
|
+
*
|
|
73
|
+
* Ten seconds is hundreds of times the measured cold cost, and the case below states its own 30s
|
|
74
|
+
* timeout so the deadline is what reports the failure rather than Vitest's default five — which the
|
|
75
|
+
* kit raises for its own suites and an adopter's runner does not.
|
|
76
|
+
*/
|
|
77
|
+
const SETTLE_DEADLINE_MS = 10_000;
|
|
78
|
+
const SETTLE_POLL_MS = 5;
|
|
79
|
+
const CASE_TIMEOUT_MS = 30_000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wait until the translator has mounted, rather than assuming it has by now.
|
|
83
|
+
*
|
|
84
|
+
* The transition **is** the signal: until the catalog lands the tree renders {@link CANARY_ENGLISH},
|
|
85
|
+
* and the whole assertion is that it stops. Polling on a macrotask rather than flushing microtasks,
|
|
86
|
+
* because `loadKitCatalog` is a real `import()` and Vitest resolves that off the module graph rather
|
|
87
|
+
* than out of the current tick.
|
|
88
|
+
*/
|
|
89
|
+
async function untilTranslated(container: HTMLElement): Promise<void> {
|
|
90
|
+
const deadline = Date.now() + SETTLE_DEADLINE_MS;
|
|
91
|
+
while (container.textContent === CANARY_ENGLISH) {
|
|
92
|
+
if (Date.now() > deadline) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`The tree still renders its baked English after ${SETTLE_DEADLINE_MS}ms — no translator was mounted over it.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
await act(async () => {
|
|
98
|
+
await new Promise((resume) => setTimeout(resume, SETTLE_POLL_MS));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A screen, as every seeded screen is written: one key, and the English it was scaffolded with. */
|
|
104
|
+
function Probe(): ReactNode {
|
|
105
|
+
const t = useTranslator({ [KEY]: CANARY_ENGLISH });
|
|
106
|
+
return t.t(KEY);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
test(
|
|
110
|
+
"the page renders the language the document declares",
|
|
111
|
+
async () => {
|
|
112
|
+
// The canary, refused. Run this at `en` and it passes against the exact defect it exists to catch:
|
|
113
|
+
// a page with no provider at all renders English, and `lang="en"` agrees with it.
|
|
114
|
+
expect(CANARY_LOCALE).not.toBe("en");
|
|
115
|
+
// And the kit really translates the key, so "not the baked English" is a reachable state.
|
|
116
|
+
const translated = KIT_CATALOGS[CANARY_LOCALE]?.[KEY];
|
|
117
|
+
expect(translated, `the kit ships no ${CANARY_LOCALE} for ${KEY}`).toBeTypeOf("string");
|
|
118
|
+
expect(translated).not.toBe(CANARY_ENGLISH);
|
|
119
|
+
|
|
120
|
+
const { PithyLocale } = await import("./pithy-locale");
|
|
121
|
+
const container = document.body.appendChild(document.createElement("div"));
|
|
122
|
+
await act(async () => {
|
|
123
|
+
createRoot(container).render(
|
|
124
|
+
<PithyLocale>
|
|
125
|
+
<Probe />
|
|
126
|
+
</PithyLocale>,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// The catalog arrives by dynamic import, so the provider mounts some time after the first paint —
|
|
131
|
+
// how long is the machine's answer, not this file's. Waited on rather than counted out.
|
|
132
|
+
await untilTranslated(container);
|
|
133
|
+
|
|
134
|
+
expect(container.textContent, "the tree rendered its baked English — no translator was mounted over it").toBe(
|
|
135
|
+
translated,
|
|
136
|
+
);
|
|
137
|
+
expect(document.documentElement.lang, "the document declares a language nothing negotiated").toBe(CANARY_LOCALE);
|
|
138
|
+
},
|
|
139
|
+
CASE_TIMEOUT_MS,
|
|
140
|
+
);
|