@konce-pt/mention 0.8.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 konce.pt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ <p align="center">
2
+ <img src="https://ui.konce.pt/images/koncept_ui_lib.png" alt="Koncept UI — Angular component library" width="100%" />
3
+ </p>
4
+
5
+ # @konce-pt/mention
6
+
7
+ Rdzeń wzmianek [Koncept UI](https://gitlab.com/konce-pt/koncept-ui): wyciąganie `@wzmianek`
8
+ z tekstu, aktywne zapytanie przy kursorze i dopasowanie podpowiedzi bez znaków diakrytycznych.
9
+ Czysty TypeScript, **zero zależności runtime**, bez wiązania z frameworkiem.
10
+
11
+ Zwykle nie instalujesz go samodzielnie: używają go `KptMention`, `KptMentions` i `KptRichText`
12
+ z `@konce-pt/angular` oraz `@konce-pt/react`. Sięgnij po niego wprost, gdy wzmianki mają działać
13
+ w twoim własnym polu tekstowym albo po stronie serwera.
14
+
15
+ ```bash
16
+ npm i @konce-pt/mention
17
+ ```
18
+
19
+ ```ts
20
+ import { filterMentions, mentionQuery, parseMentionText } from '@konce-pt/mention';
21
+
22
+ const people = [
23
+ { id: 'anna.k', label: 'Anna Kowalska' },
24
+ { id: 'lukasz.w', label: 'Łukasz Wiśniewski' },
25
+ ];
26
+
27
+ // Akapit rozbity na tekst i wzmianki — z `raw` każdej z nich wraca oryginał znak w znak.
28
+ parseMentionText('Pytanie do @anna.k o kosztorys.', people);
29
+
30
+ // Co podpowiedzieć przy kursorze (tu: po wpisaniu „@luk").
31
+ const query = mentionQuery('Pytanie do @luk', 15); // { query: 'luk', start: 11, end: 15 }
32
+ filterMentions(people, query.query); // [{ id: 'lukasz.w', … }]
33
+ ```
34
+
35
+ ## Wzmianka to nie kolorowy napis
36
+
37
+ `mail@example.com` nie może stać się wzmianką, a Enter w podpowiedziach nie może wstawić nazwy
38
+ w innym miejscu, niż użytkownik pisze. Te same reguły są potrzebne w akapicie do czytania,
39
+ w zwykłym polu tekstowym, w edytorze i w obu portach — dlatego siedzą w jednym module z testami.
40
+
41
+ ## API
42
+
43
+ - **Parsowanie** — `parseMentionText`, `mentionToken`, `collectMentions`, typ `KptMentionSegment`
44
+ - **Zapytanie przy kursorze** — `mentionQuery`, `applyMention`, typ `KptMentionQuery`
45
+ - **Dopasowanie** — `filterMentions`, `foldMentionText`, `KPT_MENTION_LIMIT`
46
+ - **Model** — typ `KptMentionItem`
47
+
48
+ Pełny opis: [`llms.txt`](https://ui.konce.pt/llms/mention/llms.txt) (EN)
49
+ i [`llms-pl.txt`](https://ui.konce.pt/llms/mention/llms-pl.txt) (PL).
50
+
51
+ ## Testy
52
+
53
+ ```bash
54
+ pnpm --filter @konce-pt/mention exec node --test "src/**/*.test.ts"
55
+ ```
56
+
57
+ ## Licencja
58
+
59
+ MIT © [konce.pt](https://konce.pt)
@@ -0,0 +1,5 @@
1
+ export type { KptMentionItem, KptMentionSegment } from './types.ts';
2
+ export { parseMentionText, mentionToken, collectMentions } from './parse.ts';
3
+ export { filterMentions, foldMentionText, KPT_MENTION_LIMIT } from './match.ts';
4
+ export { mentionQuery, applyMention } from './query.ts';
5
+ export type { KptMentionQuery } from './query.ts';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { parseMentionText, mentionToken, collectMentions } from "./parse.js";
2
+ export { filterMentions, foldMentionText, KPT_MENTION_LIMIT } from "./match.js";
3
+ export { mentionQuery, applyMention } from "./query.js";
@@ -0,0 +1,13 @@
1
+ import type { KptMentionItem } from './types.ts';
2
+ /** Ile podpowiedzi pokazujemy domyślnie: tyle mieści się bez przewijania pod kursorem. */
3
+ export declare const KPT_MENTION_LIMIT = 8;
4
+ /** Tekst do porównań: bez diakrytyków, małymi literami. */
5
+ export declare function foldMentionText(value: string): string;
6
+ /**
7
+ * Pozycje pasujące do zapytania, w kolejności trafności. Puste zapytanie zwraca początek listy —
8
+ * zaraz po `@` nie ma czego filtrować, a pusta lista podpowiedzi wyglądałaby jak brak wyników.
9
+ *
10
+ * Szukamy po etykiecie, identyfikatorze i `keywords` (alias, e-mail, numer zadania), ale ranga
11
+ * bierze się z najlepszego trafienia, nie z pierwszego lepszego pola.
12
+ */
13
+ export declare function filterMentions<T = unknown>(items: readonly KptMentionItem<T>[], query: string, limit?: number): KptMentionItem<T>[];
package/dist/match.js ADDED
@@ -0,0 +1,67 @@
1
+ /*
2
+ * Dopasowanie pozycji do tego, co użytkownik wpisał po `@`.
3
+ *
4
+ * Porównujemy bez znaków diakrytycznych i bez wielkości liter: „lukasz" ma znaleźć „Łukasza",
5
+ * bo przy pisaniu wzmianki nikt nie przełącza się na polską klawiaturę w środku zdania.
6
+ * Kolejność wyników: najpierw trafienia od początku etykiety, potem od początku słowa w środku,
7
+ * na końcu zwykłe zawieranie — lista podpowiedzi ma dawać oczywisty pierwszy wybór.
8
+ */
9
+ /** Ile podpowiedzi pokazujemy domyślnie: tyle mieści się bez przewijania pod kursorem. */
10
+ export const KPT_MENTION_LIMIT = 8;
11
+ /**
12
+ * Litery z kreską przekreślającą, których NFD nie rozkłada. `ł` to osobny znak Unicode, a nie
13
+ * `l` ze znakiem diakrytycznym, więc samo `normalize('NFD')` zostawiłoby „Łukasza" poza zasięgiem
14
+ * zapytania „lukasz" — a to najczęstszy przypadek w polskich nazwiskach.
15
+ */
16
+ const STROKED = new Map([
17
+ ['ł', 'l'],
18
+ ['đ', 'd'],
19
+ ['ø', 'o'],
20
+ ['ħ', 'h'],
21
+ ['ŧ', 't'],
22
+ ]);
23
+ /** Tekst do porównań: bez diakrytyków, małymi literami. */
24
+ export function foldMentionText(value) {
25
+ return value
26
+ .normalize('NFD')
27
+ .replace(/\p{Diacritic}/gu, '')
28
+ .toLowerCase()
29
+ .replace(/[łđøħŧ]/gu, (char) => STROKED.get(char) ?? char);
30
+ }
31
+ /** Ranga trafienia: 0 — początek etykiety, 1 — początek słowa, 2 — środek, -1 — brak. */
32
+ function rank(haystack, needle) {
33
+ const at = haystack.indexOf(needle);
34
+ if (at < 0)
35
+ return -1;
36
+ if (at === 0)
37
+ return 0;
38
+ return /[\s.\-_/]/.test(haystack[at - 1]) ? 1 : 2;
39
+ }
40
+ /**
41
+ * Pozycje pasujące do zapytania, w kolejności trafności. Puste zapytanie zwraca początek listy —
42
+ * zaraz po `@` nie ma czego filtrować, a pusta lista podpowiedzi wyglądałaby jak brak wyników.
43
+ *
44
+ * Szukamy po etykiecie, identyfikatorze i `keywords` (alias, e-mail, numer zadania), ale ranga
45
+ * bierze się z najlepszego trafienia, nie z pierwszego lepszego pola.
46
+ */
47
+ export function filterMentions(items, query, limit = KPT_MENTION_LIMIT) {
48
+ const needle = foldMentionText(query.trim());
49
+ if (!needle)
50
+ return items.slice(0, limit);
51
+ const scored = [];
52
+ items.forEach((item, index) => {
53
+ const fields = [item.label, item.id, ...(item.keywords ?? [])];
54
+ let best = -1;
55
+ for (const field of fields) {
56
+ const value = rank(foldMentionText(field), needle);
57
+ if (value >= 0 && (best < 0 || value < best))
58
+ best = value;
59
+ }
60
+ if (best >= 0)
61
+ scored.push({ item, rank: best, index });
62
+ });
63
+ // Remis rozstrzyga kolejność wejściowa: aplikacja zna wagę pozycji (ostatnio używane,
64
+ // osoby z zespołu) lepiej niż my, więc jej układu nie mieszamy.
65
+ scored.sort((a, b) => a.rank - b.rank || a.index - b.index);
66
+ return scored.slice(0, limit).map((entry) => entry.item);
67
+ }
@@ -0,0 +1,18 @@
1
+ import type { KptMentionItem, KptMentionSegment } from './types.ts';
2
+ /**
3
+ * Tekst rozbity na segmenty: zwykła treść i wzmianki. Nie renderuje niczego — z tego komponent
4
+ * składa akapit, w którym wzmianka jest elementem z kartą, a reszta zostaje tekstem.
5
+ *
6
+ * Kolejność i sklejenie segmentów oddają wejście znak w znak, więc z `segments.map(text).join('')`
7
+ * wraca oryginał — inaczej podgląd rozjechałby się z tym, co zapisano.
8
+ */
9
+ export declare function parseMentionText<T = unknown>(text: string, items?: readonly KptMentionItem<T>[]): KptMentionSegment<T>[];
10
+ /**
11
+ * Zapis wzmianki do wstawienia w tekst. Etykieta idzie do zapisu tylko wtedy, gdy jest potrzebna
12
+ * — czyli gdy różni się od `id` — żeby prosty `@anna.k` nie puchł bez powodu.
13
+ */
14
+ export declare function mentionToken(item: KptMentionItem): string;
15
+ /** Same wzmianki z tekstu, w kolejności wystąpienia (duplikaty zostają — to lista, nie zbiór). */
16
+ export declare function collectMentions<T = unknown>(text: string, items?: readonly KptMentionItem<T>[]): Extract<KptMentionSegment<T>, {
17
+ kind: 'mention';
18
+ }>[];
package/dist/parse.js ADDED
@@ -0,0 +1,58 @@
1
+ /*
2
+ * Rozbicie tekstu na treść i wzmianki.
3
+ *
4
+ * Dwa zapisy, oba zaczynają się `@`:
5
+ * - `@id` — identyfikator bez spacji (`@anna.k`, `@zad-142`); etykietę bierze się z listy pozycji,
6
+ * a gdy jej tam nie ma — zostaje samo `id`, bo tekst ma dalej dać się przeczytać.
7
+ * - `@[Anna Kowalska](anna.k)` — zapis z etykietą, jedyny sposób na nazwę ze spacją. Etykieta jest
8
+ * tylko do pokazania: gdy `id` uda się rozwiązać, wygrywa nazwa z listy, bo dane bywają
9
+ * świeższe niż tekst zapisany miesiąc temu.
10
+ *
11
+ * `@` w środku wyrazu (`mail@example.com`) wzmianką nie jest — musi stać na początku tekstu albo
12
+ * po znaku, który nie jest literą, cyfrą ani podkreśleniem.
13
+ */
14
+ /** Znaki identyfikatora: litery (z ogonkami), cyfry, `_`, `-`, `.`. Kropka nie może kończyć. */
15
+ const ID = String.raw `[\p{L}\p{N}_][\p{L}\p{N}_.-]*[\p{L}\p{N}_]|[\p{L}\p{N}_]`;
16
+ /** `@[Etykieta](id)` albo `@id`; grupa 1/2 to zapis z etykietą, grupa 3 — sam identyfikator. */
17
+ const MENTION = new RegExp(String.raw `(?<![\p{L}\p{N}_])@(?:\[([^\]\n]+)\]\((${ID})\)|(${ID}))`, 'gu');
18
+ /** Mapa `id → pozycja`; lista bywa krótka, ale tekst potrafi mieć setki wzmianek. */
19
+ function index(items) {
20
+ return new Map(items.map((item) => [item.id, item]));
21
+ }
22
+ /**
23
+ * Tekst rozbity na segmenty: zwykła treść i wzmianki. Nie renderuje niczego — z tego komponent
24
+ * składa akapit, w którym wzmianka jest elementem z kartą, a reszta zostaje tekstem.
25
+ *
26
+ * Kolejność i sklejenie segmentów oddają wejście znak w znak, więc z `segments.map(text).join('')`
27
+ * wraca oryginał — inaczej podgląd rozjechałby się z tym, co zapisano.
28
+ */
29
+ export function parseMentionText(text, items = []) {
30
+ if (!text)
31
+ return [];
32
+ const known = index(items);
33
+ const segments = [];
34
+ let last = 0;
35
+ for (const match of text.matchAll(MENTION)) {
36
+ const start = match.index;
37
+ if (start > last)
38
+ segments.push({ kind: 'text', text: text.slice(last, start) });
39
+ const id = (match[2] ?? match[3]);
40
+ const item = known.get(id) ?? null;
41
+ segments.push({ kind: 'mention', id, label: item?.label ?? match[1] ?? id, item, raw: match[0] });
42
+ last = start + match[0].length;
43
+ }
44
+ if (last < text.length)
45
+ segments.push({ kind: 'text', text: text.slice(last) });
46
+ return segments;
47
+ }
48
+ /**
49
+ * Zapis wzmianki do wstawienia w tekst. Etykieta idzie do zapisu tylko wtedy, gdy jest potrzebna
50
+ * — czyli gdy różni się od `id` — żeby prosty `@anna.k` nie puchł bez powodu.
51
+ */
52
+ export function mentionToken(item) {
53
+ return item.label === item.id ? `@${item.id}` : `@[${item.label}](${item.id})`;
54
+ }
55
+ /** Same wzmianki z tekstu, w kolejności wystąpienia (duplikaty zostają — to lista, nie zbiór). */
56
+ export function collectMentions(text, items = []) {
57
+ return parseMentionText(text, items).filter((segment) => segment.kind === 'mention');
58
+ }
@@ -0,0 +1,24 @@
1
+ /** Zapytanie otwarte przez `@` tuż przed kursorem. */
2
+ export interface KptMentionQuery {
3
+ /** Tekst po `@`, bez samego znaku. */
4
+ query: string;
5
+ /** Pozycja znaku `@` w tekście. */
6
+ start: number;
7
+ /** Pozycja kursora (koniec zapytania). */
8
+ end: number;
9
+ }
10
+ /**
11
+ * Zapytanie wzmianki przy kursorze albo `null`, gdy w tym miejscu nic się nie pisze.
12
+ *
13
+ * Warunki są celowo ciasne, bo `@` w tekście najczęściej nie jest wzmianką:
14
+ * - `@` stoi na początku tekstu albo po znaku, który nie jest literą, cyfrą ani `_`
15
+ * (dzięki temu `mail@example.com` nie otwiera podpowiedzi),
16
+ * - między `@` a kursorem nie ma białego znaku ani drugiego `@`,
17
+ * - zapytanie nie przekracza `MAX_QUERY` znaków — po tylu znakach to już nie jest nazwa.
18
+ */
19
+ export declare function mentionQuery(text: string, caret: number): KptMentionQuery | null;
20
+ /** Tekst po wstawieniu zapisu wzmianki w miejsce zapytania — razem z nową pozycją kursora. */
21
+ export declare function applyMention(text: string, query: KptMentionQuery, token: string): {
22
+ text: string;
23
+ caret: number;
24
+ };
package/dist/query.js ADDED
@@ -0,0 +1,42 @@
1
+ /*
2
+ * Aktywne zapytanie `@…` przy kursorze — to, co w polu tekstowym albo w edytorze decyduje,
3
+ * czy pokazać podpowiedzi i czego w nich szukać.
4
+ */
5
+ /** Ile znaków po `@` jeszcze uznajemy za pisanie wzmianki. Dalej to już zwykłe zdanie. */
6
+ const MAX_QUERY = 32;
7
+ /**
8
+ * Zapytanie wzmianki przy kursorze albo `null`, gdy w tym miejscu nic się nie pisze.
9
+ *
10
+ * Warunki są celowo ciasne, bo `@` w tekście najczęściej nie jest wzmianką:
11
+ * - `@` stoi na początku tekstu albo po znaku, który nie jest literą, cyfrą ani `_`
12
+ * (dzięki temu `mail@example.com` nie otwiera podpowiedzi),
13
+ * - między `@` a kursorem nie ma białego znaku ani drugiego `@`,
14
+ * - zapytanie nie przekracza `MAX_QUERY` znaków — po tylu znakach to już nie jest nazwa.
15
+ */
16
+ export function mentionQuery(text, caret) {
17
+ const end = Math.max(0, Math.min(caret, text.length));
18
+ const from = Math.max(0, end - MAX_QUERY - 1);
19
+ for (let index = end - 1; index >= from; index -= 1) {
20
+ const char = text[index];
21
+ if (/\s/.test(char))
22
+ return null;
23
+ if (char !== '@')
24
+ continue;
25
+ const before = index > 0 ? text[index - 1] : '';
26
+ if (before && /[\p{L}\p{N}_]/u.test(before))
27
+ return null;
28
+ return { query: text.slice(index + 1, end), start: index, end };
29
+ }
30
+ return null;
31
+ }
32
+ /** Tekst po wstawieniu zapisu wzmianki w miejsce zapytania — razem z nową pozycją kursora. */
33
+ export function applyMention(text, query, token) {
34
+ // Spacja po wzmiance: bez niej kolejne słowo skleiłoby się z nazwą, a kursor i tak ma iść dalej.
35
+ // Gdy spacja już tam stoi (wzmianka wpisywana w środku zdania), drugiej nie dokładamy.
36
+ const spaced = /\s/.test(text[query.end] ?? '');
37
+ const insert = spaced ? token : `${token} `;
38
+ return {
39
+ text: text.slice(0, query.start) + insert + text.slice(query.end),
40
+ caret: query.start + insert.length,
41
+ };
42
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pozycja, na którą można się powołać w tekście.
3
+ *
4
+ * `id` jest tym, co zostaje zapisane w treści — musi przeżyć zmianę nazwy, więc etykieta nigdy
5
+ * nie jest identyfikatorem. `kind` pozwala trzymać w jednym polu kilka rodzajów (osoba, zadanie,
6
+ * dokument) i wybrać po nim szablon karty. `data` to ładunek aplikacji, przekazywany bez zmian
7
+ * do szablonu karty.
8
+ */
9
+ export interface KptMentionItem<T = unknown> {
10
+ id: string;
11
+ label: string;
12
+ kind?: string;
13
+ /** Dodatkowy tekst do wyszukiwania: alias, e-mail, numer zadania. */
14
+ keywords?: readonly string[];
15
+ data?: T;
16
+ }
17
+ /** Kawałek tekstu po rozbiciu na zwykłą treść i wzmianki. */
18
+ export type KptMentionSegment<T = unknown> = {
19
+ kind: 'text';
20
+ text: string;
21
+ } | {
22
+ kind: 'mention';
23
+ /** Identyfikator z tekstu — nawet wtedy, gdy nie ma go w podanej liście pozycji. */
24
+ id: string;
25
+ /** Etykieta do pokazania: z listy pozycji, z zapisu `@[Etykieta](id)` albo samo `id`. */
26
+ label: string;
27
+ /** Pozycja z listy, o ile `id` udało się rozwiązać. */
28
+ item: KptMentionItem<T> | null;
29
+ /** Surowy zapis z tekstu — do zamiany z powrotem bez zgadywania składni. */
30
+ raw: string;
31
+ };
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /*
2
+ * Model wzmianki. Te typy przechodzą przez granicę biblioteki: aplikacja wypełnia je swoimi
3
+ * danymi (osoby, zadania, dokumenty), oba porty (Angular i React) renderują je tak samo.
4
+ */
5
+ export {};
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@konce-pt/mention",
3
+ "version": "0.8.5",
4
+ "description": "Mention core for Koncept UI — parsing @mentions in text, the active query at the caret and diacritics-insensitive matching. Framework-free TypeScript, zero runtime dependencies.",
5
+ "license": "MIT",
6
+ "author": "konce.pt",
7
+ "homepage": "https://ui.konce.pt/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://gitlab.com/konce-pt/koncept-ui.git",
11
+ "directory": "packages/mention"
12
+ },
13
+ "bugs": {
14
+ "url": "https://gitlab.com/konce-pt/koncept-ui/-/issues"
15
+ },
16
+ "keywords": [
17
+ "mention",
18
+ "mentions",
19
+ "autocomplete",
20
+ "design-system",
21
+ "koncept-ui",
22
+ "kpt"
23
+ ],
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "module": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "~6.0.3"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.build.json",
42
+ "test": "node --test \"src/**/*.test.ts\"",
43
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\""
44
+ }
45
+ }