mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-04-06 01:25:33 +00:00
Compare commits
18 Commits
b40b6c8160
...
b052a877cd
| Author | SHA1 | Date | |
|---|---|---|---|
| b052a877cd | |||
| 0ef35df335 | |||
| 237c84528b | |||
| 3d7307b759 | |||
| 3a8477f863 | |||
| 90eb66bc80 | |||
| acda71ee7b | |||
| 77391dde19 | |||
|
|
02bce69ab3 | ||
|
|
081f4f5476 | ||
|
|
25df15ef5e | ||
|
|
e80b6da89f | ||
|
|
d5c35f824e | ||
|
|
d81acb1919 | ||
|
|
22775716f9 | ||
|
|
087939636b | ||
|
|
dd3c94087a | ||
|
|
ad7ad53d26 |
376
TESTING_STRATEGY.md
Normal file
376
TESTING_STRATEGY.md
Normal 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** | `` | 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)
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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/,
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
1247
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user