@visill/test 0.1.0-rc.0 → 0.1.0-rc.2
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/package.json +6 -2
- package/scripts/structural-html-diff.mjs +159 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@visill/test",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
"import": "./dist/index.js"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"visill-structural-html-diff": "./scripts/structural-html-diff.mjs"
|
|
15
|
+
},
|
|
13
16
|
"files": [
|
|
14
|
-
"dist"
|
|
17
|
+
"dist",
|
|
18
|
+
"scripts"
|
|
15
19
|
],
|
|
16
20
|
"peerDependencies": {
|
|
17
21
|
"vitest": "^2.1.8"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { argv, exit, stderr, stdout } from 'node:process'
|
|
4
|
+
|
|
5
|
+
const USAGE =
|
|
6
|
+
'Usage: structural-html-diff <fileA.html> <fileB.html>\n' +
|
|
7
|
+
' Compares two HTML files across three regions: module-script body,\n' +
|
|
8
|
+
' inlined <style> blob, and data-island <script type="application/json"> skeletons.\n' +
|
|
9
|
+
' Exit 0 on structural equality, 1 on mismatch, 2 on usage error.\n'
|
|
10
|
+
|
|
11
|
+
const printUsageAndExit = (code) => {
|
|
12
|
+
const stream = code === 0 ? stdout : stderr
|
|
13
|
+
stream.write(USAGE)
|
|
14
|
+
exit(code)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const collapseWhitespace = (text) =>
|
|
18
|
+
text
|
|
19
|
+
.split('\n')
|
|
20
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
21
|
+
.filter((line) => line.length > 0)
|
|
22
|
+
.join('\n')
|
|
23
|
+
|
|
24
|
+
const parseAttributes = (attrString) => {
|
|
25
|
+
const pattern = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g
|
|
26
|
+
return Array.from(attrString.matchAll(pattern)).reduce((accumulator, match) => {
|
|
27
|
+
const [, name, doubleQuoted, singleQuoted, bare] = match
|
|
28
|
+
accumulator[name.toLowerCase()] = doubleQuoted ?? singleQuoted ?? bare ?? ''
|
|
29
|
+
return accumulator
|
|
30
|
+
}, {})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const findTags = (html, tagName) => {
|
|
34
|
+
const pattern = new RegExp(`<${tagName}\\b([^>]*)>([\\s\\S]*?)</${tagName}>`, 'gi')
|
|
35
|
+
return Array.from(html.matchAll(pattern)).map(([, attrString, body]) => ({
|
|
36
|
+
attributes: parseAttributes(attrString),
|
|
37
|
+
body,
|
|
38
|
+
}))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const extractModuleScriptBody = (html) => {
|
|
42
|
+
const scripts = findTags(html, 'script')
|
|
43
|
+
const moduleScript = scripts.find(({ attributes }) => attributes.type === 'module')
|
|
44
|
+
if (moduleScript === undefined) {
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
return collapseWhitespace(moduleScript.body)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const extractStyleBlob = (html) => {
|
|
51
|
+
const styles = findTags(html, 'style')
|
|
52
|
+
const concatenated = styles.map(({ body }) => body).join('\n')
|
|
53
|
+
return collapseWhitespace(concatenated)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const topLevelKeysOf = (parsedValue) => {
|
|
57
|
+
if (parsedValue === null || typeof parsedValue !== 'object' || Array.isArray(parsedValue)) {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
return Object.keys(parsedValue).sort()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const extractDataIslandSkeleton = (html) => {
|
|
64
|
+
const scripts = findTags(html, 'script')
|
|
65
|
+
const islands = scripts.filter(
|
|
66
|
+
({ attributes }) => attributes.type === 'application/json' && typeof attributes.id === 'string',
|
|
67
|
+
)
|
|
68
|
+
const skeleton = islands.map(({ attributes, body }) => {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(body.trim())
|
|
71
|
+
return { id: attributes.id, keys: topLevelKeysOf(parsed), parseError: null }
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return { id: attributes.id, keys: [], parseError: error.message }
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
return skeleton.sort((left, right) => left.id.localeCompare(right.id))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const serializeSkeleton = (skeleton) =>
|
|
80
|
+
skeleton.map(({ id, keys }) => `${id}:${keys.join(',')}`).join('|')
|
|
81
|
+
|
|
82
|
+
const formatSkeleton = (skeleton) =>
|
|
83
|
+
skeleton
|
|
84
|
+
.map(({ id, keys, parseError }) => {
|
|
85
|
+
const keyList = keys.length === 0 ? '(no keys)' : keys.join(', ')
|
|
86
|
+
const errorSuffix = parseError === null ? '' : ` [parse error: ${parseError}]`
|
|
87
|
+
return ` #${id}: ${keyList}${errorSuffix}`
|
|
88
|
+
})
|
|
89
|
+
.join('\n')
|
|
90
|
+
|
|
91
|
+
const reportRegionDiff = (regionLabel, leftValue, rightValue, fileA, fileB) => {
|
|
92
|
+
stdout.write(`--- ${fileA} (${regionLabel})\n`)
|
|
93
|
+
stdout.write(`+++ ${fileB} (${regionLabel})\n`)
|
|
94
|
+
stdout.write(`- ${leftValue}\n`)
|
|
95
|
+
stdout.write(`+ ${rightValue}\n`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const safeRead = (filePath) => {
|
|
99
|
+
try {
|
|
100
|
+
return readFileSync(filePath, 'utf8')
|
|
101
|
+
} catch (error) {
|
|
102
|
+
stderr.write(`Error reading ${filePath}: ${error.message}\n`)
|
|
103
|
+
exit(2)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const main = () => {
|
|
108
|
+
const args = argv.slice(2)
|
|
109
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
110
|
+
printUsageAndExit(0)
|
|
111
|
+
}
|
|
112
|
+
if (args.length !== 2) {
|
|
113
|
+
printUsageAndExit(2)
|
|
114
|
+
}
|
|
115
|
+
const [fileA, fileB] = args
|
|
116
|
+
const htmlA = safeRead(fileA)
|
|
117
|
+
const htmlB = safeRead(fileB)
|
|
118
|
+
|
|
119
|
+
const moduleA = extractModuleScriptBody(htmlA)
|
|
120
|
+
const moduleB = extractModuleScriptBody(htmlB)
|
|
121
|
+
const styleA = extractStyleBlob(htmlA)
|
|
122
|
+
const styleB = extractStyleBlob(htmlB)
|
|
123
|
+
const skeletonA = extractDataIslandSkeleton(htmlA)
|
|
124
|
+
const skeletonB = extractDataIslandSkeleton(htmlB)
|
|
125
|
+
|
|
126
|
+
const mismatches = []
|
|
127
|
+
if (moduleA !== moduleB) {
|
|
128
|
+
mismatches.push('module-script')
|
|
129
|
+
}
|
|
130
|
+
if (styleA !== styleB) {
|
|
131
|
+
mismatches.push('style')
|
|
132
|
+
}
|
|
133
|
+
if (serializeSkeleton(skeletonA) !== serializeSkeleton(skeletonB)) {
|
|
134
|
+
mismatches.push('data-island-skeleton')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (mismatches.length === 0) {
|
|
138
|
+
exit(0)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
stdout.write(`Structural mismatch in: ${mismatches.join(', ')}\n\n`)
|
|
142
|
+
if (mismatches.includes('module-script')) {
|
|
143
|
+
reportRegionDiff('module-script', moduleA, moduleB, fileA, fileB)
|
|
144
|
+
stdout.write('\n')
|
|
145
|
+
}
|
|
146
|
+
if (mismatches.includes('style')) {
|
|
147
|
+
reportRegionDiff('style', styleA, styleB, fileA, fileB)
|
|
148
|
+
stdout.write('\n')
|
|
149
|
+
}
|
|
150
|
+
if (mismatches.includes('data-island-skeleton')) {
|
|
151
|
+
stdout.write(`--- ${fileA} (data-island-skeleton)\n`)
|
|
152
|
+
stdout.write(`${formatSkeleton(skeletonA)}\n`)
|
|
153
|
+
stdout.write(`+++ ${fileB} (data-island-skeleton)\n`)
|
|
154
|
+
stdout.write(`${formatSkeleton(skeletonB)}\n`)
|
|
155
|
+
}
|
|
156
|
+
exit(1)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main()
|