@svgrid/grid 2.6.22 → 2.6.23

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.
Files changed (115) hide show
  1. package/dist/GridMenus.svelte +184 -6
  2. package/dist/SvAutoComplete.svelte +4 -0
  3. package/dist/SvComboBox.svelte +5 -1
  4. package/dist/SvDateRangeInput.svelte +25 -3
  5. package/dist/SvDropDownList.svelte +4 -0
  6. package/dist/SvForm.svelte +8 -8
  7. package/dist/SvGrid.controller.svelte.d.ts +48 -1
  8. package/dist/SvGrid.controller.svelte.js +214 -52
  9. package/dist/SvGrid.css +221 -0
  10. package/dist/SvGrid.helpers.d.ts +55 -0
  11. package/dist/SvGrid.helpers.js +83 -0
  12. package/dist/SvGrid.svelte +145 -0
  13. package/dist/SvGrid.types.d.ts +105 -0
  14. package/dist/SvGridCellEditor.svelte +99 -17
  15. package/dist/SvGridSelect.svelte +6 -2
  16. package/dist/SvGridSelect.svelte.d.ts +1 -1
  17. package/dist/SvListBox.svelte +8 -0
  18. package/dist/SvMultiSelect.svelte +6 -2
  19. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  20. package/dist/SvNumberInput.svelte +4 -0
  21. package/dist/SvRichCell.svelte +125 -0
  22. package/dist/SvRichCell.svelte.d.ts +15 -0
  23. package/dist/SvTextArea.svelte +5 -1
  24. package/dist/SvTreeSelect.svelte +6 -2
  25. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  26. package/dist/builtin-editors.d.ts +2 -2
  27. package/dist/builtin-editors.js +19 -3
  28. package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
  29. package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
  30. package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
  31. package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
  32. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
  33. package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
  34. package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
  35. package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
  36. package/dist/cdn/date-format-BNii4zeD.js +1393 -0
  37. package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
  38. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  39. package/dist/cdn/{src-D1lXwq1l.js → src-DGo7IHug.js} +5730 -5198
  40. package/dist/cdn/{src-C9Hihx1W.js → src-skQN5eqh.js} +7216 -6684
  41. package/dist/cdn/svgrid.js +14 -12
  42. package/dist/cdn/svgrid.svelte-external.js +14 -12
  43. package/dist/cell-values.d.ts +16 -0
  44. package/dist/cell-values.js +28 -0
  45. package/dist/clipboard.d.ts +22 -0
  46. package/dist/clipboard.js +405 -27
  47. package/dist/editing.js +12 -7
  48. package/dist/editors/cell-editors.d.ts +11 -0
  49. package/dist/editors/cell-editors.js +18 -1
  50. package/dist/grid-messages.d.ts +15 -0
  51. package/dist/grid-messages.js +15 -0
  52. package/dist/index.d.ts +4 -0
  53. package/dist/index.js +4 -0
  54. package/dist/list-option.d.ts +6 -0
  55. package/dist/menus.js +40 -7
  56. package/dist/sanitize-html.d.ts +37 -0
  57. package/dist/sanitize-html.js +234 -0
  58. package/dist/selection-bar-view.svelte.d.ts +25 -0
  59. package/dist/selection-bar-view.svelte.js +13 -0
  60. package/dist/selection.d.ts +2 -1
  61. package/dist/selection.js +129 -13
  62. package/package.json +1 -1
  63. package/src/GridMenus.svelte +184 -6
  64. package/src/SvAutoComplete.svelte +4 -0
  65. package/src/SvComboBox.svelte +5 -1
  66. package/src/SvDateRangeInput.svelte +25 -3
  67. package/src/SvDropDownList.svelte +4 -0
  68. package/src/SvForm.svelte +8 -8
  69. package/src/SvGrid.controller.svelte.ts +251 -61
  70. package/src/SvGrid.css +221 -0
  71. package/src/SvGrid.helpers.ts +87 -0
  72. package/src/SvGrid.svelte +145 -0
  73. package/src/SvGrid.types.ts +112 -0
  74. package/src/SvGridCellEditor.svelte +99 -17
  75. package/src/SvGridSelect.svelte +6 -2
  76. package/src/SvListBox.svelte +8 -0
  77. package/src/SvMultiSelect.svelte +6 -2
  78. package/src/SvNumberInput.svelte +4 -0
  79. package/src/SvRichCell.svelte +125 -0
  80. package/src/SvTextArea.svelte +5 -1
  81. package/src/SvTreeSelect.svelte +6 -2
  82. package/src/builtin-editors.test.ts +18 -1
  83. package/src/builtin-editors.ts +19 -3
  84. package/src/cell-values.ts +30 -0
  85. package/src/clipboard.test.ts +93 -20
  86. package/src/clipboard.ts +433 -33
  87. package/src/date-string-column.test.ts +87 -0
  88. package/src/editing.test.ts +25 -0
  89. package/src/editing.ts +12 -9
  90. package/src/editor-block-prop.test.ts +132 -0
  91. package/src/editors/cell-editors.ts +27 -1
  92. package/src/grid-messages.ts +34 -0
  93. package/src/index.ts +13 -0
  94. package/src/list-option.ts +6 -0
  95. package/src/menus.test.ts +43 -0
  96. package/src/menus.ts +37 -7
  97. package/src/move-cells.test.ts +850 -0
  98. package/src/number-editor-literal.test.ts +84 -0
  99. package/src/number-editor.grid.test.ts +160 -0
  100. package/src/sanitize-html.test.ts +182 -0
  101. package/src/sanitize-html.ts +235 -0
  102. package/src/selection-bar-view.svelte.ts +36 -0
  103. package/src/selection.test.ts +157 -0
  104. package/src/selection.ts +131 -13
  105. package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
  106. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  107. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  108. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  109. package/src/svgrid.upsell-license.test.ts +117 -0
  110. package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
  111. package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
  112. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  113. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  114. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  115. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Typing a decimal into an `editorType: 'number'` cell.
3
+ *
4
+ * `<input type="number">` applies the HTML value-sanitization algorithm: the
5
+ * element only reports a value it can parse as a valid floating-point number.
6
+ * Every intermediate state on the way to "12.5" - "12." - is not one, so the
7
+ * element reports "" and the keystroke vanishes. The same applies to a lone
8
+ * "-" on the way to a negative, and to "1e" on the way to "1e3".
9
+ *
10
+ * jsdom implements that sanitization, so this is reproducible here rather than
11
+ * only in a browser.
12
+ */
13
+ import { describe, expect, it } from 'vitest'
14
+ import {
15
+ getCellEditorInputType,
16
+ getEditorInputType,
17
+ isNumericEditorInput,
18
+ } from './SvGrid.helpers'
19
+
20
+ describe('what a bare number input does to intermediate values', () => {
21
+ // This is the browser behaviour the editor has to work around, pinned so the
22
+ // reasoning below cannot quietly stop being true.
23
+ const sanitized = (raw: string) => {
24
+ const el = document.createElement('input')
25
+ el.type = 'number'
26
+ el.value = raw
27
+ return el.value
28
+ }
29
+
30
+ it('throws away a trailing decimal point', () => {
31
+ expect(sanitized('12.')).toBe('')
32
+ })
33
+
34
+ it('throws away a lone minus sign', () => {
35
+ expect(sanitized('-')).toBe('')
36
+ })
37
+
38
+ it('throws away a half-typed exponent', () => {
39
+ expect(sanitized('1e')).toBe('')
40
+ })
41
+
42
+ it('keeps values that are already valid', () => {
43
+ expect(sanitized('12.5')).toBe('12.5')
44
+ expect(sanitized('-3')).toBe('-3')
45
+ expect(sanitized('1e3')).toBe('1e3')
46
+ })
47
+ })
48
+
49
+ describe('getCellEditorInputType', () => {
50
+ it('does NOT hand the number editor a type=number input', () => {
51
+ // The whole point: a text input keeps the raw keystrokes, and the value is
52
+ // coerced at commit by parseEditorValue - which the editor already did.
53
+ expect(getCellEditorInputType('number')).toBe('text')
54
+ })
55
+
56
+ it('leaves every other editor on its native input type', () => {
57
+ for (const t of ['text', 'date', 'datetime', 'time', 'password', 'color'] as const) {
58
+ expect(getCellEditorInputType(t), t).toBe(getEditorInputType(t))
59
+ }
60
+ })
61
+
62
+ it('does not change the FILTER inputs, which share the other helper', () => {
63
+ // The filter row and filter menu still ask getEditorInputType. They have
64
+ // the same defect, but changing them is a separate decision - this pins
65
+ // that they were not swept along by accident.
66
+ expect(getEditorInputType('number')).toBe('number')
67
+ })
68
+ })
69
+
70
+ describe('isNumericEditorInput', () => {
71
+ it('accepts every state on the way to a number', () => {
72
+ // Rejecting these is the exact bug this replaces.
73
+ for (const v of ['', '-', '12', '12.', '12.5', '-3', '.5', '1e', '1e3', '1e-3']) {
74
+ expect(isNumericEditorInput(v), JSON.stringify(v)).toBe(true)
75
+ }
76
+ })
77
+
78
+ it('rejects what can never become a number', () => {
79
+ // The guarantee type="number" used to provide, now that it is gone.
80
+ for (const v of ['abc', '12x', '12..5', '--3', '1 2', '12,5']) {
81
+ expect(isNumericEditorInput(v), JSON.stringify(v)).toBe(false)
82
+ }
83
+ })
84
+ })
@@ -0,0 +1,160 @@
1
+ /**
2
+ * End-to-end: typing a decimal into an `editorType: 'number'` cell keeps every
3
+ * keystroke, and the value still commits as a NUMBER.
4
+ *
5
+ * The unit half of this lives in `number-editor-literal.test.ts` - the browser
6
+ * sanitization that caused it, and the helpers that route around it. This
7
+ * drives the real editor, which is what actually regressed for users: typing
8
+ * "12.5" produced 125, because the "." was dropped and the digits ran together.
9
+ */
10
+ import { describe, expect, it, vi } from 'vitest'
11
+ import { mount, unmount } from 'svelte'
12
+ import SvGrid from './SvGrid.svelte'
13
+ import { createCoreRowModel, tableFeatures } from './index'
14
+ import type { ColumnDef, SvGridApi } from './index'
15
+
16
+ type Row = { id: number; label: string; amount: number }
17
+ const features = tableFeatures({})
18
+ const cols: ColumnDef<typeof features, Row>[] = [
19
+ { field: 'label', header: 'Label', width: 160 },
20
+ { field: 'amount', header: 'Amount', width: 140, editorType: 'number' },
21
+ ]
22
+
23
+ function mountGrid() {
24
+ return new Promise<{
25
+ api: SvGridApi<typeof features, Row>
26
+ target: HTMLElement
27
+ changes: Array<{ newValue: unknown }>
28
+ destroy: () => void
29
+ }>((res, rej) => {
30
+ const target = document.createElement('div')
31
+ document.body.appendChild(target)
32
+ const changes: Array<{ newValue: unknown }> = []
33
+ const app = mount(SvGrid, {
34
+ target,
35
+ props: {
36
+ data: [{ id: 1, label: 'One', amount: 5 }],
37
+ columns: cols,
38
+ features,
39
+ _rowModels: { coreRowModel: createCoreRowModel() },
40
+ getRowId: (r: Row) => String(r.id),
41
+ containerHeight: 200,
42
+ virtualization: false,
43
+ editable: true,
44
+ onCellValueChange: (e: { newValue: unknown }) => changes.push(e),
45
+ onApiReady(api: SvGridApi<typeof features, Row>) {
46
+ res({ api, target, changes, destroy: () => { unmount(app); target.remove() } })
47
+ },
48
+ } as any,
49
+ })
50
+ queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
51
+ })
52
+ }
53
+
54
+ /** Start editing the amount cell and return its input, once the lazy chunk lands. */
55
+ async function editAmount(api: SvGridApi<typeof features, Row>, target: HTMLElement) {
56
+ api.startEditing(0, 'amount')
57
+ await vi.waitFor(() => {
58
+ expect(target.querySelector('.sv-grid-cell-editing input')).not.toBeNull()
59
+ })
60
+ return target.querySelector('.sv-grid-cell-editing input') as HTMLInputElement
61
+ }
62
+
63
+ /** Type `text` one character at a time, as a user does. */
64
+ function typeInto(input: HTMLInputElement, text: string) {
65
+ let sofar = ''
66
+ for (const ch of text) {
67
+ sofar += ch
68
+ input.value = sofar
69
+ input.dispatchEvent(new Event('input', { bubbles: true }))
70
+ // Read back what survived - the element may have rewritten it.
71
+ sofar = input.value
72
+ }
73
+ return input.value
74
+ }
75
+
76
+ describe('number editor - decimals survive being typed', () => {
77
+ it('keeps the trailing decimal point mid-entry', async () => {
78
+ const { api, target, destroy } = await mountGrid()
79
+ try {
80
+ const input = await editAmount(api, target)
81
+ // The bug: with type="number" this read back "" and the dot was gone.
82
+ expect(typeInto(input, '12.')).toBe('12.')
83
+ } finally {
84
+ destroy()
85
+ }
86
+ })
87
+
88
+ it('commits 12.5 as the number 12.5, not 125', async () => {
89
+ const { api, target, changes, destroy } = await mountGrid()
90
+ try {
91
+ const input = await editAmount(api, target)
92
+ typeInto(input, '12.5')
93
+ input.dispatchEvent(new Event('blur', { bubbles: true }))
94
+
95
+ await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0))
96
+ expect(changes.at(-1)!.newValue).toBe(12.5)
97
+ expect(typeof changes.at(-1)!.newValue).toBe('number')
98
+ } finally {
99
+ destroy()
100
+ }
101
+ })
102
+
103
+ it('keeps a lone minus sign on the way to a negative', async () => {
104
+ const { api, target, changes, destroy } = await mountGrid()
105
+ try {
106
+ const input = await editAmount(api, target)
107
+ expect(typeInto(input, '-')).toBe('-')
108
+ typeInto(input, '-4.25')
109
+ input.dispatchEvent(new Event('blur', { bubbles: true }))
110
+
111
+ await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0))
112
+ expect(changes.at(-1)!.newValue).toBe(-4.25)
113
+ } finally {
114
+ destroy()
115
+ }
116
+ })
117
+
118
+ it('carries the mobile decimal keypad hint', async () => {
119
+ // Losing type="number" also loses the numeric soft keyboard, which is what
120
+ // inputmode restores.
121
+ const { api, target, destroy } = await mountGrid()
122
+ try {
123
+ const input = await editAmount(api, target)
124
+ expect(input.getAttribute('type')).toBe('text')
125
+ expect(input.getAttribute('inputmode')).toBe('decimal')
126
+ } finally {
127
+ destroy()
128
+ }
129
+ })
130
+
131
+ it('refuses characters that can never be part of a number', async () => {
132
+ // type="number" used to block these; a text input does not, so the editor
133
+ // has to. Without the guard "12x" would reach the commit and coerce to null.
134
+ const { api, target, destroy } = await mountGrid()
135
+ try {
136
+ const input = await editAmount(api, target)
137
+ typeInto(input, '12')
138
+ input.value = '12x'
139
+ input.dispatchEvent(new Event('input', { bubbles: true }))
140
+ expect(input.value).toBe('12')
141
+ } finally {
142
+ destroy()
143
+ }
144
+ })
145
+
146
+ it('leaves a text column alone', async () => {
147
+ const { api, target, destroy } = await mountGrid()
148
+ try {
149
+ api.startEditing(0, 'label')
150
+ await vi.waitFor(() => {
151
+ expect(target.querySelector('.sv-grid-cell-editing input')).not.toBeNull()
152
+ })
153
+ const input = target.querySelector('.sv-grid-cell-editing input') as HTMLInputElement
154
+ expect(input.getAttribute('inputmode')).toBeNull()
155
+ expect(typeInto(input, 'hello.')).toBe('hello.')
156
+ } finally {
157
+ destroy()
158
+ }
159
+ })
160
+ })
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { sanitizeHtml, renderMarkdown, htmlToText } from './sanitize-html'
3
+
4
+ /**
5
+ * The sanitizer is the one part of the rich-text cell that has to be right:
6
+ * everything else costs a rendering glitch, this costs script execution in the
7
+ * host page. The XSS cases below are the standard vectors a cell renderer gets
8
+ * hit with, so a regression here fails loudly.
9
+ */
10
+ describe('sanitizeHtml - script execution vectors', () => {
11
+ it('drops script tags and their contents', () => {
12
+ const out = sanitizeHtml('<p>hi</p><script>alert(1)</script>')
13
+ expect(out).toContain('hi')
14
+ expect(out).not.toContain('script')
15
+ expect(out).not.toContain('alert')
16
+ })
17
+
18
+ it('strips every inline event handler', () => {
19
+ for (const attr of ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']) {
20
+ const out = sanitizeHtml(`<p ${attr}="alert(1)">x</p>`)
21
+ expect(out, attr).not.toContain(attr)
22
+ expect(out, attr).not.toContain('alert')
23
+ }
24
+ })
25
+
26
+ it('strips a javascript: href, including obfuscated forms', () => {
27
+ const vectors = [
28
+ 'javascript:alert(1)',
29
+ 'JaVaScRiPt:alert(1)',
30
+ ' javascript:alert(1)',
31
+ 'java\tscript:alert(1)',
32
+ 'java\nscript:alert(1)',
33
+ 'vbscript:msgbox(1)',
34
+ ]
35
+ for (const href of vectors) {
36
+ const out = sanitizeHtml(`<a href="${href}">x</a>`)
37
+ expect(out.toLowerCase(), href).not.toContain('javascript:')
38
+ expect(out.toLowerCase(), href).not.toContain('vbscript:')
39
+ expect(out, href).not.toContain('href')
40
+ }
41
+ })
42
+
43
+ it('drops an img with an onerror payload but keeps a safe one', () => {
44
+ expect(sanitizeHtml('<img src=x onerror="alert(1)">')).not.toContain('onerror')
45
+ const safe = sanitizeHtml('<img src="/logo.png" alt="Logo">')
46
+ expect(safe).toContain('src="/logo.png"')
47
+ expect(safe).toContain('alt="Logo"')
48
+ })
49
+
50
+ it('allows a base64 image data URL but not data:text/html', () => {
51
+ const png = 'data:image/png;base64,iVBORw0KGgo='
52
+ expect(sanitizeHtml(`<img src="${png}">`)).toContain('src=')
53
+ expect(sanitizeHtml('<img src="data:text/html;base64,PHNjcmlwdD4=">')).not.toContain('src=')
54
+ })
55
+
56
+ it('removes style attributes and style/iframe subtrees', () => {
57
+ expect(sanitizeHtml('<p style="position:fixed;top:0">x</p>')).not.toContain('style')
58
+ expect(sanitizeHtml('<style>body{display:none}</style><p>x</p>')).not.toContain('display')
59
+ expect(sanitizeHtml('<iframe src="https://evil.test"></iframe>')).not.toContain('iframe')
60
+ })
61
+
62
+ it('unwraps unknown tags but keeps their text', () => {
63
+ const out = sanitizeHtml('<marquee>keep me</marquee>')
64
+ expect(out).toContain('keep me')
65
+ expect(out).not.toContain('marquee')
66
+ })
67
+
68
+ it('adds rel=noopener to links that open a new tab', () => {
69
+ const out = sanitizeHtml('<a href="https://x.test" target="_blank">x</a>')
70
+ expect(out).toContain('rel="noopener noreferrer"')
71
+ })
72
+ })
73
+
74
+ describe('sanitizeHtml - preserving legitimate content', () => {
75
+ it('keeps the formatting tags SvRichText produces', () => {
76
+ const html = '<p><strong>b</strong> <em>i</em> <u>u</u> <s>s</s> <code>c</code></p><ul><li>one</li></ul>'
77
+ expect(sanitizeHtml(html)).toBe(html)
78
+ })
79
+
80
+ it('is idempotent', () => {
81
+ const once = sanitizeHtml('<p onclick="x()">hi <b>there</b></p>')
82
+ expect(sanitizeHtml(once)).toBe(once)
83
+ })
84
+
85
+ it('passes plain text through untouched and handles empty input', () => {
86
+ expect(sanitizeHtml('just text')).toBe('just text')
87
+ expect(sanitizeHtml('')).toBe('')
88
+ expect(sanitizeHtml(null)).toBe('')
89
+ expect(sanitizeHtml(undefined)).toBe('')
90
+ })
91
+ })
92
+
93
+ describe('renderMarkdown', () => {
94
+ it('renders headings, emphasis, code and lists', () => {
95
+ expect(renderMarkdown('# Title')).toBe('<h1>Title</h1>')
96
+ expect(renderMarkdown('**bold**')).toContain('<strong>bold</strong>')
97
+ expect(renderMarkdown('*it*')).toContain('<em>it</em>')
98
+ expect(renderMarkdown('~~gone~~')).toContain('<del>gone</del>')
99
+ expect(renderMarkdown('`code`')).toContain('<code>code</code>')
100
+ expect(renderMarkdown('- a\n- b')).toBe('<ul><li>a</li><li>b</li></ul>')
101
+ expect(renderMarkdown('1. a\n2. b')).toBe('<ol><li>a</li><li>b</li></ol>')
102
+ })
103
+
104
+ it('does not let emphasis leak into inline code', () => {
105
+ expect(renderMarkdown('`**not bold**`')).toContain('<code>**not bold**</code>')
106
+ })
107
+
108
+ it('escapes raw HTML in the source instead of passing it through', () => {
109
+ const out = renderMarkdown('<script>alert(1)</script>')
110
+ expect(out).not.toContain('<script>')
111
+ expect(out).toContain('&lt;script&gt;')
112
+ })
113
+
114
+ it('will not smuggle javascript: through a Markdown link', () => {
115
+ const out = renderMarkdown('[click](javascript:alert(1))')
116
+ expect(out).toContain('click')
117
+ expect(out).not.toContain('href')
118
+ })
119
+
120
+ it('handles empty input', () => {
121
+ expect(renderMarkdown('')).toBe('')
122
+ expect(renderMarkdown(null)).toBe('')
123
+ })
124
+ })
125
+
126
+ describe('htmlToText', () => {
127
+ it('flattens markup to a single readable line', () => {
128
+ expect(htmlToText('<p>one</p><p>two</p>')).toBe('one two')
129
+ expect(htmlToText('<ul><li>a</li><li>b</li></ul>')).toBe('a b')
130
+ expect(htmlToText('a<br>b')).toBe('a b')
131
+ })
132
+
133
+ it('decodes entities and drops script content', () => {
134
+ expect(htmlToText('<p>a &amp; b</p>')).toBe('a & b')
135
+ expect(htmlToText('<script>alert(1)</script><p>safe</p>')).toBe('safe')
136
+ })
137
+
138
+ it('handles empty input', () => {
139
+ expect(htmlToText('')).toBe('')
140
+ expect(htmlToText(null)).toBe('')
141
+ })
142
+ })
143
+
144
+ describe('sanitizeHtml - SSR path (no DOMParser)', () => {
145
+ // During SSR there is no DOM, so a second, regex-based implementation runs.
146
+ // It is stricter than the DOM path, and it must not be the weak link: the
147
+ // server-rendered HTML reaches the browser before hydration re-sanitizes.
148
+ const withoutDom = (html: string): string => {
149
+ const real = globalThis.DOMParser
150
+ // @ts-expect-error - deleting a global for the duration of the assertion
151
+ delete globalThis.DOMParser
152
+ try {
153
+ return sanitizeHtml(html)
154
+ } finally {
155
+ globalThis.DOMParser = real
156
+ }
157
+ }
158
+
159
+ it('drops scripts, styles and iframes with their contents', () => {
160
+ expect(withoutDom('<p>hi</p><script>alert(1)</script>')).toBe('<p>hi</p>')
161
+ expect(withoutDom('<style>body{x:y}</style><p>hi</p>')).toBe('<p>hi</p>')
162
+ expect(withoutDom('<iframe src="https://evil.test"></iframe>ok')).toBe('ok')
163
+ })
164
+
165
+ it('strips every attribute, so no handler or URL survives', () => {
166
+ expect(withoutDom('<p onclick="alert(1)">x</p>')).toBe('<p>x</p>')
167
+ expect(withoutDom('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>')
168
+ // The tag itself may stay, but with no src and no handler it is inert.
169
+ const img = withoutDom('<img src=x onerror="alert(1)">')
170
+ expect(img).toBe('<img>')
171
+ expect(img).not.toContain('onerror')
172
+ expect(img).not.toContain('src')
173
+ })
174
+
175
+ it('keeps plain allowlisted formatting', () => {
176
+ expect(withoutDom('<p><strong>b</strong> <em>i</em></p>')).toBe('<p><strong>b</strong> <em>i</em></p>')
177
+ })
178
+
179
+ it('removes unknown tags entirely', () => {
180
+ expect(withoutDom('<marquee>text</marquee>')).toBe('text')
181
+ })
182
+ })
@@ -0,0 +1,235 @@
1
+ /**
2
+ * sanitize-html - a dependency-free HTML sanitizer and a small Markdown
3
+ * renderer, sized for what a grid cell needs to display.
4
+ *
5
+ * A rich-text cell has to paint markup that came from a user, which is the one
6
+ * place a data grid can hand an attacker script execution. Everything rendered
7
+ * through `SvRichCell` goes through `sanitizeHtml` first, and the allowlist is
8
+ * deliberately narrower than a general-purpose sanitizer: it covers the tags
9
+ * `SvRichText` can produce, plus links and images.
10
+ *
11
+ * The parse uses the platform parser where there is one (`DOMParser`, which
12
+ * neither executes scripts nor runs loaders on a detached document) and a
13
+ * conservative regex stripper during SSR, where there is no DOM. The SSR path
14
+ * is stricter by design: when in doubt it drops markup rather than passing it
15
+ * through, and the hydrated client re-sanitizes with the real parser anyway.
16
+ *
17
+ * This is not a general-purpose sanitizer for arbitrary third-party HTML. It is
18
+ * a narrow allowlist for cell content. To render something outside the
19
+ * allowlist, extend it explicitly rather than widening the defaults.
20
+ */
21
+
22
+ /** Tags that survive sanitization. Everything else is unwrapped or dropped. */
23
+ const ALLOWED_TAGS = new Set([
24
+ 'a', 'b', 'blockquote', 'br', 'code', 'del', 'div', 'em', 'h1', 'h2', 'h3',
25
+ 'h4', 'h5', 'h6', 'i', 'img', 'li', 'mark', 'ol', 'p', 'pre', 's', 'small',
26
+ 'span', 'strong', 'sub', 'sup', 'u', 'ul',
27
+ ])
28
+
29
+ /** Tags whose whole subtree is removed rather than unwrapped. What sits inside
30
+ * a <script> or <style> is code, so keeping its text would be worse than
31
+ * losing it. */
32
+ const DROP_SUBTREE = new Set([
33
+ 'script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template',
34
+ 'svg', 'math', 'form', 'input', 'button', 'textarea', 'select', 'link',
35
+ 'meta', 'base', 'title', 'head',
36
+ ])
37
+
38
+ /** Attributes allowed per tag. `*` applies to every allowed tag. A `style`
39
+ * attribute is never allowed: it is the classic sanitizer hole (url(...)
40
+ * payloads, position tricks that let a cell cover the page chrome), and cell
41
+ * styling belongs to the column rather than to the data. */
42
+ const ALLOWED_ATTRS: Record<string, Set<string>> = {
43
+ '*': new Set(['class', 'dir', 'lang', 'title']),
44
+ a: new Set(['href', 'target', 'rel']),
45
+ img: new Set(['src', 'alt', 'width', 'height']),
46
+ ol: new Set(['start']),
47
+ }
48
+
49
+ /** URL schemes a href/src may use. Anything else, `javascript:` and
50
+ * `vbscript:` included, is dropped. `data:` is allowed only on an image src
51
+ * and only for image media types, because `data:text/html` is a script
52
+ * vector. */
53
+ const SAFE_URL = /^(?:https?:|mailto:|tel:|\/|\.\/|\.\.\/|#)/i
54
+ const SAFE_IMG_DATA_URL = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=]*$/i
55
+
56
+ function isSafeUrl(value: string, tag: string, attr: string): boolean {
57
+ // Strip control characters and whitespace first: "java\tscript:" and
58
+ // "java\nscript:" are both read as javascript: by browsers.
59
+ // Matching control characters is the whole point here, hence the disable.
60
+ // eslint-disable-next-line no-control-regex
61
+ const url = value.replace(/[\u0000-\u0020\u007f]/g, '')
62
+ if (tag === 'img' && attr === 'src' && SAFE_IMG_DATA_URL.test(url)) return true
63
+ return SAFE_URL.test(url)
64
+ }
65
+
66
+ function attrAllowed(tag: string, attr: string): boolean {
67
+ if (attr.startsWith('on')) return false // every event handler, in one rule
68
+ if (ALLOWED_ATTRS['*']!.has(attr)) return true
69
+ return ALLOWED_ATTRS[tag]?.has(attr) ?? false
70
+ }
71
+
72
+ /** DOM path: walk the parsed tree and strip anything off the allowlist. */
73
+ function sanitizeWithDom(html: string): string {
74
+ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
75
+ const walk = (node: Element): void => {
76
+ // Snapshot the child list: the loop mutates it as it unwraps and removes.
77
+ for (const child of [...node.children]) {
78
+ const tag = child.tagName.toLowerCase()
79
+ if (DROP_SUBTREE.has(tag)) {
80
+ child.remove()
81
+ continue
82
+ }
83
+ if (!ALLOWED_TAGS.has(tag)) {
84
+ // Unwrap rather than drop, so an unknown wrapper does not cost the user
85
+ // the text inside it.
86
+ walk(child)
87
+ child.replaceWith(...child.childNodes)
88
+ continue
89
+ }
90
+ for (const attr of [...child.attributes]) {
91
+ const name = attr.name.toLowerCase()
92
+ if (!attrAllowed(tag, name)) {
93
+ child.removeAttribute(attr.name)
94
+ continue
95
+ }
96
+ if ((name === 'href' || name === 'src') && !isSafeUrl(attr.value, tag, name)) {
97
+ child.removeAttribute(attr.name)
98
+ }
99
+ }
100
+ // A link that opens a new tab gets rel="noopener", so the opened page
101
+ // cannot reach back through window.opener.
102
+ if (tag === 'a' && child.getAttribute('target')) {
103
+ child.setAttribute('rel', 'noopener noreferrer')
104
+ }
105
+ walk(child)
106
+ }
107
+ }
108
+ walk(doc.body)
109
+ return doc.body.innerHTML
110
+ }
111
+
112
+ /**
113
+ * SSR path: no DOM, so drop dangerous subtrees outright and keep only
114
+ * allowlisted tags in their attribute-free form. Stricter than the DOM path on
115
+ * purpose - the client re-sanitizes on hydration, so the cost is a moment of
116
+ * plainer markup rather than a hole.
117
+ */
118
+ function sanitizeWithoutDom(html: string): string {
119
+ let out = html
120
+ for (const tag of DROP_SUBTREE) {
121
+ out = out.replace(new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}\\s*>`, 'gi'), '')
122
+ out = out.replace(new RegExp(`<${tag}\\b[^>]*/?>`, 'gi'), '')
123
+ }
124
+ return out.replace(/<\/?([a-z0-9-]+)([^>]*)>/gi, (match, rawTag: string, attrs: string) => {
125
+ const tag = rawTag.toLowerCase()
126
+ if (!ALLOWED_TAGS.has(tag)) return ''
127
+ if (match.startsWith('</')) return `</${tag}>`
128
+ return attrs.trim() ? `<${tag}>` : match
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Return `html` with everything outside the allowlist removed. Idempotent, so
134
+ * it is safe to call on already-sanitized markup.
135
+ */
136
+ export function sanitizeHtml(html: string | null | undefined): string {
137
+ const input = String(html ?? '')
138
+ if (!input) return ''
139
+ if (!input.includes('<')) return input // no angle bracket, no markup to strip
140
+ return typeof DOMParser === 'undefined' ? sanitizeWithoutDom(input) : sanitizeWithDom(input)
141
+ }
142
+
143
+ /** Escape text so it embeds in HTML as literal characters. */
144
+ function escapeHtml(text: string): string {
145
+ return text
146
+ .replace(/&/g, '&amp;')
147
+ .replace(/</g, '&lt;')
148
+ .replace(/>/g, '&gt;')
149
+ .replace(/"/g, '&quot;')
150
+ }
151
+
152
+ /** A sentinel that cannot occur in escaped input, used to park inline-code
153
+ * spans while the emphasis and link passes run over the rest. */
154
+ const MARK = '\u0000'
155
+
156
+ /** Inline Markdown. Code is extracted first so backticks protect their
157
+ * contents from the emphasis and link passes. */
158
+ function renderInline(text: string): string {
159
+ const code: string[] = []
160
+ let out = escapeHtml(text).replace(/`([^`]+)`/g, (_m, c: string) => {
161
+ code.push(c)
162
+ return `${MARK}${code.length - 1}${MARK}`
163
+ })
164
+ out = out
165
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img src="$2" alt="$1">')
166
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>')
167
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
168
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
169
+ .replace(/~~([^~]+)~~/g, '<del>$1</del>')
170
+ return out.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (_m, i: string) => `<code>${code[Number(i)]}</code>`)
171
+ }
172
+
173
+ /**
174
+ * Render a common Markdown subset to HTML: headings, bold, italic,
175
+ * strikethrough, inline code, links, images, and bullet / ordered lists.
176
+ *
177
+ * Deliberately not a full CommonMark implementation - a grid cell shows a line
178
+ * or two, and a complete parser is a dependency this package does not take. The
179
+ * output is sanitized, so a Markdown link cannot smuggle `javascript:`.
180
+ */
181
+ export function renderMarkdown(markdown: string | null | undefined): string {
182
+ const src = String(markdown ?? '')
183
+ if (!src) return ''
184
+ const out: string[] = []
185
+ let list: 'ul' | 'ol' | null = null
186
+ const closeList = () => {
187
+ if (list) out.push(`</${list}>`)
188
+ list = null
189
+ }
190
+ for (const line of src.split(/\r?\n/)) {
191
+ const heading = line.match(/^(#{1,6})\s+(.*)$/)
192
+ const bullet = line.match(/^\s*[-*+]\s+(.*)$/)
193
+ const ordered = line.match(/^\s*\d+[.)]\s+(.*)$/)
194
+ if (heading) {
195
+ closeList()
196
+ const level = heading[1]!.length
197
+ out.push(`<h${level}>${renderInline(heading[2]!)}</h${level}>`)
198
+ } else if (bullet || ordered) {
199
+ const want = bullet ? 'ul' : 'ol'
200
+ if (list !== want) {
201
+ closeList()
202
+ out.push(`<${want}>`)
203
+ list = want
204
+ }
205
+ out.push(`<li>${renderInline((bullet ?? ordered)![1]!)}</li>`)
206
+ } else if (!line.trim()) {
207
+ closeList()
208
+ } else {
209
+ closeList()
210
+ out.push(`<p>${renderInline(line)}</p>`)
211
+ }
212
+ }
213
+ closeList()
214
+ return sanitizeHtml(out.join(''))
215
+ }
216
+
217
+ /** Strip markup down to readable text, for exports, aria labels and tooltips
218
+ * where a cell's HTML has to collapse to one plain string. */
219
+ export function htmlToText(html: string | null | undefined): string {
220
+ const clean = sanitizeHtml(html)
221
+ if (!clean) return ''
222
+ const spaced = clean
223
+ .replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, ' ')
224
+ .replace(/<br\s*\/?>/gi, ' ')
225
+ return spaced
226
+ .replace(/<[^>]*>/g, '')
227
+ .replace(/&nbsp;/gi, ' ')
228
+ .replace(/&lt;/gi, '<')
229
+ .replace(/&gt;/gi, '>')
230
+ .replace(/&quot;/gi, '"')
231
+ .replace(/&#39;/gi, "'")
232
+ .replace(/&amp;/gi, '&')
233
+ .replace(/\s+/g, ' ')
234
+ .trim()
235
+ }