@redeemid/emdash 0.0.9 → 0.0.10

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.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Member preferences: customer metafields members may see or change, such as
3
+ * the topics they follow. Shopify defines each field (Settings → Custom data
4
+ * → Customers); `preferences` in the config picks which ones members get, and
5
+ * whether they may change them. Answers live on the Shopify customer, where
6
+ * Flow, segments and email can use them. The site keeps no copy.
7
+ *
8
+ * Reading and saving use the Admin API app (the staff tools' client ID), and
9
+ * always on the signed-in member's own customer. Preferences never open
10
+ * content: access still comes only from tags.
11
+ */
12
+
13
+ import { admin, adminConfigured, AdminError } from "./admin-api.js";
14
+ import type { PreferenceConfig, RedeemIDConfig } from "./config.js";
15
+ import type { MemberRecord } from "./storage.js";
16
+
17
+ /** Metafield types members can use, and the value each takes. */
18
+ export const PREFERENCE_TYPES = [
19
+ "single_line_text_field",
20
+ "multi_line_text_field",
21
+ "list.single_line_text_field",
22
+ "boolean",
23
+ "number_integer",
24
+ "number_decimal",
25
+ "date",
26
+ ] as const;
27
+ export type PreferenceType = (typeof PREFERENCE_TYPES)[number];
28
+ export type PreferenceValue = string | string[] | boolean | number | null;
29
+
30
+ export interface PreferenceField {
31
+ /** "namespace.key" */
32
+ key: string;
33
+ name: string;
34
+ description: string | null;
35
+ type: PreferenceType;
36
+ choices: string[] | null;
37
+ /** Length for text, value for numbers and dates. */
38
+ min: string | null;
39
+ max: string | null;
40
+ editable: boolean;
41
+ value: PreferenceValue;
42
+ }
43
+
44
+ export type Refusal = "unknown_field" | "read_only" | "invalid_type" | "invalid_choice" | "invalid_length" | "invalid_range";
45
+
46
+ export class PreferenceError extends Error {
47
+ constructor(
48
+ message: string,
49
+ readonly fields: Record<string, Refusal> = {},
50
+ ) {
51
+ super(message);
52
+ }
53
+ }
54
+
55
+ /** Saves allowed per member per minute. */
56
+ export const SAVES_PER_MINUTE = 10;
57
+ const DEFINITIONS_TTL_MS = 5 * 60 * 1000;
58
+ /** RedeemID reads membership dates from this namespace, so members never get it. */
59
+ const RESERVED_NAMESPACE = "membership";
60
+
61
+ type Definition = Omit<PreferenceField, "editable" | "value">;
62
+ let cache: { at: number; shop: string; defs: Definition[] } | null = null;
63
+
64
+ export function resetPreferenceCache(): void {
65
+ cache = null;
66
+ }
67
+
68
+ /** Can preferences run at all? */
69
+ export function preferencesConfigured(config: RedeemIDConfig | null): boolean {
70
+ return Boolean(config?.preferences?.length && adminConfigured());
71
+ }
72
+
73
+ function wanted(config: RedeemIDConfig): PreferenceConfig[] {
74
+ return (config.preferences ?? []).filter((p) => /^[\w-]+\.[\w-]+$/.test(p.key) && !p.key.startsWith(`${RESERVED_NAMESPACE}.`));
75
+ }
76
+
77
+ /** The customer metafield definitions Shopify has for the configured keys, cached for five minutes. */
78
+ export async function definitions(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<Definition[]> {
79
+ const shop = config.shop ?? "";
80
+ if (cache && cache.shop === shop && Date.now() - cache.at < DEFINITIONS_TTL_MS) return cache.defs;
81
+ type Node = { namespace: string; key: string; name: string; description: string | null; type: { name: string }; validations: Array<{ name: string; value: string | null }> };
82
+ const data = await admin<{ metafieldDefinitions: { nodes: Node[] } }>(
83
+ config,
84
+ `query RedeemIDPreferenceDefinitions { metafieldDefinitions(ownerType: CUSTOMER, first: 250) { nodes { namespace key name description type { name } validations { name value } } } }`,
85
+ {},
86
+ fetcher,
87
+ );
88
+ const defs = data.metafieldDefinitions.nodes
89
+ .filter((n) => (PREFERENCE_TYPES as readonly string[]).includes(n.type.name))
90
+ .map((n) => {
91
+ const v = (name: string) => n.validations.find((x) => x.name === name)?.value ?? null;
92
+ let choices: string[] | null = null;
93
+ try {
94
+ const raw = v("choices");
95
+ const parsed = raw ? (JSON.parse(raw) as unknown) : null;
96
+ if (Array.isArray(parsed)) choices = parsed.map(String);
97
+ } catch {
98
+ choices = null;
99
+ }
100
+ return {
101
+ key: `${n.namespace}.${n.key}`,
102
+ name: n.name,
103
+ description: n.description || null,
104
+ type: n.type.name as PreferenceType,
105
+ choices,
106
+ // Length for text types, value for numbers and dates.
107
+ min: v("min") || null,
108
+ max: v("max") || null,
109
+ };
110
+ });
111
+ cache = { at: Date.now(), shop, defs };
112
+ return defs;
113
+ }
114
+
115
+ /** Turn a stored metafield value into what members see. */
116
+ export function decode(type: PreferenceType, raw: string | null | undefined): PreferenceValue {
117
+ if (raw == null || raw === "") return type.startsWith("list.") ? [] : null;
118
+ switch (type) {
119
+ case "list.single_line_text_field":
120
+ try {
121
+ const v = JSON.parse(raw) as unknown;
122
+ return Array.isArray(v) ? v.map(String) : [];
123
+ } catch {
124
+ return [];
125
+ }
126
+ case "boolean":
127
+ return raw === "true";
128
+ case "number_integer":
129
+ return Number.parseInt(raw, 10);
130
+ default:
131
+ return raw;
132
+ }
133
+ }
134
+
135
+ const isEmpty = (v: unknown) => v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0);
136
+
137
+ /**
138
+ * Check one value against its definition. Returns the metafield value to
139
+ * save, null to clear the field, or a refusal.
140
+ */
141
+ export function encode(def: Definition, input: unknown): { value: string | null } | { refused: Refusal } {
142
+ if (isEmpty(input)) return { value: null };
143
+ const len = (s: string) => {
144
+ if (def.min !== null && s.length < Number(def.min)) return false;
145
+ if (def.max !== null && s.length > Number(def.max)) return false;
146
+ return true;
147
+ };
148
+ const choice = (s: string) => !def.choices || def.choices.includes(s);
149
+ switch (def.type) {
150
+ case "single_line_text_field":
151
+ case "multi_line_text_field": {
152
+ if (typeof input !== "string") return { refused: "invalid_type" };
153
+ const s = def.type === "single_line_text_field" ? input.trim() : input;
154
+ if (def.type === "single_line_text_field" && /[\r\n]/.test(s)) return { refused: "invalid_type" };
155
+ if (!choice(s)) return { refused: "invalid_choice" };
156
+ if (!len(s)) return { refused: "invalid_length" };
157
+ return { value: s };
158
+ }
159
+ case "list.single_line_text_field": {
160
+ const list = typeof input === "string" ? [input] : input;
161
+ if (!Array.isArray(list) || list.some((x) => typeof x !== "string")) return { refused: "invalid_type" };
162
+ const items = [...new Set((list as string[]).map((x) => x.trim()).filter(Boolean))];
163
+ if (items.some((x) => /[\r\n]/.test(x))) return { refused: "invalid_type" };
164
+ if (!items.every(choice)) return { refused: "invalid_choice" };
165
+ if (!items.every(len)) return { refused: "invalid_length" };
166
+ return { value: items.length ? JSON.stringify(items) : null };
167
+ }
168
+ case "boolean": {
169
+ const b = input === true || input === "true" || input === "on" ? true : input === false || input === "false" ? false : null;
170
+ if (b === null) return { refused: "invalid_type" };
171
+ return { value: String(b) };
172
+ }
173
+ case "number_integer":
174
+ case "number_decimal": {
175
+ const s = typeof input === "number" ? String(input) : typeof input === "string" ? input.trim() : "";
176
+ const ok = def.type === "number_integer" ? /^-?\d+$/.test(s) : /^-?\d+(\.\d+)?$/.test(s);
177
+ if (!ok) return { refused: "invalid_type" };
178
+ const n = Number(s);
179
+ if ((def.min !== null && n < Number(def.min)) || (def.max !== null && n > Number(def.max))) return { refused: "invalid_range" };
180
+ return { value: s };
181
+ }
182
+ case "date": {
183
+ const s = typeof input === "string" ? input.trim() : "";
184
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || Number.isNaN(Date.parse(`${s}T00:00:00Z`))) return { refused: "invalid_type" };
185
+ if ((def.min !== null && s < def.min.slice(0, 10)) || (def.max !== null && s > def.max.slice(0, 10))) return { refused: "invalid_range" };
186
+ return { value: s };
187
+ }
188
+ }
189
+ }
190
+
191
+ /** The fields members can use, in config order, with their access. Without values. */
192
+ export async function preferenceFields(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<Array<Omit<PreferenceField, "value">>> {
193
+ const defs = await definitions(config, fetcher);
194
+ return wanted(config).flatMap((p) => {
195
+ const def = defs.find((d) => d.key === p.key);
196
+ return def ? [{ ...def, editable: p.access !== "view" }] : [];
197
+ });
198
+ }
199
+
200
+ const gid = (id: string) => `gid://shopify/Customer/${id}`;
201
+
202
+ /** The member's fields with their current values, read from Shopify. */
203
+ export async function readPreferences(config: RedeemIDConfig, customerId: string, fetcher: typeof fetch = fetch): Promise<PreferenceField[]> {
204
+ const fields = await preferenceFields(config, fetcher);
205
+ if (fields.length === 0) return [];
206
+ type Node = { namespace: string; key: string; value: string };
207
+ const data = await admin<{ customer: { metafields: { nodes: Node[] } } | null }>(
208
+ config,
209
+ `query RedeemIDPreferences($id: ID!, $keys: [String!]) { customer(id: $id) { metafields(first: 100, keys: $keys) { nodes { namespace key value } } } }`,
210
+ { id: gid(customerId), keys: fields.map((f) => f.key) },
211
+ fetcher,
212
+ );
213
+ if (!data.customer) throw new AdminError("Shopify has no such customer.");
214
+ const values = new Map(data.customer.metafields.nodes.map((n) => [`${n.namespace}.${n.key}`, n.value]));
215
+ return fields.map((f) => ({ ...f, value: decode(f.type, values.get(f.key)) }));
216
+ }
217
+
218
+ /** Room for one more save this minute? Records it when there is. */
219
+ export function allowSave(member: Pick<MemberRecord, "prefSaves">, now = Date.now()): number[] | null {
220
+ const recent = (member.prefSaves ?? []).filter((t) => now - t < 60_000);
221
+ if (recent.length >= SAVES_PER_MINUTE) return null;
222
+ return [...recent, now];
223
+ }
224
+
225
+ /**
226
+ * Save some of the member's fields to their Shopify customer, then read them
227
+ * back. Every value is checked first; one refusal saves nothing. Keys left
228
+ * out are left alone; an empty value clears the field.
229
+ */
230
+ export async function savePreferences(
231
+ config: RedeemIDConfig,
232
+ customerId: string,
233
+ values: Record<string, unknown>,
234
+ fetcher: typeof fetch = fetch,
235
+ ): Promise<{ fields: PreferenceField[]; changed: string[] }> {
236
+ const fields = await preferenceFields(config, fetcher);
237
+ const refused: Record<string, Refusal> = {};
238
+ const set: Array<{ namespace: string; key: string; type: string; value: string }> = [];
239
+ const clear: Array<{ namespace: string; key: string }> = [];
240
+ for (const [key, input] of Object.entries(values)) {
241
+ const f = fields.find((x) => x.key === key);
242
+ if (!f) {
243
+ refused[key] = "unknown_field";
244
+ continue;
245
+ }
246
+ if (!f.editable) {
247
+ refused[key] = "read_only";
248
+ continue;
249
+ }
250
+ const out = encode(f, input);
251
+ if ("refused" in out) {
252
+ refused[key] = out.refused;
253
+ continue;
254
+ }
255
+ const [namespace = "", k = ""] = key.split(".");
256
+ if (out.value === null) clear.push({ namespace, key: k });
257
+ else set.push({ namespace, key: k, type: f.type, value: out.value });
258
+ }
259
+ if (Object.keys(refused).length) throw new PreferenceError("Some answers weren't saved.", refused);
260
+
261
+ const ownerId = gid(customerId);
262
+ if (set.length) {
263
+ const data = await admin<{ metafieldsSet: { userErrors: Array<{ field?: string[] | null; message: string }> } }>(
264
+ config,
265
+ `mutation RedeemIDSavePreferences($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { userErrors { field message } } }`,
266
+ { metafields: set.map((m) => ({ ownerId, ...m })) },
267
+ fetcher,
268
+ );
269
+ const err = data.metafieldsSet.userErrors[0];
270
+ if (err) throw new AdminError(err.message);
271
+ }
272
+ if (clear.length) {
273
+ const data = await admin<{ metafieldsDelete: { userErrors: Array<{ message: string }> } }>(
274
+ config,
275
+ `mutation RedeemIDClearPreferences($metafields: [MetafieldIdentifierInput!]!) { metafieldsDelete(metafields: $metafields) { userErrors { field message } } }`,
276
+ { metafields: clear.map((m) => ({ ownerId, ...m })) },
277
+ fetcher,
278
+ );
279
+ const err = data.metafieldsDelete.userErrors[0];
280
+ if (err) throw new AdminError(err.message);
281
+ }
282
+ const changed = [...set, ...clear].map((m) => `${m.namespace}.${m.key}`);
283
+ return { fields: await readPreferences(config, customerId, fetcher), changed };
284
+ }
285
+
286
+ /** Read a posted form into values: `pref:<key>` fields, with `present:<key>` marking checkboxes and lists that may be empty. */
287
+ export function formValues(form: FormData, fields: Array<Pick<PreferenceField, "key" | "type" | "editable">>): Record<string, unknown> {
288
+ const out: Record<string, unknown> = {};
289
+ for (const f of fields) {
290
+ if (!f.editable || !form.has(`present:${f.key}`)) continue;
291
+ const all = form.getAll(`pref:${f.key}`).map(String);
292
+ // A list without choices comes from a textarea: one answer per line.
293
+ if (f.type === "list.single_line_text_field") out[f.key] = all.flatMap((v) => v.split(/\r?\n/));
294
+ else if (f.type === "boolean") out[f.key] = all.includes("true");
295
+ else out[f.key] = all[0] ?? "";
296
+ }
297
+ return out;
298
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * /_emdash/api/auth/redeemid/preferences: the signed-in member's preferences.
3
+ *
4
+ * GET answers JSON: { fields } with values, for the member's own customer.
5
+ * POST takes JSON { values: { "namespace.key": value } } and answers JSON,
6
+ * or a form (from <Preferences>) and goes back to `return` with the notice
7
+ * preferences_saved, preferences_refused or preferences_unavailable.
8
+ * The customer always comes from the session, never from the request.
9
+ * Refused JSON saves answer 400 with { fields: { key: reason } }; more than
10
+ * 10 saves a minute answer 429.
11
+ */
12
+
13
+ import type { APIRoute } from "astro";
14
+
15
+ import { safeReturn, signInUrl } from "../access.js";
16
+ import { readConfig } from "../config.js";
17
+ import { emit } from "../events.js";
18
+ import { allowSave, formValues, PreferenceError, preferenceFields, preferencesConfigured, readPreferences, savePreferences } from "../preferences.js";
19
+ import { memberForUser, stores } from "../storage.js";
20
+ import { fail } from "./shared.js";
21
+
22
+ export const prerender = false;
23
+
24
+ const json = (status: number, body: unknown) =>
25
+ new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "private, no-store" } });
26
+
27
+ async function context(locals: unknown, session: { get(k: string): Promise<unknown> } | undefined) {
28
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
29
+ const config = readConfig(emdash?.config);
30
+ if (!config || !preferencesConfigured(config) || !emdash?.db) return { error: "unavailable" as const };
31
+ const user = (await session?.get("user")) as { id?: string } | undefined;
32
+ if (!user?.id) return { error: "sign_in" as const, config };
33
+ const s = stores(emdash.db);
34
+ const member = await memberForUser(s, user.id);
35
+ if (!member) return { error: "sign_in" as const, config };
36
+ if (member.status === "disabled") return { error: "disabled" as const, config };
37
+ return { config, s, member };
38
+ }
39
+
40
+ export const GET: APIRoute = async ({ locals, session }) => {
41
+ const c = await context(locals, session);
42
+ if ("error" in c) return json(c.error === "unavailable" ? 404 : 401, { error: c.error });
43
+ try {
44
+ return json(200, { fields: await readPreferences(c.config, c.member.customerId) });
45
+ } catch (e) {
46
+ console.error("[redeemid] preferences not read:", (e as Error).message);
47
+ return json(502, { error: "unavailable" });
48
+ }
49
+ };
50
+
51
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
52
+ const isJson = (request.headers.get("content-type") ?? "").includes("application/json");
53
+ const form = isJson ? null : await request.formData().catch(() => null);
54
+ const returnTo = safeReturn(String(form?.get("return") ?? "/"));
55
+ const c = await context(locals, session);
56
+ if ("error" in c) {
57
+ if (isJson) return json(c.error === "unavailable" ? 404 : 401, { error: c.error });
58
+ return c.error === "sign_in" ? redirect(signInUrl(returnTo)) : fail(redirect, returnTo, "preferences_unavailable");
59
+ }
60
+
61
+ const slots = allowSave(c.member);
62
+ if (!slots) return isJson ? json(429, { error: "too_many_saves" }) : fail(redirect, returnTo, "preferences_unavailable");
63
+
64
+ let values: Record<string, unknown>;
65
+ try {
66
+ if (isJson) {
67
+ const body = (await request.json()) as { values?: unknown };
68
+ values = body.values && typeof body.values === "object" && !Array.isArray(body.values) ? (body.values as Record<string, unknown>) : {};
69
+ } else {
70
+ values = formValues(form ?? new FormData(), await preferenceFields(c.config));
71
+ }
72
+ await c.s.members.put(c.member.customerId, { ...c.member, prefSaves: slots });
73
+ const { fields, changed } = await savePreferences(c.config, c.member.customerId, values);
74
+ // Field names only, never the answers.
75
+ if (changed.length) await emit(c.config, "preferences.saved", { customerId: c.member.customerId, fields: changed });
76
+ return isJson ? json(200, { fields }) : fail(redirect, returnTo, "preferences_saved");
77
+ } catch (e) {
78
+ if (e instanceof PreferenceError) return isJson ? json(400, { error: "refused", fields: e.fields }) : fail(redirect, returnTo, "preferences_refused");
79
+ console.error("[redeemid] preferences not saved:", (e as Error).message);
80
+ return isJson ? json(502, { error: "unavailable" }) : fail(redirect, returnTo, "preferences_unavailable");
81
+ }
82
+ };
package/src/storage.ts CHANGED
@@ -37,6 +37,8 @@ export interface MemberRecord {
37
37
  linkedAt?: string | null;
38
38
  /** Tags Shopify has removed, with when each was held (newest last, at most 50). */
39
39
  pastTags?: PastTag[];
40
+ /** When the member last saved preferences (ms), newest last, at most 10: the rate limit. */
41
+ prefSaves?: number[];
40
42
  }
41
43
 
42
44
  /** A tag the customer held from `from` (when known) until `to`. */
package/src/text.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Replacement text: give any string members and visitors see new words, one
3
+ * string at a time. Config `text` maps a string's original English to its
4
+ * replacement:
5
+ *
6
+ * text: {
7
+ * "Sign in": "Log in",
8
+ * "This is for {level} members.": "{level} members only.",
9
+ * }
10
+ *
11
+ * Only the strings named change: there is no find-and-replace across
12
+ * strings, so nothing changes that nobody looked at. STRINGS lists every
13
+ * original. Some have placeholders such as `{level}`, filled in when the
14
+ * text shows; a replacement may use its original's placeholders, in any
15
+ * order or not at all, but no others. A replacement that breaks this, or
16
+ * names a string RedeemID doesn't show, is ignored (the original shows) and
17
+ * listed on the Status page. Text is plain: HTML in a replacement shows as
18
+ * typed. Explicit component props (`label`, `title`, Notices `messages`) win
19
+ * over `text`.
20
+ */
21
+
22
+ import type { RedeemIDConfig } from "./config.js";
23
+
24
+ /** Every string RedeemID shows members and visitors, by where it shows. */
25
+ export const STRINGS = {
26
+ account: ["Signed in", "Account", "Sign out", "Sign in"],
27
+ badge: ["Free", "Signed in", " (locked)"],
28
+ cart: [
29
+ "Cart",
30
+ "Your cart is empty.",
31
+ "Your cart isn't available right now. Try again in a few minutes.",
32
+ "{price} each · {total}",
33
+ "Quantity",
34
+ "Update",
35
+ "Remove",
36
+ "Discount code",
37
+ "Apply",
38
+ "{code} doesn't apply to this cart.",
39
+ "Subtotal",
40
+ "Total",
41
+ "Taxes and shipping are worked out at checkout.",
42
+ "Checkout",
43
+ ],
44
+ collection: ["From {price}", "Sold out"],
45
+ contribution: ["Give", "Another amount ({currency})", "Quantity ({price} each)"],
46
+ meter: ["This is your last free read this week.", "This is your last free read this month.", "You have 1 free read left this week.", "You have 1 free read left this month.", "You have {count} free reads left this week.", "You have {count} free reads left this month."],
47
+ wall: [
48
+ "Become a member",
49
+ "This is for members. {signIn} to read on.",
50
+ "Sign in",
51
+ "Not a member yet? {join}.",
52
+ "You've used your free reads this week.",
53
+ "You've used your free reads this month.",
54
+ "This opens for you on {date}.",
55
+ "This opens for you after you've been a member a little longer.",
56
+ "This is for {level} members.",
57
+ "Your membership doesn't include this yet.",
58
+ "Your account is turned off. Contact the store for help.",
59
+ "This is for members.",
60
+ ],
61
+ member: [
62
+ "Your membership",
63
+ "Your account is turned off. Contact the store for help.",
64
+ "since {date}",
65
+ "You don't have a membership yet.",
66
+ "Become a member",
67
+ "Includes: {features}",
68
+ "Before",
69
+ "{from} to {to}",
70
+ "until {to}",
71
+ "Free reads this week: {used} of {limit} used.",
72
+ "Free reads this month: {used} of {limit} used.",
73
+ "Orders and details",
74
+ "Refresh membership",
75
+ "Sign out",
76
+ "Just bought a plan? It can take a minute to show here. Refresh membership checks again.",
77
+ "{signIn} to see your membership.",
78
+ "Sign in",
79
+ ],
80
+ notices: [
81
+ "Sign-in isn't set up on this site yet.",
82
+ "Sign-in isn't available right now. Try again in a few minutes.",
83
+ "That sign-in took too long. Try again.",
84
+ "This account can't sign in here. Use a different email.",
85
+ "This site isn't ready for sign-in yet.",
86
+ "Your account needs an email address to sign in.",
87
+ "Sign-in didn't finish. Try again.",
88
+ "That plan isn't available right now.",
89
+ "Checkout didn't open. Try again.",
90
+ "Your cart is empty.",
91
+ "Added to your cart.",
92
+ "Your cart is updated.",
93
+ "Discount applied.",
94
+ "That discount code doesn't apply to this cart.",
95
+ "Your membership is up to date.",
96
+ "Your preferences are saved.",
97
+ "Some answers weren't saved. Check them and try again.",
98
+ "Your preferences can't be saved right now. Try again in a few minutes.",
99
+ "Your account page isn't available right now. Try again in a few minutes.",
100
+ ],
101
+ plan: ["Join", "You're on this plan.", "Not available right now."],
102
+ preferences: ["Your preferences", "Save preferences", "Your preferences can't be loaded right now. Try again in a few minutes.", "Not set", "Yes", "No", "One per line."],
103
+ product: ["Add to cart", "Buy now", "Option", "{option} ({price})", "{option} ({price}) sold out", "Quantity", "Sold out."],
104
+ files: ["File not available", "Sign in to download this file.", "Sign in", "Your membership doesn't include this file."],
105
+ } as const;
106
+
107
+ const ALL = new Set<string>(Object.values(STRINGS).flat());
108
+
109
+ const PLACEHOLDER = /\{(\w+)\}/g;
110
+
111
+ export function placeholders(s: string): string[] {
112
+ return [...s.matchAll(PLACEHOLDER)].map((m) => m[1] as string);
113
+ }
114
+
115
+ /** Why a replacement can't be used, or null when it can. */
116
+ export function textProblem(original: string, replacement: unknown): string | null {
117
+ if (!ALL.has(original)) return "RedeemID doesn't show this string";
118
+ if (typeof replacement !== "string") return "The replacement isn't text";
119
+ const allowed = new Set(placeholders(original));
120
+ const extra = placeholders(replacement).filter((p) => !allowed.has(p));
121
+ if (extra.length) return `Uses ${[...new Set(extra)].map((p) => `{${p}}`).join(", ")}, which the original doesn't have`;
122
+ return null;
123
+ }
124
+
125
+ const checked = new WeakMap<object, { ok: Map<string, string>; problems: Array<{ original: string; problem: string }> }>();
126
+
127
+ function table(config: RedeemIDConfig | null | undefined) {
128
+ const map = config?.text;
129
+ if (!map || typeof map !== "object") return null;
130
+ let t = checked.get(map);
131
+ if (!t) {
132
+ t = { ok: new Map(), problems: [] };
133
+ for (const [original, replacement] of Object.entries(map)) {
134
+ const problem = textProblem(original, replacement);
135
+ if (problem) t.problems.push({ original, problem });
136
+ else t.ok.set(original, replacement as string);
137
+ }
138
+ if (t.problems.length) console.warn(`[redeemid] ${t.problems.length} text replacement(s) ignored; see Status.`);
139
+ checked.set(map, t);
140
+ }
141
+ return t;
142
+ }
143
+
144
+ /** Replacements that are ignored, and why. */
145
+ export function textProblems(config: RedeemIDConfig | null | undefined): Array<{ original: string; problem: string }> {
146
+ return table(config)?.problems ?? [];
147
+ }
148
+
149
+ /** The text for one string, with plain placeholders filled in. */
150
+ export function text(config: RedeemIDConfig | null | undefined, original: string, vars: Record<string, string | number> = {}): string {
151
+ const template = table(config)?.ok.get(original) ?? original;
152
+ return template.replace(PLACEHOLDER, (whole, name: string) => (name in vars ? String(vars[name]) : whole));
153
+ }
154
+
155
+ /**
156
+ * The text for a string whose placeholders are elements (a link inside a
157
+ * sentence): the parts to render, in order.
158
+ *
159
+ * {textParts(config, "This is for members. {signIn} to read on.", { signIn: <a href={u}>Sign in</a> })}
160
+ */
161
+ export function textParts(config: RedeemIDConfig | null | undefined, original: string, slots: Record<string, unknown>): unknown[] {
162
+ const template = table(config)?.ok.get(original) ?? original;
163
+ const out: unknown[] = [];
164
+ let last = 0;
165
+ for (const m of template.matchAll(PLACEHOLDER)) {
166
+ const name = m[1] as string;
167
+ if (!(name in slots)) continue;
168
+ if (m.index > last) out.push(template.slice(last, m.index));
169
+ out.push(slots[name]);
170
+ last = m.index + m[0].length;
171
+ }
172
+ if (last < template.length) out.push(template.slice(last));
173
+ return out;
174
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.9";
1
+ export const VERSION = "0.0.10";