Skip to content

Commit 6084dd6

Browse files
author
HugoFara
committed
feat(annotations): adds support for inline markdown! (#126)
1 parent ff4a8b0 commit 6084dd6

File tree

17 files changed

+797
-173
lines changed

17 files changed

+797
-173
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ ones are marked like "v1.0.0-fork".
1313
Added a dedicated "Notes" field to terms/words, allowing users to add personal
1414
notes separate from translations. Includes database migration, updated entity
1515
classes, service layer, UI forms, and API endpoints.
16+
* **Inline Markdown for Translations and Notes** ([#126](https://github.com/HugoFara/lwt/issues/126)):
17+
Translations and notes now support inline Markdown formatting: `**bold**`,
18+
`*italic*`, `~~strikethrough~~`, and `[links](url)`. Markdown is rendered in
19+
the word modal, annotations (translations shown above/below words), word list,
20+
test screens, and word detail views. Implemented with custom lightweight parsers
21+
in both TypeScript and PHP with XSS protection (HTML escaped before parsing,
22+
URLs sanitized to block javascript: schemes).
1623
* Official support for PHP 8.3 and 8.4.
1724
* **Multi-user support** ([#221](https://github.com/HugoFara/lwt/issues/221)):
1825
Users are now stored in a dedicated `users` table with proper foreign key

src/backend/Core/StringUtils.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,58 @@ public static function replaceFirst(string $needle, string $replace, string $hay
309309
}
310310
return $haystack;
311311
}
312+
313+
/**
314+
* Parse inline Markdown to HTML.
315+
*
316+
* Supports: **bold**, *italic*, [links](url), ~~strikethrough~~
317+
*
318+
* Security:
319+
* - HTML is escaped before parsing (XSS prevention)
320+
* - Only http/https/relative URLs allowed in links
321+
* - Generated tags: <strong>, <em>, <del>, <a>
322+
*
323+
* @param string $text Input text with Markdown
324+
*
325+
* @return string HTML string
326+
*/
327+
public static function parseInlineMarkdown(string $text): string
328+
{
329+
if (empty($text)) {
330+
return '';
331+
}
332+
333+
// Step 1: Escape HTML first (security)
334+
$result = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
335+
336+
// Step 2: Links [text](url) - sanitize URLs
337+
$result = preg_replace_callback(
338+
'/\[([^\]]+)\]\(([^)]+)\)/',
339+
function (array $matches): string {
340+
$linkText = $matches[1];
341+
$url = trim($matches[2]);
342+
343+
// Only allow http, https, and relative URLs
344+
if (preg_match('#^(https?://|/|\./|\.\./.)#i', $url)) {
345+
$safeUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
346+
return '<a href="' . $safeUrl . '" target="_blank" rel="noopener noreferrer">' . $linkText . '</a>';
347+
}
348+
349+
// Block dangerous protocols (javascript:, data:, etc.)
350+
return $linkText;
351+
},
352+
$result
353+
);
354+
355+
// Step 3: Bold **text**
356+
$result = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $result);
357+
358+
// Step 4: Italic *text* (not preceded/followed by asterisk)
359+
$result = preg_replace('/(?<!\*)\*([^*]+)\*(?!\*)/', '<em>$1</em>', $result);
360+
361+
// Step 5: Strikethrough ~~text~~
362+
$result = preg_replace('/~~([^~]+)~~/', '<del>$1</del>', $result);
363+
364+
return $result;
365+
}
312366
}

src/backend/Views/Test/table_test_row.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
namespace Lwt\Views\Test;
2222

23+
use Lwt\Core\StringUtils;
2324
use Lwt\Services\ExportService;
2425
use Lwt\View\Helper\StatusHelper;
2526
use Lwt\View\Helper\IconHelper;
@@ -64,7 +65,7 @@
6465
</td>
6566
<td class="td1 center">
6667
<span id="TRAN<?php echo $word['WoID']; ?>">
67-
<?php echo \htmlspecialchars($word['WoTranslation'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
68+
<?php echo StringUtils::parseInlineMarkdown($word['WoTranslation'] ?? ''); ?>
6869
</span>
6970
</td>
7071
<td class="td1 center">

src/backend/Views/Text/read_desktop.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,7 @@ class="content"
170170
.status99 { border-bottom: solid 2px #CCFFCC; } /* Well-known */
171171

172172
/* Hide translations class */
173-
.hide-translations .wsty[data_trans]::after,
174-
.hide-translations .wsty[data_trans]::before,
175-
.hide-translations .mwsty[data_trans]::after,
176-
.hide-translations .mwsty[data_trans]::before {
173+
.hide-translations .word-ann {
177174
display: none !important;
178175
}
179176

src/backend/Views/Text/word_modal.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,21 @@
4848
<!-- Translation/Romanization for known words -->
4949
<template x-if="!isUnknown && word.translation">
5050
<div class="mb-3">
51-
<p class="has-text-grey-dark" x-text="word.translation"></p>
51+
<p class="has-text-grey-dark" x-html="$markdown(word.translation)"></p>
5252
<template x-if="word.romanization">
5353
<p class="is-size-7 has-text-grey" x-text="word.romanization"></p>
5454
</template>
5555
</div>
5656
</template>
5757

58+
<!-- Notes for known words -->
59+
<template x-if="!isUnknown && word.notes">
60+
<div class="mb-3">
61+
<p class="is-size-7 has-text-grey mb-1">Notes:</p>
62+
<p class="has-text-grey-dark is-size-7" x-html="$markdown(word.notes)"></p>
63+
</div>
64+
</template>
65+
5866
<!-- Tags if present -->
5967
<template x-if="!isUnknown && word.tags">
6068
<div class="mb-3">

src/backend/Views/Word/list_alpine.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ class="markcheck"
401401
<template x-if="!isEditing(word.id, 'translation')">
402402
<span class="clickedit"
403403
@click="startEdit(word.id, 'translation')"
404-
x-text="getDisplayValue(word, 'translation')"></span>
404+
x-html="$markdown(getDisplayValue(word, 'translation'))"></span>
405405
</template>
406406
<span x-show="word.tags" class="has-text-grey is-size-7 ml-1" x-text="word.tags"></span>
407407
</td>
@@ -493,7 +493,7 @@ class="markcheck"
493493
</span>
494494
</template>
495495
<template x-if="!isEditing(word.id, 'translation')">
496-
<span class="clickedit" @click="startEdit(word.id, 'translation')" x-text="getDisplayValue(word, 'translation')"></span>
496+
<span class="clickedit" @click="startEdit(word.id, 'translation')" x-html="$markdown(getDisplayValue(word, 'translation'))"></span>
497497
</template>
498498
</p>
499499

src/backend/Views/Word/show.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,25 @@
3131
<tr>
3232
<td class="td1 right">Translation:</td>
3333
<td class="td1 word-show-value"><b><?php
34+
$translationHtml = StringUtils::parseInlineMarkdown($word['translation'] ?? '');
3435
if (!empty($ann)) {
36+
// Highlight annotation in the rendered HTML
3537
echo StringUtils::replaceFirst(
36-
htmlspecialchars($ann ?? '', ENT_QUOTES, 'UTF-8'),
37-
'<span class="word-show-highlight">' . htmlspecialchars($ann ?? '', ENT_QUOTES, 'UTF-8') . '</span>',
38-
htmlspecialchars($word['translation'] ?? '', ENT_QUOTES, 'UTF-8')
38+
htmlspecialchars($ann, ENT_QUOTES, 'UTF-8'),
39+
'<span class="word-show-highlight">' . htmlspecialchars($ann, ENT_QUOTES, 'UTF-8') . '</span>',
40+
$translationHtml
3941
);
4042
} else {
41-
echo htmlspecialchars($word['translation'] ?? '', ENT_QUOTES, 'UTF-8');
43+
echo $translationHtml;
4244
}
4345
?></b></td>
4446
</tr>
47+
<?php if (!empty($word['notes'])) : ?>
48+
<tr>
49+
<td class="td1 right">Notes:</td>
50+
<td class="td1 word-show-value"><?php echo StringUtils::parseInlineMarkdown($word['notes']); ?></td>
51+
</tr>
52+
<?php endif; ?>
4553
<?php if ($tags !== '') : ?>
4654
<tr>
4755
<td class="td1 right">Tags:</td>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Inline Markdown Parser
3+
*
4+
* Parses inline Markdown syntax to HTML.
5+
* Supports: **bold**, *italic*, [links](url), ~~strikethrough~~
6+
*
7+
* Security:
8+
* - HTML is escaped before parsing (XSS prevention)
9+
* - Only http/https/relative URLs allowed in links
10+
* - Generated tags: <strong>, <em>, <del>, <a>
11+
*
12+
* @license Unlicense <http://unlicense.org/>
13+
* @since 3.0.0
14+
*/
15+
16+
/**
17+
* Escape HTML special characters.
18+
*
19+
* @param str - Input string
20+
* @returns String with HTML entities escaped
21+
*/
22+
function escapeHtml(str: string): string {
23+
return str
24+
.replace(/&/g, '&amp;')
25+
.replace(/</g, '&lt;')
26+
.replace(/>/g, '&gt;')
27+
.replace(/"/g, '&quot;')
28+
.replace(/'/g, '&#039;');
29+
}
30+
31+
/**
32+
* Sanitize URL for safe use in href attribute.
33+
*
34+
* Only allows http, https, and relative URLs.
35+
* Blocks javascript:, data:, and other potentially dangerous protocols.
36+
*
37+
* @param url - URL to sanitize
38+
* @returns Safe URL or '#' if blocked
39+
*/
40+
function sanitizeUrl(url: string): string {
41+
const trimmed = url.trim();
42+
43+
// Allow relative URLs
44+
if (
45+
trimmed.startsWith('/') ||
46+
trimmed.startsWith('./') ||
47+
trimmed.startsWith('../')
48+
) {
49+
return trimmed;
50+
}
51+
52+
// Allow http/https
53+
if (/^https?:\/\//i.test(trimmed)) {
54+
return trimmed;
55+
}
56+
57+
// Block everything else (javascript:, data:, etc.)
58+
return '#';
59+
}
60+
61+
/**
62+
* Parse inline Markdown to HTML.
63+
*
64+
* Processing order matters:
65+
* 1. Escape HTML first (security)
66+
* 2. Links [text](url) - most specific pattern
67+
* 3. Bold **text**
68+
* 4. Italic *text* (after bold to avoid conflicts)
69+
* 5. Strikethrough ~~text~~
70+
*
71+
* @param text - Input text with Markdown
72+
* @returns HTML string
73+
*/
74+
export function parseInlineMarkdown(text: string): string {
75+
if (!text) return '';
76+
77+
// Step 1: Escape HTML characters first
78+
let result = escapeHtml(text);
79+
80+
// Step 2: Links [text](url)
81+
result = result.replace(
82+
/\[([^\]]+)\]\(([^)]+)\)/g,
83+
(_, linkText: string, url: string) => {
84+
const safeUrl = sanitizeUrl(url);
85+
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
86+
}
87+
);
88+
89+
// Step 3: Bold **text**
90+
result = result.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
91+
92+
// Step 4: Italic *text* (not preceded/followed by asterisk)
93+
result = result.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
94+
95+
// Step 5: Strikethrough ~~text~~
96+
result = result.replace(/~~([^~]+)~~/g, '<del>$1</del>');
97+
98+
return result;
99+
}
100+
101+
/**
102+
* Check if text contains any inline Markdown syntax.
103+
*
104+
* Useful for optimization - skip parsing if no Markdown present.
105+
*
106+
* @param text - Input text
107+
* @returns True if Markdown syntax detected
108+
*/
109+
export function containsMarkdown(text: string): boolean {
110+
if (!text) return false;
111+
// Check for: **bold**, *italic*, [link](url), ~~strike~~
112+
return /\*\*|(?<!\*)\*(?!\*)|\[.+\]\(.+\)|~~/.test(text);
113+
}

src/frontend/js/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import './core/ui_utilities';
3030
import './core/user_interactions';
3131
import './core/language_settings';
3232
import './core/simple_interactions';
33+
import { parseInlineMarkdown } from './core/inline_markdown';
3334

3435
// API modules (Phase 1 - centralized API client)
3536
import './api/terms';
@@ -172,6 +173,10 @@ declare global {
172173
// Initialize Alpine.js globally
173174
window.Alpine = Alpine;
174175

176+
// Register Alpine.js magic method for inline Markdown parsing
177+
// Usage in templates: x-html="$markdown(text)"
178+
Alpine.magic('markdown', () => (text: string) => parseInlineMarkdown(text));
179+
175180
// Start Alpine.js
176181
Alpine.start();
177182

src/frontend/js/reading/components/text_reader.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,12 @@ export function textReaderData(): TextReaderData {
142142
showAll: this.showAll,
143143
showTranslations: this.showTranslations,
144144
rightToLeft: this.store.rightToLeft,
145-
textSize: this.store.textSize
145+
textSize: this.store.textSize,
146+
// Annotation settings required for Markdown-rendered translations
147+
showLearning: this.store.showLearning,
148+
displayStatTrans: this.store.displayStatTrans,
149+
modeTrans: this.store.modeTrans,
150+
annTextSize: this.store.annTextSize
146151
};
147152
},
148153

0 commit comments

Comments
 (0)