Skip to content

Commit 549dd73

Browse files
accorvinclaudegithub-actions[bot]
authored
feat: render field descriptions as sanitized HTML (#38)
* feat: render field descriptions as sanitized HTML Replace the manual HTML-escaping + mini-markdown parser in FieldHelpText with DOMPurify sanitization so admins can write raw HTML in field descriptions. Only safe tags (a, p, br, strong, em, ul, ol, li, span, code) and attributes (href, target, rel) are allowed. All links are forced to target="_blank" with rel="noopener noreferrer". Adds the field-description-html CSS class and Tailwind styles for links and block-level element spacing (paragraphs, lists). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use dedicated DOMPurify instance to avoid global side effects Create a separate DOMPurify instance for FieldHelpText so the afterSanitizeAttributes hook doesn't affect other DOMPurify consumers (e.g. TeamRosterView). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
1 parent 6e653b1 commit 549dd73

2 files changed

Lines changed: 16 additions & 22 deletions

File tree

modules/team-tracker/client/components/FieldDefinitionManager.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ async function toggleVisibility(field) {
255255
v-model="editHelpText"
256256
rows="2"
257257
class="block w-full rounded border-gray-300 dark:border-gray-600 shadow-sm text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
258-
placeholder="Help text (optional) — supports **bold** and [links](https://...)"
258+
placeholder="Help text (optional) — supports HTML (e.g., <a href=&quot;...&quot;>links</a>, <strong>bold</strong>)"
259259
@keyup.escape="editingFieldId = null"
260260
></textarea>
261261
</div>
@@ -340,7 +340,7 @@ async function toggleVisibility(field) {
340340
v-model="newFieldHelpText"
341341
rows="2"
342342
class="block w-full rounded border-gray-300 shadow-sm text-sm focus:ring-primary-500 focus:border-primary-500"
343-
placeholder="Guidance shown to users when filling in this field. Supports **bold** and [links](https://...)."
343+
placeholder="Guidance shown to users when filling in this field. Supports HTML (e.g., <a href=&quot;...&quot;>links</a>, <strong>bold</strong>)."
344344
></textarea>
345345
</div>
346346
<!-- Allowed values for constrained type -->

modules/team-tracker/client/components/FieldHelpText.vue

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
<script setup>
22
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
3+
import DOMPurify from 'dompurify'
4+
5+
const purify = DOMPurify(window)
6+
purify.addHook('afterSanitizeAttributes', (node) => {
7+
if (node.tagName === 'A') {
8+
node.setAttribute('target', '_blank')
9+
node.setAttribute('rel', 'noopener noreferrer')
10+
}
11+
})
312
413
const props = defineProps({
514
text: { type: String, default: null },
@@ -15,27 +24,12 @@ const sizeClasses = computed(() =>
1524
props.size === 'xs' ? 'h-3 w-3' : 'h-3.5 w-3.5'
1625
)
1726
18-
/**
19-
* Render a minimal inline-markdown subset to HTML:
20-
* **bold** -> <strong>
21-
* [text](url) -> <a>
22-
* Escapes HTML entities first to prevent XSS.
23-
*/
2427
const renderedHtml = computed(() => {
2528
if (!props.text) return ''
26-
let s = props.text
27-
.replace(/&/g, '&amp;')
28-
.replace(/</g, '&lt;')
29-
.replace(/>/g, '&gt;')
30-
.replace(/"/g, '&quot;')
31-
32-
// **bold**
33-
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
34-
// [text](url) — only allow http(s) URLs
35-
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-400 underline">$1</a>')
36-
// Newlines to <br>
37-
s = s.replace(/\n/g, '<br>')
38-
return s
29+
return purify.sanitize(props.text, {
30+
ALLOWED_TAGS: ['a', 'p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'span', 'code'],
31+
ALLOWED_ATTR: ['href', 'target', 'rel']
32+
})
3933
})
4034
4135
function updatePosition() {
@@ -90,7 +84,7 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside, true))
9084
class="fixed z-[100] max-w-xs w-72 p-3 text-sm text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg"
9185
:style="{ top: popoverPos.top + 'px', left: popoverPos.left + 'px' }"
9286
>
93-
<div v-html="renderedHtml" class="leading-relaxed [&_strong]:font-semibold"></div>
87+
<div v-html="renderedHtml" class="field-description-html leading-relaxed [&_strong]:font-semibold [&_a]:text-primary-600 dark:[&_a]:text-primary-400 [&_a]:underline [&_a:hover]:text-primary-700 dark:[&_a:hover]:text-primary-300 [&_p]:mt-2 [&_p:first-child]:mt-0 [&_ul]:mt-2 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:mt-2 [&_ol]:ml-4 [&_ol]:list-decimal [&_li]:mt-1"></div>
9488
</div>
9589
</Teleport>
9690
</span>

0 commit comments

Comments
 (0)