@nexpress/core 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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/audit-54XLVCWD.js +14 -0
  4. package/dist/audit-54XLVCWD.js.map +1 -0
  5. package/dist/auth.d.ts +640 -0
  6. package/dist/auth.js +94 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/can-YLUHRJAB.js +19 -0
  9. package/dist/can-YLUHRJAB.js.map +1 -0
  10. package/dist/chunk-2G264RCD.js +68 -0
  11. package/dist/chunk-2G264RCD.js.map +1 -0
  12. package/dist/chunk-2YDGE7YX.js +92 -0
  13. package/dist/chunk-2YDGE7YX.js.map +1 -0
  14. package/dist/chunk-473S4TER.js +538 -0
  15. package/dist/chunk-473S4TER.js.map +1 -0
  16. package/dist/chunk-4ZLMEKFX.js +18 -0
  17. package/dist/chunk-4ZLMEKFX.js.map +1 -0
  18. package/dist/chunk-55FU6WED.js +179 -0
  19. package/dist/chunk-55FU6WED.js.map +1 -0
  20. package/dist/chunk-6YI5K2TI.js +1959 -0
  21. package/dist/chunk-6YI5K2TI.js.map +1 -0
  22. package/dist/chunk-BHK3AD3Q.js +41 -0
  23. package/dist/chunk-BHK3AD3Q.js.map +1 -0
  24. package/dist/chunk-CRUQBZUF.js +39 -0
  25. package/dist/chunk-CRUQBZUF.js.map +1 -0
  26. package/dist/chunk-CTSQ7BRI.js +175 -0
  27. package/dist/chunk-CTSQ7BRI.js.map +1 -0
  28. package/dist/chunk-DK2JBJH7.js +81 -0
  29. package/dist/chunk-DK2JBJH7.js.map +1 -0
  30. package/dist/chunk-DP2PREDU.js +597 -0
  31. package/dist/chunk-DP2PREDU.js.map +1 -0
  32. package/dist/chunk-EQ2Z3KMD.js +24 -0
  33. package/dist/chunk-EQ2Z3KMD.js.map +1 -0
  34. package/dist/chunk-FZ7O6DWI.js +305 -0
  35. package/dist/chunk-FZ7O6DWI.js.map +1 -0
  36. package/dist/chunk-ISLYFQWL.js +1270 -0
  37. package/dist/chunk-ISLYFQWL.js.map +1 -0
  38. package/dist/chunk-JJL74ZPK.js +68 -0
  39. package/dist/chunk-JJL74ZPK.js.map +1 -0
  40. package/dist/chunk-JKXAPSU4.js +24 -0
  41. package/dist/chunk-JKXAPSU4.js.map +1 -0
  42. package/dist/chunk-KU5M27ZC.js +24 -0
  43. package/dist/chunk-KU5M27ZC.js.map +1 -0
  44. package/dist/chunk-LSHHRDVR.js +34 -0
  45. package/dist/chunk-LSHHRDVR.js.map +1 -0
  46. package/dist/chunk-M43PGOQY.js +715 -0
  47. package/dist/chunk-M43PGOQY.js.map +1 -0
  48. package/dist/chunk-MEJAHXIO.js +150 -0
  49. package/dist/chunk-MEJAHXIO.js.map +1 -0
  50. package/dist/chunk-NUCGHWCF.js +101 -0
  51. package/dist/chunk-NUCGHWCF.js.map +1 -0
  52. package/dist/chunk-OK5HOCQI.js +845 -0
  53. package/dist/chunk-OK5HOCQI.js.map +1 -0
  54. package/dist/chunk-OROPGO65.js +13 -0
  55. package/dist/chunk-OROPGO65.js.map +1 -0
  56. package/dist/chunk-PPAS4SZR.js +176 -0
  57. package/dist/chunk-PPAS4SZR.js.map +1 -0
  58. package/dist/chunk-PPBWRKO2.js +171 -0
  59. package/dist/chunk-PPBWRKO2.js.map +1 -0
  60. package/dist/chunk-PZ5AY32C.js +10 -0
  61. package/dist/chunk-PZ5AY32C.js.map +1 -0
  62. package/dist/chunk-QO7LAQZH.js +321 -0
  63. package/dist/chunk-QO7LAQZH.js.map +1 -0
  64. package/dist/chunk-QVJ2HCAX.js +225 -0
  65. package/dist/chunk-QVJ2HCAX.js.map +1 -0
  66. package/dist/chunk-RIPHIRPP.js +68 -0
  67. package/dist/chunk-RIPHIRPP.js.map +1 -0
  68. package/dist/chunk-S27S42QY.js +134 -0
  69. package/dist/chunk-S27S42QY.js.map +1 -0
  70. package/dist/chunk-SBCVAC2Z.js +40 -0
  71. package/dist/chunk-SBCVAC2Z.js.map +1 -0
  72. package/dist/chunk-TFJ4MKPH.js +694 -0
  73. package/dist/chunk-TFJ4MKPH.js.map +1 -0
  74. package/dist/chunk-THX3SHYA.js +75 -0
  75. package/dist/chunk-THX3SHYA.js.map +1 -0
  76. package/dist/chunk-UGQSQO5B.js +222 -0
  77. package/dist/chunk-UGQSQO5B.js.map +1 -0
  78. package/dist/chunk-V2UNHGAP.js +26 -0
  79. package/dist/chunk-V2UNHGAP.js.map +1 -0
  80. package/dist/chunk-VGTPQXNQ.js +2790 -0
  81. package/dist/chunk-VGTPQXNQ.js.map +1 -0
  82. package/dist/chunk-VNIHXQ7W.js +194 -0
  83. package/dist/chunk-VNIHXQ7W.js.map +1 -0
  84. package/dist/chunk-WV272MPW.js +31 -0
  85. package/dist/chunk-WV272MPW.js.map +1 -0
  86. package/dist/chunk-X5KKBOUS.js +26 -0
  87. package/dist/chunk-X5KKBOUS.js.map +1 -0
  88. package/dist/chunk-XANPEOJC.js +17 -0
  89. package/dist/chunk-XANPEOJC.js.map +1 -0
  90. package/dist/chunk-XPVQIHAQ.js +83 -0
  91. package/dist/chunk-XPVQIHAQ.js.map +1 -0
  92. package/dist/chunk-ZCINJSS4.js +75 -0
  93. package/dist/chunk-ZCINJSS4.js.map +1 -0
  94. package/dist/community.d.ts +1425 -0
  95. package/dist/community.js +206 -0
  96. package/dist/community.js.map +1 -0
  97. package/dist/config-2GDU7PCK.js +32 -0
  98. package/dist/config-2GDU7PCK.js.map +1 -0
  99. package/dist/context-MNZ4QXPC.js +16 -0
  100. package/dist/context-MNZ4QXPC.js.map +1 -0
  101. package/dist/db-schema.d.ts +4 -0
  102. package/dist/db-schema.js +102 -0
  103. package/dist/db-schema.js.map +1 -0
  104. package/dist/db.d.ts +7 -0
  105. package/dist/db.js +117 -0
  106. package/dist/db.js.map +1 -0
  107. package/dist/digest-SY42GQSU.js +17 -0
  108. package/dist/digest-SY42GQSU.js.map +1 -0
  109. package/dist/errors-5OS3S2J3.js +22 -0
  110. package/dist/errors-5OS3S2J3.js.map +1 -0
  111. package/dist/host-OBOI4MJK.js +51 -0
  112. package/dist/host-OBOI4MJK.js.map +1 -0
  113. package/dist/i18n.d.ts +301 -0
  114. package/dist/i18n.js +68 -0
  115. package/dist/i18n.js.map +1 -0
  116. package/dist/index-B6-_vr_m.d.ts +590 -0
  117. package/dist/index-CY55LC0u.d.ts +4722 -0
  118. package/dist/index-CeiTvwbp.d.ts +168 -0
  119. package/dist/index-XwP1ET8b.d.ts +61 -0
  120. package/dist/index.d.ts +2037 -0
  121. package/dist/index.js +2205 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/job-log-VZXWQUDK.js +24 -0
  124. package/dist/job-log-VZXWQUDK.js.map +1 -0
  125. package/dist/jobs.d.ts +4 -0
  126. package/dist/jobs.js +76 -0
  127. package/dist/jobs.js.map +1 -0
  128. package/dist/logger-DqGaOU_j.d.ts +29 -0
  129. package/dist/logger-S7REWDNE.js +16 -0
  130. package/dist/logger-S7REWDNE.js.map +1 -0
  131. package/dist/media.d.ts +5 -0
  132. package/dist/media.js +41 -0
  133. package/dist/media.js.map +1 -0
  134. package/dist/mentions-2IHFVSHW.js +23 -0
  135. package/dist/mentions-2IHFVSHW.js.map +1 -0
  136. package/dist/mutes-EWAE5FZR.js +21 -0
  137. package/dist/mutes-EWAE5FZR.js.map +1 -0
  138. package/dist/notification-prefs-VPJDU7I6.js +21 -0
  139. package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
  140. package/dist/observability.d.ts +156 -0
  141. package/dist/observability.js +32 -0
  142. package/dist/observability.js.map +1 -0
  143. package/dist/profanity-adapter-NU2JQSLX.js +12 -0
  144. package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
  145. package/dist/queue-XE5BC75T.js +14 -0
  146. package/dist/queue-XE5BC75T.js.map +1 -0
  147. package/dist/rate-limit.d.ts +99 -0
  148. package/dist/rate-limit.js +14 -0
  149. package/dist/rate-limit.js.map +1 -0
  150. package/dist/registry-XIXDEPVI.js +31 -0
  151. package/dist/registry-XIXDEPVI.js.map +1 -0
  152. package/dist/reputation-JRL2YQHM.js +11 -0
  153. package/dist/reputation-JRL2YQHM.js.map +1 -0
  154. package/dist/routes.d.ts +43 -0
  155. package/dist/routes.js +12 -0
  156. package/dist/routes.js.map +1 -0
  157. package/dist/scheduled-CIQM57HT.js +20 -0
  158. package/dist/scheduled-CIQM57HT.js.map +1 -0
  159. package/dist/seo.d.ts +410 -0
  160. package/dist/seo.js +44 -0
  161. package/dist/seo.js.map +1 -0
  162. package/dist/settings-FOBIESPB.js +17 -0
  163. package/dist/settings-FOBIESPB.js.map +1 -0
  164. package/dist/spam-adapter-XX3G737Z.js +12 -0
  165. package/dist/spam-adapter-XX3G737Z.js.map +1 -0
  166. package/dist/strings-VAE47B2C.js +29 -0
  167. package/dist/strings-VAE47B2C.js.map +1 -0
  168. package/dist/templates-IFVJMCJ6.js +12 -0
  169. package/dist/templates-IFVJMCJ6.js.map +1 -0
  170. package/dist/types-TlsbXS0T.d.ts +871 -0
  171. package/package.json +129 -0
@@ -0,0 +1,321 @@
1
+ import {
2
+ getPluginRegistration
3
+ } from "./chunk-VGTPQXNQ.js";
4
+ import {
5
+ getCurrentSiteId
6
+ } from "./chunk-SBCVAC2Z.js";
7
+ import {
8
+ NpValidationError
9
+ } from "./chunk-ZCINJSS4.js";
10
+ import {
11
+ getDb
12
+ } from "./chunk-XANPEOJC.js";
13
+ import {
14
+ npSettings
15
+ } from "./chunk-M43PGOQY.js";
16
+
17
+ // src/plugins/config.ts
18
+ import { and, eq } from "drizzle-orm";
19
+
20
+ // src/themes/settings-schema.ts
21
+ var COLOR_REGEX_PATTERNS = [
22
+ /^\^#\[0-9a-f\]\{6\}\$$/i,
23
+ /^\^#\[0-9a-f\]\{3,8\}\$$/i,
24
+ /^\^#\[\\da-f\]\{6\}\$$/i
25
+ ];
26
+ function unwrap(node) {
27
+ let current = node;
28
+ let defaultValue = void 0;
29
+ let required = true;
30
+ while (true) {
31
+ const t = current._def.type;
32
+ if (t === "default") {
33
+ defaultValue = typeof current._def.defaultValue === "function" ? current._def.defaultValue() : current._def.defaultValue;
34
+ current = current._def.innerType ?? current;
35
+ if (!current._def.innerType) break;
36
+ continue;
37
+ }
38
+ if (t === "optional" || t === "nullable") {
39
+ required = false;
40
+ const next = current._def.innerType;
41
+ if (!next) break;
42
+ current = next;
43
+ continue;
44
+ }
45
+ break;
46
+ }
47
+ return { inner: current, defaultValue, required };
48
+ }
49
+ function detectStringFormat(checks) {
50
+ if (!checks) return "text";
51
+ for (const c of checks) {
52
+ const fmt = c._zod?.def?.format;
53
+ if (fmt === "url") return "url";
54
+ if (fmt === "regex") {
55
+ const src = c._zod?.def?.pattern?.source;
56
+ if (src && COLOR_REGEX_PATTERNS.some((p) => p.test(src))) {
57
+ return "color";
58
+ }
59
+ }
60
+ }
61
+ return "text";
62
+ }
63
+ function readMeta(node) {
64
+ const fn = node.meta;
65
+ if (typeof fn !== "function") return void 0;
66
+ const out = fn.call(node);
67
+ return out && typeof out === "object" ? out : void 0;
68
+ }
69
+ function detectNumberConstraints(checks) {
70
+ const out = {};
71
+ if (!checks) return out;
72
+ for (const c of checks) {
73
+ const def = c._zod?.def;
74
+ if (!def) continue;
75
+ if (def.format === "safeint" || def.check === "int") out.int = true;
76
+ if (def.check === "greater_than" && typeof def.value === "number")
77
+ out.min = def.value;
78
+ if (def.check === "less_than" && typeof def.value === "number")
79
+ out.max = def.value;
80
+ }
81
+ return out;
82
+ }
83
+ function introspectField(name, node) {
84
+ const description = node.description;
85
+ const { inner, defaultValue, required } = unwrap(node);
86
+ const innerDef = inner._def;
87
+ const base = {
88
+ name,
89
+ description,
90
+ label: description,
91
+ required,
92
+ default: defaultValue
93
+ };
94
+ switch (innerDef.type) {
95
+ case "string": {
96
+ const meta = readMeta(node) ?? readMeta(inner);
97
+ if (meta && meta.sensitive === true) {
98
+ return { ...base, type: "password" };
99
+ }
100
+ if (meta && meta.widget === "textarea") {
101
+ const rows = typeof meta.rows === "number" && meta.rows > 0 ? meta.rows : void 0;
102
+ return {
103
+ ...base,
104
+ type: "textarea",
105
+ ...rows !== void 0 ? { rows } : {}
106
+ };
107
+ }
108
+ const fmt = detectStringFormat(innerDef.checks);
109
+ return { ...base, type: fmt };
110
+ }
111
+ case "number": {
112
+ const c = detectNumberConstraints(innerDef.checks);
113
+ return { ...base, type: "number", ...c };
114
+ }
115
+ case "boolean":
116
+ return { ...base, type: "boolean" };
117
+ case "enum": {
118
+ const entries = innerDef.entries ?? {};
119
+ return { ...base, type: "enum", options: Object.values(entries) };
120
+ }
121
+ case "array": {
122
+ const element = innerDef.element;
123
+ if (element?._def.type === "object" && element._def.shape) {
124
+ const childFields = introspectShape(element._def.shape);
125
+ return { ...base, type: "array", element: childFields };
126
+ }
127
+ if (element?._def.type === "string") {
128
+ return { ...base, type: "string-array" };
129
+ }
130
+ return { ...base, type: "unsupported", zodTypeName: "array" };
131
+ }
132
+ case "object": {
133
+ const shape = innerDef.shape;
134
+ if (shape) {
135
+ return { ...base, type: "object", fields: introspectShape(shape) };
136
+ }
137
+ return { ...base, type: "unsupported", zodTypeName: "object" };
138
+ }
139
+ default:
140
+ return {
141
+ ...base,
142
+ type: "unsupported",
143
+ zodTypeName: innerDef.type ?? "unknown"
144
+ };
145
+ }
146
+ }
147
+ function introspectShape(shape) {
148
+ const out = [];
149
+ for (const [name, raw] of Object.entries(shape)) {
150
+ out.push(introspectField(name, raw));
151
+ }
152
+ return out;
153
+ }
154
+ function introspectThemeSettingsSchema(schema) {
155
+ if (!schema) return [];
156
+ const { inner } = unwrap(schema);
157
+ if (inner._def.type !== "object" || !inner._def.shape) return [];
158
+ return introspectShape(inner._def.shape);
159
+ }
160
+
161
+ // src/plugins/config.ts
162
+ var DEFAULT_SITE = "default";
163
+ var CONFIG_KEY_PREFIX = "plugin.config:";
164
+ function configKey(pluginId) {
165
+ return `${CONFIG_KEY_PREFIX}${pluginId}`;
166
+ }
167
+ function isVersionedPluginConfig(value) {
168
+ if (!value || typeof value !== "object") return false;
169
+ const candidate = value;
170
+ return typeof candidate.__npVersion === "number" && Number.isFinite(candidate.__npVersion) && "__npSettings" in candidate;
171
+ }
172
+ function applyPluginConfigMigration(registration, rawValue, fromVersion) {
173
+ const target = registration.configVersion ?? 1;
174
+ if (fromVersion >= target) return rawValue;
175
+ const migrate = registration.configMigrate;
176
+ if (typeof migrate !== "function") return rawValue;
177
+ try {
178
+ return migrate(rawValue, fromVersion);
179
+ } catch {
180
+ return rawValue;
181
+ }
182
+ }
183
+ function defaultsFrom(fields) {
184
+ const out = {};
185
+ for (const f of fields) {
186
+ if (f.default !== void 0) {
187
+ out[f.name] = f.default;
188
+ continue;
189
+ }
190
+ if (f.type === "object") {
191
+ out[f.name] = defaultsFrom(f.fields);
192
+ }
193
+ if (f.type === "array") {
194
+ out[f.name] = [];
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+ async function getPluginConfig(pluginId) {
200
+ const result = await getPluginConfigWithStatus(pluginId);
201
+ return result.value;
202
+ }
203
+ async function getPluginConfigWithStatus(pluginId) {
204
+ const registration = getPluginRegistration(pluginId);
205
+ if (!registration) {
206
+ return { pluginId, value: {}, hasPersisted: false };
207
+ }
208
+ const schema = registration.configSchema;
209
+ let row;
210
+ try {
211
+ const db = getDb();
212
+ const siteId = await getCurrentSiteId() ?? DEFAULT_SITE;
213
+ const rows = await db.select().from(npSettings).where(
214
+ and(eq(npSettings.siteId, siteId), eq(npSettings.key, configKey(pluginId)))
215
+ ).limit(1);
216
+ row = rows[0];
217
+ } catch {
218
+ return { pluginId, value: schema ? defaultsFromSchema(schema) : {}, hasPersisted: false };
219
+ }
220
+ if (!schema) {
221
+ if (!row) {
222
+ return { pluginId, value: {}, hasPersisted: false };
223
+ }
224
+ const versioned2 = isVersionedPluginConfig(row.value) ? row.value : null;
225
+ const rawValue2 = versioned2 ? versioned2.__npSettings : row.value;
226
+ return {
227
+ pluginId,
228
+ value: rawValue2 ?? {},
229
+ hasPersisted: true
230
+ };
231
+ }
232
+ const fields = introspectThemeSettingsSchema(schema);
233
+ const defaults = defaultsFrom(fields);
234
+ if (!row) {
235
+ const parsed2 = schema.safeParse(defaults);
236
+ return {
237
+ pluginId,
238
+ value: parsed2.success ? parsed2.data : defaults,
239
+ hasPersisted: false
240
+ };
241
+ }
242
+ const versioned = isVersionedPluginConfig(row.value) ? row.value : null;
243
+ const storedVersion = versioned ? versioned.__npVersion : 1;
244
+ const rawValue = versioned ? versioned.__npSettings : row.value;
245
+ const valueToParse = applyPluginConfigMigration(registration, rawValue, storedVersion);
246
+ const parsed = schema.safeParse(valueToParse);
247
+ if (parsed.success) {
248
+ return { pluginId, value: parsed.data, hasPersisted: true };
249
+ }
250
+ return {
251
+ pluginId,
252
+ value: defaults,
253
+ hasPersisted: true,
254
+ parseError: parsed.error.message
255
+ };
256
+ }
257
+ function defaultsFromSchema(schema) {
258
+ return defaultsFrom(introspectThemeSettingsSchema(schema));
259
+ }
260
+ async function setPluginConfig(pluginId, value, updatedBy = null) {
261
+ const registration = getPluginRegistration(pluginId);
262
+ if (!registration) {
263
+ throw new NpValidationError("Invalid input", [
264
+ {
265
+ field: "pluginId",
266
+ message: `Unknown plugin '${pluginId}'. Register it in nexpress.config.ts first.`
267
+ }
268
+ ]);
269
+ }
270
+ const schema = registration.configSchema;
271
+ if (!schema) {
272
+ throw new NpValidationError("Invalid input", [
273
+ {
274
+ field: "pluginId",
275
+ message: `Plugin '${pluginId}' does not declare a configSchema.`
276
+ }
277
+ ]);
278
+ }
279
+ const parsed = schema.safeParse(value);
280
+ if (!parsed.success) {
281
+ throw new NpValidationError(
282
+ "Config failed validation",
283
+ parsed.error.issues.map((i) => ({
284
+ field: i.path.join("."),
285
+ message: i.message
286
+ }))
287
+ );
288
+ }
289
+ const wrapped = {
290
+ __npVersion: registration.configVersion ?? 1,
291
+ __npSettings: parsed.data
292
+ };
293
+ const db = getDb();
294
+ const now = /* @__PURE__ */ new Date();
295
+ const siteId = await getCurrentSiteId() ?? DEFAULT_SITE;
296
+ await db.insert(npSettings).values({
297
+ siteId,
298
+ key: configKey(pluginId),
299
+ value: wrapped,
300
+ updatedAt: now,
301
+ updatedBy
302
+ }).onConflictDoUpdate({
303
+ target: [npSettings.siteId, npSettings.key],
304
+ set: { value: wrapped, updatedAt: now, updatedBy }
305
+ });
306
+ return parsed.data;
307
+ }
308
+ function pluginConfigCacheTag(pluginId) {
309
+ return `np:plugin:${pluginId}`;
310
+ }
311
+
312
+ export {
313
+ introspectThemeSettingsSchema,
314
+ isVersionedPluginConfig,
315
+ applyPluginConfigMigration,
316
+ getPluginConfig,
317
+ getPluginConfigWithStatus,
318
+ setPluginConfig,
319
+ pluginConfigCacheTag
320
+ };
321
+ //# sourceMappingURL=chunk-QO7LAQZH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/plugins/config.ts","../src/themes/settings-schema.ts"],"sourcesContent":["import { and, eq } from \"drizzle-orm\";\nimport type { ZodTypeAny } from \"zod\";\n\nimport { getDb } from \"../db/index.js\";\nimport { npSettings } from \"../db/schema/system.js\";\nimport { NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { getPluginRegistration } from \"./host.js\";\nimport {\n introspectThemeSettingsSchema,\n type NpThemeSettingsField,\n} from \"../themes/settings-schema.js\";\n\nconst DEFAULT_SITE = \"default\";\nconst CONFIG_KEY_PREFIX = \"plugin.config:\";\n\n/**\n * G.1 — per-plugin operator config.\n *\n * Stored at `np_settings.(site_id, key=\"plugin.config:<pluginId>\")`.\n * Mirrors theme settings storage exactly, including the `__npVersion` /\n * `__npSettings` envelope, so a future shared `getCachedSetting<T>(key)`\n * helper can read both surfaces. Cache invalidation rides a new\n * `np:plugin:<id>` tag (see `packages/next/src/cache.ts`).\n *\n * Per locked decision E (`docs/design/plugin-config-auto-form.md` § 2):\n * we store under `np_settings`, NOT `np_plugins.config` (the legacy\n * column was dropped in the same migration that introduced this module).\n */\n\nfunction configKey(pluginId: string): string {\n return `${CONFIG_KEY_PREFIX}${pluginId}`;\n}\n\n/**\n * Versioned envelope shape for persisted plugin config — identical to the\n * theme `NpVersionedSettings` shape. Two parallel definitions instead of a\n * shared one because (a) themes and plugins share zero schema surface\n * otherwise, (b) the type is only ~5 lines, and (c) collapsing them would\n * couple `themes/` and `plugins/` modules without functional benefit.\n */\nexport interface NpVersionedPluginConfig {\n __npVersion: number;\n __npSettings: unknown;\n}\n\nexport function isVersionedPluginConfig(\n value: unknown,\n): value is NpVersionedPluginConfig {\n if (!value || typeof value !== \"object\") return false;\n const candidate = value as Partial<NpVersionedPluginConfig>;\n return (\n typeof candidate.__npVersion === \"number\" &&\n Number.isFinite(candidate.__npVersion) &&\n \"__npSettings\" in candidate\n );\n}\n\n/**\n * Run the plugin's `configMigrate` from `from` to current schema version.\n * No-op when versions match or the plugin doesn't declare a migrator.\n * Defensive try/catch — a buggy migrate fn shouldn't blow up the read\n * path; we fall back to the original value and let `safeParse` decide.\n *\n * Mirrors `applyMigration` in `packages/core/src/themes/settings.ts` line\n * for line.\n */\nexport function applyPluginConfigMigration(\n registration: {\n configVersion?: number;\n configMigrate?: (old: unknown, fromVersion: number) => unknown;\n },\n rawValue: unknown,\n fromVersion: number,\n): unknown {\n const target = registration.configVersion ?? 1;\n if (fromVersion >= target) return rawValue;\n const migrate = registration.configMigrate;\n if (typeof migrate !== \"function\") return rawValue;\n try {\n return migrate(rawValue, fromVersion);\n } catch {\n return rawValue;\n }\n}\n\nfunction defaultsFrom(fields: NpThemeSettingsField[]): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const f of fields) {\n if (f.default !== undefined) {\n out[f.name] = f.default;\n continue;\n }\n if (f.type === \"object\") {\n out[f.name] = defaultsFrom(f.fields);\n }\n if (f.type === \"array\") {\n out[f.name] = [];\n }\n }\n return out;\n}\n\nexport interface NpPluginConfigResult {\n pluginId: string;\n /** Parsed config or schema defaults. Empty object when the plugin has\n * no configSchema. */\n value: unknown;\n /** True when there's a stored row, regardless of whether it passed\n * validation. */\n hasPersisted: boolean;\n /** Set when the persisted value failed `schema.parse()`. The admin\n * surface uses this to render a \"settings were reset\" banner. */\n parseError?: string;\n}\n\n/**\n * Read the persisted config for a plugin and parse it via the plugin's\n * `configSchema`. Returns the parsed value when valid; falls back to\n * schema defaults on parse failure (with the failure recorded for the\n * admin to surface, see `getPluginConfigWithStatus`).\n *\n * Return type is `unknown` because core can't type-narrow to the plugin's\n * `z.infer<typeof configSchema>` — the schema lives in the plugin\n * package, not in core. Plugin code that reads its own config should\n * cast at the call site, ideally against an exported type alias from the\n * plugin package itself:\n *\n * // packages/plugins/oauth-github/src/index.ts\n * export const configSchema = z.object({ ... });\n * export type GithubOauthConfig = z.infer<typeof configSchema>;\n *\n * // a plugin handler\n * const config = (await getPluginConfig(\"oauth-github\")) as GithubOauthConfig;\n */\nexport async function getPluginConfig(pluginId: string): Promise<unknown> {\n const result = await getPluginConfigWithStatus(pluginId);\n return result.value;\n}\n\nexport async function getPluginConfigWithStatus(\n pluginId: string,\n): Promise<NpPluginConfigResult> {\n const registration = getPluginRegistration(pluginId);\n if (!registration) {\n // Plugin not registered — return empty config so callers (plugin\n // hosts iterating contexts, route handlers reading their own config)\n // get a stable shape without having to special-case \"plugin not\n // found\". The admin surface checks registration separately.\n return { pluginId, value: {}, hasPersisted: false };\n }\n const schema = registration.configSchema as ZodTypeAny | undefined;\n\n let row: { value: unknown } | undefined;\n try {\n const db = getDb();\n const siteId = (await getCurrentSiteId()) ?? DEFAULT_SITE;\n const rows = (await db\n .select()\n .from(npSettings)\n .where(\n and(eq(npSettings.siteId, siteId), eq(npSettings.key, configKey(pluginId))),\n )\n .limit(1)) as Array<{ value: unknown }>;\n row = rows[0];\n } catch {\n // DB not ready — caller is asking before bootstrap. Return empty\n // shape; treats DB-not-ready the same as \"no row stored yet\".\n return { pluginId, value: schema ? defaultsFromSchema(schema) : {}, hasPersisted: false };\n }\n\n if (!schema) {\n // Plugin doesn't declare a configSchema. If a row exists (legacy\n // hand-coded UI saved into np_settings, or migrated from\n // np_plugins.config), surface it raw — callers can still read it.\n if (!row) {\n return { pluginId, value: {}, hasPersisted: false };\n }\n const versioned = isVersionedPluginConfig(row.value) ? row.value : null;\n const rawValue = versioned ? versioned.__npSettings : row.value;\n return {\n pluginId,\n value: rawValue ?? {},\n hasPersisted: true,\n };\n }\n\n const fields = introspectThemeSettingsSchema(schema);\n const defaults = defaultsFrom(fields);\n\n if (!row) {\n const parsed = schema.safeParse(defaults);\n return {\n pluginId,\n value: parsed.success ? parsed.data : defaults,\n hasPersisted: false,\n };\n }\n\n // Versioned envelope detection + lazy migration. Mirrors\n // `getThemeSettingsWithStatus` exactly.\n const versioned = isVersionedPluginConfig(row.value) ? row.value : null;\n const storedVersion = versioned ? versioned.__npVersion : 1;\n const rawValue = versioned ? versioned.__npSettings : row.value;\n const valueToParse = applyPluginConfigMigration(registration, rawValue, storedVersion);\n\n const parsed = schema.safeParse(valueToParse);\n if (parsed.success) {\n return { pluginId, value: parsed.data, hasPersisted: true };\n }\n\n return {\n pluginId,\n value: defaults,\n hasPersisted: true,\n parseError: parsed.error.message,\n };\n}\n\nfunction defaultsFromSchema(schema: ZodTypeAny): Record<string, unknown> {\n return defaultsFrom(introspectThemeSettingsSchema(schema));\n}\n\n/**\n * Validate and persist a plugin's config. Throws `NpValidationError` when\n * `value` doesn't pass the schema — the admin form must surface\n * field-level errors before calling this.\n *\n * **Cache invalidation is the caller's responsibility.** This function\n * writes to `np_settings` only; it doesn't import `next/cache`. The\n * admin API route (`PUT /api/admin/plugins/[id]/config`) busts\n * `np:plugin:<id>` after a successful write.\n *\n * Mirrors `setThemeSettings` in `packages/core/src/themes/settings.ts`.\n */\nexport async function setPluginConfig(\n pluginId: string,\n value: unknown,\n updatedBy: string | null = null,\n): Promise<unknown> {\n const registration = getPluginRegistration(pluginId);\n if (!registration) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"pluginId\",\n message: `Unknown plugin '${pluginId}'. Register it in nexpress.config.ts first.`,\n },\n ]);\n }\n const schema = registration.configSchema as ZodTypeAny | undefined;\n if (!schema) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"pluginId\",\n message: `Plugin '${pluginId}' does not declare a configSchema.`,\n },\n ]);\n }\n\n const parsed = schema.safeParse(value);\n if (!parsed.success) {\n throw new NpValidationError(\n \"Config failed validation\",\n parsed.error.issues.map((i) => ({\n field: i.path.join(\".\"),\n message: i.message,\n })),\n );\n }\n\n const wrapped: NpVersionedPluginConfig = {\n __npVersion: registration.configVersion ?? 1,\n __npSettings: parsed.data,\n };\n\n const db = getDb();\n const now = new Date();\n const siteId = (await getCurrentSiteId()) ?? DEFAULT_SITE;\n await db\n .insert(npSettings)\n .values({\n siteId,\n key: configKey(pluginId),\n value: wrapped,\n updatedAt: now,\n updatedBy,\n })\n .onConflictDoUpdate({\n target: [npSettings.siteId, npSettings.key],\n set: { value: wrapped, updatedAt: now, updatedBy },\n });\n\n return parsed.data;\n}\n\n/** Cache tag for a plugin's config invalidation. Per the prefix policy\n * in CLAUDE.md (Naming convention table) every framework-owned tag\n * uses the `np` prefix. Distinct from the legacy `nx:theme:<siteId>`\n * tag — see `docs/design/plugin-config-auto-form.md` § 7. */\nexport function pluginConfigCacheTag(pluginId: string): string {\n return `np:plugin:${pluginId}`;\n}\n","import type { ZodTypeAny } from \"zod\";\n\n/**\n * Phase F.3 — server-side introspection of a theme's\n * `settingsSchema` Zod tree into a JSON metadata shape the\n * admin form generator consumes.\n *\n * The schema lives in the theme package (server bundle); we\n * don't ship the schema itself to the browser. Instead, this\n * function walks the tree once on the server, emits the\n * metadata as plain JSON, and the admin renders form fields\n * from the metadata. The browser doesn't need zod at runtime.\n *\n * Coverage in v0.2: text, url, color (regex heuristic), number,\n * boolean, enum, array(object), object. Anything else\n * introspects as `{ type: \"unsupported\" }` so the form generator\n * can render a JSON textarea fallback (operator can still edit;\n * a follow-up phase widens coverage).\n */\n\nexport type NpThemeSettingsField =\n | NpThemeSettingsTextField\n | NpThemeSettingsTextareaField\n | NpThemeSettingsPasswordField\n | NpThemeSettingsUrlField\n | NpThemeSettingsColorField\n | NpThemeSettingsNumberField\n | NpThemeSettingsBooleanField\n | NpThemeSettingsEnumField\n | NpThemeSettingsArrayField\n | NpThemeSettingsStringArrayField\n | NpThemeSettingsObjectField\n | NpThemeSettingsUnsupportedField;\n\ninterface NpThemeSettingsFieldBase {\n /** Field path key (\"hero\", \"social.0.url\", etc. — the\n * introspector returns flat keys per node; nested objects\n * carry their own children). */\n name: string;\n label?: string;\n description?: string;\n required: boolean;\n default?: unknown;\n}\n\nexport interface NpThemeSettingsTextField extends NpThemeSettingsFieldBase {\n type: \"text\";\n}\n\nexport interface NpThemeSettingsTextareaField extends NpThemeSettingsFieldBase {\n type: \"textarea\";\n /** Optional row count hint for the rendered `<textarea>`.\n * Theme authors set this via `.meta({ widget: \"textarea\",\n * rows: 6 })`. Defaults to 4 when unset. */\n rows?: number;\n}\n\nexport interface NpThemeSettingsPasswordField extends NpThemeSettingsFieldBase {\n type: \"password\";\n}\n\nexport interface NpThemeSettingsUrlField extends NpThemeSettingsFieldBase {\n type: \"url\";\n}\n\nexport interface NpThemeSettingsColorField extends NpThemeSettingsFieldBase {\n type: \"color\";\n}\n\nexport interface NpThemeSettingsNumberField extends NpThemeSettingsFieldBase {\n type: \"number\";\n int?: boolean;\n min?: number;\n max?: number;\n}\n\nexport interface NpThemeSettingsBooleanField extends NpThemeSettingsFieldBase {\n type: \"boolean\";\n}\n\nexport interface NpThemeSettingsEnumField extends NpThemeSettingsFieldBase {\n type: \"enum\";\n options: string[];\n}\n\nexport interface NpThemeSettingsArrayField extends NpThemeSettingsFieldBase {\n type: \"array\";\n /** v0.2 supports `z.array(z.object(...))`. The element\n * schema introspects as the array's child fields. */\n element: NpThemeSettingsField[];\n}\n\n/** Phase G follow-up — `z.array(z.string())`. Renders as a\n * one-item-per-line input. Surfaced for OAuth scopes and\n * similar string-list configs that don't fit the object-array\n * shape; previously fell through to the JSON-textarea\n * `unsupported` fallback. */\nexport interface NpThemeSettingsStringArrayField extends NpThemeSettingsFieldBase {\n type: \"string-array\";\n}\n\nexport interface NpThemeSettingsObjectField extends NpThemeSettingsFieldBase {\n type: \"object\";\n fields: NpThemeSettingsField[];\n}\n\nexport interface NpThemeSettingsUnsupportedField extends NpThemeSettingsFieldBase {\n type: \"unsupported\";\n /** Best-effort label for what was at this position so\n * operators can recognize their schema in the JSON fallback. */\n zodTypeName: string;\n}\n\n// Heuristic: regex sources that look like a hex color check.\n// We test against the regex `source` string (no flags, no\n// surrounding slashes), so e.g. `/^#[0-9a-f]{6}$/i` arrives\n// as `^#[0-9a-f]{6}$`. Matches both 6-digit and 3-to-8 digit\n// variants, case sensitivity-agnostic via the `i` flag on\n// the heuristic itself.\nconst COLOR_REGEX_PATTERNS = [\n /^\\^#\\[0-9a-f\\]\\{6\\}\\$$/i,\n /^\\^#\\[0-9a-f\\]\\{3,8\\}\\$$/i,\n /^\\^#\\[\\\\da-f\\]\\{6\\}\\$$/i,\n];\n\ninterface ZodCheck {\n _zod?: { def?: { format?: string; pattern?: { source: string }; check?: string; value?: number } };\n}\n\ninterface ZodDef {\n type: string;\n innerType?: { _def: ZodDef };\n defaultValue?: unknown;\n description?: string;\n shape?: Record<string, { _def: ZodDef; description?: string }>;\n entries?: Record<string, string>;\n element?: { _def: ZodDef };\n checks?: ZodCheck[];\n}\n\ninterface ZodNode {\n _def: ZodDef;\n description?: string;\n shape?: Record<string, ZodNode>;\n}\n\n/**\n * Strip `default` / `optional` / `nullable` wrappers, returning\n * the inner schema, the resolved default value, and whether\n * the field is required (i.e. neither optional nor nullable).\n */\nfunction unwrap(node: ZodNode): {\n inner: ZodNode;\n defaultValue: unknown;\n required: boolean;\n} {\n let current = node;\n let defaultValue: unknown = undefined;\n let required = true;\n\n while (true) {\n const t = current._def.type;\n if (t === \"default\") {\n defaultValue =\n typeof current._def.defaultValue === \"function\"\n ? (current._def.defaultValue as () => unknown)()\n : current._def.defaultValue;\n current = (current._def.innerType as ZodNode | undefined) ?? current;\n if (!current._def.innerType) break;\n continue;\n }\n if (t === \"optional\" || t === \"nullable\") {\n required = false;\n const next = current._def.innerType as ZodNode | undefined;\n if (!next) break;\n current = next;\n continue;\n }\n break;\n }\n\n return { inner: current, defaultValue, required };\n}\n\nfunction detectStringFormat(\n checks: ZodCheck[] | undefined,\n): \"url\" | \"color\" | \"text\" {\n if (!checks) return \"text\";\n for (const c of checks) {\n const fmt = c._zod?.def?.format;\n if (fmt === \"url\") return \"url\";\n if (fmt === \"regex\") {\n const src = c._zod?.def?.pattern?.source;\n if (src && COLOR_REGEX_PATTERNS.some((p) => p.test(src))) {\n return \"color\";\n }\n }\n }\n return \"text\";\n}\n\n/**\n * Phase F.3 follow-up — pull `.meta()` off a Zod node when\n * present. Used to read theme-author hints like\n * `{ widget: \"textarea\", rows: 6 }` that don't fit Zod's\n * narrow widget matrix (z.string() has no textarea variant\n * built in).\n */\nfunction readMeta(node: ZodNode): Record<string, unknown> | undefined {\n const fn = (node as unknown as { meta?: () => unknown }).meta;\n if (typeof fn !== \"function\") return undefined;\n const out = fn.call(node);\n return out && typeof out === \"object\" ? (out as Record<string, unknown>) : undefined;\n}\n\nfunction detectNumberConstraints(\n checks: ZodCheck[] | undefined,\n): { int?: boolean; min?: number; max?: number } {\n const out: { int?: boolean; min?: number; max?: number } = {};\n if (!checks) return out;\n for (const c of checks) {\n const def = c._zod?.def;\n if (!def) continue;\n if (def.format === \"safeint\" || def.check === \"int\") out.int = true;\n if (def.check === \"greater_than\" && typeof def.value === \"number\")\n out.min = def.value;\n if (def.check === \"less_than\" && typeof def.value === \"number\")\n out.max = def.value;\n }\n return out;\n}\n\nfunction introspectField(\n name: string,\n node: ZodNode,\n): NpThemeSettingsField {\n const description = node.description;\n const { inner, defaultValue, required } = unwrap(node);\n const innerDef = inner._def;\n const base: NpThemeSettingsFieldBase = {\n name,\n description,\n label: description,\n required,\n default: defaultValue,\n };\n\n switch (innerDef.type) {\n case \"string\": {\n // Phase F.3 follow-up — `.meta({ widget: \"textarea\" })`\n // opts a `z.string()` into multi-line rendering. Theme\n // authors pair it with `.describe()` for the field\n // label; row count is optional (defaults to 4).\n //\n // Check `node` (outer) first then `inner` because Zod v4's\n // `.meta()` returns a new instance, so the meta lives at\n // whichever level the author called .meta() at:\n //\n // z.string().meta({...}).optional() → meta on inner string\n // z.string().optional().meta({...}) → meta on outer optional\n //\n // Both patterns are valid in author code; both should work.\n const meta = readMeta(node) ?? readMeta(inner);\n if (meta && meta.sensitive === true) {\n return { ...base, type: \"password\" };\n }\n if (meta && meta.widget === \"textarea\") {\n const rows =\n typeof meta.rows === \"number\" && meta.rows > 0\n ? meta.rows\n : undefined;\n return {\n ...base,\n type: \"textarea\",\n ...(rows !== undefined ? { rows } : {}),\n };\n }\n const fmt = detectStringFormat(innerDef.checks);\n return { ...base, type: fmt };\n }\n case \"number\": {\n const c = detectNumberConstraints(innerDef.checks);\n return { ...base, type: \"number\", ...c };\n }\n case \"boolean\":\n return { ...base, type: \"boolean\" };\n case \"enum\": {\n const entries = innerDef.entries ?? {};\n return { ...base, type: \"enum\", options: Object.values(entries) };\n }\n case \"array\": {\n const element = innerDef.element as ZodNode | undefined;\n // v0.2 supports z.array(z.object(...)) — typed nested form\n // for each item.\n if (element?._def.type === \"object\" && element._def.shape) {\n const childFields = introspectShape(element._def.shape);\n return { ...base, type: \"array\", element: childFields };\n }\n // Phase G follow-up — z.array(z.string()) gets a dedicated\n // string-array widget (one item per line). Surfaced for\n // OAuth scopes and similar string-list configs.\n if (element?._def.type === \"string\") {\n return { ...base, type: \"string-array\" };\n }\n return { ...base, type: \"unsupported\", zodTypeName: \"array\" };\n }\n case \"object\": {\n const shape = innerDef.shape;\n if (shape) {\n return { ...base, type: \"object\", fields: introspectShape(shape) };\n }\n return { ...base, type: \"unsupported\", zodTypeName: \"object\" };\n }\n default:\n return {\n ...base,\n type: \"unsupported\",\n zodTypeName: innerDef.type ?? \"unknown\",\n };\n }\n}\n\nfunction introspectShape(\n shape: Record<string, { _def: ZodDef; description?: string }>,\n): NpThemeSettingsField[] {\n const out: NpThemeSettingsField[] = [];\n for (const [name, raw] of Object.entries(shape)) {\n out.push(introspectField(name, raw as ZodNode));\n }\n return out;\n}\n\n/**\n * Walk a theme's `settingsSchema` (top-level z.object) and emit\n * the form metadata. Returns an empty array when the schema\n * isn't a top-level object — themes are expected to ship\n * `settingsSchema: z.object({...})` (validated implicitly: a\n * non-object top schema yields an empty form, signalling\n * \"nothing to configure\").\n */\nexport function introspectThemeSettingsSchema(\n schema: ZodTypeAny | undefined,\n): NpThemeSettingsField[] {\n if (!schema) return [];\n // Strip any top-level default/optional/nullable wrapper before\n // checking for object shape — themes that wrap their whole\n // schema in `.default({...})` are unusual but valid; without\n // unwrap we'd silently render an empty form.\n const { inner } = unwrap(schema as unknown as ZodNode);\n if (inner._def.type !== \"object\" || !inner._def.shape) return [];\n return introspectShape(inner._def.shape);\n}\n"],"mappings":"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","names":["versioned","rawValue","parsed"]}
@@ -0,0 +1,225 @@
1
+ import {
2
+ getI18nConfig
3
+ } from "./chunk-4ZLMEKFX.js";
4
+ import {
5
+ NP_DEFAULT_SITE_ID
6
+ } from "./chunk-FZ7O6DWI.js";
7
+ import {
8
+ getCurrentSiteId
9
+ } from "./chunk-SBCVAC2Z.js";
10
+ import {
11
+ getLogger
12
+ } from "./chunk-JJL74ZPK.js";
13
+ import {
14
+ getDb
15
+ } from "./chunk-XANPEOJC.js";
16
+ import {
17
+ npStringOverrides
18
+ } from "./chunk-M43PGOQY.js";
19
+
20
+ // src/i18n/strings.ts
21
+ import IntlMessageFormat from "intl-messageformat";
22
+
23
+ // src/i18n/string-overrides.ts
24
+ import { and, eq } from "drizzle-orm";
25
+ var cacheBySite = /* @__PURE__ */ new Map();
26
+ async function loadStringOverridesForSite(siteId) {
27
+ const db = getDb();
28
+ const rows = await db.select({
29
+ locale: npStringOverrides.locale,
30
+ key: npStringOverrides.key,
31
+ value: npStringOverrides.value
32
+ }).from(npStringOverrides).where(eq(npStringOverrides.siteId, siteId));
33
+ const map = /* @__PURE__ */ new Map();
34
+ for (const row of rows) {
35
+ const bundle = map.get(row.locale) ?? {};
36
+ bundle[row.key] = row.value;
37
+ map.set(row.locale, bundle);
38
+ }
39
+ cacheBySite.set(siteId, map);
40
+ return map;
41
+ }
42
+ async function getStringOverridesForSite(siteId) {
43
+ const cached = cacheBySite.get(siteId);
44
+ if (cached) return cached;
45
+ return loadStringOverridesForSite(siteId);
46
+ }
47
+ function clearStringOverrideCacheForSite(siteId) {
48
+ cacheBySite.delete(siteId);
49
+ }
50
+ function resetStringOverrideCache() {
51
+ cacheBySite.clear();
52
+ }
53
+ function getStringOverride(siteId, locale, key) {
54
+ const cached = cacheBySite.get(siteId);
55
+ if (!cached) return null;
56
+ const bundle = cached.get(locale);
57
+ if (!bundle) return null;
58
+ const value = bundle[key];
59
+ return value ?? null;
60
+ }
61
+ async function setStringOverride(locale, key, value, options) {
62
+ const db = getDb();
63
+ const siteId = options?.siteId ?? await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
64
+ const now = /* @__PURE__ */ new Date();
65
+ await db.insert(npStringOverrides).values({
66
+ siteId,
67
+ locale,
68
+ key,
69
+ value,
70
+ updatedAt: now,
71
+ updatedBy: options?.updatedBy ?? null
72
+ }).onConflictDoUpdate({
73
+ target: [
74
+ npStringOverrides.siteId,
75
+ npStringOverrides.locale,
76
+ npStringOverrides.key
77
+ ],
78
+ set: {
79
+ value,
80
+ updatedAt: now,
81
+ updatedBy: options?.updatedBy ?? null
82
+ }
83
+ });
84
+ clearStringOverrideCacheForSite(siteId);
85
+ }
86
+ async function deleteStringOverride(locale, key, options) {
87
+ const db = getDb();
88
+ const siteId = options?.siteId ?? await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
89
+ await db.delete(npStringOverrides).where(
90
+ and(
91
+ eq(npStringOverrides.siteId, siteId),
92
+ eq(npStringOverrides.locale, locale),
93
+ eq(npStringOverrides.key, key)
94
+ )
95
+ );
96
+ clearStringOverrideCacheForSite(siteId);
97
+ }
98
+ async function listStringOverridesForSite(siteId) {
99
+ const db = getDb();
100
+ const rows = await db.select().from(npStringOverrides).where(eq(npStringOverrides.siteId, siteId));
101
+ return rows;
102
+ }
103
+
104
+ // src/i18n/strings.ts
105
+ var registry = /* @__PURE__ */ new Map();
106
+ function addStrings(locale, bundle) {
107
+ const existing = registry.get(locale) ?? {};
108
+ registry.set(locale, { ...existing, ...bundle });
109
+ }
110
+ function setStrings(locale, bundle) {
111
+ registry.set(locale, { ...bundle });
112
+ }
113
+ function resetStrings() {
114
+ registry.clear();
115
+ }
116
+ function getStrings(locale) {
117
+ return { ...registry.get(locale) ?? {} };
118
+ }
119
+ function getAllStrings() {
120
+ const out = {};
121
+ for (const [locale, bundle] of registry.entries()) {
122
+ out[locale] = { ...bundle };
123
+ }
124
+ return out;
125
+ }
126
+ async function t(key, locale, params) {
127
+ const config = getI18nConfig();
128
+ const requested = locale ?? config?.defaultLocale ?? null;
129
+ const defaultLocale = config?.defaultLocale ?? null;
130
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
131
+ await getStringOverridesForSite(siteId);
132
+ if (requested) {
133
+ const override = getStringOverride(siteId, requested, key);
134
+ if (override !== null) return interpolate(override, params, requested);
135
+ }
136
+ if (requested) {
137
+ const bundle = registry.get(requested)?.[key];
138
+ if (bundle !== void 0) return interpolate(bundle, params, requested);
139
+ }
140
+ if (defaultLocale && defaultLocale !== requested) {
141
+ const override = getStringOverride(siteId, defaultLocale, key);
142
+ if (override !== null) return interpolate(override, params, defaultLocale);
143
+ }
144
+ if (defaultLocale && defaultLocale !== requested) {
145
+ const bundle = registry.get(defaultLocale)?.[key];
146
+ if (bundle !== void 0) return interpolate(bundle, params, defaultLocale);
147
+ }
148
+ return interpolate(key, params, requested ?? defaultLocale ?? "en");
149
+ }
150
+ function tSync(key, locale, params) {
151
+ const config = getI18nConfig();
152
+ const requested = locale ?? config?.defaultLocale ?? null;
153
+ const defaultLocale = config?.defaultLocale ?? null;
154
+ let template;
155
+ let foundLocale = null;
156
+ if (requested) {
157
+ template = registry.get(requested)?.[key];
158
+ if (template !== void 0) foundLocale = requested;
159
+ }
160
+ if (template === void 0 && defaultLocale && defaultLocale !== requested) {
161
+ template = registry.get(defaultLocale)?.[key];
162
+ if (template !== void 0) foundLocale = defaultLocale;
163
+ }
164
+ if (template === void 0) {
165
+ return interpolate(key, params, requested ?? defaultLocale ?? "en");
166
+ }
167
+ return interpolate(template, params, foundLocale ?? "en");
168
+ }
169
+ var compiledCache = /* @__PURE__ */ new Map();
170
+ function compile(template, locale) {
171
+ const cacheKey = `${locale}::${template}`;
172
+ const cached = compiledCache.get(cacheKey);
173
+ if (cached) return cached;
174
+ try {
175
+ const fmt = new IntlMessageFormat(template, locale);
176
+ compiledCache.set(cacheKey, fmt);
177
+ return fmt;
178
+ } catch (error) {
179
+ getLogger().warn("Failed to compile ICU translation template", {
180
+ locale,
181
+ template,
182
+ error: error instanceof Error ? error.message : String(error)
183
+ });
184
+ return null;
185
+ }
186
+ }
187
+ function resetTranslationCache() {
188
+ compiledCache.clear();
189
+ }
190
+ function interpolate(template, params, locale) {
191
+ if (!params && !template.includes("{")) return template;
192
+ const fmt = compile(template, locale);
193
+ if (!fmt) return template;
194
+ try {
195
+ const formatted = fmt.format(params ?? {});
196
+ return Array.isArray(formatted) ? formatted.join("") : String(formatted);
197
+ } catch (error) {
198
+ getLogger().warn("Failed to format ICU translation template", {
199
+ locale,
200
+ template,
201
+ error: error instanceof Error ? error.message : String(error)
202
+ });
203
+ return template;
204
+ }
205
+ }
206
+
207
+ export {
208
+ loadStringOverridesForSite,
209
+ getStringOverridesForSite,
210
+ clearStringOverrideCacheForSite,
211
+ resetStringOverrideCache,
212
+ getStringOverride,
213
+ setStringOverride,
214
+ deleteStringOverride,
215
+ listStringOverridesForSite,
216
+ addStrings,
217
+ setStrings,
218
+ resetStrings,
219
+ getStrings,
220
+ getAllStrings,
221
+ t,
222
+ tSync,
223
+ resetTranslationCache
224
+ };
225
+ //# sourceMappingURL=chunk-QVJ2HCAX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/i18n/strings.ts","../src/i18n/string-overrides.ts"],"sourcesContent":["import IntlMessageFormat from \"intl-messageformat\";\n\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\nimport { getLogger } from \"../observability/logger.js\";\n\nimport { getI18nConfig } from \"./registry.js\";\nimport {\n getStringOverride,\n getStringOverridesForSite,\n} from \"./string-overrides.js\";\n\n/**\n * Phase 12.5 — UI string translation registry.\n *\n * Plugins and themes ship key → string bundles per locale; the\n * framework merges them at boot and exposes a `t()` helper that\n * the runtime calls with `(key, locale?, params?)`. Lookup\n * order: requested locale → default locale → key itself\n * (so missing translations don't crash; they surface the key\n * to the operator who can fill it in).\n *\n * Distinct from Phase 12.1 collection content i18n: that's\n * about USER-AUTHORED content (page bodies, blog posts);\n * THIS is about FRAMEWORK + PLUGIN + THEME chrome (\"Read\n * more\", \"min read\", \"Submit\", error messages, dashboard\n * widget labels, etc.).\n *\n * No DB persistence in 12.5 — bundles are loaded from\n * plugins / themes / app at boot. Admin-overridable bundles\n * are a follow-up; the registry already supports\n * `addStrings(locale, bundle)` so an admin-side override\n * loader can layer on top without changing this surface.\n *\n * Phase 12.7 — message format upgraded from a private\n * `{{name}}` regex to ICU MessageFormat via\n * `intl-messageformat`. Plain strings still work unchanged\n * (\"Read more\"); `{name}` interpolation replaces the old\n * `{{name}}`; plural / select / date / number formatting\n * follow ICU syntax. Compiled message instances are cached\n * keyed by `(locale, template)` so a hot path doesn't re-parse\n * every call.\n */\n\n/** A flat key → translated string map for a single locale. */\nexport type NpTranslationBundle = Record<string, string>;\n\nconst registry = new Map<string, NpTranslationBundle>();\n\n/**\n * Merge a translation bundle into the registry for the given\n * locale. Keys not in the existing bundle are added; keys\n * already present are overwritten by the new value (last\n * writer wins). Plugins / themes call this from their\n * registration code via the `i18n` manifest field; sites\n * call it directly for app-level overrides.\n */\nexport function addStrings(\n locale: string,\n bundle: NpTranslationBundle,\n): void {\n const existing = registry.get(locale) ?? {};\n registry.set(locale, { ...existing, ...bundle });\n}\n\n/** Replace (not merge) a locale's bundle. Tests use this between cases. */\nexport function setStrings(\n locale: string,\n bundle: NpTranslationBundle,\n): void {\n registry.set(locale, { ...bundle });\n}\n\n/** Wipe every locale's bundle. Tests use this between cases. */\nexport function resetStrings(): void {\n registry.clear();\n}\n\n/** Read a single locale's merged bundle (frozen view). */\nexport function getStrings(locale: string): NpTranslationBundle {\n return { ...(registry.get(locale) ?? {}) };\n}\n\n/** Read the full registry, keyed by locale. Useful for export / admin tooling. */\nexport function getAllStrings(): Record<string, NpTranslationBundle> {\n const out: Record<string, NpTranslationBundle> = {};\n for (const [locale, bundle] of registry.entries()) {\n out[locale] = { ...bundle };\n }\n return out;\n}\n\n/**\n * Acceptable param value types for ICU MessageFormat. Beyond\n * primitives, `Date` is accepted because ICU's `{x, date, ...}`\n * and `{x, time, ...}` formatters expect them. `boolean` is\n * accepted because ICU's `{x, select, true {...} false {...}}`\n * pattern is occasionally useful even though `select` keys are\n * stringified in matching.\n */\nexport type NpTranslationParams = Record<\n string,\n string | number | boolean | Date | null | undefined\n>;\n\n/**\n * Resolve a translated string.\n *\n * await t(\"readingTime\", \"ko\", { minutes: 5 })\n * → \"5분 읽기\"\n * await t(\"items.count\", \"en\", { count: 3 })\n * → \"3 items\" (ICU plural)\n * await t(\"missing\")\n * → \"missing\"\n *\n * Lookup order (Phase D):\n * 1. site-scoped admin override for the requested locale\n * 2. requested-locale plugin / theme bundle\n * 3. site-scoped admin override for defaultLocale\n * 4. defaultLocale plugin / theme bundle\n * 5. the key itself (last-resort identity fallback)\n *\n * The locale-locality rule: a requested-locale BUNDLE wins\n * over a default-locale OVERRIDE. That keeps an English\n * override from accidentally bleeding into a fully-translated\n * Korean page — the override is only the cross-locale\n * fallback when the requested locale has nothing at all.\n *\n * Async because the override cache loads from DB on first\n * access. Subsequent calls within the same process hit the\n * in-memory cache for free; admin writes invalidate the\n * site's cache so the next call reloads.\n *\n * Phase 12.7 — message format is ICU MessageFormat. Plain\n * strings work unchanged; `{name}` interpolation replaces the\n * old `{{name}}`; plural / select / date / number formatters\n * are available via the standard ICU syntax. The locale used\n * for plural rules / number formatting is the locale the\n * matched template came from (so an English fallback message\n * gets English plural rules even on a Korean request).\n */\nexport async function t(\n key: string,\n locale?: string,\n params?: NpTranslationParams,\n): Promise<string> {\n const config = getI18nConfig();\n const requested = locale ?? config?.defaultLocale ?? null;\n const defaultLocale = config?.defaultLocale ?? null;\n\n // Site-scoped overrides are populated lazily; ensure the\n // cache for THIS site has been loaded once before the\n // synchronous getStringOverride lookups below.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n await getStringOverridesForSite(siteId);\n\n // 1. requested-locale override\n if (requested) {\n const override = getStringOverride(siteId, requested, key);\n if (override !== null) return interpolate(override, params, requested);\n }\n // 2. requested-locale bundle\n if (requested) {\n const bundle = registry.get(requested)?.[key];\n if (bundle !== undefined) return interpolate(bundle, params, requested);\n }\n // 3. defaultLocale override (cross-locale fallback)\n if (defaultLocale && defaultLocale !== requested) {\n const override = getStringOverride(siteId, defaultLocale, key);\n if (override !== null) return interpolate(override, params, defaultLocale);\n }\n // 4. defaultLocale bundle\n if (defaultLocale && defaultLocale !== requested) {\n const bundle = registry.get(defaultLocale)?.[key];\n if (bundle !== undefined) return interpolate(bundle, params, defaultLocale);\n }\n // 5. key fallback — use the requested locale (or default)\n // for any plural rules in the literal key, though in\n // practice keys don't carry ICU syntax.\n return interpolate(key, params, requested ?? defaultLocale ?? \"en\");\n}\n\n/**\n * Synchronous variant for non-async contexts (rare). Skips\n * the override layer entirely and resolves only against the\n * in-memory plugin/theme bundles. Use `t()` everywhere\n * possible — that's the surface admins control via the\n * Strings settings tab.\n */\nexport function tSync(\n key: string,\n locale?: string,\n params?: NpTranslationParams,\n): string {\n const config = getI18nConfig();\n const requested = locale ?? config?.defaultLocale ?? null;\n const defaultLocale = config?.defaultLocale ?? null;\n let template: string | undefined;\n let foundLocale: string | null = null;\n if (requested) {\n template = registry.get(requested)?.[key];\n if (template !== undefined) foundLocale = requested;\n }\n if (template === undefined && defaultLocale && defaultLocale !== requested) {\n template = registry.get(defaultLocale)?.[key];\n if (template !== undefined) foundLocale = defaultLocale;\n }\n if (template === undefined) {\n return interpolate(key, params, requested ?? defaultLocale ?? \"en\");\n }\n return interpolate(template, params, foundLocale ?? \"en\");\n}\n\n/**\n * Compiled-message cache keyed by `${locale}::${template}`.\n * The IntlMessageFormat constructor parses the ICU AST, which\n * isn't free; caching means a hot key (e.g. a header tagline\n * rendered on every request) parses once per process.\n *\n * The cache is unbounded by design — keys are bounded by\n * (locales × templates × site overrides), all small in\n * practice. If a misconfigured site managed to register\n * thousands of templates it would still grow into the low MB\n * range, well under the existing in-memory caches in this\n * file.\n */\nconst compiledCache = new Map<string, IntlMessageFormat>();\n\nfunction compile(template: string, locale: string): IntlMessageFormat | null {\n const cacheKey = `${locale}::${template}`;\n const cached = compiledCache.get(cacheKey);\n if (cached) return cached;\n try {\n const fmt = new IntlMessageFormat(template, locale);\n compiledCache.set(cacheKey, fmt);\n return fmt;\n } catch (error) {\n // Malformed ICU template — log once at warn so the\n // operator can fix the bundle, then fall through to the\n // raw template (better to render the source than to crash\n // a page render over a typo).\n getLogger().warn(\"Failed to compile ICU translation template\", {\n locale,\n template,\n error: error instanceof Error ? error.message : String(error),\n });\n return null;\n }\n}\n\n/** Drop the compile cache. Tests use this between cases. */\nexport function resetTranslationCache(): void {\n compiledCache.clear();\n}\n\nfunction interpolate(\n template: string,\n params: NpTranslationParams | undefined,\n locale: string,\n): string {\n // Plain string fast path: no params + no ICU syntax.\n // Skipping the parser saves a measurable amount of work\n // for the common \"Read more\" / \"Submit\" case.\n if (!params && !template.includes(\"{\")) return template;\n\n const fmt = compile(template, locale);\n if (!fmt) return template;\n try {\n const formatted = fmt.format(params ?? {});\n // intl-messageformat returns string for plain templates,\n // (string | object)[] for templates that pass non-string\n // values through `{x, plural, ...}` selectors with\n // <Component> placeholders. We don't use rich-text so\n // coerce to string for safety.\n return Array.isArray(formatted) ? formatted.join(\"\") : String(formatted);\n } catch (error) {\n getLogger().warn(\"Failed to format ICU translation template\", {\n locale,\n template,\n error: error instanceof Error ? error.message : String(error),\n });\n return template;\n }\n}\n","import { and, eq } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npStringOverrides } from \"../db/schema/system.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\n/**\n * Phase D — admin-overridable UI string layer on top of the\n * Phase 12.5 plugin/theme bundle registry.\n *\n * Plugins and themes ship base translations via\n * `addStrings()`; admins layer overrides on top via the\n * `np_string_overrides` table without editing plugin/theme\n * code. Per-site composite key (siteId, locale, key) so each\n * tenant can override the same plugin's string differently.\n *\n * The override map is held in-memory per process, keyed by\n * site, populated lazily by `loadStringOverridesForSite()`\n * and busted by `clearStringOverrideCacheForSite()` after\n * admin writes. Multi-process deployments live with eventual\n * consistency — workers reload from DB on their own\n * cache-miss path; that's acceptable because override edits\n * are infrequent. Sites that need strict consistency add a\n * pubsub channel later.\n */\n\ntype OverrideMap = Map<string, Record<string, string | null>>; // locale → key → value (null = explicitly cleared)\n\nconst cacheBySite = new Map<string, OverrideMap>();\n\n/**\n * Read every override row for a site and rebuild that site's\n * cache entry from the DB. Idempotent; safe to call\n * concurrently (the writers are admin actions, not hot\n * paths).\n */\nexport async function loadStringOverridesForSite(\n siteId: string,\n): Promise<OverrideMap> {\n const db = getDb();\n const rows = (await db\n .select({\n locale: npStringOverrides.locale,\n key: npStringOverrides.key,\n value: npStringOverrides.value,\n })\n .from(npStringOverrides)\n .where(eq(npStringOverrides.siteId, siteId))) as Array<{\n locale: string;\n key: string;\n value: string | null;\n }>;\n\n const map: OverrideMap = new Map();\n for (const row of rows) {\n const bundle = map.get(row.locale) ?? {};\n bundle[row.key] = row.value;\n map.set(row.locale, bundle);\n }\n cacheBySite.set(siteId, map);\n return map;\n}\n\n/**\n * Get the cached override map for a site, loading it on a\n * cache miss. Async because the cache miss has to round-trip\n * to the DB.\n */\nexport async function getStringOverridesForSite(\n siteId: string,\n): Promise<OverrideMap> {\n const cached = cacheBySite.get(siteId);\n if (cached) return cached;\n return loadStringOverridesForSite(siteId);\n}\n\nexport function clearStringOverrideCacheForSite(siteId: string): void {\n cacheBySite.delete(siteId);\n}\n\n/** Tests use this between cases. Production never wipes globally. */\nexport function resetStringOverrideCache(): void {\n cacheBySite.clear();\n}\n\n/**\n * Resolve an override for a single (locale, key) on the\n * current site, or null if no override is set. Synchronous\n * after the cache is warm; the async wrapper used by `t()`\n * ensures the cache is loaded before this is called.\n */\nexport function getStringOverride(\n siteId: string,\n locale: string,\n key: string,\n): string | null {\n const cached = cacheBySite.get(siteId);\n if (!cached) return null;\n const bundle = cached.get(locale);\n if (!bundle) return null;\n // null in the bundle means \"explicitly cleared, fall back\n // to the registry\"; undefined means \"no override at all\"\n // — both behave the same for resolution but the column\n // distinguishes them for audit-trail UIs.\n const value = bundle[key];\n return value ?? null;\n}\n\n/**\n * Persist an override row. Pass `null` for `value` to mark\n * the key as explicitly reverted (the resolution result is\n * the same as if no row existed; the row itself stays as a\n * marker for audit trails).\n */\nexport async function setStringOverride(\n locale: string,\n key: string,\n value: string | null,\n options?: { siteId?: string; updatedBy?: string | null },\n): Promise<void> {\n const db = getDb();\n const siteId =\n options?.siteId ?? (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const now = new Date();\n await db\n .insert(npStringOverrides)\n .values({\n siteId,\n locale,\n key,\n value,\n updatedAt: now,\n updatedBy: options?.updatedBy ?? null,\n })\n .onConflictDoUpdate({\n target: [\n npStringOverrides.siteId,\n npStringOverrides.locale,\n npStringOverrides.key,\n ],\n set: {\n value,\n updatedAt: now,\n updatedBy: options?.updatedBy ?? null,\n },\n });\n clearStringOverrideCacheForSite(siteId);\n}\n\n/**\n * Delete an override row (vs. setting value=null which\n * preserves the audit trail). Useful when an admin\n * explicitly wants to \"stop tracking\" an override.\n */\nexport async function deleteStringOverride(\n locale: string,\n key: string,\n options?: { siteId?: string },\n): Promise<void> {\n const db = getDb();\n const siteId =\n options?.siteId ?? (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n await db\n .delete(npStringOverrides)\n .where(\n and(\n eq(npStringOverrides.siteId, siteId),\n eq(npStringOverrides.locale, locale),\n eq(npStringOverrides.key, key),\n ),\n );\n clearStringOverrideCacheForSite(siteId);\n}\n\n/**\n * List every override row for a site (used by the admin UI\n * and by exporters). Returns the raw rows including null-\n * valued markers so the UI can show \"this WAS overridden\".\n */\nexport interface NpStringOverrideRow {\n siteId: string;\n locale: string;\n key: string;\n value: string | null;\n updatedAt: Date;\n updatedBy: string | null;\n}\n\nexport async function listStringOverridesForSite(\n siteId: string,\n): Promise<NpStringOverrideRow[]> {\n const db = getDb();\n const rows = (await db\n .select()\n .from(npStringOverrides)\n .where(eq(npStringOverrides.siteId, siteId))) as NpStringOverrideRow[];\n return rows;\n}\n"],"mappings":"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","names":[]}