@jsenv/ast 1.1.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/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@jsenv/ast",
3
+ "version": "1.1.0",
4
+ "description": "Parse and transform HTML,CSS and JS",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/jsenv/jsenv-core",
9
+ "directory": "packages/ast"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org"
14
+ },
15
+ "author": {
16
+ "name": "dmail",
17
+ "email": "dmaillard06@gmail.com",
18
+ "url": "https://twitter.com/damienmaillard"
19
+ },
20
+ "engines": {
21
+ "node": ">=16.14.0"
22
+ },
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "import": "./src/main.js"
27
+ },
28
+ "./*": "./*"
29
+ },
30
+ "files": [
31
+ "/src/"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --conditions=development ./scripts/test.mjs"
35
+ },
36
+ "dependencies": {
37
+ "@babel/parser": "7.18.5",
38
+ "@parcel/css": "1.10.1",
39
+ "acorn": "8.7.1",
40
+ "acorn-import-assertions": "1.8.0",
41
+ "acorn-walk": "8.2.0",
42
+ "parse5": "7.0.0",
43
+ "postcss": "8.4.14",
44
+ "postcss-value-parser": "4.2.0"
45
+ }
46
+ }
@@ -0,0 +1,57 @@
1
+ import { fileURLToPath } from "node:url"
2
+
3
+ export const applyPostCss = async ({
4
+ sourcemaps = "comment",
5
+ plugins,
6
+ // https://github.com/postcss/postcss#options
7
+ options = {},
8
+ url,
9
+ map,
10
+ content,
11
+ }) => {
12
+ const { default: postcss } = await import("postcss")
13
+
14
+ try {
15
+ const cssFileUrl = urlToFileUrl(url)
16
+ const result = await postcss(plugins).process(content, {
17
+ collectUrls: true,
18
+ from: fileURLToPath(cssFileUrl),
19
+ to: fileURLToPath(cssFileUrl),
20
+ map: {
21
+ annotation: sourcemaps === "file",
22
+ inline: sourcemaps === "inline",
23
+ // https://postcss.org/api/#sourcemapoptions
24
+ ...(map ? { prev: JSON.stringify(map) } : {}),
25
+ },
26
+ ...options,
27
+ })
28
+ return {
29
+ postCssMessages: result.messages,
30
+ map: result.map.toJSON(),
31
+ content: result.css,
32
+ }
33
+ } catch (error) {
34
+ if (error.name === "CssSyntaxError") {
35
+ console.error(String(error))
36
+ throw error
37
+ }
38
+ throw error
39
+ }
40
+ }
41
+
42
+ // the goal of this function is to take an url that is likely an http url
43
+ // info a file:// url
44
+ // for instance http://example.com/dir/file.js
45
+ // must becomes file:///dir/file.js
46
+ // but in windows it must be file://C:/dir/file.js
47
+ const filesystemRootUrl =
48
+ process.platform === "win32" ? `file:///${process.cwd()[0]}:/` : "file:///"
49
+ const urlToFileUrl = (url) => {
50
+ const urlString = String(url)
51
+ if (urlString.startsWith("file:")) {
52
+ return urlString
53
+ }
54
+ const origin = new URL(url).origin
55
+ const afterOrigin = urlString.slice(origin.length)
56
+ return new URL(afterOrigin, filesystemRootUrl).href
57
+ }
@@ -0,0 +1,58 @@
1
+ import { createRequire } from "node:module"
2
+ import { fileURLToPath } from "node:url"
3
+
4
+ const require = createRequire(import.meta.url)
5
+
6
+ export const transpileWithParcel = (urlInfo, context) => {
7
+ const css = require("@parcel/css")
8
+ const targets = runtimeCompatToTargets(context.runtimeCompat)
9
+ const { code, map } = css.transform({
10
+ filename: fileURLToPath(urlInfo.originalUrl),
11
+ code: Buffer.from(urlInfo.content),
12
+ targets,
13
+ minify: false,
14
+ })
15
+ return { code, map }
16
+ }
17
+
18
+ export const minifyWithParcel = (urlInfo, context) => {
19
+ const css = require("@parcel/css")
20
+ const targets = runtimeCompatToTargets(context.runtimeCompat)
21
+ const { code, map } = css.transform({
22
+ filename: fileURLToPath(urlInfo.originalUrl),
23
+ code: Buffer.from(urlInfo.content),
24
+ targets,
25
+ minify: true,
26
+ })
27
+ return { code, map }
28
+ }
29
+
30
+ export const bundleWithParcel = (urlInfo, context) => {
31
+ const targets = runtimeCompatToTargets(context.runtimeCompat)
32
+ const css = require("@parcel/css")
33
+ const { code, map } = css.bundle({
34
+ filename: fileURLToPath(urlInfo.originalUrl),
35
+ targets,
36
+ minify: false,
37
+ })
38
+ return { code, map }
39
+ }
40
+
41
+ const runtimeCompatToTargets = (runtimeCompat) => {
42
+ const targets = {}
43
+ ;["chrome", "firefox", "ie", "opera", "safari"].forEach((runtimeName) => {
44
+ const version = runtimeCompat[runtimeName]
45
+ if (version) {
46
+ targets[runtimeName] = versionToBits(version)
47
+ }
48
+ })
49
+ return targets
50
+ }
51
+
52
+ const versionToBits = (version) => {
53
+ const [major, minor = 0, patch = 0] = version
54
+ .split("-")[0]
55
+ .split(".")
56
+ .map((v) => parseInt(v, 10))
57
+ return (major << 16) | (minor << 8) | patch
58
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+
3
+ https://github.com/postcss/postcss/blob/master/docs/writing-a-plugin.md
4
+ https://github.com/postcss/postcss/blob/master/docs/guidelines/plugin.md
5
+ https://github.com/postcss/postcss/blob/master/docs/guidelines/runner.md#31-dont-show-js-stack-for-csssyntaxerror
6
+
7
+ In case css sourcemap contains no%20source
8
+ This is because of https://github.com/postcss/postcss/blob/fd30d3df5abc0954a0ec642a3cdc644ab2aacf9c/lib/map-generator.js#L231
9
+ and it indicates a node has been replaced without passing source
10
+ hence sourcemap cannot point the original source location
11
+
12
+ */
13
+
14
+ import { pathToFileURL } from "node:url"
15
+
16
+ import { require } from "../require.js"
17
+
18
+ export const postCssPluginUrlVisitor = ({ urlVisitor = () => null }) => {
19
+ const parseCssValue = require("postcss-value-parser")
20
+ const stringifyCssNodes = parseCssValue.stringify
21
+
22
+ return {
23
+ postcssPlugin: "url_visitor",
24
+ prepare: (result) => {
25
+ const { from } = result.opts
26
+ const fromUrl = String(pathToFileURL(from))
27
+ const mutations = []
28
+ return {
29
+ AtRule: {
30
+ import: (atImportNode, { AtRule }) => {
31
+ if (atImportNode.parent.type !== "root") {
32
+ atImportNode.warn(result, "`@import` should be top level")
33
+ return
34
+ }
35
+ if (atImportNode.nodes) {
36
+ atImportNode.warn(
37
+ result,
38
+ "`@import` was not terminated correctly",
39
+ )
40
+ return
41
+ }
42
+ const parsed = parseCssValue(atImportNode.params)
43
+ let [urlNode] = parsed.nodes
44
+ if (
45
+ !urlNode ||
46
+ (urlNode.type !== "string" && urlNode.type !== "function")
47
+ ) {
48
+ atImportNode.warn(
49
+ result,
50
+ `No URL in \`${atImportNode.toString()}\``,
51
+ )
52
+ return
53
+ }
54
+ let url = ""
55
+ if (urlNode.type === "string") {
56
+ url = urlNode.value
57
+ } else if (urlNode.type === "function") {
58
+ // Invalid function
59
+ if (!/^url$/i.test(urlNode.value)) {
60
+ atImportNode.warn(
61
+ result,
62
+ `Invalid \`url\` function in \`${atImportNode.toString()}\``,
63
+ )
64
+ return
65
+ }
66
+ const firstNode = urlNode.nodes[0]
67
+ if (firstNode && firstNode.type === "string") {
68
+ urlNode = firstNode
69
+ url = urlNode.value
70
+ } else {
71
+ urlNode = urlNode.nodes
72
+ url = stringifyCssNodes(urlNode.nodes)
73
+ }
74
+ }
75
+
76
+ url = url.trim()
77
+ if (url.length === 0) {
78
+ atImportNode.warn(
79
+ result,
80
+ `Empty URL in \`${atImportNode.toString()}\``,
81
+ )
82
+ return
83
+ }
84
+
85
+ const specifier = url
86
+ url = new URL(specifier, fromUrl).href
87
+ if (url === fromUrl) {
88
+ atImportNode.warn(
89
+ result,
90
+ `\`@import\` loop in \`${atImportNode.toString()}\``,
91
+ )
92
+ return
93
+ }
94
+
95
+ const atRuleStart = atImportNode.source.start.offset
96
+ const atRuleEnd = atImportNode.source.end.offset + 1 // for the ";"
97
+ const atRuleRaw = atImportNode.source.input.css.slice(
98
+ atRuleStart,
99
+ atRuleEnd,
100
+ )
101
+ const specifierIndex = atRuleRaw.indexOf(atImportNode.params)
102
+ const specifierStart = atRuleStart + specifierIndex
103
+ const specifierEnd = specifierStart + atImportNode.params.length
104
+ const specifierLine = atImportNode.source.start.line
105
+ const specifierColumn =
106
+ atImportNode.source.start.column + specifierIndex
107
+ urlVisitor({
108
+ declarationNode: atImportNode,
109
+ type: "@import",
110
+ atRuleStart,
111
+ atRuleEnd,
112
+ specifier,
113
+ specifierLine,
114
+ specifierColumn,
115
+ specifierStart,
116
+ specifierEnd,
117
+ replace: (newUrlSpecifier) => {
118
+ if (newUrlSpecifier === urlNode.value) {
119
+ return
120
+ }
121
+ urlNode.value = newUrlSpecifier
122
+ const newParams = parsed.toString()
123
+ const newAtImportRule = new AtRule({
124
+ name: "import",
125
+ params: newParams,
126
+ source: atImportNode.source,
127
+ })
128
+ atImportNode.replaceWith(newAtImportRule)
129
+ },
130
+ })
131
+ },
132
+ },
133
+ Declaration: (declarationNode) => {
134
+ const parsed = parseCssValue(declarationNode.value)
135
+ const urlMutations = []
136
+ walkUrls(parsed, {
137
+ stringifyCssNodes,
138
+ visitor: ({ url, urlNode }) => {
139
+ // Empty URL
140
+ if (!urlNode || url.length === 0) {
141
+ declarationNode.warn(
142
+ result,
143
+ `Empty URL in \`${declarationNode.toString()}\``,
144
+ )
145
+ return
146
+ }
147
+ // Skip Data URI
148
+ if (isDataUrl(url)) {
149
+ return
150
+ }
151
+ const specifier = url
152
+ url = new URL(specifier, pathToFileURL(from))
153
+
154
+ const declarationNodeStart = declarationNode.source.start.offset
155
+ const afterDeclarationNode =
156
+ declarationNode.source.input.css.slice(declarationNodeStart)
157
+ const valueIndex = afterDeclarationNode.indexOf(
158
+ declarationNode.value,
159
+ )
160
+ const valueStart = declarationNodeStart + valueIndex
161
+ const specifierStart = valueStart + urlNode.sourceIndex
162
+ const specifierEnd =
163
+ specifierStart +
164
+ (urlNode.type === "word"
165
+ ? urlNode.value.length
166
+ : urlNode.value.length + 2) // the quotes
167
+ // value raw
168
+ // declarationNode.source.input.css.slice(valueStart)
169
+ // specifier raw
170
+ // declarationNode.source.input.css.slice(specifierStart, specifierEnd)
171
+ const specifierLine = declarationNode.source.start.line
172
+ const specifierColumn =
173
+ declarationNode.source.start.column +
174
+ (specifierStart - declarationNodeStart)
175
+
176
+ urlVisitor({
177
+ declarationNode,
178
+ type: "url",
179
+ specifier,
180
+ specifierLine,
181
+ specifierColumn,
182
+ specifierStart,
183
+ specifierEnd,
184
+ replace: (newUrlSpecifier) => {
185
+ urlMutations.push(() => {
186
+ // the specifier desires to be inside double quotes
187
+ if (newUrlSpecifier[0] === `"`) {
188
+ urlNode.type = "word"
189
+ urlNode.value = newUrlSpecifier
190
+ return
191
+ }
192
+ // the specifier desires to be inside simple quotes
193
+ if (newUrlSpecifier[0] === `'`) {
194
+ urlNode.type = "word"
195
+ urlNode.value = newUrlSpecifier
196
+ return
197
+ }
198
+ // the specifier desired to be just a word
199
+ // for the "word" type so that newUrlSpecifier can opt-out of being between quotes
200
+ // useful to inject __v__ calls for css inside js
201
+ urlNode.type = "word"
202
+ urlNode.value = newUrlSpecifier
203
+ })
204
+ },
205
+ })
206
+ },
207
+ })
208
+ if (urlMutations.length) {
209
+ mutations.push(() => {
210
+ urlMutations.forEach((urlMutation) => {
211
+ urlMutation()
212
+ })
213
+ declarationNode.value = parsed.toString()
214
+ })
215
+ }
216
+ },
217
+ OnceExit: () => {
218
+ mutations.forEach((mutation) => {
219
+ mutation()
220
+ })
221
+ },
222
+ }
223
+ },
224
+ }
225
+ }
226
+ postCssPluginUrlVisitor.postcss = true
227
+
228
+ const walkUrls = (parsed, { stringifyCssNodes, visitor }) => {
229
+ parsed.walk((node) => {
230
+ // https://github.com/andyjansson/postcss-functions
231
+ if (isUrlFunctionNode(node)) {
232
+ const { nodes } = node
233
+ const [urlNode] = nodes
234
+ const url =
235
+ urlNode && urlNode.type === "string"
236
+ ? urlNode.value
237
+ : stringifyCssNodes(nodes)
238
+ visitor({
239
+ url: url.trim(),
240
+ urlNode,
241
+ })
242
+ return
243
+ }
244
+
245
+ if (isImageSetFunctionNode(node)) {
246
+ Array.from(node.nodes).forEach((childNode) => {
247
+ if (childNode.type === "string") {
248
+ visitor({
249
+ url: childNode.value.trim(),
250
+ urlNode: childNode,
251
+ })
252
+ return
253
+ }
254
+
255
+ if (isUrlFunctionNode(node)) {
256
+ const { nodes } = childNode
257
+ const [urlNode] = nodes
258
+ const url =
259
+ urlNode && urlNode.type === "string"
260
+ ? urlNode.value
261
+ : stringifyCssNodes(nodes)
262
+ visitor({
263
+ url: url.trim(),
264
+ urlNode,
265
+ })
266
+ return
267
+ }
268
+ })
269
+ }
270
+ })
271
+ }
272
+
273
+ const isUrlFunctionNode = (node) => {
274
+ return node.type === "function" && /^url$/i.test(node.value)
275
+ }
276
+
277
+ const isImageSetFunctionNode = (node) => {
278
+ return (
279
+ node.type === "function" && /^(?:-webkit-)?image-set$/i.test(node.value)
280
+ )
281
+ }
282
+
283
+ const isDataUrl = (url) => {
284
+ return /data:[^\n\r;]+?(?:;charset=[^\n\r;]+?)?;base64,([\d+/A-Za-z]+={0,2})/.test(
285
+ url,
286
+ )
287
+ }
@@ -0,0 +1,35 @@
1
+ import { getHtmlNodeAttribute } from "./html_node_attributes.js"
2
+
3
+ export const analyzeScriptNode = (scriptNode) => {
4
+ const type = getHtmlNodeAttribute(scriptNode, "type")
5
+ if (type === undefined || type === "text/javascript") {
6
+ return "classic"
7
+ }
8
+ if (type === "module") {
9
+ return "module"
10
+ }
11
+ if (type === "importmap") {
12
+ return "importmap"
13
+ }
14
+ return type
15
+ }
16
+
17
+ export const analyzeLinkNode = (linkNode) => {
18
+ const rel = getHtmlNodeAttribute(linkNode, "rel")
19
+ if (rel === "stylesheet") {
20
+ return {
21
+ isStylesheet: true,
22
+ }
23
+ }
24
+ const isRessourceHint = [
25
+ "preconnect",
26
+ "dns-prefetch",
27
+ "prefetch",
28
+ "preload",
29
+ "modulepreload",
30
+ ].includes(rel)
31
+ return {
32
+ isRessourceHint,
33
+ rel,
34
+ }
35
+ }
@@ -0,0 +1,40 @@
1
+ import {
2
+ getHtmlNodeAttribute,
3
+ setHtmlNodeAttributes,
4
+ } from "./html_node_attributes.js"
5
+ import { setHtmlNodeText } from "./html_node_text.js"
6
+
7
+ export const inlineScriptNode = (script, textContent) => {
8
+ const src = getHtmlNodeAttribute(script, "src")
9
+ setHtmlNodeAttributes(script, {
10
+ "generated-from-src": src,
11
+ "src": undefined,
12
+ "crossorigin": undefined,
13
+ "integrity": undefined,
14
+ })
15
+ setHtmlNodeText(script, textContent)
16
+ }
17
+
18
+ export const inlineLinkStylesheetNode = (link, textContent) => {
19
+ const href = getHtmlNodeAttribute(link, "href")
20
+ setHtmlNodeAttributes(link, {
21
+ "generated-from-href": href,
22
+ "href": undefined,
23
+ "rel": undefined,
24
+ "type": undefined,
25
+ "as": undefined,
26
+ "crossorigin": undefined,
27
+ "integrity": undefined,
28
+ })
29
+ link.nodeName = "style"
30
+ link.tagName = "style"
31
+ setHtmlNodeText(link, textContent)
32
+ }
33
+
34
+ export const inlineImgNode = (img, contentAsBase64) => {
35
+ const src = getHtmlNodeAttribute(img, "src")
36
+ setHtmlNodeAttributes(img, {
37
+ "generated-from-src": src,
38
+ "src": contentAsBase64,
39
+ })
40
+ }
@@ -0,0 +1,112 @@
1
+ import { parseFragment } from "parse5"
2
+
3
+ import {
4
+ getHtmlNodeAttribute,
5
+ setHtmlNodeAttributes,
6
+ } from "./html_node_attributes.js"
7
+ import { findHtmlNode } from "./html_search.js"
8
+ import { analyzeScriptNode } from "./html_analysis.js"
9
+
10
+ export const removeHtmlNode = (htmlNode) => {
11
+ const { childNodes } = htmlNode.parentNode
12
+ childNodes.splice(childNodes.indexOf(htmlNode), 1)
13
+ }
14
+
15
+ export const createHtmlNode = ({ tagName, textContent = "", ...rest }) => {
16
+ const html = `<${tagName} ${stringifyAttributes(
17
+ rest,
18
+ )}>${textContent}</${tagName}>`
19
+ const fragment = parseFragment(html)
20
+ return fragment.childNodes[0]
21
+ }
22
+
23
+ export const injectScriptNodeAsEarlyAsPossible = (htmlAst, scriptNode) => {
24
+ const injectedBy = getHtmlNodeAttribute(scriptNode, "injected-by")
25
+ if (injectedBy === undefined) {
26
+ setHtmlNodeAttributes(scriptNode, {
27
+ "injected-by": "jsenv",
28
+ })
29
+ }
30
+ const isModule = analyzeScriptNode(scriptNode) === "module"
31
+ if (isModule) {
32
+ const firstImportmapScript = findHtmlNode(htmlAst, (node) => {
33
+ if (node.nodeName !== "script") return false
34
+ return analyzeScriptNode(node) === "importmap"
35
+ })
36
+ if (firstImportmapScript) {
37
+ return insertAfter(
38
+ scriptNode,
39
+ firstImportmapScript.parentNode,
40
+ firstImportmapScript,
41
+ )
42
+ }
43
+ }
44
+ const headNode = findChild(htmlAst, (node) => node.nodeName === "html")
45
+ .childNodes[0]
46
+ const firstHeadScript = findChild(headNode, (node) => {
47
+ return node.nodeName === "script"
48
+ })
49
+ return insertBefore(scriptNode, headNode, firstHeadScript)
50
+ }
51
+
52
+ const insertBefore = (nodeToInsert, futureParentNode, futureNextSibling) => {
53
+ const { childNodes = [] } = futureParentNode
54
+ const futureIndex = futureNextSibling
55
+ ? childNodes.indexOf(futureNextSibling)
56
+ : 0
57
+ injectWithWhitespaces(nodeToInsert, futureParentNode, futureIndex)
58
+ }
59
+
60
+ const insertAfter = (nodeToInsert, futureParentNode, futurePrevSibling) => {
61
+ const { childNodes = [] } = futureParentNode
62
+ const futureIndex = futurePrevSibling
63
+ ? childNodes.indexOf(futurePrevSibling) + 1
64
+ : childNodes.length
65
+ injectWithWhitespaces(nodeToInsert, futureParentNode, futureIndex)
66
+ }
67
+
68
+ const injectWithWhitespaces = (nodeToInsert, futureParentNode, futureIndex) => {
69
+ const { childNodes = [] } = futureParentNode
70
+ const previousSiblings = childNodes.slice(0, futureIndex)
71
+ const nextSiblings = childNodes.slice(futureIndex)
72
+ const futureChildNodes = []
73
+ const previousSibling = previousSiblings[0]
74
+ if (previousSibling) {
75
+ futureChildNodes.push(...previousSiblings)
76
+ }
77
+ if (!previousSibling || previousSibling.nodeName !== "#text") {
78
+ futureChildNodes.push({
79
+ nodeName: "#text",
80
+ value: "\n ",
81
+ parentNode: futureParentNode,
82
+ })
83
+ }
84
+ futureChildNodes.push(nodeToInsert)
85
+ const nextSibling = nextSiblings[0]
86
+ if (!nextSibling || nextSibling.nodeName !== "#text") {
87
+ futureChildNodes.push({
88
+ nodeName: "#text",
89
+ value: "\n ",
90
+ parentNode: futureParentNode,
91
+ })
92
+ }
93
+ if (nextSibling) {
94
+ futureChildNodes.push(...nextSiblings)
95
+ }
96
+ futureParentNode.childNodes = futureChildNodes
97
+ }
98
+
99
+ const findChild = ({ childNodes = [] }, predicate) => childNodes.find(predicate)
100
+
101
+ const stringifyAttributes = (object) => {
102
+ return Object.keys(object)
103
+ .map((key) => `${key}=${valueToHtmlAttributeValue(object[key])}`)
104
+ .join(" ")
105
+ }
106
+
107
+ const valueToHtmlAttributeValue = (value) => {
108
+ if (typeof value === "string") {
109
+ return JSON.stringify(value)
110
+ }
111
+ return `"${JSON.stringify(value)}"`
112
+ }
@@ -0,0 +1,43 @@
1
+ export const getHtmlNodeAttribute = (htmlNode, attributeName) => {
2
+ const attribute = getHtmlAttributeByName(htmlNode, attributeName)
3
+ return attribute ? attribute.value || "" : undefined
4
+ }
5
+
6
+ export const setHtmlNodeAttributes = (htmlNode, attributesToAssign) => {
7
+ if (typeof attributesToAssign !== "object") {
8
+ throw new TypeError(`attributesToAssign must be an object`)
9
+ }
10
+ const { attrs } = htmlNode
11
+ if (!attrs) return
12
+ Object.keys(attributesToAssign).forEach((key) => {
13
+ const existingAttributeIndex = attrs.findIndex(({ name }) => name === key)
14
+ const value = attributesToAssign[key]
15
+ // remove no-op
16
+ if (existingAttributeIndex === -1 && value === undefined) {
17
+ return
18
+ }
19
+ // add
20
+ if (existingAttributeIndex === -1 && value !== undefined) {
21
+ attrs.push({
22
+ name: key,
23
+ value,
24
+ })
25
+ return
26
+ }
27
+ // remove
28
+ if (value === undefined) {
29
+ attrs.splice(existingAttributeIndex, 1)
30
+ return
31
+ }
32
+ // update
33
+ attrs[existingAttributeIndex].value = value
34
+ })
35
+ }
36
+
37
+ const getHtmlAttributeByName = (htmlNode, attributeName) => {
38
+ const attrs = htmlNode.attrs
39
+ const attribute = attrs
40
+ ? attrs.find((attr) => attr.name === attributeName)
41
+ : null
42
+ return attribute
43
+ }