@tribe-nest/forge 3.25.0 → 3.29.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/package.json +1 -1
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeFunnel.ts +35 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +22 -0
- package/src/contexts/CartContext.tsx +84 -1
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useForms.ts +48 -3
- package/src/data/queries/usePageActions.ts +2 -2
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +960 -0
- package/src/i18n/en.json +960 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +59 -14
- package/src/ui/headless/event/useEventCheckout.ts +3 -0
- package/src/ui/headless/funnel/Funnel.tsx +13 -0
- package/src/ui/headless/index.ts +18 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +197 -93
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BundleConfirmation.tsx +86 -46
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +189 -88
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +311 -125
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +22 -14
- package/src/ui/styled/MembershipCheckout.tsx +32 -13
- package/src/ui/styled/MembershipTiers.tsx +27 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PageActions.tsx +13 -9
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ProductGrid.tsx +17 -4
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +27 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +29 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
1
2
|
import type { FormData, FormAnswerEntry } from "../../types/models";
|
|
2
3
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
4
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
@@ -18,14 +19,58 @@ export function usePublicForm(formId?: string) {
|
|
|
18
19
|
});
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
export interface SubmitPublicFormInput {
|
|
23
|
+
answers: FormAnswerEntry[];
|
|
24
|
+
/**
|
|
25
|
+
* Send this whenever the form collects an email.
|
|
26
|
+
*
|
|
27
|
+
* It is what resolves the submission to a CRM contact, and therefore what
|
|
28
|
+
* makes the lead attributable to the funnel at all. A submission without it
|
|
29
|
+
* is stored, but anonymous: no contact, no attribution, and no automation,
|
|
30
|
+
* because the form's automations are contact-scoped.
|
|
31
|
+
*/
|
|
32
|
+
respondentEmail?: string;
|
|
33
|
+
respondentName?: string;
|
|
34
|
+
metadata?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function newIdempotencyKey(): string {
|
|
38
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
39
|
+
return `fk_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Submit a public form.
|
|
44
|
+
*
|
|
45
|
+
* Accepts either a bare answers array or the full input. The full form is what
|
|
46
|
+
* a funnel step wants, because `respondentEmail` is what turns a submission
|
|
47
|
+
* into a lead.
|
|
48
|
+
*
|
|
49
|
+
* **Idempotency.** A key is minted per mount and rotated after each successful
|
|
50
|
+
* submit. A double-click or a network retry therefore reuses it and replays the
|
|
51
|
+
* first submission instead of writing a second row or re-firing the form's
|
|
52
|
+
* automations. Going back and deliberately submitting again is a new mount, so
|
|
53
|
+
* a new key and a genuine second submission. Nothing is ever overwritten: a
|
|
54
|
+
* submission can be the record of what somebody agreed to.
|
|
55
|
+
*/
|
|
22
56
|
export function useSubmitPublicForm(formId?: string) {
|
|
23
57
|
const { client } = useForge();
|
|
58
|
+
const keyRef = useRef<string>(newIdempotencyKey());
|
|
24
59
|
|
|
25
60
|
return useMutation({
|
|
26
|
-
mutationFn: async (
|
|
27
|
-
const
|
|
61
|
+
mutationFn: async (input: FormAnswerEntry[] | SubmitPublicFormInput) => {
|
|
62
|
+
const payload = Array.isArray(input) ? { answers: input } : input;
|
|
63
|
+
const res = await client.post(`/public/forms/${formId}/submit`, {
|
|
64
|
+
...payload,
|
|
65
|
+
idempotencyKey: keyRef.current,
|
|
66
|
+
});
|
|
28
67
|
return res.data;
|
|
29
68
|
},
|
|
69
|
+
// Rotate only once the write has landed. A failed submit keeps the key so
|
|
70
|
+
// the retry is recognised as the same act; a successful one starts a fresh
|
|
71
|
+
// act, so a form left mounted for corrections records each as its own.
|
|
72
|
+
onSuccess: () => {
|
|
73
|
+
keyRef.current = newIdempotencyKey();
|
|
74
|
+
},
|
|
30
75
|
});
|
|
31
76
|
}
|
|
@@ -5,9 +5,9 @@ export type PageActionType =
|
|
|
5
5
|
| "newsletter"
|
|
6
6
|
| "lead_magnet"
|
|
7
7
|
| "offer"
|
|
8
|
+
// Products on a page. `config.isAddon` decides whether they can be bought on
|
|
9
|
+
// their own or only alongside what the page is selling.
|
|
8
10
|
| "product_cards"
|
|
9
|
-
// Specific products sold as attachments to the page's own entity.
|
|
10
|
-
| "addons"
|
|
11
11
|
| "donation"
|
|
12
12
|
| "membership"
|
|
13
13
|
| "button";
|
|
@@ -7,6 +7,13 @@ export interface SiteConfig {
|
|
|
7
7
|
/** The tenant's settlement currency (e.g. "USD") — the currency API amounts
|
|
8
8
|
* are in and payments are charged in. */
|
|
9
9
|
currency: string;
|
|
10
|
+
/** The language the tenant speaks ("en" | "de" today), from
|
|
11
|
+
* `profile_configurations.locale`. Runtime, like `currency`: Forge renders its
|
|
12
|
+
* own copy in it, the site puts it on `<html lang>`, and the axios client sends
|
|
13
|
+
* it as `x-locale` so API messages arrive in the same language. Optional here
|
|
14
|
+
* because a site pinned to an older backend gets no such field, and every
|
|
15
|
+
* reader falls back to English. */
|
|
16
|
+
locale?: string;
|
|
10
17
|
/** Sales-tax display mode: true = listing prices already contain tax
|
|
11
18
|
* ("incl. VAT" style, EU/UK), false/absent = tax added at checkout (US).
|
|
12
19
|
* Display-only — checkout totals come from the authoritative taxQuote. */
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Forge i18n convention
|
|
2
|
+
|
|
3
|
+
> **`de.json` is machine-authored and has NOT been reviewed by a native speaker.**
|
|
4
|
+
> Treat every German string as provisional. The initiative doc names this as a
|
|
5
|
+
> real risk: a fan reading awkward or wrong copy at checkout is a conversion
|
|
6
|
+
> problem, not a cosmetic one. A native review has to happen before any tenant
|
|
7
|
+
> ships a German storefront.
|
|
8
|
+
|
|
9
|
+
This file is the rule. Eight agents are converting components in parallel, so
|
|
10
|
+
the naming has to be mechanical enough that two people who never spoke produce
|
|
11
|
+
the same key for the same string.
|
|
12
|
+
|
|
13
|
+
Spec: [`docs/proposals/i18n-initiative.md`](../../../../docs/proposals/i18n-initiative.md),
|
|
14
|
+
Workstream 2, P0 and P1.
|
|
15
|
+
|
|
16
|
+
## The import line
|
|
17
|
+
|
|
18
|
+
Every component under `src/ui/styled/` uses exactly this:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { useForgeT } from "../../i18n";
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
One directory deeper (`src/ui/styled/chat/`, `styled/work/`, `styled/members/`,
|
|
25
|
+
`styled/community/`) it is `"../../../i18n"`. The rule is: a relative path to
|
|
26
|
+
`src/i18n`, never a package self-import, never the `@/` alias. Sites consume
|
|
27
|
+
this package from source, so a self-import would resolve here and break there.
|
|
28
|
+
|
|
29
|
+
Call it at the top of the component, beside the other hooks:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
export function CartSummary() {
|
|
33
|
+
const t = useForgeT();
|
|
34
|
+
return <button>{t("forge.cart.checkout")}</button>;
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`t` is a plain function, not an object. Do not destructure anything off it.
|
|
39
|
+
|
|
40
|
+
## The key shape
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
forge.<component>.<slug>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Exactly three segments, all lowercase.
|
|
47
|
+
|
|
48
|
+
| Segment | Rule |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `forge.` | Mandatory on every key, no exceptions. It is the namespace that makes a collision with a tenant's own copy impossible. |
|
|
51
|
+
| `<component>` | The component file's name in `snake_case`, minus the extension. `CartSummary.tsx` gives `cart_summary`. `AiAgentWidget.tsx` gives `ai_agent_widget`. |
|
|
52
|
+
| `<slug>` | What the string is for, in `snake_case`, 1 to 4 words. |
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
forge.cart_summary.checkout
|
|
56
|
+
forge.cart_summary.empty_title
|
|
57
|
+
forge.event_card.sold_out
|
|
58
|
+
forge.booking_form.submit_error
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Keys are **flat strings containing dots**, not nested objects. `en.json` is one
|
|
62
|
+
level deep:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"forge.cart_summary.checkout": "Checkout",
|
|
67
|
+
"forge.cart_summary.empty_title": "Your cart is empty"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Flat is deliberate. Eight agents appending to two shared JSON files produce
|
|
72
|
+
line-level conflicts that git resolves; nested objects produce structural
|
|
73
|
+
conflicts that it does not.
|
|
74
|
+
|
|
75
|
+
### Naming the slug
|
|
76
|
+
|
|
77
|
+
Name it after the **meaning**, never the rendered English, and never the
|
|
78
|
+
position.
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
NO forge.cart_summary.your_cart_is_empty (renames when the copy is edited)
|
|
82
|
+
NO forge.cart_summary.text_2 (says nothing)
|
|
83
|
+
NO forge.cart_summary.bottom_button (says where, not what)
|
|
84
|
+
YES forge.cart_summary.empty_title
|
|
85
|
+
YES forge.cart_summary.checkout
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Conventional slugs, so the same concept lands on the same word everywhere:
|
|
89
|
+
`title`, `subtitle`, `description`, `empty_title`, `empty_body`, `submit`,
|
|
90
|
+
`cancel`, `retry`, `loading`, `error`, `success`, `confirm_title`,
|
|
91
|
+
`confirm_body`, `aria_close`.
|
|
92
|
+
|
|
93
|
+
### Shared strings
|
|
94
|
+
|
|
95
|
+
A string used by several components is **not** hoisted to a `forge.common.*`
|
|
96
|
+
bucket by default. Duplicate it under each component's namespace. Shared keys
|
|
97
|
+
look like savings and turn into the thing nobody can safely edit, because the
|
|
98
|
+
edit lands in four screens at once.
|
|
99
|
+
|
|
100
|
+
The single exception is `forge.common.*`, reserved for strings whose wording is
|
|
101
|
+
a product decision rather than a component's copy, and that decision is already
|
|
102
|
+
recorded elsewhere. Do not add to it during the conversion pass without saying
|
|
103
|
+
so in the PR.
|
|
104
|
+
|
|
105
|
+
## Interpolation
|
|
106
|
+
|
|
107
|
+
`{var}` placeholders, filled from the second argument:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{ "forge.order_summary.item_count": "{count} items" }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
t("forge.order_summary.item_count", { count: items.length })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Rules:
|
|
118
|
+
|
|
119
|
+
- Placeholder names are `snake_case` and describe the value (`{count}`,
|
|
120
|
+
`{artist_name}`, `{date}`), never `{0}` or `{value}`.
|
|
121
|
+
- A placeholder with no matching var is left standing as `{count}`. It is not
|
|
122
|
+
blanked, so an unfilled slot is visible in review.
|
|
123
|
+
- **Never concatenate.** `t("forge.x.hello") + " " + name` cannot be translated,
|
|
124
|
+
because German puts the pieces in a different order. Put the whole sentence in
|
|
125
|
+
one key with a placeholder.
|
|
126
|
+
- Numbers, currency and dates are formatted by the existing Forge formatters,
|
|
127
|
+
then passed in as a var. Do not build them out of translated fragments.
|
|
128
|
+
|
|
129
|
+
## What does NOT get a key
|
|
130
|
+
|
|
131
|
+
- Machine values: ids, slugs, enum values, URLs, query keys, `data-*`
|
|
132
|
+
attributes, CSS class names, analytics event names.
|
|
133
|
+
- Values that come from the API. A product title is the tenant's content, not
|
|
134
|
+
Forge's copy, and translating it is a different feature.
|
|
135
|
+
- Developer diagnostics no visitor reads (a `console.warn`, a thrown
|
|
136
|
+
programming error).
|
|
137
|
+
|
|
138
|
+
Everything a visitor can read does get a key, including `aria-label`,
|
|
139
|
+
`placeholder`, `title` and `alt` text. Those are the ones the conversion pass
|
|
140
|
+
misses.
|
|
141
|
+
|
|
142
|
+
## Adding a string
|
|
143
|
+
|
|
144
|
+
1. Add the key to `en.json` with the exact English the component renders today.
|
|
145
|
+
Do not improve the copy in the same change. A translation pass that also
|
|
146
|
+
rewords is unreviewable.
|
|
147
|
+
2. Add the same key to `de.json`. Never leave it out: a key present in `en` and
|
|
148
|
+
absent in `de` falls back to English, which reads as a half-translated page
|
|
149
|
+
rather than as an error.
|
|
150
|
+
3. Replace the literal with `t("...")`.
|
|
151
|
+
|
|
152
|
+
Both files stay sorted by key, so a diff is a diff and not a reshuffle.
|
|
153
|
+
|
|
154
|
+
## The fallback chain
|
|
155
|
+
|
|
156
|
+
`t(key)` resolves in this order:
|
|
157
|
+
|
|
158
|
+
1. the active locale's bundle,
|
|
159
|
+
2. the English bundle,
|
|
160
|
+
3. the key string itself.
|
|
161
|
+
|
|
162
|
+
It never returns empty. A missing key renders as `forge.cart_summary.checkout`
|
|
163
|
+
on the page, which is ugly on purpose: an untranslated string should be visible,
|
|
164
|
+
not invisible.
|
|
165
|
+
|
|
166
|
+
Outside a `<ForgeI18nProvider>` the hook returns a working English translator
|
|
167
|
+
instead of throwing, so a component still renders standalone. This mirrors
|
|
168
|
+
`useFunnelStep`, which returns a working no-op outside a `<Funnel>` for the same
|
|
169
|
+
reason.
|
|
170
|
+
|
|
171
|
+
## Locales
|
|
172
|
+
|
|
173
|
+
`en` and `de` only, and `en` is the source language. `resolveForgeLocale`
|
|
174
|
+
narrows anything a site passes ("de-DE", "DE", `undefined`, "fr") to one of the
|
|
175
|
+
two, defaulting to `en`. Adding a third locale means a third JSON file and one
|
|
176
|
+
entry in `FORGE_LOCALES`.
|
|
177
|
+
|
|
178
|
+
## Bundled, not fetched
|
|
179
|
+
|
|
180
|
+
The JSON ships inside the package and is imported synchronously. There is no
|
|
181
|
+
network call, no loading state and no API surface for translations. A site
|
|
182
|
+
therefore picks up new or corrected copy on its next Forge upgrade. That
|
|
183
|
+
adoption lag is accepted, because the always-latest initiative removes it for
|
|
184
|
+
every package at once. See "Why bundle-only is safe" in the initiative doc.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
import en from "../en.json";
|
|
6
|
+
import de from "../de.json";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every `forge.*` key a component references must exist in EVERY bundle.
|
|
10
|
+
*
|
|
11
|
+
* `translateForge` resolves a key through the active locale, then English, then
|
|
12
|
+
* the key string itself. That last step is deliberate (see CONVENTION.md, "The
|
|
13
|
+
* fallback chain"): a missing key must be visible rather than blank. But
|
|
14
|
+
* "visible" only helps if somebody looks. Nothing throws, nothing warns, and no
|
|
15
|
+
* component spec fails, so the failure mode of a typo is a fan reading
|
|
16
|
+
* `forge.cart.checkout` on a checkout button.
|
|
17
|
+
*
|
|
18
|
+
* The de-only half is quieter still. A key present in `en.json` and absent from
|
|
19
|
+
* `de.json` silently falls back to English, so a German storefront renders a
|
|
20
|
+
* page that is 90% German with an English button in the middle of it. There is
|
|
21
|
+
* no error anywhere. This spec is the only thing that reports it.
|
|
22
|
+
*
|
|
23
|
+
* ## Why the scanner matches string literals, not `t(` call sites
|
|
24
|
+
*
|
|
25
|
+
* The obvious scanner is `/t\(\s*"(forge\.[^"]+)"/`. It would miss most of the
|
|
26
|
+
* risk in this package, because Forge routinely reaches a key through a
|
|
27
|
+
* variable:
|
|
28
|
+
*
|
|
29
|
+
* const TAB_LABELS = { orders: "forge.account_dashboard.tab_orders", ... };
|
|
30
|
+
* tr(TAB_LABELS[tab])
|
|
31
|
+
*
|
|
32
|
+
* t(isPaid ? "forge.bundle_confirmation.summary_paid" : "...summary_order")
|
|
33
|
+
*
|
|
34
|
+
* A key that reaches `t` through a lookup map or a ternary is exactly the key a
|
|
35
|
+
* rename breaks without tsc noticing, and it is exactly the key a call-site
|
|
36
|
+
* regex cannot see. So the scanner matches any `"forge.…"` string literal in
|
|
37
|
+
* the source, wherever it sits. It also makes the guard indifferent to what the
|
|
38
|
+
* translator is called at the call site: this package binds `useForgeT()` to
|
|
39
|
+
* `t`, `tr` and `translate` in different files, and a call-site regex would
|
|
40
|
+
* have to know all three.
|
|
41
|
+
*
|
|
42
|
+
* The cost is that a `forge.*` literal that is NOT a translation key would be
|
|
43
|
+
* flagged. Machine values never carry the `forge.` prefix (CONVENTION.md, "What
|
|
44
|
+
* does NOT get a key"), so that cost is theoretical, and a false positive here
|
|
45
|
+
* is a one-line fix while a false negative ships broken copy.
|
|
46
|
+
*
|
|
47
|
+
* `src/i18n/` itself is excluded from the walk: the `useForgeT` docblock shows
|
|
48
|
+
* `t("forge.cart.item_count", …)` as an EXAMPLE, and an example is not a usage.
|
|
49
|
+
* The bundles are imported directly rather than scanned for the same reason.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** `src`, two levels up from `src/i18n/_tests`. */
|
|
53
|
+
const SRC = join(__dirname, "..", "..");
|
|
54
|
+
|
|
55
|
+
/** The bundle files are flat `Record<string, string>` by convention. */
|
|
56
|
+
const LOCALES: Record<string, Record<string, string>> = {
|
|
57
|
+
en: en as Record<string, string>,
|
|
58
|
+
de: de as Record<string, string>,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Any bundle key literal, wherever it appears. See the docblock above. */
|
|
62
|
+
const KEY_LITERAL = /"(forge\.[a-zA-Z0-9_]+\.[a-zA-Z0-9_.]+)"/g;
|
|
63
|
+
|
|
64
|
+
/** Forge interpolates single braces (`{count}`), not the `{{count}}` of i18next. */
|
|
65
|
+
const PLACEHOLDER = /\{\s*([a-zA-Z0-9_]+)\s*\}/g;
|
|
66
|
+
|
|
67
|
+
const walk = (dir: string, out: string[] = []): string[] => {
|
|
68
|
+
for (const entry of readdirSync(dir)) {
|
|
69
|
+
// `i18n` holds the bundles and the docblock example; `_tests` holds specs,
|
|
70
|
+
// which assert on rendered copy rather than on keys.
|
|
71
|
+
if (entry === "node_modules" || entry === "_tests" || entry === "i18n") continue;
|
|
72
|
+
const full = join(dir, entry);
|
|
73
|
+
if (statSync(full).isDirectory()) walk(full, out);
|
|
74
|
+
else if (/\.tsx?$/.test(entry) && !/\.(spec|test|d)\.tsx?$/.test(entry)) out.push(full);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** key -> the source files that name it, for a failure message worth reading. */
|
|
80
|
+
const usedKeys = (): Map<string, string[]> => {
|
|
81
|
+
const found = new Map<string, string[]>();
|
|
82
|
+
for (const file of walk(SRC)) {
|
|
83
|
+
const text = readFileSync(file, "utf8");
|
|
84
|
+
for (const match of text.matchAll(KEY_LITERAL)) {
|
|
85
|
+
const key = match[1];
|
|
86
|
+
const where = found.get(key) ?? [];
|
|
87
|
+
where.push(file.replace(`${SRC}/`, ""));
|
|
88
|
+
found.set(key, where);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const placeholders = (value: unknown): string[] =>
|
|
95
|
+
typeof value === "string" ? [...value.matchAll(PLACEHOLDER)].map((m) => m[1]).sort() : [];
|
|
96
|
+
|
|
97
|
+
describe("Forge i18n: every key a component uses exists in every bundle", () => {
|
|
98
|
+
const used = usedKeys();
|
|
99
|
+
|
|
100
|
+
it("finds a meaningful number of keys, so the scanner itself still works", () => {
|
|
101
|
+
// A regex that quietly stopped matching would make every assertion below
|
|
102
|
+
// pass vacuously. Pin a floor rather than an exact count, so adding copy
|
|
103
|
+
// does not fail the guard.
|
|
104
|
+
expect(used.size).toBeGreaterThan(500);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (const [lang, bundle] of Object.entries(LOCALES)) {
|
|
108
|
+
it(`${lang}: no key used in src is missing from the bundle`, () => {
|
|
109
|
+
const missing = [...used.entries()]
|
|
110
|
+
.filter(([key]) => bundle[key] === undefined)
|
|
111
|
+
.map(([key, files]) => `${key} <- ${[...new Set(files)].join(", ")}`);
|
|
112
|
+
|
|
113
|
+
expect(
|
|
114
|
+
missing,
|
|
115
|
+
`Missing ${lang} copy. translateForge would render the raw key to a visitor:\n${missing.join("\n")}`,
|
|
116
|
+
).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it("en.json and de.json carry the same key set", () => {
|
|
121
|
+
const enKeys = new Set(Object.keys(LOCALES.en));
|
|
122
|
+
for (const [lang, bundle] of Object.entries(LOCALES)) {
|
|
123
|
+
if (lang === "en") continue;
|
|
124
|
+
const missing = [...enKeys].filter((k) => bundle[k] === undefined);
|
|
125
|
+
const extra = Object.keys(bundle).filter((k) => !enKeys.has(k));
|
|
126
|
+
|
|
127
|
+
// Missing means a silent English fallback inside a translated page.
|
|
128
|
+
expect(missing, `${lang} is missing keys English has:\n${missing.join("\n")}`).toEqual([]);
|
|
129
|
+
// Extra means copy nobody can ever read, usually the residue of a rename
|
|
130
|
+
// applied to en.json alone.
|
|
131
|
+
expect(extra, `${lang} has keys English does not:\n${extra.join("\n")}`).toEqual([]);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("no bundle disagrees with English about interpolation placeholders", () => {
|
|
136
|
+
// A `{count}` present in one locale and absent from another is a value that
|
|
137
|
+
// vanishes from the sentence for that locale only, and it renders as a
|
|
138
|
+
// grammatical sentence with the number missing, which reads as correct.
|
|
139
|
+
const mismatches: string[] = [];
|
|
140
|
+
for (const [lang, bundle] of Object.entries(LOCALES)) {
|
|
141
|
+
if (lang === "en") continue;
|
|
142
|
+
for (const [key, value] of Object.entries(LOCALES.en)) {
|
|
143
|
+
if (bundle[key] === undefined) continue;
|
|
144
|
+
const source = placeholders(value);
|
|
145
|
+
const target = placeholders(bundle[key]);
|
|
146
|
+
if (source.join(",") !== target.join(",")) {
|
|
147
|
+
mismatches.push(`${key}: en={${source.join(",")}} ${lang}={${target.join(",")}}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
expect(mismatches, `Interpolation drift:\n${mismatches.join("\n")}`).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|