nuxt-spec 0.2.4-alpha.3 → 0.2.4

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/utils/index.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { gotoPage, getDataHtml, getAPIResultHtml } from './e2e'
2
- import { compareScreenshot } from './screenshot'
3
- import type { CompareScreenshotOptions } from './screenshot'
4
-
5
- export {
6
- compareScreenshot,
7
- gotoPage,
8
- getDataHtml,
9
- getAPIResultHtml,
10
- }
11
-
12
- export type { CompareScreenshotOptions }
1
+ import { gotoPage, getDataHtml, getAPIResultHtml } from './e2e'
2
+ import { compareScreenshot } from './screenshot'
3
+ import type { CompareScreenshotOptions } from './screenshot'
4
+
5
+ export {
6
+ compareScreenshot,
7
+ gotoPage,
8
+ getDataHtml,
9
+ getAPIResultHtml,
10
+ }
11
+
12
+ export type { CompareScreenshotOptions }
@@ -1,8 +1,8 @@
1
- <div class="failure">
2
- <h2>{{FILE_NAME}}</h2>
3
- <p class="message">{{MESSAGE}}</p>
4
- <div class="pair">
5
- <figure><figcaption>Baseline image</figcaption><img src="{{BASELINE_URI}}" alt="Baseline image for reference"></figure>
6
- <figure><figcaption>Actual screenshot</figcaption><img src="{{ACTUAL_URI}}" alt="Actual screenshot taken"></figure>
7
- </div>
8
- </div>
1
+ <div class="failure">
2
+ <h2>{{FILE_NAME}}</h2>
3
+ <p class="message">{{MESSAGE}}</p>
4
+ <div class="pair">
5
+ <figure><figcaption>Baseline image</figcaption><img src="{{BASELINE_URI}}" alt="Baseline image for reference"></figure>
6
+ <figure><figcaption>Actual screenshot</figcaption><img src="{{ACTUAL_URI}}" alt="Actual screenshot taken"></figure>
7
+ </div>
8
+ </div>
@@ -1,24 +1,24 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <title>Visual Regression Report</title>
6
- <style>
7
- body { font-family: system-ui, sans-serif; margin: 2rem; background: #f5f5f5; color: #222; }
8
- h1 { font-size: 1.4rem; }
9
- .failure { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin: 1rem 0; box-shadow: 0 1px 3px rgba(0, 0, 0, .1); }
10
- .failure h2 { font-size: 1rem; margin: 0 0 .5rem; }
11
- .failure .message { color: #b00; font-size: .85rem; margin: 0 0 .75rem; }
12
- .pair { display: flex; gap: 1rem; flex-wrap: wrap; }
13
- .pair figure { margin: 0; flex: 1 1 0; min-width: 240px; }
14
- .pair figcaption { font-size: .8rem; margin-bottom: .25rem; font-weight: bold; }
15
- .pair figure:first-child figcaption { color: #0b0; }
16
- .pair figure:last-child figcaption { color: #b00; }
17
- .pair img { max-width: 100%; background: #fff; }
18
- .pair figure:first-child img { border: 2px solid #0b0; }
19
- .pair figure:last-child img { border: 2px solid #b00; }
20
- </style>
21
- </head>
22
- <body>
23
- <h1>Visual Regression Report - {{TIMESTAMP}}</h1>
24
- <!-- BEGIN failed screenshot entries -->
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Visual Regression Report</title>
6
+ <style>
7
+ body { font-family: system-ui, sans-serif; margin: 2rem; background: #f5f5f5; color: #222; }
8
+ h1 { font-size: 1.4rem; }
9
+ .failure { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin: 1rem 0; box-shadow: 0 1px 3px rgba(0, 0, 0, .1); }
10
+ .failure h2 { font-size: 1rem; margin: 0 0 .5rem; }
11
+ .failure .message { color: #b00; font-size: .85rem; margin: 0 0 .75rem; }
12
+ .pair { display: flex; gap: 1rem; flex-wrap: wrap; }
13
+ .pair figure { margin: 0; flex: 1 1 0; min-width: 240px; }
14
+ .pair figcaption { font-size: .8rem; margin-bottom: .25rem; font-weight: bold; }
15
+ .pair figure:first-child figcaption { color: #0b0; }
16
+ .pair figure:last-child figcaption { color: #b00; }
17
+ .pair img { max-width: 100%; background: #fff; }
18
+ .pair figure:first-child img { border: 2px solid #0b0; }
19
+ .pair figure:last-child img { border: 2px solid #b00; }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <h1>Visual Regression Report - {{TIMESTAMP}}</h1>
24
+ <!-- BEGIN failed screenshot entries -->
@@ -1,4 +1,4 @@
1
- <!-- END failed screenshot entries -->
2
- <div>Generated by <a href="https://github.com/nuxt/nuxt-spec">Nuxt Spec</a> @ {{TIMESTAMP}}</div>
3
- </body>
4
- </html>
1
+ <!-- END failed screenshot entries -->
2
+ <div>Generated by <a href="https://github.com/nuxt/nuxt-spec">Nuxt Spec</a> @ {{TIMESTAMP}}</div>
3
+ </body>
4
+ </html>
@@ -1,211 +1,227 @@
1
- import { exec } from 'node:child_process'
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
- import { platform } from 'node:os'
4
- import { resolve, sep } from 'node:path'
5
- import { fileURLToPath } from 'node:url'
6
- import type { DecodedPng } from 'fast-png'
7
-
8
- const templatesDir = resolve(fileURLToPath(import.meta.url), '..')
9
- const REPORT_ENTRY = readFileSync(resolve(templatesDir, 'report-entry.html'), 'utf-8')
10
- const REPORT_PATH = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-path')
11
- const REPORT_LOCK = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-lock')
12
-
13
- // create report file on first call
14
- // protected from parallel execution issues
15
- export function ensureReportCreated(targetDir = 'test/e2e'): void {
16
- withConcurrentLock(() => {
17
- let reportPath: string
18
-
19
- if (existsSync(REPORT_PATH)) {
20
- // Keep exactly one shared report per test run; first writer wins.
21
- reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
22
- } else {
23
- const root = process.cwd()
24
- const dir = resolveWithin(root, targetDir)
25
- const fileTimestamp = process.env.SCREENSHOT_REPORT_TIMESTAMP ?? reportTimestamp(new Date())
26
- reportPath = resolve(dir, '__current__', `report-${fileTimestamp}.html`)
27
- writeFileSync(REPORT_PATH, reportPath)
28
- }
29
-
30
- process.env.SCREENSHOT_REPORT_PATH = reportPath
31
- if (!reportPath || existsSync(reportPath)) return
32
-
33
- const titleTimestamp = process.env.SCREENSHOT_REPORT_TITLE ?? reportTimestamp(new Date(), true)
34
- mkdirSync(resolve(reportPath, '..'), { recursive: true })
35
-
36
- const template = readFileSync(resolve(templatesDir, 'report-head.html'), 'utf-8')
37
- const report = template.replace('{{TIMESTAMP}}', titleTimestamp)
38
- writeFileSync(reportPath, report)
39
- })
40
- }
41
-
42
- // append a side-by-side baseline/actual comparison
43
- // to the HTML report if the screenshots don't match
44
- // protected from parallel execution issues
45
- export function appendToReport(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): void {
46
- const baselineUri = `data:image/png;base64,${Buffer.from(baseline).toString('base64')}`
47
- const actualUri = `data:image/png;base64,${Buffer.from(actual).toString('base64')}`
48
-
49
- const entry = REPORT_ENTRY
50
- .replace('{{FILE_NAME}}', escapeHtml(fileName))
51
- .replace('{{MESSAGE}}', escapeHtml(message))
52
- .replace('{{BASELINE_URI}}', baselineUri)
53
- .replace('{{ACTUAL_URI}}', actualUri)
54
-
55
- withConcurrentLock(() => {
56
- const reportPath = process.env.SCREENSHOT_REPORT_PATH || (existsSync(REPORT_PATH)
57
- ? readFileSync(REPORT_PATH, 'utf-8').trim()
58
- : '')
59
- if (!reportPath || !existsSync(reportPath)) return
60
-
61
- appendFileSync(reportPath, entry)
62
- })
63
- }
64
-
65
- // Vitest globalSetup entry point
66
- // - computes stable timestamp values and exposes them via env variables
67
- // - the report file itself is created lazily on first compareScreenshot call
68
- // - provides a callback to close the HTML report once tests are finished (if it was created)
69
- export function screenshotSetup() {
70
- const NOW = new Date()
71
- const fileTimestamp = reportTimestamp(NOW)
72
- const titleTimestamp = reportTimestamp(NOW, true)
73
-
74
- process.env.SCREENSHOT_REPORT_TIMESTAMP = fileTimestamp
75
- process.env.SCREENSHOT_REPORT_TITLE = titleTimestamp
76
-
77
- return () => {
78
- if (!existsSync(REPORT_PATH)) return
79
-
80
- const reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
81
- unlinkSync(REPORT_PATH)
82
-
83
- if (!reportPath) return
84
- if (!existsSync(reportPath)) return
85
-
86
- let footer = readFileSync(resolve(templatesDir, 'report-tail.html'), 'utf-8')
87
- footer = footer.replace('{{TIMESTAMP}}', new Date().toISOString())
88
- appendFileSync(reportPath, footer)
89
- console.log(`\n(nuxt-spec) Visual regression report available at:\nfile://${reportPath}`)
90
-
91
- if (!process.env.CI) {
92
- console.log('(nuxt-spec) Opening report in default browser...')
93
- const openCmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open'
94
- exec(`${openCmd} "${reportPath}"`, (err) => {
95
- if (err) {
96
- console.log('(nuxt-spec) Failed to automatically open report')
97
- }
98
- })
99
- }
100
-
101
- console.log('\n')
102
- }
103
- }
104
-
105
- // helper to keep user-provided targetDir inside the current project root
106
- export function resolveWithin(base: string, segment: string): string {
107
- const target = resolve(base, segment)
108
- if (target !== base && !target.startsWith(base + sep)) {
109
- throw new Error(`Invalid path: "${segment}" resolves outside of "${base}"`)
110
- }
111
- return target
112
- }
113
-
114
- // helper for bridging difference between Vitest PNG saving and fast-png encoding
115
- export function toRGBA(img: DecodedPng): Uint8Array {
116
- const { width, height, data, channels = 4 } = img
117
- if (channels === 4) return data as Uint8Array
118
- const pixels = width * height
119
- const rgba = new Uint8Array(pixels * 4)
120
- for (let i = 0; i < pixels; i++) {
121
- const src = i * 3
122
- rgba[i * 4 + 0] = data[src + 0] ?? 0
123
- rgba[i * 4 + 1] = data[src + 1] ?? 0
124
- rgba[i * 4 + 2] = data[src + 2] ?? 0
125
- rgba[i * 4 + 3] = 255
126
- }
127
- return rgba
128
- }
129
-
130
- // protection from parallel execution issues
131
- function withConcurrentLock<T>(fn: () => T): T {
132
- const start = Date.now()
133
-
134
- while (true) {
135
- try {
136
- // try acquiring the lock
137
- writeFileSync(REPORT_LOCK, String(process.pid), { flag: 'wx' })
138
- break
139
- } catch (error) {
140
- // we only expect "file already exists" error
141
- // otherwise it is an issue
142
- const code = (error as NodeJS.ErrnoException).code
143
- if (code !== 'EEXIST') throw error
144
-
145
- try {
146
- const lockAge = Date.now() - statSync(REPORT_LOCK).mtimeMs
147
- if (lockAge > 30_000) {
148
- // stale lock
149
- unlinkSync(REPORT_LOCK)
150
- continue
151
- }
152
- } catch {
153
- // lock disappeared between checks, retry acquisition
154
- }
155
-
156
- if (Date.now() - start > 10_000) {
157
- // not successful in 10 minutes, give up
158
- throw new Error('Timed out while waiting for screenshot report lock', {
159
- cause: error,
160
- })
161
- }
162
-
163
- // sleep before next attempt
164
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
165
- }
166
- }
167
-
168
- try {
169
- return fn()
170
- } finally {
171
- try {
172
- unlinkSync(REPORT_LOCK)
173
- } catch {
174
- // lock file may already be removed
175
- }
176
- }
177
- }
178
-
179
- // timestamp string
180
- // separators=false produces YYYYMMDDHHMMSS for report file name
181
- // separators=true produces YYYY-MM-DD HH:MM:SS for report title
182
- function reportTimestamp(date: Date, separators = false): string {
183
- const pad2 = (n: number) => String(n).padStart(2, '0')
184
- const dateSeparator = separators ? '-' : ''
185
- const midSeparator = separators ? ' ' : ''
186
- const timeSeparator = separators ? ':' : ''
187
- return [
188
- date.getFullYear(),
189
- dateSeparator,
190
- pad2(date.getMonth() + 1),
191
- dateSeparator,
192
- pad2(date.getDate()),
193
- midSeparator,
194
- pad2(date.getHours()),
195
- timeSeparator,
196
- pad2(date.getMinutes()),
197
- timeSeparator,
198
- pad2(date.getSeconds()),
199
- ].join('')
200
- }
201
-
202
- // helper to escape a string for safe interpolation into the HTML report
203
- function escapeHtml(value: string): string {
204
- return value.replace(/[&<>"']/g, char => ({
205
- '&': '&amp;',
206
- '<': '&lt;',
207
- '>': '&gt;',
208
- '"': '&quot;',
209
- '\'': '&#39;',
210
- }[char] ?? char))
211
- }
1
+ import { exec } from 'node:child_process'
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { platform } from 'node:os'
4
+ import { resolve, sep } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import type { DecodedPng } from 'fast-png'
7
+
8
+ const templatesDir = resolve(fileURLToPath(import.meta.url), '..')
9
+ const REPORT_ENTRY = readFileSync(resolve(templatesDir, 'report-entry.html'), 'utf-8')
10
+ const REPORT_PATH = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-path')
11
+ const REPORT_LOCK = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-lock')
12
+
13
+ // create report file on first call
14
+ // protected from parallel execution issues
15
+ export function ensureReportCreated(targetDir = 'test/e2e'): void {
16
+ withConcurrentLock(() => {
17
+ let reportPath: string
18
+
19
+ if (existsSync(REPORT_PATH)) {
20
+ // Keep exactly one shared report per test run; first writer wins.
21
+ reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
22
+ } else {
23
+ const root = process.cwd()
24
+ const dir = resolveWithin(root, targetDir)
25
+ const fileTimestamp = process.env.SCREENSHOT_REPORT_TIMESTAMP ?? reportTimestamp(new Date())
26
+ reportPath = resolve(dir, '__current__', `report-${fileTimestamp}.html`)
27
+ writeFileSync(REPORT_PATH, reportPath)
28
+ }
29
+
30
+ process.env.SCREENSHOT_REPORT_PATH = reportPath
31
+ if (!reportPath || existsSync(reportPath)) return
32
+
33
+ const titleTimestamp = process.env.SCREENSHOT_REPORT_TITLE ?? reportTimestamp(new Date(), true)
34
+ mkdirSync(resolve(reportPath, '..'), { recursive: true })
35
+
36
+ const template = readFileSync(resolve(templatesDir, 'report-head.html'), 'utf-8')
37
+ const report = template.replace('{{TIMESTAMP}}', titleTimestamp)
38
+ writeFileSync(reportPath, report)
39
+ })
40
+ }
41
+
42
+ // append a side-by-side baseline/actual comparison
43
+ // to the HTML report if the screenshots don't match
44
+ // protected from parallel execution issues
45
+ export function appendToReport(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): void {
46
+ const baselineUri = `data:image/png;base64,${Buffer.from(baseline).toString('base64')}`
47
+ const actualUri = `data:image/png;base64,${Buffer.from(actual).toString('base64')}`
48
+
49
+ const entry = REPORT_ENTRY
50
+ .replace('{{FILE_NAME}}', escapeHtml(fileName))
51
+ .replace('{{MESSAGE}}', escapeHtml(message))
52
+ .replace('{{BASELINE_URI}}', baselineUri)
53
+ .replace('{{ACTUAL_URI}}', actualUri)
54
+
55
+ withConcurrentLock(() => {
56
+ const reportPath = process.env.SCREENSHOT_REPORT_PATH || (existsSync(REPORT_PATH)
57
+ ? readFileSync(REPORT_PATH, 'utf-8').trim()
58
+ : '')
59
+ if (!reportPath || !existsSync(reportPath)) return
60
+
61
+ appendFileSync(reportPath, entry)
62
+ })
63
+ }
64
+
65
+ // Vitest globalSetup entry point
66
+ // - computes stable timestamp values and exposes them via env variables
67
+ // - the report file itself is created lazily on first compareScreenshot call
68
+ // - provides a callback to close the HTML report once tests are finished (if it was created)
69
+ export function screenshotSetup() {
70
+ const NOW = new Date()
71
+ const fileTimestamp = reportTimestamp(NOW)
72
+ const titleTimestamp = reportTimestamp(NOW, true)
73
+
74
+ process.env.SCREENSHOT_REPORT_TIMESTAMP = fileTimestamp
75
+ process.env.SCREENSHOT_REPORT_TITLE = titleTimestamp
76
+
77
+ // callback that is executed upon Vitest teardown phase
78
+ return () => {
79
+ if (!existsSync(REPORT_PATH)) return
80
+
81
+ const reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
82
+ unlinkSync(REPORT_PATH)
83
+
84
+ if (!reportPath) return
85
+ if (!existsSync(reportPath)) return
86
+
87
+ // add "success" / "error" message as a conclusion
88
+ const reportBody = readFileSync(reportPath, 'utf-8')
89
+ const hasFailure = reportBody.includes('<div class="failure"')
90
+ if (hasFailure) {
91
+ appendFileSync(reportPath, '<p style="color: #b00; font-weight: 700; font-size: 18px;">Tests finished with errors</p>\n')
92
+ } else {
93
+ appendFileSync(reportPath, '<p style="color: #0b0; font-weight: 700; font-size: 18px;">All tests have passed</p>\n')
94
+ }
95
+
96
+ // wrap the report up with a footer
97
+ let footer = readFileSync(resolve(templatesDir, 'report-tail.html'), 'utf-8')
98
+ footer = footer.replace('{{TIMESTAMP}}', new Date().toISOString())
99
+ appendFileSync(reportPath, footer)
100
+ console.log(`\n(nuxt-spec) Visual regression report available at:\n${pathToFileURL(reportPath).href}`)
101
+
102
+ if (!process.env.CI) {
103
+ console.log('(nuxt-spec) Opening report in default browser...')
104
+ const openCmd
105
+ = platform() === 'darwin'
106
+ ? `open "${reportPath}"`
107
+ : platform() === 'win32'
108
+ ? `start "" "${reportPath}"`
109
+ : `xdg-open "${reportPath}"`
110
+ exec(openCmd, (err) => {
111
+ if (err) {
112
+ console.log('(nuxt-spec) Failed to automatically open report')
113
+ }
114
+ })
115
+ }
116
+
117
+ console.log('\n')
118
+ }
119
+ }
120
+
121
+ // helper to keep user-provided targetDir inside the current project root
122
+ export function resolveWithin(base: string, segment: string): string {
123
+ const target = resolve(base, segment)
124
+ if (target !== base && !target.startsWith(base + sep)) {
125
+ throw new Error(`Invalid path: "${segment}" resolves outside of "${base}"`)
126
+ }
127
+ return target
128
+ }
129
+
130
+ // helper for bridging difference between Vitest PNG saving and fast-png encoding
131
+ export function toRGBA(img: DecodedPng): Uint8Array {
132
+ const { width, height, data, channels = 4 } = img
133
+ if (channels === 4) return data as Uint8Array
134
+ const pixels = width * height
135
+ const rgba = new Uint8Array(pixels * 4)
136
+ for (let i = 0; i < pixels; i++) {
137
+ const src = i * 3
138
+ rgba[i * 4 + 0] = data[src + 0] ?? 0
139
+ rgba[i * 4 + 1] = data[src + 1] ?? 0
140
+ rgba[i * 4 + 2] = data[src + 2] ?? 0
141
+ rgba[i * 4 + 3] = 255
142
+ }
143
+ return rgba
144
+ }
145
+
146
+ // protection from parallel execution issues
147
+ function withConcurrentLock<T>(fn: () => T): T {
148
+ const start = Date.now()
149
+
150
+ while (true) {
151
+ try {
152
+ // try acquiring the lock
153
+ writeFileSync(REPORT_LOCK, String(process.pid), { flag: 'wx' })
154
+ break
155
+ } catch (error) {
156
+ // we only expect "file already exists" error
157
+ // otherwise it is an issue
158
+ const code = (error as NodeJS.ErrnoException).code
159
+ if (code !== 'EEXIST') throw error
160
+
161
+ try {
162
+ const lockAge = Date.now() - statSync(REPORT_LOCK).mtimeMs
163
+ if (lockAge > 30_000) {
164
+ // stale lock
165
+ unlinkSync(REPORT_LOCK)
166
+ continue
167
+ }
168
+ } catch {
169
+ // lock disappeared between checks, retry acquisition
170
+ }
171
+
172
+ if (Date.now() - start > 10_000) {
173
+ // not successful in 10 minutes, give up
174
+ throw new Error('Timed out while waiting for screenshot report lock', {
175
+ cause: error,
176
+ })
177
+ }
178
+
179
+ // sleep before next attempt
180
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
181
+ }
182
+ }
183
+
184
+ try {
185
+ return fn()
186
+ } finally {
187
+ try {
188
+ unlinkSync(REPORT_LOCK)
189
+ } catch {
190
+ // lock file may already be removed
191
+ }
192
+ }
193
+ }
194
+
195
+ // timestamp string
196
+ // separators=false produces YYYYMMDDHHMMSS for report file name
197
+ // separators=true produces YYYY-MM-DD HH:MM:SS for report title
198
+ function reportTimestamp(date: Date, separators = false): string {
199
+ const pad2 = (n: number) => String(n).padStart(2, '0')
200
+ const dateSeparator = separators ? '-' : ''
201
+ const midSeparator = separators ? ' ' : ''
202
+ const timeSeparator = separators ? ':' : ''
203
+ return [
204
+ date.getFullYear(),
205
+ dateSeparator,
206
+ pad2(date.getMonth() + 1),
207
+ dateSeparator,
208
+ pad2(date.getDate()),
209
+ midSeparator,
210
+ pad2(date.getHours()),
211
+ timeSeparator,
212
+ pad2(date.getMinutes()),
213
+ timeSeparator,
214
+ pad2(date.getSeconds()),
215
+ ].join('')
216
+ }
217
+
218
+ // helper to escape a string for safe interpolation into the HTML report
219
+ function escapeHtml(value: string): string {
220
+ return value.replace(/[&<>"']/g, char => ({
221
+ '&': '&amp;',
222
+ '<': '&lt;',
223
+ '>': '&gt;',
224
+ '"': '&quot;',
225
+ '\'': '&#39;',
226
+ }[char] ?? char))
227
+ }