Compare commits

...

18 Commits

Author SHA1 Message Date
b052a877cd fix: address GitHub Copilot review feedback
- Add missing dependencies to useEditor in TextView.tsx (items, getItemColor, addFilterTag)
- Remove redundant useEffect that duplicated editor initialization
- Update all TipTap packages to v3.15.3 for version consistency
- Make YouTube video ID pattern more flexible (10-12 chars instead of exactly 11)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:51:40 +01:00
0ef35df335 fix: use @tiptap/markdown API for Hashtag and ItemMention extensions
- Replace addStorage() with markdownTokenizer/parseMarkdown/renderMarkdown
- Remove tiptap-markdown from rollup commonjs config
- These changes fix serialization of hashtags and mentions to markdown
2026-01-15 09:12:35 +01:00
237c84528b fix: lint import order 2026-01-15 09:08:39 +01:00
3d7307b759 fix(lib): improve markdown processing and truncation
- Add convertNakedUrls() to skip URLs already inside markdown links
- Rewrite truncateMarkdown() with token-aware truncation
- Add @tiptap/markdown support to VideoEmbed, ItemMention, Hashtag
- Fix double-conversion of URLs in existing links
- Fix truncation cutting tokens in the middle
- Fix eslint warnings with proper types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:53:37 +01:00
3a8477f863 fix: use preprocessMarkdown for parsing hashtags and item mentions
The @tiptap/markdown extension doesn't automatically parse custom
markdown syntax like [@Label](/item/id). We need to preprocess the
markdown before loading it into the editor to convert these patterns
to HTML spans that the extensions' parseHTML handlers can recognize.

- RichTextEditor: preprocess defaultValue before loading
- TextView: preprocess innerText before loading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:53:27 +01:00
90eb66bc80 refactor: clean up markdown serialization, use @tiptap/markdown API
- Remove old addStorage.markdown.serialize (community tiptap-markdown)
- Keep only renderMarkdown (official @tiptap/markdown)
- Update TextView.tsx to use @tiptap/markdown with contentType

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:53:13 +01:00
acda71ee7b fix(lib): remove extra blank line in RichTextEditor
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:52:57 +01:00
77391dde19 refactor(editor): migrate to native @tiptap/markdown extension
- Replace community `tiptap-markdown` with official `@tiptap/markdown`
- Use new API: `editor.getMarkdown()` instead of `editor.storage.markdown.getMarkdown()`
- Add `contentType: 'markdown'` for direct markdown loading
- Remove unused `@tiptap/extension-color` (no UI was using it)
- Remove custom MarkdownStorage type declaration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:52:57 +01:00
Anton Tranelis
02bce69ab3
Merge branch 'main' into feature/tiptap-textview-migration 2026-01-15 08:36:28 +01:00
dependabot[bot]
081f4f5476
build(deps-dev): bump rollup from 4.54.0 to 4.55.1 (#653)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 08:47:18 +00:00
dependabot[bot]
25df15ef5e
build(deps-dev): bump globals from 16.5.0 to 17.0.0 (#654)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 09:42:07 +01:00
dependabot[bot]
e80b6da89f
build(deps-dev): bump the vitest-ecosystem group with 2 updates (#659)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 09:36:11 +01:00
dependabot[bot]
d5c35f824e
build(deps-dev): bump typedoc from 0.28.15 to 0.28.16 (#660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 08:19:26 +00:00
dependabot[bot]
d81acb1919
build(deps-dev): bump @eslint-community/eslint-plugin-eslint-comments from 4.5.0 to 4.6.0 (#669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 08:14:18 +00:00
dependabot[bot]
22775716f9
build(deps-dev): bump happy-dom from 20.0.11 to 20.1.0 (#661)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 08:07:51 +00:00
dependabot[bot]
087939636b
build(deps-dev): bump cypress from 15.8.1 to 15.9.0 (#663)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 08:02:41 +00:00
dependabot[bot]
dd3c94087a
build(deps-dev): bump vite from 7.3.0 to 7.3.1 (#664)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 07:57:26 +00:00
dependabot[bot]
ad7ad53d26
build(deps): bump maplibre-gl from 5.15.0 to 5.16.0 (#665)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 08:51:50 +01:00
12 changed files with 1411 additions and 771 deletions

376
TESTING_STRATEGY.md Normal file
View File

@ -0,0 +1,376 @@
# Teststrategie für TipTap Markdown-Migration
## Zusammenfassung der Analyse
Die TipTap-Migration umfasst folgende Kernkomponenten:
| Komponente | Beschreibung | Komplexität |
|------------|--------------|-------------|
| `lib/src/Components/TipTap/utils/preprocessMarkdown.ts` | 6-stufige Preprocessing-Pipeline | Hoch |
| `lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx` | Statische HTML-Konvertierung | Mittel |
| `lib/src/Components/TipTap/extensions/Hashtag.tsx` | Custom Extension mit Tokenizer | Mittel |
| `lib/src/Components/TipTap/extensions/ItemMention.tsx` | Custom Extension mit Tokenizer | Mittel |
| `lib/src/Components/TipTap/extensions/VideoEmbed.tsx` | Block-Element für Videos | Mittel |
| `lib/src/Components/Input/RichTextEditor.tsx` | Haupt-Editor-Komponente | Hoch |
| `lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx` | Read-Only Editor | Mittel |
| `lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.tsx` | Lightweight Static Renderer | Niedrig |
---
## Empfohlene Teststrategie: Testing Pyramid
```
┌─────────────────┐
│ E2E Tests │ ← Wenige, kritische User Journeys
│ (Cypress) │
└────────┬────────┘
┌────────┴────────┐
│ Integration │ ← TipTap + Extensions zusammen
│ Tests (Vitest) │
└────────┬────────┘
┌───────────────────┴───────────────────┐
│ Unit Tests (Vitest) │ ← Utility-Funktionen isoliert
│ preprocessMarkdown, simpleMarkdownToHtml │
└────────────────────────────────────────┘
```
### Begründung der Strategie
1. **Unit Tests für Utility-Funktionen (Hauptfokus)**
- `preprocessMarkdown.ts` und `simpleMarkdownToHtml.tsx` sind **pure Funktionen** ohne Abhängigkeiten
- Extrem schnelle Ausführung, hohe Coverage möglich
- Einfach zu warten und zu debuggen
- Hier liegt die meiste **Geschäftslogik** der Markdown-Verarbeitung
2. **Integration Tests für TipTap Extensions**
- Extensions benötigen einen Editor-Kontext
- Testen der Markdown ↔ JSON ↔ HTML Roundtrips
- Mäßiger Aufwand, gute Fehlererkennung
3. **E2E Tests nur für kritische User Journeys**
- Hoher Wartungsaufwand
- Langsame Ausführung
- Für Smoke Tests und Regressionsschutz
---
## Detaillierte Testfälle
### 1. Unit Tests für `preprocessMarkdown.ts`
#### A) `convertNakedUrls`
| Testfall | Input | Expected Output |
|----------|-------|-----------------|
| **Happy Path** | `Check https://example.com out` | `Check [example.com](https://example.com) out` |
| **www entfernen** | `https://www.example.com` | `[example.com](https://example.com)` |
| **URL in Markdown-Link (Skip)** | `[link](https://example.com)` | Unverändert |
| **URL in Autolink (Skip)** | `<https://example.com>` | Unverändert |
| **Mehrere URLs** | `https://a.com and https://b.com` | Beide konvertiert |
| **URL am Satzende mit Punkt** | `Visit https://example.com.` | Punkt nicht Teil der URL |
| **URL mit Klammern** | `(https://example.com)` | Klammern korrekt behandelt |
| **URL mit Query-Params** | `https://example.com?a=1&b=2` | Vollständig konvertiert |
#### B) `preprocessVideoLinks`
| Testfall | Input | Expected Output |
|----------|-------|-----------------|
| **YouTube Standard** | `<https://www.youtube.com/watch?v=abc123>` | `<video-embed provider="youtube" video-id="abc123">` |
| **YouTube Short URL** | `<https://youtu.be/abc123>` | Korrekt konvertiert |
| **YouTube Markdown Link** | `[Video](https://youtube.com/watch?v=abc123)` | Korrekt konvertiert |
| **Rumble Embed** | `<https://rumble.com/embed/xyz789>` | `<video-embed provider="rumble"...>` |
| **URL mit Extra-Params** | `<https://youtube.com/watch?v=abc&t=120>` | Nur Video-ID extrahiert |
| **Kein Video-Link** | `<https://example.com>` | Unverändert |
| **Gemischter Content** | `Text <https://youtu.be/x> more` | Nur Video konvertiert |
#### C) `preprocessHashtags`
| Testfall | Input | Expected Output |
|----------|-------|-----------------|
| **Einfacher Hashtag** | `Hello #world` | `Hello <span data-hashtag...>#world</span>` |
| **Hashtag mit Umlauten** | `#München` | Korrekt erkannt |
| **Hashtag mit Zahlen** | `#test123` | Korrekt erkannt |
| **Hashtag in Link (Skip)** | `[#tag](#anchor)` | Unverändert |
| **Hashtag nach Klammer (Skip)** | `(#section)` | Unverändert |
| **Mehrere Hashtags** | `#one #two #three` | Alle konvertiert |
| **Ungültiger Hashtag** | `#` | Unverändert (kein Text) |
| **Hashtag mit Underscore** | `#my_tag` | Korrekt erkannt |
#### D) `preprocessItemMentions`
| Testfall | Input | Expected Output |
|----------|-------|-----------------|
| **Standard Format** | `[@Person](/item/uuid-123)` | `<span data-item-mention...>@Person</span>` |
| **Mit Layer (Legacy)** | `[@Name](/item/layer/uuid)` | Korrekt konvertiert |
| **Relativer Pfad** | `[@Name](item/uuid)` | Korrekt konvertiert |
| **Mehrere Mentions** | `[@A](/item/1) und [@B](/item/2)` | Beide konvertiert |
| **Kein Item-Link** | `[@Name](/other/path)` | Unverändert |
| **UUID Case-Insensitive** | `[@Name](/item/ABC-def-123)` | Korrekt erkannt |
| **Label mit Sonderzeichen** | `[@Max Müller](/item/uuid)` | Korrekt konvertiert |
#### E) `truncateMarkdown`
| Testfall | Input | Limit | Expected |
|----------|-------|-------|----------|
| **Unter Limit** | `Short text` | 100 | Unverändert |
| **Über Limit (Plain)** | `A very long text...` | 10 | `A very lo...` |
| **Hashtag nicht schneiden** | `Text #verylonghashtag more` | 15 | Vollständiger Hashtag oder davor abschneiden |
| **Mention nicht schneiden** | `Hi [@Person](/item/x) bye` | 10 | Vollständige Mention oder davor |
| **Link nicht schneiden** | `See [link](url) more` | 8 | Vollständiger Link oder davor |
| **Newlines nicht zählen** | `Line1\n\nLine2` | 10 | Newlines ignoriert bei Zählung |
| **Gemischter Content** | `#tag [@m](/item/1) text` | 20 | Tokens atomar |
#### F) `removeMarkdownSyntax`
| Testfall | Input | Expected |
|----------|-------|----------|
| **Bold** | `**bold**` | `bold` |
| **Italic** | `*italic*` | `italic` |
| **Headers** | `# Heading` | `Heading` |
| **Links** | `[text](url)` | `text` |
| **Item Mentions erhalten** | `[@Name](/item/x)` | Erhalten |
| **Bilder entfernen** | `![alt](img.png)` | Leer |
| **Code** | `` `code` `` | `code` |
---
### 2. Unit Tests für `simpleMarkdownToHtml.tsx`
| Testfall | Input | Expected HTML |
|----------|-------|---------------|
| **Bold** | `**bold**` | `<strong>bold</strong>` |
| **Italic** | `*italic*` | `<em>italic</em>` |
| **Link** | `[text](url)` | `<a href="url">text</a>` |
| **External Link** | `[text](https://ext.com)` | `<a href="..." target="_blank"...>` |
| **Header H1** | `# Title` | `<h1>Title</h1>` |
| **Header H2-H6** | `## ... ######` | Entsprechende h-Tags |
| **Inline Code** | `` `code` `` | `<code>code</code>` |
| **Blockquote** | `> quote` | `<blockquote>quote</blockquote>` |
| **Video Embed** | `<video-embed provider="youtube"...>` | `<iframe src="youtube-nocookie...">` |
| **Hashtag mit Farbe** | Preprocessed Hashtag + Tag mit color | Style mit korrekter Farbe |
| **Item Mention** | Preprocessed Mention + Item | Link mit korrekter Farbe |
| **XSS Prevention** | `<script>alert('xss')</script>` | Escaped, kein Script-Tag |
| **Newlines** | `Line1\n\nLine2` | `</p><p>` Trennung |
---
### 3. Integration Tests für TipTap Extensions
Diese Tests benötigen einen TipTap Editor-Kontext. Setup via `@tiptap/core`:
```typescript
// Test-Setup Beispiel
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { Hashtag } from './Hashtag'
```
#### A) Hashtag Extension
| Testfall | Beschreibung |
|----------|--------------|
| **Markdown → JSON** | `#tag` wird zu `{ type: 'hashtag', attrs: { label: 'tag' } }` |
| **JSON → Markdown** | Hashtag-Node wird zu `#tag` serialisiert |
| **HTML Parse** | `<span data-hashtag data-label="x">#x</span>` wird erkannt |
| **HTML Render** | Node rendert korrekte HTML-Struktur |
| **Tokenizer Start** | `/(?<!\[)#[a-zA-Z]/` matched korrekt |
| **Click Handler (read-only)** | `onTagClick` wird aufgerufen |
| **No Click (editable)** | Kein Click-Handler im Edit-Modus |
#### B) ItemMention Extension
| Testfall | Beschreibung |
|----------|--------------|
| **Markdown → JSON** | `[@Name](/item/uuid)` wird zu korrektem Node |
| **JSON → Markdown** | Node wird korrekt serialisiert |
| **Mit Layer** | Legacy-Format wird korrekt geparst |
| **UUID Case-Insensitive** | Groß-/Kleinschreibung egal |
| **Farbe aus Item** | `getItemColor` wird korrekt verwendet |
#### C) VideoEmbed Extension
| Testfall | Beschreibung |
|----------|--------------|
| **YouTube Parse** | Autolink zu Video-Node |
| **Rumble Parse** | Embed-URL zu Video-Node |
| **Iframe Render** | Korrekter `youtube-nocookie.com` Embed |
| **Paste Handler** | Video-URL wird beim Einfügen erkannt |
#### D) Roundtrip Tests
| Testfall | Flow |
|----------|------|
| **Markdown Roundtrip** | Markdown → Editor → `getMarkdown()` → identisch |
| **Komplexer Content** | Text + #tag + @mention + Video → Roundtrip |
| **Preserve Formatting** | Bold, Italic, Listen bleiben erhalten |
---
### 4. Component Tests (React Testing Library)
#### RichTextEditor
| Testfall | Beschreibung |
|----------|--------------|
| **Render mit Default** | Editor rendert mit initialem Content |
| **onChange Callback** | `updateFormValue` erhält Markdown |
| **Placeholder** | Placeholder wird angezeigt |
| **Hashtag Suggestion** | `#` triggert Suggestion Popup |
| **Item Mention Suggestion** | `@` triggert Suggestion Popup |
| **Keyboard Navigation** | Arrow Keys in Suggestions |
| **Suggestion Select** | Enter fügt Tag ein |
#### TextView
| Testfall | Beschreibung |
|----------|--------------|
| **Read-Only** | Editor ist nicht editierbar |
| **Truncation** | Langer Text wird gekürzt + `...` |
| **Hashtag Click** | `addFilterTag` wird aufgerufen |
| **Internal Link** | React Router Navigation |
| **External Link** | Neuer Tab |
#### TextViewStatic
| Testfall | Beschreibung |
|----------|--------------|
| **HTML Render** | Markdown wird zu HTML |
| **Hashtag Farbe** | Farbe aus Tags-Array |
| **Item Mention Link** | Korrekter `/item/` Link |
| **Video Embed** | Iframe wird gerendert |
---
### 5. E2E Tests (Cypress)
Nur **kritische User Journeys** - minimaler Scope für maximale Stabilität:
#### A) Editor Flow
```typescript
describe('Rich Text Editor', () => {
it('should create and save content with hashtags and mentions', () => {
// 1. Neues Item erstellen
// 2. Text eingeben mit #tag und @mention
// 3. Speichern
// 4. Popup öffnen und Rendering prüfen
})
it('should handle video embeds', () => {
// YouTube URL einfügen → Video-Embed sichtbar
})
})
```
#### B) Display Flow
```typescript
describe('Text Display', () => {
it('should render hashtags clickable in popup', () => {
// Item mit Hashtag öffnen
// Hashtag klicken
// Filter wird aktiviert
})
it('should navigate to mentioned items', () => {
// Item mit @mention öffnen
// Mention klicken
// Navigation zum verlinkten Item
})
})
```
---
## Priorisierung der Implementierung
### Phase 1: Unit Tests (Höchste Priorität)
| Datei | Geschätzte Tests | Grund |
|-------|------------------|-------|
| `preprocessMarkdown.spec.ts` | ~40 Tests | Pure Functions, schnell, hohe Coverage |
| `simpleMarkdownToHtml.spec.ts` | ~25 Tests | Pure Function, XSS-kritisch |
### Phase 2: Integration Tests
| Datei | Geschätzte Tests | Grund |
|-------|------------------|-------|
| `Hashtag.spec.ts` | ~15 Tests | Custom Extension mit Tokenizer |
| `ItemMention.spec.ts` | ~15 Tests | Custom Extension mit Tokenizer |
| `VideoEmbed.spec.ts` | ~10 Tests | Block-Element |
### Phase 3: Component Tests
| Datei | Geschätzte Tests | Grund |
|-------|------------------|-------|
| `RichTextEditor.spec.tsx` | ~15 Tests | Haupt-Editor |
| `TextView.spec.tsx` | ~10 Tests | Read-Only Variante |
| `TextViewStatic.spec.tsx` | ~8 Tests | Lightweight Renderer |
### Phase 4: E2E Tests (Niedrigste Priorität)
| Datei | Geschätzte Tests | Grund |
|-------|------------------|-------|
| `editor-flow.cy.ts` | 3-5 Tests | Kritische User Journey |
---
## Edge Cases und Error Handling
### Besonders wichtige Grenzfälle
1. **Leerer Input** - Alle Funktionen sollten mit `''`, `null`, `undefined` umgehen
2. **Sehr langer Text** - Performance bei >10.000 Zeichen
3. **Verschachtelte Syntax** - `**#bold-hashtag**`, `[[@mention](/item/x)](url)`
4. **Unicode** - Emojis, RTL-Text, Sonderzeichen
5. **Malformed Markdown** - Ungeschlossene Tags: `**bold`, `[link(`
6. **XSS Vectors** - `<script>`, Event-Handler in Links
7. **Concurrent Tokens** - `#tag1#tag2` (ohne Leerzeichen)
8. **URLs in Code-Blöcken** - Sollten nicht konvertiert werden
---
## Test-Setup Empfehlungen
### Vitest Setup erweitern (`lib/setupTest.ts`)
```typescript
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// TipTap DOM-Mocks für Vitest (basierend auf Community-Empfehlungen)
Range.prototype.getBoundingClientRect = () => ({
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
toJSON: vi.fn(),
})
Range.prototype.getClientRects = () => ({
item: () => null, length: 0, [Symbol.iterator]: vi.fn(),
})
Document.prototype.elementFromPoint = vi.fn()
```
---
## Fazit
Die empfohlene Strategie fokussiert auf **Unit Tests für die Markdown-Utility-Funktionen**, da hier:
- Die meiste Geschäftslogik liegt
- Pure Functions einfach testbar sind
- Hohe Coverage mit geringem Aufwand erreichbar ist
E2E Tests sollten auf ein Minimum beschränkt bleiben und nur kritische User Journeys abdecken. Dies folgt der Testing Pyramid Best Practice und sorgt für:
- **Schnelles Feedback** (Unit Tests <1s)
- **Hohe Wartbarkeit** (keine flaky UI-Tests)
- **Gute Fehlerlokalisierung** (isolierte Tests)
---
## Quellen
- [TipTap Testing Discussion #4008](https://github.com/ueberdosis/tiptap/discussions/4008)
- [TipTap Jest Issue #5108](https://github.com/ueberdosis/tiptap/issues/5108)
- [Testing TipTap CodeSandbox](https://codesandbox.io/s/testing-tiptap-p0oomz)
- [TipTap Contributing Guide](https://tiptap.dev/docs/resources/contributing)

View File

@ -26,7 +26,7 @@
"vite-tsconfig-paths": "^5.1.4"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/js": "^9.36.0",
"@types/node": "^24.10.2",
"@types/react": "^18.2.79",
@ -44,12 +44,12 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-security": "^3.0.1",
"globals": "^16.3.0",
"globals": "^17.0.0",
"postcss": "^8.4.30",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.9.0",
"vite": "^7.3.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0"
}
}

View File

@ -43,7 +43,7 @@
"author": "Anton Tranelis",
"license": "GPL-3.0-only",
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/js": "^9.36.0",
"@rollup/plugin-alias": "^6.0.0",
"@rollup/plugin-commonjs": "^29.0.0",
@ -58,8 +58,8 @@
"@types/react": "^18.2.0",
"@types/react-dom": "^18.0.5",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.0.16",
"cypress": "^15.7.1",
"@vitest/coverage-v8": "^4.0.17",
"cypress": "^15.9.0",
"daisyui": "^5.5.14",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
@ -73,23 +73,23 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-security": "^3.0.1",
"globals": "^16.3.0",
"happy-dom": "^20.0.11",
"globals": "^17.0.0",
"happy-dom": "^20.1.0",
"postcss": "^8.4.21",
"prettier": "^3.7.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rollup": "^4.54.0",
"rollup": "^4.55.1",
"rollup-plugin-dts": "^6.3.0",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-svg": "^2.0.0",
"tailwindcss": "^4.1.18",
"typedoc": "^0.28.15",
"typedoc": "^0.28.16",
"typedoc-plugin-coverage": "^4.0.2",
"typedoc-plugin-missing-exports": "^4.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.9.0",
"vite": "^7.3.0",
"vite": "^7.3.1",
"vite-plugin-svgr": "^4.3.0",
"vitest": "^4.0.16"
},
@ -101,23 +101,23 @@
"dependencies": {
"@heroicons/react": "^2.0.17",
"@maplibre/maplibre-gl-leaflet": "^0.1.3",
"@tiptap/core": "^3.13.0",
"@tiptap/extension-bubble-menu": "^3.13.0",
"@tiptap/extension-color": "^3.13.0",
"@tiptap/extension-image": "^3.13.0",
"@tiptap/extension-link": "^3.13.0",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/extension-youtube": "^3.13.0",
"@tiptap/pm": "^3.6.5",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@tiptap/core": "^3.15.3",
"@tiptap/extension-bubble-menu": "^3.15.3",
"@tiptap/extension-image": "^3.15.3",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/extension-youtube": "^3.15.3",
"@tiptap/markdown": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"@tiptap/suggestion": "^3.15.3",
"axios": "^1.13.2",
"browser-image-compression": "^2.0.2",
"classnames": "^2.5.1",
"leaflet": "^1.9.4",
"leaflet.locatecontrol": "^0.79.0",
"maplibre-gl": "^5.15.0",
"maplibre-gl": "^5.16.0",
"radash": "^12.1.0",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.3.8",
@ -132,7 +132,6 @@
"react-toastify": "^9.1.3",
"remark-breaks": "^4.0.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"yet-another-react-lightbox": "^3.28.0"
},
"imports": {

View File

@ -44,8 +44,6 @@ export default [
commonjs({
include: [
/node_modules\/attr-accept/,
/node_modules\/tiptap-markdown/,
/node_modules\/markdown-it-task-lists/,
/node_modules\/classnames/,
/node_modules\/react-qr-code/,
/node_modules\/use-sync-external-store/,

View File

@ -1,11 +1,10 @@
import { Color } from '@tiptap/extension-color'
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Placeholder } from '@tiptap/extension-placeholder'
import { Markdown } from '@tiptap/markdown'
import { EditorContent, useEditor } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { useEffect, useMemo } from 'react'
import { Markdown } from 'tiptap-markdown'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useItems } from '#components/Map/hooks/useItems'
@ -17,13 +16,10 @@ import {
createHashtagSuggestion,
createItemMentionSuggestion,
} from '#components/TipTap/extensions'
import { preprocessMarkdown } from '#components/TipTap/utils/preprocessMarkdown'
import { InputLabel } from './InputLabel'
import { TextEditorMenu } from './TextEditorMenu'
import type { MarkdownStorage } from 'tiptap-markdown'
interface RichTextEditorProps {
labelTitle?: string
labelStyle?: string
@ -34,12 +30,6 @@ interface RichTextEditorProps {
updateFormValue?: (value: string) => void
}
declare module '@tiptap/core' {
interface Storage {
markdown: MarkdownStorage
}
}
/**
* @category Input
*/
@ -64,7 +54,7 @@ export function RichTextEditor({
)
const handleChange = () => {
let newValue: string | undefined = editor.storage.markdown.getMarkdown()
let newValue: string | undefined = editor.getMarkdown()
const regex = /!\[.*?\]\(.*?\)/g
newValue = newValue.replace(regex, (match: string) => match + '\n\n')
@ -75,7 +65,6 @@ export function RichTextEditor({
const editor = useEditor({
extensions: [
Color.configure({ types: ['textStyle', 'listItem'] }),
StarterKit.configure({
bulletList: {
keepMarks: true,
@ -86,11 +75,7 @@ export function RichTextEditor({
keepAttributes: false,
},
}),
Markdown.configure({
linkify: true,
transformCopiedText: true,
transformPastedText: true,
}),
Markdown,
Image,
Link,
Placeholder.configure({
@ -108,7 +93,8 @@ export function RichTextEditor({
getItemColor,
}),
],
content: preprocessMarkdown(defaultValue),
content: defaultValue,
contentType: 'markdown',
onUpdate: handleChange,
editorProps: {
attributes: {
@ -118,8 +104,8 @@ export function RichTextEditor({
})
useEffect(() => {
if (editor.storage.markdown.getMarkdown() === '' || !editor.storage.markdown.getMarkdown()) {
editor.commands.setContent(preprocessMarkdown(defaultValue))
if (editor.getMarkdown() === '' || !editor.getMarkdown()) {
editor.commands.setContent(defaultValue, { contentType: 'markdown' })
}
}, [defaultValue, editor])

View File

@ -1,19 +1,15 @@
import { Markdown } from '@tiptap/markdown'
import { EditorContent, useEditor } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Markdown } from 'tiptap-markdown'
import { useAddFilterTag } from '#components/Map/hooks/useFilter'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useItems } from '#components/Map/hooks/useItems'
import { useTags } from '#components/Map/hooks/useTags'
import { Hashtag, ItemMention, VideoEmbed } from '#components/TipTap/extensions'
import {
preprocessMarkdown,
removeMarkdownSyntax,
truncateMarkdown,
} from '#components/TipTap/utils/preprocessMarkdown'
import { removeMarkdownSyntax, truncateMarkdown } from '#components/TipTap/utils/preprocessMarkdown'
import type { Item } from '#types/Item'
@ -63,18 +59,11 @@ export const TextView = ({
innerText = truncateMarkdown(removeMarkdownSyntax(innerText), 100)
}
// Pre-process the markdown
const processedText = innerText ? preprocessMarkdown(innerText) : ''
const editor = useEditor(
{
extensions: [
StarterKit,
Markdown.configure({
html: true, // Allow HTML in markdown (for our preprocessed tags)
transformPastedText: true,
linkify: true,
}),
Markdown,
Hashtag.configure({
tags,
onTagClick: (tag) => {
@ -87,7 +76,9 @@ export const TextView = ({
}),
VideoEmbed,
],
content: processedText,
// Load content as markdown - the extensions' markdownTokenizer handles parsing
content: innerText,
contentType: 'markdown',
editable: false,
editorProps: {
attributes: {
@ -95,14 +86,9 @@ export const TextView = ({
},
},
},
[processedText, tags],
[innerText, tags, items, getItemColor, addFilterTag],
)
// Update content when text changes
useEffect(() => {
editor.commands.setContent(processedText)
}, [editor, processedText])
// Handle link clicks for internal navigation
useEffect(() => {
const container = containerRef.current

View File

@ -5,11 +5,7 @@ import { useAddFilterTag } from '#components/Map/hooks/useFilter'
import { useGetItemColor } from '#components/Map/hooks/useItemColor'
import { useItems } from '#components/Map/hooks/useItems'
import { useTags } from '#components/Map/hooks/useTags'
import {
preprocessMarkdown,
removeMarkdownSyntax,
truncateMarkdown,
} from '#components/TipTap/utils/preprocessMarkdown'
import { preprocessMarkdown, truncateMarkdown } from '#components/TipTap/utils/preprocessMarkdown'
import { simpleMarkdownToHtml } from '#components/TipTap/utils/simpleMarkdownToHtml'
import type { Item } from '#types/Item'
@ -60,17 +56,22 @@ export const TextViewStatic = ({
innerText = text
}
// Apply truncation if needed
if (innerText && truncate) {
innerText = truncateMarkdown(removeMarkdownSyntax(innerText), 100)
}
// Pre-process and convert to HTML
// Pre-process markdown first (converts naked URLs to links, etc.)
// Then truncate the processed markdown
// Finally convert to HTML
const html = useMemo(() => {
if (!innerText) return ''
const processed = preprocessMarkdown(innerText)
// First preprocess to normalize all URLs/mentions/hashtags
let processed = preprocessMarkdown(innerText)
// Then truncate if needed (works on normalized markdown)
if (truncate) {
processed = truncateMarkdown(processed, 100)
}
return simpleMarkdownToHtml(processed, tags, { items, getItemColor })
}, [innerText, tags, items, getItemColor])
}, [innerText, truncate, tags, items, getItemColor])
// Handle clicks for internal navigation and hashtags
useEffect(() => {

View File

@ -40,6 +40,45 @@ export const Hashtag = Node.create<HashtagOptions>({
}
},
// Markdown tokenizer for @tiptap/markdown - recognizes #hashtag syntax
markdownTokenizer: {
name: 'hashtag',
level: 'inline',
// Fast hint for the lexer - where might a hashtag start?
start: (src: string) => {
// Look for # followed by word characters (but not inside links)
const match = /(?<!\[)#[a-zA-Z0-9À-ÖØ-öø-ʸ_-]/.exec(src)
return match ? match.index : -1
},
tokenize: (src: string) => {
// Match hashtag: #tagname (not preceded by [)
const match = /^#([a-zA-Z0-9À-ÖØ-öø-ʸ_-]+)/.exec(src)
if (match) {
return {
type: 'hashtag',
raw: match[0],
label: match[1],
}
}
return undefined
},
},
// Parse Markdown token to Tiptap JSON
parseMarkdown(token: { label: string }) {
return {
type: 'hashtag',
attrs: {
label: token.label,
},
}
},
// Serialize Tiptap node to Markdown
renderMarkdown(node: { attrs: { label: string } }) {
return `#${node.attrs.label}`
},
addAttributes() {
return {
id: {
@ -89,20 +128,6 @@ export const Hashtag = Node.create<HashtagOptions>({
}
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void }, node: { attrs: { label: string } }) {
// Write as plain hashtag
state.write(`#${node.attrs.label}`)
},
parse: {
// Parsing is handled by preprocessHashtags
},
},
}
},
addNodeView() {
return ReactNodeViewRenderer(HashtagComponent)
},

View File

@ -39,6 +39,46 @@ export const ItemMention = Node.create<ItemMentionOptions>({
}
},
// Markdown tokenizer for @tiptap/markdown - recognizes [@Label](/item/id) syntax
markdownTokenizer: {
name: 'itemMention',
level: 'inline',
// Fast hint for the lexer - where might an item mention start?
start: (src: string) => src.indexOf('[@'),
tokenize: (src: string) => {
// Match [@Label](/item/id) or [@Label](/item/layer/id)
// UUID pattern: hex characters (case-insensitive) with dashes
// eslint-disable-next-line security/detect-unsafe-regex
const match = /^\[@([^\]]+?)\]\(\/item\/(?:[^/]+\/)?([a-fA-F0-9-]+)\)/.exec(src)
if (match) {
return {
type: 'itemMention',
raw: match[0],
label: match[1],
id: match[2],
}
}
return undefined
},
},
// Parse Markdown token to Tiptap JSON
parseMarkdown(token: { label: string; id: string }) {
return {
type: 'itemMention',
attrs: {
label: token.label,
id: token.id,
},
}
},
// Serialize Tiptap node to Markdown
renderMarkdown(node: { attrs: { label: string; id: string } }) {
const { label, id } = node.attrs
return `[@${label}](/item/${id})`
},
addAttributes() {
return {
id: {
@ -88,24 +128,6 @@ export const ItemMention = Node.create<ItemMentionOptions>({
}
},
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void },
node: { attrs: { id: string; label: string } },
) {
// Write as markdown link: [@Label](/item/id)
const { id, label } = node.attrs
state.write(`[@${label}](/item/${id})`)
},
parse: {
// Parsing is handled by preprocessItemMentions
},
},
}
},
addNodeView() {
return ReactNodeViewRenderer(ItemMentionComponent)
},

View File

@ -6,8 +6,9 @@ import type { NodeViewProps } from '@tiptap/react'
// Regex patterns for video URL detection
// Using possessive-like patterns with specific character classes to avoid ReDoS
const YOUTUBE_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})(?:&|$)/
const YOUTUBE_SHORT_REGEX = /^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})(?:\?|$)/
// YouTube IDs are typically 11 chars but we allow 10-12 for flexibility
const YOUTUBE_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{10,12})(?:&|$)/
const YOUTUBE_SHORT_REGEX = /^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{10,12})(?:\?|$)/
const RUMBLE_REGEX = /^https?:\/\/rumble\.com\/embed\/([a-zA-Z0-9]+)(?:\/|$)/
/**
@ -55,28 +56,81 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
}
},
addStorage() {
// Markdown tokenizer for @tiptap/markdown - recognizes <https://youtube.com/...> and <https://rumble.com/...> syntax
markdownTokenizer: {
name: 'videoEmbed',
level: 'inline',
// Fast hint for the lexer - where might a video embed start?
start: (src: string) => {
// Look for autolinks with video URLs
const youtubeIndex = src.indexOf('<https://www.youtube.com/watch')
const youtubeShortIndex = src.indexOf('<https://youtu.be/')
const rumbleIndex = src.indexOf('<https://rumble.com/embed/')
const indices = [youtubeIndex, youtubeShortIndex, rumbleIndex].filter((i) => i >= 0)
return indices.length > 0 ? Math.min(...indices) : -1
},
tokenize: (src: string) => {
// Match YouTube autolinks: <https://www.youtube.com/watch?v=VIDEO_ID>
let match = /^<https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{10,12})[^>]*>/.exec(
src,
)
if (match) {
return {
type: 'videoEmbed',
raw: match[0],
provider: 'youtube',
videoId: match[1],
}
}
// Match YouTube short autolinks: <https://youtu.be/VIDEO_ID>
match = /^<https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{10,12})[^>]*>/.exec(src)
if (match) {
return {
type: 'videoEmbed',
raw: match[0],
provider: 'youtube',
videoId: match[1],
}
}
// Match Rumble autolinks: <https://rumble.com/embed/VIDEO_ID>
match = /^<https?:\/\/rumble\.com\/embed\/([a-zA-Z0-9]+)[^>]*>/.exec(src)
if (match) {
return {
type: 'videoEmbed',
raw: match[0],
provider: 'rumble',
videoId: match[1],
}
}
return undefined
},
},
// Parse Markdown token to Tiptap JSON
parseMarkdown(token: { provider: string; videoId: string }) {
return {
markdown: {
serialize(
state: { write: (text: string) => void },
node: { attrs: { provider: string; videoId: string } },
) {
const { provider, videoId } = node.attrs
const url =
provider === 'youtube'
? `https://www.youtube.com/watch?v=${videoId}`
: `https://rumble.com/embed/${videoId}`
// Write as markdown autolink
state.write(`<${url}>`)
},
parse: {
// Parsing is handled by preprocessVideoLinks
},
type: 'videoEmbed',
attrs: {
provider: token.provider,
videoId: token.videoId,
},
}
},
// Serialize Tiptap node to Markdown
renderMarkdown(node: { attrs: { provider: string; videoId: string } }) {
const { provider, videoId } = node.attrs
const url =
provider === 'youtube'
? `https://www.youtube.com/watch?v=${videoId}`
: `https://rumble.com/embed/${videoId}`
return `<${url}>`
},
addAttributes() {
return {
provider: {

View File

@ -4,6 +4,68 @@ import { fixUrls, mailRegex } from '#utils/ReplaceURLs'
import type { JSONContent, Extensions } from '@tiptap/core'
/**
* Converts naked URLs to markdown links, but skips URLs that are already
* inside markdown link syntax [text](url) or autolinks <url>.
*/
function convertNakedUrls(text: string): string {
// Find all existing markdown links and autolinks to know which ranges to skip
const skipRanges: { start: number; end: number }[] = []
// Find markdown links: [text](url)
const linkRegex = /\[[^\]]*\]\([^)]+\)/g
let linkMatch: RegExpExecArray | null
while ((linkMatch = linkRegex.exec(text)) !== null) {
skipRanges.push({ start: linkMatch.index, end: linkMatch.index + linkMatch[0].length })
}
// Find autolinks: <url>
const autolinkRegex = /<https?:\/\/[^>]+>/g
let autolinkMatch: RegExpExecArray | null
while ((autolinkMatch = autolinkRegex.exec(text)) !== null) {
skipRanges.push({
start: autolinkMatch.index,
end: autolinkMatch.index + autolinkMatch[0].length,
})
}
// Now find naked URLs and convert only those not in skip ranges
const urlRegex = /https?:\/\/[^\s)<>\]]+/g
let result = ''
let lastIndex = 0
let urlMatch: RegExpExecArray | null
while ((urlMatch = urlRegex.exec(text)) !== null) {
const urlStart = urlMatch.index
const urlEnd = urlMatch.index + urlMatch[0].length
const url = urlMatch[0]
// Check if this URL is inside a skip range
const isInsideSkipRange = skipRanges.some(
(range) => urlStart >= range.start && urlEnd <= range.end,
)
if (isInsideSkipRange) {
// Keep the URL as-is (it's already part of a link)
continue
}
// Add text before this URL
result += text.slice(lastIndex, urlStart)
// Convert naked URL to markdown link
const displayText = url.replace(/^https?:\/\/(www\.)?/, '')
result += `[${displayText}](${url})`
lastIndex = urlEnd
}
// Add remaining text
result += text.slice(lastIndex)
return result
}
/**
* Converts pre-processed markdown/HTML to TipTap JSON format.
* Creates a temporary editor instance to parse the content.
@ -38,11 +100,9 @@ export function preprocessMarkdown(text: string): string {
result = fixUrls(result)
// 2. Convert naked URLs to markdown links
// Match URLs that are NOT already inside markdown link syntax
result = result.replace(
/(?<!\]?\()(?<!<)https?:\/\/[^\s)]+(?!\))(?!>)/g,
(url) => `[${url.replace(/https?:\/\/w{3}\./gi, '')}](${url})`,
)
// Skip URLs that are already inside markdown link syntax [text](url) or autolinks <url>
// Process the text in segments to avoid matching URLs inside existing links
result = convertNakedUrls(result)
// 3. Convert email addresses to mailto links
result = result.replace(mailRegex, (email) => `[${email}](mailto:${email})`)
@ -148,20 +208,30 @@ export function preprocessItemMentions(text: string): string {
/**
* Removes markdown syntax for plain text display (used for truncation calculation).
* Preserves @mentions ([@Label](/item/id)) and #hashtags for rendering.
*/
export function removeMarkdownSyntax(text: string): string {
return text
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
.replace(/(`{1,3})(.*?)\1/g, '$2') // Remove inline code
.replace(/(\*{1,2}|_{1,2})(.*?)\1/g, '$2') // Remove bold and italic
.replace(/(#+)\s+(.*)/g, '$2') // Remove headers
.replace(/>\s+(.*)/g, '$1') // Remove blockquotes
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links, keep text
.replace(/<[^>]+>/g, '') // Remove HTML tags
return (
text
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
.replace(/(`{1,3})(.*?)\1/g, '$2') // Remove inline code
.replace(/(\*{1,2}|_{1,2})(.*?)\1/g, '$2') // Remove bold and italic
.replace(/(#+)\s+(.*)/g, '$2') // Remove headers
.replace(/>\s+(.*)/g, '$1') // Remove blockquotes
// Remove regular links but preserve @mentions ([@Label](/item/...))
.replace(/\[([^\]]+)\]\((?!\/item\/)[^)]+\)/g, '$1')
.replace(/<[^>]+>/g, '')
) // Remove HTML tags
}
/**
* Truncates text to a character limit, respecting paragraph boundaries.
* Truncates text to a character limit based on visible/plain text length.
* Preserves complete tokens - won't cut in the middle of:
* - @mentions: [@Label](/item/id)
* - #hashtags: #tagname
* - Links: [text](url)
*
* The limit applies to the rendered/visible text, not the raw markdown.
*/
export function truncateMarkdown(text: string, limit: number): string {
const plainText = removeMarkdownSyntax(text)
@ -170,26 +240,124 @@ export function truncateMarkdown(text: string, limit: number): string {
return text
}
let truncated = ''
let length = 0
// Tokenize the text into segments: either special tokens or plain text
// This allows us to count visible characters correctly
// Order matters: more specific patterns first
const tokenPatterns = [
{ pattern: /\[@([^\]]+?)\]\(\/?item\/[^)]+\)/g, type: 'mention' }, // @mentions - visible: @label
{ pattern: /<https?:\/\/[^>]+>/g, type: 'autolink' }, // <url> autolinks - visible: the whole thing (for videos etc)
{ pattern: /\[([^\]]*)\]\([^)]+\)/g, type: 'link' }, // [text](url) - visible: text
{ pattern: /(?<!\(|<)https?:\/\/[^\s)<>]+/g, type: 'nakedurl' }, // naked URLs - visible: URL without protocol
{ pattern: /(?<!\[)#([a-zA-Z0-9À-ÖØ-öø-ʸ_-]+)/g, type: 'hashtag' }, // #tag - visible: #tag (not inside links)
]
const paragraphs = text.split('\n')
// Find all tokens with their positions
interface Token {
start: number
end: number
raw: string
visible: string
type: string
}
for (const paragraph of paragraphs) {
const plainParagraph = removeMarkdownSyntax(paragraph)
const tokens: Token[] = []
if (length + plainParagraph.length > limit) {
// Calculate how many chars we can take from this paragraph
const remaining = limit - length
if (remaining > 0) {
truncated += paragraph.slice(0, remaining) + '...'
for (const { pattern, type } of tokenPatterns) {
pattern.lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const matchIndex = match.index
const matchFull = match[0]
const matchGroup = match[1] || ''
let visible: string
if (type === 'mention') {
visible = '@' + matchGroup
} else if (type === 'link') {
visible = matchGroup
} else if (type === 'autolink') {
// Autolinks like <https://youtube.com/...> - for truncation, count as short placeholder
// since they'll be rendered as embeds or converted
visible = '[video]'
} else if (type === 'nakedurl') {
// Naked URLs will be converted to links by preprocessMarkdown
// The visible text will be the URL without https://www.
visible = matchFull.replace(/^https?:\/\/(www\.)?/, '')
} else {
visible = matchFull // hashtag includes the #
}
// Check if this position overlaps with existing tokens (avoid duplicates)
const overlaps = tokens.some(
(t) =>
(matchIndex >= t.start && matchIndex < t.end) ||
(matchIndex + matchFull.length > t.start && matchIndex + matchFull.length <= t.end),
)
if (!overlaps) {
tokens.push({
start: matchIndex,
end: matchIndex + matchFull.length,
raw: matchFull,
visible,
type,
})
}
break
} else {
truncated += paragraph + '\n'
length += plainParagraph.length
}
}
return truncated.trim()
// Sort tokens by position
tokens.sort((a, b) => a.start - b.start)
// Build truncated output by walking through text
let result = ''
let visibleLength = 0
let pos = 0
while (pos < text.length && visibleLength < limit) {
// Check if we're at a token
const token = tokens.find((t) => t.start === pos)
if (token) {
// Would this token exceed the limit?
if (visibleLength + token.visible.length > limit) {
// Don't include partial token - stop here
break
}
result += token.raw
visibleLength += token.visible.length
pos = token.end
} else {
// Check if next position is inside a token (shouldn't happen, but safety check)
const insideToken = tokens.find((t) => pos > t.start && pos < t.end)
if (insideToken) {
pos = insideToken.end
continue
}
// Regular character - check for newline
// eslint-disable-next-line security/detect-object-injection
const char = text[pos]
if (char === '\n') {
result += char
pos++
// Don't count newlines toward visible limit
} else {
// Would this char exceed limit?
if (visibleLength + 1 > limit) {
break
}
result += char
visibleLength++
pos++
}
}
}
// Add ellipsis if we truncated
if (pos < text.length) {
result = result.trimEnd() + '...'
}
return result.trim()
}

1247
package-lock.json generated

File diff suppressed because it is too large Load Diff