mgv-backoffice 1.3.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -0
- package/dist/src/components/BaseActionButton.vue.d.ts +41 -0
- package/dist/src/components/BasePageHeader.vue.d.ts +31 -0
- package/dist/src/components/BaseToolbarButton.vue.d.ts +37 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/utils/sanitizeHtml.d.ts +31 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +267 -12
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseActionButton.vue +142 -0
- package/src/components/BasePageHeader.vue +97 -0
- package/src/components/BaseToolbarButton.vue +79 -0
- package/src/index.ts +4 -0
- package/src/utils/sanitizeHtml.ts +116 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Compact ghost action button — the colour-coded "Edit / Logs / Stub /
|
|
4
|
+
Delete" actions that sit on a card footer or in an action row. No
|
|
5
|
+
border or fill at rest; a tinted hover background keyed to the
|
|
6
|
+
semantic colour. Pairs an optional leading icon with a label.
|
|
7
|
+
|
|
8
|
+
Slots:
|
|
9
|
+
• icon — leading Heroicon. Receives an `iconClass` slot prop
|
|
10
|
+
('w-4 h-4') so callers can spread it onto their icon.
|
|
11
|
+
|
|
12
|
+
Props:
|
|
13
|
+
• label — button text. Optional (some actions are label-only,
|
|
14
|
+
e.g. "click to create").
|
|
15
|
+
• color — semantic colour token. Determines text + hover tint.
|
|
16
|
+
• disabled — greys via opacity + blocks the click; the hover tint
|
|
17
|
+
is suppressed so a disabled button doesn't light up.
|
|
18
|
+
• fullWidth — stretch to fill its flex row (adds `flex-1`). Used
|
|
19
|
+
on card footers where actions share the width evenly.
|
|
20
|
+
• title / ariaLabel — native tooltip / accessibility text.
|
|
21
|
+
• type — native button type. Defaults to 'button'.
|
|
22
|
+
-->
|
|
23
|
+
<button
|
|
24
|
+
:type="type"
|
|
25
|
+
:disabled="disabled"
|
|
26
|
+
:title="title"
|
|
27
|
+
:aria-label="ariaLabel"
|
|
28
|
+
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
29
|
+
:class="[fullWidth ? 'flex-1' : '', stateClass]"
|
|
30
|
+
@click="$emit('click', $event)"
|
|
31
|
+
>
|
|
32
|
+
<slot name="icon" :icon-class="'w-4 h-4'" />
|
|
33
|
+
<span v-if="label">{{ label }}</span>
|
|
34
|
+
</button>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup lang="ts">
|
|
38
|
+
import { computed } from 'vue'
|
|
39
|
+
import { useTheme } from '../composables/useTheme'
|
|
40
|
+
|
|
41
|
+
type ActionColor =
|
|
42
|
+
| 'emerald'
|
|
43
|
+
| 'sky'
|
|
44
|
+
| 'indigo'
|
|
45
|
+
| 'teal'
|
|
46
|
+
| 'purple'
|
|
47
|
+
| 'red'
|
|
48
|
+
| 'amber'
|
|
49
|
+
| 'amberStrong'
|
|
50
|
+
|
|
51
|
+
const props = withDefaults(
|
|
52
|
+
defineProps<{
|
|
53
|
+
label?: string
|
|
54
|
+
color?: ActionColor
|
|
55
|
+
disabled?: boolean
|
|
56
|
+
fullWidth?: boolean
|
|
57
|
+
title?: string
|
|
58
|
+
ariaLabel?: string
|
|
59
|
+
type?: 'button' | 'submit' | 'reset'
|
|
60
|
+
}>(),
|
|
61
|
+
{
|
|
62
|
+
label: '',
|
|
63
|
+
color: 'emerald',
|
|
64
|
+
disabled: false,
|
|
65
|
+
fullWidth: false,
|
|
66
|
+
title: undefined,
|
|
67
|
+
ariaLabel: undefined,
|
|
68
|
+
type: 'button',
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
defineEmits<{
|
|
73
|
+
click: [event: MouseEvent]
|
|
74
|
+
}>()
|
|
75
|
+
|
|
76
|
+
const { isDark } = useTheme()
|
|
77
|
+
|
|
78
|
+
// Per-colour text + hover-tint classes for each theme. `amberStrong`
|
|
79
|
+
// is the higher-contrast amber used for the "click to create" call to
|
|
80
|
+
// action; `amber` is the softer shade used for Clone-style actions.
|
|
81
|
+
const colorClass = computed(() => {
|
|
82
|
+
const palette: Record<ActionColor, { dark: string; light: string }> = {
|
|
83
|
+
emerald: {
|
|
84
|
+
dark: 'text-emerald-400 hover:bg-emerald-500/10',
|
|
85
|
+
light: 'text-emerald-600 hover:bg-emerald-50',
|
|
86
|
+
},
|
|
87
|
+
sky: {
|
|
88
|
+
dark: 'text-sky-400 hover:bg-sky-500/10',
|
|
89
|
+
light: 'text-sky-600 hover:bg-sky-50',
|
|
90
|
+
},
|
|
91
|
+
indigo: {
|
|
92
|
+
dark: 'text-indigo-400 hover:bg-indigo-500/10',
|
|
93
|
+
light: 'text-indigo-600 hover:bg-indigo-50',
|
|
94
|
+
},
|
|
95
|
+
teal: {
|
|
96
|
+
dark: 'text-teal-400 hover:bg-teal-500/10',
|
|
97
|
+
light: 'text-teal-600 hover:bg-teal-50',
|
|
98
|
+
},
|
|
99
|
+
purple: {
|
|
100
|
+
dark: 'text-purple-400 hover:bg-purple-500/10',
|
|
101
|
+
light: 'text-purple-600 hover:bg-purple-50',
|
|
102
|
+
},
|
|
103
|
+
red: {
|
|
104
|
+
dark: 'text-red-400 hover:bg-red-500/10',
|
|
105
|
+
light: 'text-red-500 hover:bg-red-50',
|
|
106
|
+
},
|
|
107
|
+
amber: {
|
|
108
|
+
dark: 'text-amber-400 hover:bg-amber-500/10',
|
|
109
|
+
light: 'text-amber-600 hover:bg-amber-50',
|
|
110
|
+
},
|
|
111
|
+
amberStrong: {
|
|
112
|
+
dark: 'text-amber-300 hover:bg-amber-500/10',
|
|
113
|
+
light: 'text-amber-700 hover:bg-amber-50',
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
const entry = palette[props.color]
|
|
117
|
+
return isDark.value ? entry.dark : entry.light
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Text colour without the hover tint, for the disabled state. Keeps the
|
|
121
|
+
// semantic colour legible but stops the button reacting to the pointer.
|
|
122
|
+
const disabledColorClass = computed(() => {
|
|
123
|
+
const palette: Record<ActionColor, { dark: string; light: string }> = {
|
|
124
|
+
emerald: { dark: 'text-emerald-400', light: 'text-emerald-600' },
|
|
125
|
+
sky: { dark: 'text-sky-400', light: 'text-sky-600' },
|
|
126
|
+
indigo: { dark: 'text-indigo-400', light: 'text-indigo-600' },
|
|
127
|
+
teal: { dark: 'text-teal-400', light: 'text-teal-600' },
|
|
128
|
+
purple: { dark: 'text-purple-400', light: 'text-purple-600' },
|
|
129
|
+
red: { dark: 'text-red-400', light: 'text-red-500' },
|
|
130
|
+
amber: { dark: 'text-amber-400', light: 'text-amber-600' },
|
|
131
|
+
amberStrong: { dark: 'text-amber-300', light: 'text-amber-700' },
|
|
132
|
+
}
|
|
133
|
+
const entry = palette[props.color]
|
|
134
|
+
return isDark.value ? entry.dark : entry.light
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const stateClass = computed(() =>
|
|
138
|
+
props.disabled
|
|
139
|
+
? `${disabledColorClass.value} opacity-50 cursor-not-allowed`
|
|
140
|
+
: `${colorClass.value} cursor-pointer`,
|
|
141
|
+
)
|
|
142
|
+
</script>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Page-level header. Standardises the "icon badge + title/subtitle on
|
|
4
|
+
the left, action buttons on the right" pattern for top-level views.
|
|
5
|
+
|
|
6
|
+
Slots:
|
|
7
|
+
• icon — the page-specific Heroicon. Receives an `iconClass`
|
|
8
|
+
slot prop carrying the theme-aware text colour, so callers can
|
|
9
|
+
bind `:class="iconClass"` on their icon component.
|
|
10
|
+
• actions — refresh / destructive buttons rendered on the right.
|
|
11
|
+
|
|
12
|
+
Props:
|
|
13
|
+
• title — the H1 text.
|
|
14
|
+
• subtitle — optional muted line below the title.
|
|
15
|
+
• iconColor — badge background + icon text colour
|
|
16
|
+
('emerald' | 'sky' | 'red' | 'amber'). Defaults to 'emerald'.
|
|
17
|
+
• maxWidthClass — Tailwind max-w utility constraining the header
|
|
18
|
+
width so sibling views can share a footprint. Defaults to
|
|
19
|
+
'max-w-4xl'.
|
|
20
|
+
-->
|
|
21
|
+
<div :class="['mx-auto px-4 sm:px-6', maxWidthClass]">
|
|
22
|
+
<div class="flex items-center justify-between mb-8">
|
|
23
|
+
<div class="flex items-center gap-3">
|
|
24
|
+
<div
|
|
25
|
+
class="w-10 h-10 rounded-lg flex items-center justify-center"
|
|
26
|
+
:class="badgeBgClass"
|
|
27
|
+
>
|
|
28
|
+
<slot name="icon" :icon-class="iconTextClass" />
|
|
29
|
+
</div>
|
|
30
|
+
<div>
|
|
31
|
+
<h1 class="text-2xl font-bold" :class="t.primaryTextSoft">
|
|
32
|
+
{{ title }}
|
|
33
|
+
</h1>
|
|
34
|
+
<p v-if="subtitle" class="text-sm" :class="t.dimTextAlt">
|
|
35
|
+
{{ subtitle }}
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="flex items-center gap-2">
|
|
40
|
+
<slot name="actions" />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<script setup lang="ts">
|
|
47
|
+
import { computed } from 'vue'
|
|
48
|
+
import { useTheme } from '../composables/useTheme'
|
|
49
|
+
import { useThemeClasses } from '../composables/useThemeClasses'
|
|
50
|
+
|
|
51
|
+
type IconColor = 'emerald' | 'sky' | 'red' | 'amber'
|
|
52
|
+
|
|
53
|
+
const props = withDefaults(
|
|
54
|
+
defineProps<{
|
|
55
|
+
title: string
|
|
56
|
+
subtitle?: string
|
|
57
|
+
iconColor?: IconColor
|
|
58
|
+
maxWidthClass?: string
|
|
59
|
+
}>(),
|
|
60
|
+
{
|
|
61
|
+
subtitle: '',
|
|
62
|
+
iconColor: 'emerald',
|
|
63
|
+
maxWidthClass: 'max-w-4xl',
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const { isDark } = useTheme()
|
|
68
|
+
const t = useThemeClasses()
|
|
69
|
+
|
|
70
|
+
// Maps the named colour palette to the badge-background classes for
|
|
71
|
+
// dark/light themes. Callers just pass `iconColor="sky"` and the
|
|
72
|
+
// component resolves both modes.
|
|
73
|
+
const badgeBgClass = computed(() => {
|
|
74
|
+
const palette: Record<IconColor, { dark: string; light: string }> = {
|
|
75
|
+
emerald: { dark: 'bg-emerald-500/10', light: 'bg-emerald-50' },
|
|
76
|
+
sky: { dark: 'bg-sky-500/10', light: 'bg-sky-50' },
|
|
77
|
+
red: { dark: 'bg-red-500/10', light: 'bg-red-50' },
|
|
78
|
+
amber: { dark: 'bg-amber-500/10', light: 'bg-amber-50' },
|
|
79
|
+
}
|
|
80
|
+
const entry = palette[props.iconColor]
|
|
81
|
+
return isDark.value ? entry.dark : entry.light
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Icon text colour, routed through the same name → palette mapping so
|
|
85
|
+
// callers don't need to know the exact Tailwind shade per theme. Exposed
|
|
86
|
+
// to the `icon` slot via the `iconClass` slot prop.
|
|
87
|
+
const iconTextClass = computed(() => {
|
|
88
|
+
const palette: Record<IconColor, { dark: string; light: string }> = {
|
|
89
|
+
emerald: { dark: 'text-emerald-400', light: 'text-emerald-600' },
|
|
90
|
+
sky: { dark: 'text-sky-400', light: 'text-sky-600' },
|
|
91
|
+
red: { dark: 'text-red-400', light: 'text-red-500' },
|
|
92
|
+
amber: { dark: 'text-amber-400', light: 'text-amber-600' },
|
|
93
|
+
}
|
|
94
|
+
const entry = palette[props.iconColor]
|
|
95
|
+
return isDark.value ? entry.dark : entry.light
|
|
96
|
+
})
|
|
97
|
+
</script>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Bordered toolbar button — the "Refresh / Delete All" row that sits
|
|
4
|
+
under a page header. Pairs an optional leading icon with a label.
|
|
5
|
+
|
|
6
|
+
Slots:
|
|
7
|
+
• icon — leading Heroicon. Receives an `iconClass` slot prop
|
|
8
|
+
('w-4 h-4') so callers can spread it onto their icon and still
|
|
9
|
+
add their own state classes (e.g. `:class="['animate-spin']"`).
|
|
10
|
+
|
|
11
|
+
Props:
|
|
12
|
+
• label — button text (rendered in a <span>). Optional so an
|
|
13
|
+
icon-only toolbar button is possible.
|
|
14
|
+
• variant — 'neutral' (default grey) or 'danger' (solid red).
|
|
15
|
+
• disabled — greys out + blocks the click.
|
|
16
|
+
• title — native tooltip / accessibility text.
|
|
17
|
+
• type — native button type. Defaults to 'button' so the
|
|
18
|
+
button never accidentally submits a surrounding form.
|
|
19
|
+
-->
|
|
20
|
+
<button
|
|
21
|
+
:type="type"
|
|
22
|
+
:disabled="disabled"
|
|
23
|
+
:title="title"
|
|
24
|
+
class="inline-flex shrink-0 items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
|
25
|
+
:class="variantClass"
|
|
26
|
+
@click="$emit('click', $event)"
|
|
27
|
+
>
|
|
28
|
+
<slot name="icon" :icon-class="'w-4 h-4'" />
|
|
29
|
+
<span v-if="label">{{ label }}</span>
|
|
30
|
+
</button>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<script setup lang="ts">
|
|
34
|
+
import { computed } from 'vue'
|
|
35
|
+
import { useTheme } from '../composables/useTheme'
|
|
36
|
+
|
|
37
|
+
type ToolbarVariant = 'neutral' | 'danger'
|
|
38
|
+
|
|
39
|
+
const props = withDefaults(
|
|
40
|
+
defineProps<{
|
|
41
|
+
label?: string
|
|
42
|
+
variant?: ToolbarVariant
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
title?: string
|
|
45
|
+
type?: 'button' | 'submit' | 'reset'
|
|
46
|
+
}>(),
|
|
47
|
+
{
|
|
48
|
+
label: '',
|
|
49
|
+
variant: 'neutral',
|
|
50
|
+
disabled: false,
|
|
51
|
+
title: undefined,
|
|
52
|
+
type: 'button',
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
defineEmits<{
|
|
57
|
+
click: [event: MouseEvent]
|
|
58
|
+
}>()
|
|
59
|
+
|
|
60
|
+
const { isDark } = useTheme()
|
|
61
|
+
|
|
62
|
+
// Resolves the variant + theme into the colour classes. The danger
|
|
63
|
+
// style is normalised here so every destructive toolbar button reads
|
|
64
|
+
// identically (red-500 hover in dark, red-700 hover in light).
|
|
65
|
+
const variantClass = computed(() => {
|
|
66
|
+
const palette: Record<ToolbarVariant, { dark: string; light: string }> = {
|
|
67
|
+
neutral: {
|
|
68
|
+
dark: 'bg-gray-800 text-gray-100 border-gray-700 hover:bg-gray-700',
|
|
69
|
+
light: 'bg-white text-gray-700 border-gray-200 hover:bg-gray-100',
|
|
70
|
+
},
|
|
71
|
+
danger: {
|
|
72
|
+
dark: 'bg-red-600 text-white border-red-600 hover:bg-red-500',
|
|
73
|
+
light: 'bg-red-600 text-white border-red-600 hover:bg-red-700',
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
const entry = palette[props.variant]
|
|
77
|
+
return isDark.value ? entry.dark : entry.light
|
|
78
|
+
})
|
|
79
|
+
</script>
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,9 @@ export { default as BaseNotFoundPage } from './components/BaseNotFoundPage.vue'
|
|
|
26
26
|
export { default as BaseSidebar } from './components/BaseSidebar.vue'
|
|
27
27
|
export { default as BaseEntityPickerModal } from './components/BaseEntityPickerModal.vue'
|
|
28
28
|
export { default as BaseAppLayout } from './components/BaseAppLayout.vue'
|
|
29
|
+
export { default as BasePageHeader } from './components/BasePageHeader.vue'
|
|
30
|
+
export { default as BaseToolbarButton } from './components/BaseToolbarButton.vue'
|
|
31
|
+
export { default as BaseActionButton } from './components/BaseActionButton.vue'
|
|
29
32
|
|
|
30
33
|
// Composables
|
|
31
34
|
export { useTheme } from './composables/useTheme'
|
|
@@ -62,3 +65,4 @@ export {
|
|
|
62
65
|
statusBadgeSolid,
|
|
63
66
|
statusBadgeTinted,
|
|
64
67
|
} from './utils/httpColors'
|
|
68
|
+
export { sanitizeHtml, isSafeHref } from './utils/sanitizeHtml'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allow-list HTML sanitizer for strings bound into `v-html`.
|
|
3
|
+
*
|
|
4
|
+
* Use whenever rich HTML from any source (backend payloads, user input,
|
|
5
|
+
* imported content) is rendered via `v-html`. Even "trusted" sources
|
|
6
|
+
* should be sanitised belt-and-suspenders so a future change can't
|
|
7
|
+
* smuggle script tags, inline event handlers, or `javascript:` hrefs
|
|
8
|
+
* into the page.
|
|
9
|
+
*
|
|
10
|
+
* Implementation:
|
|
11
|
+
* 1. Parse with `DOMParser` into an off-document tree.
|
|
12
|
+
* 2. Walk the tree once, replacing any disallowed element with its
|
|
13
|
+
* `textContent` (the readable text survives, the wrapper is gone).
|
|
14
|
+
* 3. On every element, strip every attribute except the per-tag
|
|
15
|
+
* allow-list below.
|
|
16
|
+
* 4. Re-validate `a[href]` against an explicit scheme whitelist —
|
|
17
|
+
* `javascript:`, `data:`, `vbscript:`, and `file:` are rejected.
|
|
18
|
+
* 5. Force `rel="noopener noreferrer" target="_blank"` on every
|
|
19
|
+
* surviving anchor so external links can't reach back through
|
|
20
|
+
* `window.opener`.
|
|
21
|
+
*
|
|
22
|
+
* Returns the cleaned HTML string, ready for `v-html`. Browser-only —
|
|
23
|
+
* relies on `DOMParser`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ALLOWED_TAGS: ReadonlySet<string> = new Set([
|
|
27
|
+
'A', 'B', 'STRONG', 'I', 'EM', 'CODE', 'PRE', 'BR',
|
|
28
|
+
'P', 'UL', 'OL', 'LI', 'SPAN', 'DIV',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tags whose contents are also dropped (not unwrapped as text). For
|
|
33
|
+
* most disallowed elements we keep the inner text so the user-visible
|
|
34
|
+
* copy survives even when the wrapper is stripped, but for
|
|
35
|
+
* code-bearing elements like `<script>` and `<style>` the inner text
|
|
36
|
+
* is the payload itself — leaving it as a visible text node would
|
|
37
|
+
* splat raw JS / CSS source into the rendered output. Drop the
|
|
38
|
+
* element entirely instead.
|
|
39
|
+
*/
|
|
40
|
+
const STRIP_WITH_CONTENT: ReadonlySet<string> = new Set([
|
|
41
|
+
'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'NOSCRIPT', 'TEMPLATE',
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const ALLOWED_ATTRS_BY_TAG: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
45
|
+
A: new Set(['href', 'title']),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns true when the supplied href is safe to render as-is. Blocks
|
|
50
|
+
* every scheme that has historically been a vector for XSS or local-
|
|
51
|
+
* file leakage.
|
|
52
|
+
*/
|
|
53
|
+
export function isSafeHref(value: string): boolean {
|
|
54
|
+
const trimmed = value.trim().toLowerCase()
|
|
55
|
+
return (
|
|
56
|
+
trimmed.startsWith('http://')
|
|
57
|
+
|| trimmed.startsWith('https://')
|
|
58
|
+
|| trimmed.startsWith('mailto:')
|
|
59
|
+
|| trimmed.startsWith('tel:')
|
|
60
|
+
|| trimmed.startsWith('/')
|
|
61
|
+
|| trimmed.startsWith('#')
|
|
62
|
+
|| trimmed === ''
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitizeNode(node: Node): void {
|
|
67
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
68
|
+
const el = node as Element
|
|
69
|
+
const tag = el.tagName.toUpperCase()
|
|
70
|
+
// Disallowed element: replace with its textContent so the text the
|
|
71
|
+
// user is meant to read survives, but the wrapper is gone. For
|
|
72
|
+
// code-bearing elements (`<script>`, `<style>`, etc.) we drop the
|
|
73
|
+
// contents too so the JS/CSS source itself doesn't render as
|
|
74
|
+
// visible text.
|
|
75
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
76
|
+
if (STRIP_WITH_CONTENT.has(tag)) {
|
|
77
|
+
el.remove()
|
|
78
|
+
} else {
|
|
79
|
+
el.replaceWith(document.createTextNode(el.textContent ?? ''))
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
// Strip every attribute that isn't in the per-tag allow-list, and
|
|
84
|
+
// re-validate `a[href]` against `isSafeHref`.
|
|
85
|
+
const allowedAttrs = ALLOWED_ATTRS_BY_TAG[tag] ?? new Set<string>()
|
|
86
|
+
for (const attr of Array.from(el.attributes)) {
|
|
87
|
+
if (!allowedAttrs.has(attr.name.toLowerCase())) {
|
|
88
|
+
el.removeAttribute(attr.name)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (tag === 'A' && attr.name.toLowerCase() === 'href' && !isSafeHref(attr.value)) {
|
|
92
|
+
el.removeAttribute(attr.name)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// External-link hardening: every surviving anchor opens safely.
|
|
96
|
+
if (tag === 'A' && el.hasAttribute('href')) {
|
|
97
|
+
el.setAttribute('rel', 'noopener noreferrer')
|
|
98
|
+
el.setAttribute('target', '_blank')
|
|
99
|
+
}
|
|
100
|
+
// Recurse over children — copy the live list first so removals don't
|
|
101
|
+
// skip siblings.
|
|
102
|
+
for (const child of Array.from(el.childNodes)) {
|
|
103
|
+
sanitizeNode(child)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function sanitizeHtml(raw: string | undefined | null): string {
|
|
108
|
+
if (!raw) return ''
|
|
109
|
+
const doc = new DOMParser().parseFromString(`<div>${raw}</div>`, 'text/html')
|
|
110
|
+
const root = doc.body.firstElementChild
|
|
111
|
+
if (!root) return ''
|
|
112
|
+
for (const child of Array.from(root.childNodes)) {
|
|
113
|
+
sanitizeNode(child)
|
|
114
|
+
}
|
|
115
|
+
return root.innerHTML
|
|
116
|
+
}
|