@usequeek/theme-kit 0.1.20 → 0.1.22
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 +264 -77
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -0
- package/components/auth/auth-email-login-step.tsx +9 -7
- package/components/auth/auth-email-otp-step.tsx +9 -7
- package/components/auth/auth-email-otp-verify-step.tsx +8 -6
- package/components/auth/auth-email-register-step.tsx +10 -8
- package/components/auth/auth-signup-step.tsx +8 -6
- package/components/blog/category-filter.tsx +6 -5
- package/components/blog/pagination.tsx +8 -7
- package/components/blog/post-meta.tsx +15 -8
- package/components/blog/related-posts.tsx +5 -3
- package/components/blog/share-buttons.tsx +10 -8
- package/components/checkout/checkout-contact.tsx +9 -7
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/components/image.tsx +5 -5
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +144 -0
- package/components/markdown.tsx +1 -1
- package/components/powered-by-queek.tsx +4 -4
- package/components/product-media-frame.tsx +1 -1
- package/components/product-metafields.tsx +2 -3
- package/components/variant-picker.tsx +6 -9
- package/demo.ts +10 -14
- package/design-token-preview.tsx +0 -2
- package/edit-mode-active.tsx +3 -4
- package/hooks/use-announcement.ts +1 -1
- package/hooks/use-auth-flow.tsx +19 -18
- package/hooks/use-locales.ts +107 -0
- package/hooks/use-posts.ts +1 -1
- package/hooks/use-product-detail.ts +1 -2
- package/hooks/use-product-sections.ts +4 -6
- package/hooks/use-products.ts +5 -8
- package/index.ts +16 -15
- package/locales/en.default.json +49 -7
- package/next-reference.d.ts +2 -4
- package/package.json +3 -2
- package/page-renderer.tsx +5 -6
- package/provider.tsx +22 -2
- package/scripts/verify-package.ts +6 -8
- package/sdk/client.ts +13 -12
- package/sdk/error-handler.ts +1 -1
- package/sdk/platform.ts +6 -8
- package/sdk/queek-client.ts +11 -12
- package/sdk/server-store-client.ts +13 -17
- package/shared-blocks/metaobjects.tsx +1 -1
- package/stores/cart-store.ts +2 -2
- package/stores/checkout-store.ts +1 -1
- package/stores/edit-preview-store.ts +5 -6
- package/stores/user-store.ts +4 -5
- package/strings/pseudo-locale.ts +263 -0
- package/strings/theme-strings.ts +185 -20
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +356 -2
- package/tests/fixtures/external-theme.tsx +18 -2
- package/tests/language-switcher.test.tsx +350 -0
- package/tests/list-pagination.test.tsx +8 -8
- package/tests/locale-switch.test.ts +197 -0
- package/tests/locale-transport.test.tsx +3 -3
- package/tests/markdown-render.test.ts +5 -6
- package/tests/orders-walk.test.ts +5 -5
- package/tests/page-renderer-signature.test.tsx +1 -1
- package/tests/powered-by-queek.test.tsx +3 -3
- package/tests/powered-by-url.test.ts +2 -2
- package/tests/public-surface.test.ts +1 -4
- package/tests/shopper-chrome-strings.test.tsx +271 -0
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +10 -7
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/tests/use-shop-prefetch.test.tsx +1 -1
- package/theme-mount.tsx +6 -11
- package/types/block.ts +7 -7
- package/types/media.ts +8 -9
- package/types/product.ts +8 -12
- package/types/theme.ts +26 -25
- package/types/vendor.ts +10 -12
- package/utils/announcement.ts +3 -3
- package/utils/benefit-icon.tsx +9 -11
- package/utils/brand.ts +7 -10
- package/utils/format.ts +100 -14
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +56 -5
- package/utils/menu-link.ts +1 -1
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- package/utils/vendor-host.ts +7 -11
package/sdk/queek-client.ts
CHANGED
|
@@ -5,13 +5,13 @@ import { triggerReauth } from './error-handler';
|
|
|
5
5
|
import { getActivePlatform } from './platform';
|
|
6
6
|
|
|
7
7
|
// This file is 'use client' — every export here only ever runs in the browser.
|
|
8
|
-
// ALWAYS same-origin-relative, on purpose: next.config
|
|
9
|
-
// proxies /api/v1/client/:path* to the real
|
|
10
|
-
// browser never needs (or should have) the
|
|
8
|
+
// ALWAYS same-origin-relative, on purpose: the host app's `next.config` rewrite
|
|
9
|
+
// already proxies /api/v1/client/:path* to the real API server-side, so the
|
|
10
|
+
// browser never needs (or should have) the API's actual host. Reading
|
|
11
11
|
// NEXT_PUBLIC_QUEEK_API_URL here would let a misconfigured env var (e.g. the
|
|
12
12
|
// same absolute value used for the SERVER-side QUEEK_API_URL) leak
|
|
13
|
-
// api.usequeek.com straight into every client-side request
|
|
14
|
-
//
|
|
13
|
+
// api.usequeek.com straight into every client-side request, visible in the
|
|
14
|
+
// browser Network tab for autocomplete/resolve/fees/couriers/
|
|
15
15
|
// addresses. No such var read = structurally impossible to leak it from here.
|
|
16
16
|
export const API_BASE_URL = '/api/v1';
|
|
17
17
|
export const QUEEK_AUTH_URL = process.env.NEXT_PUBLIC_QUEEK_AUTH_URL ?? 'https://auth.usequeek.com';
|
|
@@ -45,12 +45,11 @@ const platformFetch: typeof fetch = (input, init) => {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Every API response carries a
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* customer support ticket needs to be traceable to a backend request.
|
|
48
|
+
* Every API response carries a request id. The SDK's `request()` returns only
|
|
49
|
+
* the parsed body, so this wrapper — the one place every SDK call's raw Response
|
|
50
|
+
* passes through — is where it can be read at all. Kept as the last-seen value:
|
|
51
|
+
* the only consumer is the checkout failure log, which is exactly the report a
|
|
52
|
+
* customer support ticket needs to be traceable to an API request.
|
|
54
53
|
*/
|
|
55
54
|
const REQUEST_ID_HEADERS = ['x-request-id', 'x-correlation-id', 'request-id'];
|
|
56
55
|
|
|
@@ -68,7 +67,7 @@ function captureRequestId(response: Response): Response {
|
|
|
68
67
|
return response;
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
/** The
|
|
70
|
+
/** The API request id of the most recent SDK call, when it sent one. */
|
|
72
71
|
export function getLastRequestId(): string | null {
|
|
73
72
|
return lastRequestId;
|
|
74
73
|
}
|
|
@@ -5,7 +5,7 @@ import { API_BASE_URL, request, type RequestCachePolicy, type RequestOptions } f
|
|
|
5
5
|
import { getActivePlatform } from './platform';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Origin of the incoming storefront request, forwarded to the
|
|
8
|
+
* Origin of the incoming storefront request, forwarded to the API so it
|
|
9
9
|
* resolves the vendor exactly as it does for a real browser request (subdomain,
|
|
10
10
|
* apex/path and custom-domain storefronts all work with zero configuration).
|
|
11
11
|
*
|
|
@@ -26,9 +26,7 @@ export async function getRequestOrigin(): Promise<string | undefined> {
|
|
|
26
26
|
* for the subdomain-vs-path `basePath` resolution (subdomain/custom-domain
|
|
27
27
|
* storefronts serve at the bare origin; path-based access serves under
|
|
28
28
|
* `/vendor-slug`). Every canonical URL, sitemap entry, and JSON-LD `url` field
|
|
29
|
-
* must derive from this instead of re-deriving `basePath` inline
|
|
30
|
-
* `app/[vendor]/layout.tsx` and `app/[vendor]/products/[slug]/page.tsx`, which
|
|
31
|
-
* used to duplicate this logic before it was extracted here.
|
|
29
|
+
* must derive from this instead of re-deriving `basePath` inline.
|
|
32
30
|
*/
|
|
33
31
|
export async function resolveStoreBaseUrl(vendor: string): Promise<string | null> {
|
|
34
32
|
const origin = await getRequestOrigin();
|
|
@@ -56,7 +54,7 @@ export async function resolveStoreBaseUrl(vendor: string): Promise<string | null
|
|
|
56
54
|
* Abort budget for SSR reads. Sized above the observed p99 of the slowest
|
|
57
55
|
* bootstrap call (`/theme`, ~4.4s) with headroom, so it never fails a legitimate
|
|
58
56
|
* slow response — its job is to stop in-flight renders accumulating without
|
|
59
|
-
* bound when the
|
|
57
|
+
* bound when the API hangs, not to enforce an SLA.
|
|
60
58
|
*/
|
|
61
59
|
const SSR_TIMEOUT_MS = 8_000;
|
|
62
60
|
|
|
@@ -70,7 +68,7 @@ const SSR_TIMEOUT_MS = 8_000;
|
|
|
70
68
|
*
|
|
71
69
|
* Tags are per vendor so one store's edit never flushes another's. The TTL is a
|
|
72
70
|
* safety net, not the primary mechanism — `POST /api/revalidate?tag=…` from the
|
|
73
|
-
*
|
|
71
|
+
* API is what makes an edit show up immediately.
|
|
74
72
|
*/
|
|
75
73
|
function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolicy | null {
|
|
76
74
|
const vendor = `v:${vendorSlug}`;
|
|
@@ -80,15 +78,13 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
|
|
|
80
78
|
});
|
|
81
79
|
|
|
82
80
|
// `/config` is deliberately NOT here: nothing fetches it server-side today, and
|
|
83
|
-
// its response varies by platform
|
|
81
|
+
// its response varies by platform, so allowlisting it
|
|
84
82
|
// would let a future SSR caller that omits `platform` collapse two platforms
|
|
85
83
|
// onto one cached entry.
|
|
86
84
|
if (path === '/info') return policy(300, 'profile');
|
|
87
|
-
// Combines /theme + /menus + /pages-chrome into one
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// existing observers (MenuObserver, PostObserver, VendorConfigObserver)
|
|
91
|
-
// already emit exactly these suffixes, no backend change needed.
|
|
85
|
+
// Combines /theme + /menus + /pages-chrome into one API round trip. Tagged
|
|
86
|
+
// with all three resource suffixes so a merchant edit to any one of them busts
|
|
87
|
+
// this entry.
|
|
92
88
|
if (path === '/bootstrap') return { revalidate: 300, tags: [vendor, `${vendor}:theme`, `${vendor}:menus`, `${vendor}:pages`] };
|
|
93
89
|
if (path === '/blog-categories') return policy(300, 'posts');
|
|
94
90
|
if (/^\/pages(\/[^/]+)?$/.test(path)) return policy(300, 'pages');
|
|
@@ -99,7 +95,7 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
|
|
|
99
95
|
if (/^\/collections(\/[^/]+\/products)?$/.test(path)) return policy(300, 'collections');
|
|
100
96
|
|
|
101
97
|
// Shorter, because price and stock live here: a stale card that a shopper then
|
|
102
|
-
// cannot buy at the shown price is a trust problem, even though the
|
|
98
|
+
// cannot buy at the shown price is a trust problem, even though the API
|
|
103
99
|
// recomputes both at order time.
|
|
104
100
|
if (path === '/products') return policy(60, 'products');
|
|
105
101
|
|
|
@@ -127,14 +123,14 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
|
|
|
127
123
|
* storefront with `?preview=true&id=<vendorId>`. Set by `proxy.ts`, which is the only
|
|
128
124
|
* place that can see query params before the fetch layer runs.
|
|
129
125
|
*
|
|
130
|
-
* A preview render MUST bypass the cache outright rather than rely on the
|
|
126
|
+
* A preview render MUST bypass the cache outright rather than rely on the API's
|
|
131
127
|
* invalidation call. Invalidation is a distributed handshake — it needs two env vars
|
|
132
|
-
* to match across two
|
|
128
|
+
* to match across two services and a live HTTP round trip — and when any part of that
|
|
133
129
|
* is wrong it fails SILENTLY, leaving a merchant staring at an unchanged store for
|
|
134
130
|
* the full TTL. The editor is the one surface where "eventually" is not acceptable,
|
|
135
131
|
* and it already knows it is the editor, so it should not have to ask anyone.
|
|
136
132
|
*
|
|
137
|
-
* The
|
|
133
|
+
* The API still flushes tags on save; that is what keeps the PUBLIC store fresh.
|
|
138
134
|
* This just removes the editor's dependence on it.
|
|
139
135
|
*/
|
|
140
136
|
async function isPreviewRender(): Promise<boolean> {
|
|
@@ -147,7 +143,7 @@ async function isPreviewRender(): Promise<boolean> {
|
|
|
147
143
|
* Validated locale of the incoming storefront request, or null for the
|
|
148
144
|
* primary rendering. The proxy sets `x-queek-locale` for published
|
|
149
145
|
* non-primary locales ONLY (absent on the primary); anything else is
|
|
150
|
-
* ignored, mirroring the
|
|
146
|
+
* ignored, mirroring the API's unknown-locale → source-text rule.
|
|
151
147
|
*
|
|
152
148
|
* THE single header-parsing site — every server fetcher resolves its locale
|
|
153
149
|
* through here (directly or via `resolveRequestLocale`), never by reading
|
|
@@ -15,7 +15,7 @@ export interface CoreMetaobjectsBlockProps extends MetaobjectsResolved {
|
|
|
15
15
|
* `EntryCard` a product's linked-entry metafields render. Renders nothing
|
|
16
16
|
* when the type is unknown, hidden, or has no active entries (the server
|
|
17
17
|
* seeds `entries: []` for all three cases — see `hydrateProductBlocks` in
|
|
18
|
-
* the
|
|
18
|
+
* the host app). Each card links to `/{type}/{handle}` only when the
|
|
19
19
|
* definition opted into entry pages (`has_pages`); otherwise it's inert.
|
|
20
20
|
*/
|
|
21
21
|
export function CoreMetaobjectsBlock({
|
package/stores/cart-store.ts
CHANGED
|
@@ -59,8 +59,8 @@ export const useCartStore = create<CartState>()(
|
|
|
59
59
|
},
|
|
60
60
|
addItemWithAddons: (product, quantity = 1, addons = [], variant = null) => {
|
|
61
61
|
// A variant-enabled product has no valid "no selection" line: the cart
|
|
62
|
-
// save fails server-side
|
|
63
|
-
//
|
|
62
|
+
// save fails server-side, so persisting one only moves the failure to
|
|
63
|
+
// checkout. Themes gate
|
|
64
64
|
// their add button too, but a theme is presentation — correctness
|
|
65
65
|
// cannot depend on all seven, or on the next one, getting it right.
|
|
66
66
|
if (!variant && productRequiresVariant(product)) {
|
package/stores/checkout-store.ts
CHANGED
|
@@ -66,7 +66,7 @@ interface CheckoutState {
|
|
|
66
66
|
isEditingAddress: boolean;
|
|
67
67
|
/** The customer's saved address (CustomerAddress uuid) currently selected for
|
|
68
68
|
* delivery, when they picked one from their address book instead of
|
|
69
|
-
* searching a fresh address. Lets the
|
|
69
|
+
* searching a fresh address. Lets the API reuse the cached courier
|
|
70
70
|
* geocode. Null whenever the address came from a fresh Google search. */
|
|
71
71
|
savedAddressId: string | null;
|
|
72
72
|
|
|
@@ -21,15 +21,14 @@ interface EditPreviewState {
|
|
|
21
21
|
* Draft page content streamed live from the merchant editor, so a section edit
|
|
22
22
|
* renders immediately instead of waiting for a network round trip.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
* draft → postMessage 'refresh' → router.refresh() → RSC fetch →
|
|
26
|
-
*
|
|
24
|
+
* Without it, every field change would take the long way: 900ms debounce → PUT
|
|
25
|
+
* the draft → postMessage 'refresh' → router.refresh() → RSC fetch → API render,
|
|
26
|
+
* and the preview would show the OLD content the whole time. The merchant
|
|
27
27
|
* already holds the edited section in its own state; sending it costs nothing
|
|
28
28
|
* and removes both network hops from the interactive path.
|
|
29
29
|
*
|
|
30
|
-
* Design tokens
|
|
31
|
-
*
|
|
32
|
-
* actually spend their time editing.
|
|
30
|
+
* Design tokens work the same way (design-token-preview.tsx) — this applies the
|
|
31
|
+
* idea to section content, which is what merchants spend their time editing.
|
|
33
32
|
*
|
|
34
33
|
* The server stays authoritative: PageRenderer drops this override the moment a
|
|
35
34
|
* fresh server payload arrives, so the debounced save still reconciles and a
|
package/stores/user-store.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface StorefrontUser {
|
|
|
11
11
|
email: string | null;
|
|
12
12
|
phone: string;
|
|
13
13
|
avatar: string | null;
|
|
14
|
-
/**
|
|
14
|
+
/** API flag: false for social/email signups that still lack a real phone. */
|
|
15
15
|
profile_complete?: boolean;
|
|
16
16
|
delivery_info?: {
|
|
17
17
|
map_lat: number;
|
|
@@ -22,10 +22,9 @@ export interface StorefrontUser {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* A signed-in customer whose account is missing the details we require to
|
|
25
|
-
* fulfil an order. The
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `requiresPhone` together — there's no case where one applies without the
|
|
25
|
+
* fulfil an order. The API's `profile_complete` is itself derived purely from
|
|
26
|
+
* phone+name presence (`phone !== null && name !== ''`), so both it and the raw
|
|
27
|
+
* `!user.phone` check are gated by `requiresPhone` together — there's no case where one applies without the
|
|
29
28
|
* other.
|
|
30
29
|
*
|
|
31
30
|
* `requiresPhone` — defaults true (delivery/pickup orders need a real number
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pseudo-locale QA helper.
|
|
3
|
+
*
|
|
4
|
+
* PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
|
|
5
|
+
* from React, so server components, Node tooling (such as the theme check), tests and the
|
|
6
|
+
* browser all share it (precedent: `utils/locale.ts`, `strings/theme-strings.ts`).
|
|
7
|
+
* Keep it that way.
|
|
8
|
+
*
|
|
9
|
+
* What it is: `pseudoLocalize(dictionary, options?)` returns a pseudo
|
|
10
|
+
* dictionary with the SAME keys and the SAME plural-map shape, where every
|
|
11
|
+
* string value is accent-mapped, ~30% longer, and wrapped in markers like
|
|
12
|
+
* `[!! … !!]`. `{placeholders}` and `{{`/`}}` escapes pass through
|
|
13
|
+
* untouched, keys are never touched.
|
|
14
|
+
*
|
|
15
|
+
* Two uses (both documented in the README):
|
|
16
|
+
* - Dev-only locale: mount the app with the pseudo dictionary as the
|
|
17
|
+
* `strings` prop. Every string that went through `t()` renders pseudo;
|
|
18
|
+
* any text that remains plain English next to pseudo text is a hard-coded
|
|
19
|
+
* (un-wrapped) string. Never ship pseudo to shoppers.
|
|
20
|
+
* - Tests: render a component with the pseudo dictionary, then run the
|
|
21
|
+
* English-leak scan (`findEnglishLeaks` / `assertNoEnglishLeak`) over the
|
|
22
|
+
* HTML. That scan is the reusable primitive theme tests import.
|
|
23
|
+
*
|
|
24
|
+
* English safety: this module never changes an English value — pseudo
|
|
25
|
+
* dictionaries are built on demand in dev/test and never enter a bundle.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ThemeStringsDictionary, ThemeStringsValue } from './theme-strings';
|
|
29
|
+
|
|
30
|
+
export interface PseudoLocalizeOptions {
|
|
31
|
+
/** Marker prepended to every value. Default `'[!! '`. */
|
|
32
|
+
prefix?: string;
|
|
33
|
+
/** Marker appended to every value. Default `' !!]'`. */
|
|
34
|
+
suffix?: string;
|
|
35
|
+
/** Fraction of extra padding appended inside the markers. Default `0.3`. */
|
|
36
|
+
expansionRatio?: number;
|
|
37
|
+
/** Padding character. Default `'~'`. */
|
|
38
|
+
expansionChar?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Wrap the core in U+202B … U+202C (RTL embedding) for a visual RTL check.
|
|
41
|
+
* Default `false`. This is a spot-check aid, not bidi-correct output.
|
|
42
|
+
*/
|
|
43
|
+
rtl?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const PSEUDO_PREFIX = '[!! ';
|
|
47
|
+
export const PSEUDO_SUFFIX = ' !!]';
|
|
48
|
+
export const PSEUDO_EXPANSION_RATIO = 0.3;
|
|
49
|
+
export const PSEUDO_EXPANSION_CHAR = '~';
|
|
50
|
+
|
|
51
|
+
const ACCENT_MAP: Record<string, string> = {
|
|
52
|
+
a: 'à', A: 'À', b: 'ƀ', B: 'Ɓ', c: 'ç', C: 'Ç', d: 'ď', D: 'Ď',
|
|
53
|
+
e: 'è', E: 'È', f: 'ƒ', F: 'Ƒ', g: 'ĝ', G: 'Ĝ', h: 'ĥ', H: 'Ĥ',
|
|
54
|
+
i: 'ì', I: 'Ì', j: 'ĵ', J: 'Ĵ', k: 'ķ', K: 'Ķ', l: 'ŀ', L: 'Ŀ',
|
|
55
|
+
m: 'ṁ', M: 'Ṁ', n: 'ñ', N: 'Ñ', o: 'ò', O: 'Ò', p: 'ṗ', P: 'Ṗ',
|
|
56
|
+
q: 'ǫ', Q: 'Ǫ', r: 'ŕ', R: 'Ŕ', s: 'š', S: 'Š', t: 'ţ', T: 'Ţ',
|
|
57
|
+
u: 'ù', U: 'Ù', v: 'ṽ', V: 'Ṽ', w: 'ŵ', W: 'Ŵ', x: 'ẋ', X: 'Ẋ',
|
|
58
|
+
y: 'ÿ', Y: 'Ÿ', z: 'ž', Z: 'Ž',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const PLURAL_FORMS = ['one', 'two', 'few', 'many', 'other'];
|
|
62
|
+
|
|
63
|
+
/** Same leaf rule as `t()`'s runtime: all keys are plural forms, all values strings. */
|
|
64
|
+
function isPluralMapValue(value: unknown): value is Record<string, string> {
|
|
65
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
66
|
+
const keys = Object.keys(value);
|
|
67
|
+
return (
|
|
68
|
+
keys.length > 0 &&
|
|
69
|
+
keys.every((k) => PLURAL_FORMS.includes(k)) &&
|
|
70
|
+
keys.every((k) => typeof (value as Record<string, unknown>)[k] === 'string')
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isPlaceholderName(name: string): boolean {
|
|
75
|
+
return /^[A-Za-z0-9_]+$/.test(name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pseudo-localize one string value. `{placeholder}` interpolations and
|
|
80
|
+
* `{{`/`}}` escapes are preserved byte-for-byte; only plain-text letters are
|
|
81
|
+
* accent-mapped. `''` stays `''` (an intentional empty renders nothing, so
|
|
82
|
+
* there is no node to mark).
|
|
83
|
+
*/
|
|
84
|
+
export function pseudoLocalizeText(value: string, options?: PseudoLocalizeOptions): string {
|
|
85
|
+
if (value === '') return '';
|
|
86
|
+
const prefix = options?.prefix ?? PSEUDO_PREFIX;
|
|
87
|
+
const suffix = options?.suffix ?? PSEUDO_SUFFIX;
|
|
88
|
+
const ratio = options?.expansionRatio ?? PSEUDO_EXPANSION_RATIO;
|
|
89
|
+
const expansionChar = options?.expansionChar ?? PSEUDO_EXPANSION_CHAR;
|
|
90
|
+
const rtl = options?.rtl ?? false;
|
|
91
|
+
|
|
92
|
+
let core = '';
|
|
93
|
+
let i = 0;
|
|
94
|
+
while (i < value.length) {
|
|
95
|
+
const ch = value[i];
|
|
96
|
+
if (ch === '{' && value[i + 1] === '{') {
|
|
97
|
+
core += '{{';
|
|
98
|
+
i += 2;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === '}' && value[i + 1] === '}') {
|
|
102
|
+
core += '}}';
|
|
103
|
+
i += 2;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === '{') {
|
|
107
|
+
const close = value.indexOf('}', i + 1);
|
|
108
|
+
const name = close === -1 ? '' : value.slice(i + 1, close);
|
|
109
|
+
if (close !== -1 && isPlaceholderName(name)) {
|
|
110
|
+
core += value.slice(i, close + 1);
|
|
111
|
+
i = close + 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
core += ACCENT_MAP[ch] ?? ch;
|
|
115
|
+
i += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
core += /[A-Za-z]/.test(ch) ? (ACCENT_MAP[ch] ?? ch) : ch;
|
|
119
|
+
i += 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const padLength = Math.max(0, Math.round(core.length * ratio));
|
|
123
|
+
const expanded = core + expansionChar.repeat(padLength);
|
|
124
|
+
const inner = rtl ? '\u202B' + expanded + '\u202C' : expanded;
|
|
125
|
+
return `${prefix}${inner}${suffix}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pseudoLocalizeValue(value: ThemeStringsValue, options?: PseudoLocalizeOptions): ThemeStringsValue {
|
|
129
|
+
if (typeof value === 'string') return pseudoLocalizeText(value, options);
|
|
130
|
+
if (isPluralMapValue(value)) {
|
|
131
|
+
const out: Record<string, string> = {};
|
|
132
|
+
for (const [form, template] of Object.entries(value)) {
|
|
133
|
+
out[form] = pseudoLocalizeText(template, options);
|
|
134
|
+
}
|
|
135
|
+
return out as ThemeStringsValue;
|
|
136
|
+
}
|
|
137
|
+
if (typeof value === 'object' && value !== null) {
|
|
138
|
+
const out: ThemeStringsDictionary = {};
|
|
139
|
+
for (const [k, v] of Object.entries(value as ThemeStringsDictionary)) {
|
|
140
|
+
out[k] = pseudoLocalizeValue(v, options);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pseudo-localize a whole dictionary. Keys (including flat literal dotted
|
|
149
|
+
* keys) are copied verbatim; nested objects recurse; plural maps keep their
|
|
150
|
+
* exact form keys with each template pseudo-localized.
|
|
151
|
+
*/
|
|
152
|
+
export function pseudoLocalize(
|
|
153
|
+
dictionary: ThemeStringsDictionary,
|
|
154
|
+
options?: PseudoLocalizeOptions,
|
|
155
|
+
): ThemeStringsDictionary {
|
|
156
|
+
const out: ThemeStringsDictionary = {};
|
|
157
|
+
for (const [key, value] of Object.entries(dictionary)) {
|
|
158
|
+
out[key] = pseudoLocalizeValue(value, options);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** True when the rendered text carries this run's pseudo markers. */
|
|
164
|
+
export function isPseudoLocalized(text: string, options?: PseudoLocalizeOptions): boolean {
|
|
165
|
+
const prefix = options?.prefix ?? PSEUDO_PREFIX;
|
|
166
|
+
const suffix = options?.suffix ?? PSEUDO_SUFFIX;
|
|
167
|
+
const trimmed = text.trim();
|
|
168
|
+
return (
|
|
169
|
+
trimmed.length >= prefix.length + suffix.length &&
|
|
170
|
+
trimmed.startsWith(prefix) &&
|
|
171
|
+
trimmed.endsWith(suffix)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface EnglishLeakScanOptions {
|
|
176
|
+
/**
|
|
177
|
+
* Visible strings that are English by design (brand names, codes) and must
|
|
178
|
+
* not be reported. Exact strings or patterns, matched against the trimmed
|
|
179
|
+
* node text.
|
|
180
|
+
*/
|
|
181
|
+
allowlist?: Array<string | RegExp>;
|
|
182
|
+
/**
|
|
183
|
+
* Also scan user-visible attributes (`aria-label`, `aria-valuetext`,
|
|
184
|
+
* `placeholder`, `title`, `alt`). Default `true` — hard-coded aria copy is
|
|
185
|
+
* the same bug class as hard-coded text nodes.
|
|
186
|
+
*/
|
|
187
|
+
includeAttributes?: boolean;
|
|
188
|
+
/** Marker options used for the pseudo run being scanned. */
|
|
189
|
+
pseudo?: PseudoLocalizeOptions;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function decodeEntities(value: string): string {
|
|
193
|
+
return value
|
|
194
|
+
.replace(/&/g, '&')
|
|
195
|
+
.replace(/</g, '<')
|
|
196
|
+
.replace(/>/g, '>')
|
|
197
|
+
.replace(/"/g, '"')
|
|
198
|
+
.replace(/'|'/g, "'")
|
|
199
|
+
.replace(/ /g, ' ');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function visibleTexts(html: string, includeAttributes: boolean): string[] {
|
|
203
|
+
const found: string[] = [];
|
|
204
|
+
let text = html
|
|
205
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
206
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
207
|
+
.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
208
|
+
if (includeAttributes) {
|
|
209
|
+
for (const match of text.matchAll(
|
|
210
|
+
/(?:aria-label|aria-valuetext|placeholder|title|alt)\s*=\s*"([^"]*)"/gi,
|
|
211
|
+
)) {
|
|
212
|
+
const attr = decodeEntities(match[1] ?? '').trim().replace(/\s+/g, ' ');
|
|
213
|
+
if (attr) found.push(attr);
|
|
214
|
+
}
|
|
215
|
+
for (const match of text.matchAll(
|
|
216
|
+
/(?:aria-label|aria-valuetext|placeholder|title|alt)\s*=\s*'([^']*)'/gi,
|
|
217
|
+
)) {
|
|
218
|
+
const attr = decodeEntities(match[1] ?? '').trim().replace(/\s+/g, ' ');
|
|
219
|
+
if (attr) found.push(attr);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const stripped = text.replace(/<[^>]*>/g, '\u0000');
|
|
223
|
+
for (const part of stripped.split('\u0000')) {
|
|
224
|
+
const node = decodeEntities(part).trim().replace(/\s+/g, ' ');
|
|
225
|
+
if (node) found.push(node);
|
|
226
|
+
}
|
|
227
|
+
return found;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The English-leak scan primitive: every visible text node of the rendered
|
|
232
|
+
* HTML that is NOT pseudo-localized (and not allowlisted, and containing at
|
|
233
|
+
* least one ASCII letter so numbers/symbols never report) is a hard-coded
|
|
234
|
+
* English string. Returns the offending node texts (trimmed, in document
|
|
235
|
+
* order). Pure — theme tests render with the pseudo dictionary, then call
|
|
236
|
+
* this (or `assertNoEnglishLeak`).
|
|
237
|
+
*/
|
|
238
|
+
export function findEnglishLeaks(html: string, options?: EnglishLeakScanOptions): string[] {
|
|
239
|
+
const allowlist = options?.allowlist ?? [];
|
|
240
|
+
const includeAttributes = options?.includeAttributes ?? true;
|
|
241
|
+
return visibleTexts(html, includeAttributes).filter((node) => {
|
|
242
|
+
if (isPseudoLocalized(node, options?.pseudo)) return false;
|
|
243
|
+
if (!/[A-Za-z]/.test(node)) return false;
|
|
244
|
+
if (allowlist.some((rule) => (typeof rule === 'string' ? rule === node : rule.test(node)))) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Throw listing every leaked node, or return silently when the render is clean. */
|
|
252
|
+
export function assertNoEnglishLeak(html: string, options?: EnglishLeakScanOptions): void {
|
|
253
|
+
const leaks = findEnglishLeaks(html, options);
|
|
254
|
+
if (leaks.length > 0) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`[theme-kit] English leak: ${leaks.length} visible string(s) are not pseudo-localized:\n` +
|
|
257
|
+
leaks
|
|
258
|
+
.slice(0, 20)
|
|
259
|
+
.map((leak) => ` - ${JSON.stringify(leak)}`)
|
|
260
|
+
.join('\n'),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|