@smounters/kit 2.24.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.25.0 - 2026-09-11
4
+
5
+ ### Added
6
+ - `kit/text` — подстановка `{{ path.to.value }}` с экранированием ПО ЗНАЧЕНИЮ под получателя
7
+ (`html`, `url`, `markdownv2`), экранирование для SQL `LIKE` (поиск по «100%» не должен находить
8
+ всё подряд) и наивный разбор ФИО. Подпуть был в плане пакета с самого начала и закрывает третью
9
+ переписанную копию этих же функций.
10
+ Словаря предметной области здесь нет: канал отправки в режим экранирования переводит потребитель.
11
+
3
12
  ## 2.24.0 - 2026-09-11
4
13
 
5
14
  ### Added
package/README.md CHANGED
@@ -19,6 +19,7 @@ npm i @smounters/kit @smounters/core
19
19
  | `kit/config` | zod-препроцессоры для разбора env | `zod` |
20
20
  | `kit/money` | decimal-арифметика, масштабы, округление, проверка сбалансированности проводки | — |
21
21
  | `kit/rpc` | `ProtoValidateInterceptor` — правила `buf.validate` из контракта, enforced транспортом | `@smounters/core`, `@connectrpc/connect`, `@bufbuild/*` |
22
+ | `kit/text` | подстановка `{{ }}` с экранированием под получателя, экранирование для SQL `LIKE`, разбор ФИО | — |
22
23
  | `kit/util` | `ulid`, `redactSecrets` | — |
23
24
 
24
25
  ## Что здесь НЕ лежит и почему
@@ -0,0 +1,32 @@
1
+ /** Where the rendered value is about to land. Picking the wrong one is an injection, not a typo. */
2
+ export type EscapeMode = "none" | "url" | "html" | "markdownv2" | "plain";
3
+ /**
4
+ * Escape a value for a SQL `LIKE` pattern: a user searching for "100%" must not match everything.
5
+ *
6
+ * The backslash goes first — escaping it after `%`/`_` would double-escape the escapes. Postgres needs
7
+ * no `ESCAPE` clause with this, since backslash is the default.
8
+ */
9
+ export declare function escapeLike(value: string): string;
10
+ export declare function escapeHtml(str: string): string;
11
+ /** Telegram MarkdownV2 reserves this whole set; one unescaped character fails the whole send. */
12
+ export declare function escapeMarkdownV2(str: string): string;
13
+ export declare function escapeValue(str: string, mode: EscapeMode): string;
14
+ /** Read `a.b.c` out of a plain object, returning undefined instead of throwing on a missing branch. */
15
+ export declare function resolvePath(context: Record<string, unknown>, path: string): unknown;
16
+ /**
17
+ * Substitute `{{ path.to.value }}` placeholders, escaping each substituted value for the target.
18
+ *
19
+ * Escaping is per-VALUE, never over the finished string: the template itself is authored by us and may
20
+ * legitimately contain markup, while the values come from data and may contain anything. A missing key
21
+ * renders as an empty string rather than leaving the placeholder visible to a customer.
22
+ */
23
+ export declare function renderTemplate(template: string, context: Record<string, unknown>, escape?: boolean | EscapeMode): string;
24
+ /**
25
+ * Split a full name into first + rest. Deliberately naive: one word is a first name, everything after
26
+ * the first space is the last name. Anything smarter guesses wrong on the half of the world that writes
27
+ * the family name first.
28
+ */
29
+ export declare function splitPersonName(full: string | undefined): {
30
+ firstName?: string;
31
+ lastName?: string;
32
+ };
@@ -0,0 +1,73 @@
1
+ // Text mechanics that every service rewrites: placeholder substitution with the right escaping for the
2
+ // channel it is going out on, escaping for a SQL LIKE pattern, splitting a person's full name.
3
+ //
4
+ // No product vocabulary lives here — no channel enum, no header names, no field names. The caller says
5
+ // which escape mode it needs; the mapping from its own channel enum to a mode is the caller's policy.
6
+ /**
7
+ * Escape a value for a SQL `LIKE` pattern: a user searching for "100%" must not match everything.
8
+ *
9
+ * The backslash goes first — escaping it after `%`/`_` would double-escape the escapes. Postgres needs
10
+ * no `ESCAPE` clause with this, since backslash is the default.
11
+ */
12
+ export function escapeLike(value) {
13
+ return value.replace(/\\/g, "\\\\").replace(/[%_]/g, "\\$&");
14
+ }
15
+ export function escapeHtml(str) {
16
+ return str
17
+ .replace(/&/g, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/'/g, "&#39;");
22
+ }
23
+ /** Telegram MarkdownV2 reserves this whole set; one unescaped character fails the whole send. */
24
+ export function escapeMarkdownV2(str) {
25
+ return str.replace(/[\\_*[\]()~`>#+\-=|{}.!]/g, (m) => `\\${m}`);
26
+ }
27
+ export function escapeValue(str, mode) {
28
+ switch (mode) {
29
+ case "url":
30
+ return encodeURIComponent(str);
31
+ case "html":
32
+ return escapeHtml(str);
33
+ case "markdownv2":
34
+ return escapeMarkdownV2(str);
35
+ default:
36
+ return str;
37
+ }
38
+ }
39
+ /** Read `a.b.c` out of a plain object, returning undefined instead of throwing on a missing branch. */
40
+ export function resolvePath(context, path) {
41
+ let cur = context;
42
+ for (const key of path.split(".")) {
43
+ if (cur == null || typeof cur !== "object")
44
+ return undefined;
45
+ cur = cur[key];
46
+ }
47
+ return cur;
48
+ }
49
+ /**
50
+ * Substitute `{{ path.to.value }}` placeholders, escaping each substituted value for the target.
51
+ *
52
+ * Escaping is per-VALUE, never over the finished string: the template itself is authored by us and may
53
+ * legitimately contain markup, while the values come from data and may contain anything. A missing key
54
+ * renders as an empty string rather than leaving the placeholder visible to a customer.
55
+ */
56
+ export function renderTemplate(template, context, escape = false) {
57
+ const mode = escape === true ? "url" : escape === false ? "none" : escape;
58
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, path) => {
59
+ const value = resolvePath(context, path);
60
+ return escapeValue(value == null ? "" : String(value), mode);
61
+ });
62
+ }
63
+ /**
64
+ * Split a full name into first + rest. Deliberately naive: one word is a first name, everything after
65
+ * the first space is the last name. Anything smarter guesses wrong on the half of the world that writes
66
+ * the family name first.
67
+ */
68
+ export function splitPersonName(full) {
69
+ const words = (full ?? "").trim().split(/\s+/).filter(Boolean);
70
+ if (!words.length)
71
+ return {};
72
+ return { firstName: words[0], lastName: words.slice(1).join(" ") || undefined };
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smounters/kit",
3
- "version": "2.24.0",
3
+ "version": "2.26.0",
4
4
  "description": "Batteries for Imperium services: structured logging, Redis module with a distributed lock, SSRF-safe fetch, decimal money, request context, env schemas",
5
5
  "keywords": [
6
6
  "imperium",
@@ -45,6 +45,10 @@
45
45
  "./util": {
46
46
  "types": "./dist/util/index.d.ts",
47
47
  "import": "./dist/util/index.js"
48
+ },
49
+ "./text": {
50
+ "types": "./dist/text/index.d.ts",
51
+ "import": "./dist/text/index.js"
48
52
  }
49
53
  },
50
54
  "files": [