Files
postcss/lib/node.js
T
Mahin AnowarandGitHub 9e1092edf0 Don't clone root-less child nodes in container constructor (#2097)
The container constructor cloned every real Node passed in `nodes`, so a
freshly created (parent-less) node was adopted as a copy and the caller's
original reference never had its `parent` set — later operating on it threw
`Cannot read properties of undefined`. The clone was added only to keep the
source tree intact when moving nodes between roots, so it's skipped when the
node has no parent. Nodes that already belong to another tree are still
cloned, preserving the existing behavior. Closes #1987.
2026-06-28 16:50:07 +02:00

457 lines
11 KiB
JavaScript

'use strict'
let CssSyntaxError = require('./css-syntax-error')
let Stringifier = require('./stringifier')
let stringify = require('./stringify')
let { isClean, my } = require('./symbols')
function cloneNode(obj, parent) {
let cloned = new obj.constructor()
for (let i in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, i)) {
/* c8 ignore next 2 */
continue
}
if (i === 'proxyCache') continue
let value = obj[i]
let type = typeof value
if (i === 'parent' && type === 'object') {
if (parent) cloned[i] = parent
} else if (i === 'source') {
cloned[i] = value
} else if (Array.isArray(value)) {
cloned[i] = value.map(j => cloneNode(j, cloned))
} else {
if (type === 'object' && value !== null) value = cloneNode(value)
cloned[i] = value
}
}
return cloned
}
function sourceOffset(inputCSS, position) {
// Not all custom syntaxes support `offset` in `source.start` and `source.end`
if (position && typeof position.offset !== 'undefined') {
return position.offset
}
let column = 1
let line = 1
let offset = 0
for (let i = 0; i < inputCSS.length; i++) {
if (line === position.line && column === position.column) {
offset = i
break
}
if (inputCSS[i] === '\n') {
column = 1
line += 1
} else {
column += 1
}
}
return offset
}
class Node {
get proxyOf() {
return this
}
constructor(defaults = {}) {
this.raws = {}
this[isClean] = false
this[my] = true
for (let name in defaults) {
if (name === 'nodes') {
this.nodes = []
for (let node of defaults[name]) {
// Clone only nodes that already belong to another tree, so passing a
// freshly created (parent-less) node adopts that instance instead of
// a copy and keeps the caller's reference usable. See #1987.
if (typeof node.clone === 'function' && node.parent) {
this.append(node.clone())
} else {
this.append(node)
}
}
} else {
this[name] = defaults[name]
}
}
}
addToError(error) {
error.postcssNode = this
if (error.stack && this.source && /\n\s{4}at /.test(error.stack)) {
let s = this.source
error.stack = error.stack.replace(
/\n\s{4}at /,
`$&${s.input.from}:${s.start.line}:${s.start.column}$&`
)
}
return error
}
after(add) {
this.parent.insertAfter(this, add)
return this
}
assign(overrides = {}) {
for (let name in overrides) {
this[name] = overrides[name]
}
return this
}
before(add) {
this.parent.insertBefore(this, add)
return this
}
cleanRaws(keepBetween) {
delete this.raws.before
delete this.raws.after
if (!keepBetween) delete this.raws.between
}
clone(overrides = {}) {
let cloned = cloneNode(this)
for (let name in overrides) {
cloned[name] = overrides[name]
}
return cloned
}
cloneAfter(overrides = {}) {
let cloned = this.clone(overrides)
this.parent.insertAfter(this, cloned)
return cloned
}
cloneBefore(overrides = {}) {
let cloned = this.clone(overrides)
this.parent.insertBefore(this, cloned)
return cloned
}
error(message, opts = {}) {
if (this.source) {
let { end, start } = this.rangeBy(opts)
return this.source.input.error(
message,
{ column: start.column, line: start.line },
{ column: end.column, line: end.line },
opts
)
}
return new CssSyntaxError(message)
}
getProxyProcessor() {
return {
get(node, prop) {
if (prop === 'proxyOf') {
return node
} else if (prop === 'root') {
return () => node.root().toProxy()
} else {
return node[prop]
}
},
set(node, prop, value) {
if (node[prop] === value) return true
node[prop] = value
if (
prop === 'prop' ||
prop === 'value' ||
prop === 'name' ||
prop === 'params' ||
prop === 'important' ||
/* c8 ignore next */
prop === 'text'
) {
node.markDirty()
}
return true
}
}
}
/* c8 ignore next 3 */
markClean() {
this[isClean] = true
}
markDirty() {
if (this[isClean]) {
this[isClean] = false
let next = this
while ((next = next.parent)) {
next[isClean] = false
}
}
}
next() {
if (!this.parent) return undefined
let index = this.parent.index(this)
return this.parent.nodes[index + 1]
}
positionBy(opts = {}) {
let inputString =
'document' in this.source.input
? this.source.input.document
: this.source.input.css
let pos = {
column: this.source.start.column,
line: this.source.start.line,
offset: sourceOffset(inputString, this.source.start)
}
if (opts.index) {
pos = this.positionInside(opts.index)
} else if (opts.word) {
let stringRepresentation = inputString.slice(
sourceOffset(inputString, this.source.start),
sourceOffset(inputString, this.source.end)
)
let index = stringRepresentation.indexOf(opts.word)
if (index !== -1) pos = this.positionInside(index)
}
return pos
}
positionInside(index) {
let column = this.source.start.column
let line = this.source.start.line
let inputString =
'document' in this.source.input
? this.source.input.document
: this.source.input.css
let offset = sourceOffset(inputString, this.source.start)
let end = offset + index
for (let i = offset; i < end; i++) {
if (inputString[i] === '\n') {
column = 1
line += 1
} else {
column += 1
}
}
return { column, line, offset: end }
}
prev() {
if (!this.parent) return undefined
let index = this.parent.index(this)
return this.parent.nodes[index - 1]
}
rangeBy(opts = {}) {
let inputString =
'document' in this.source.input
? this.source.input.document
: this.source.input.css
let start = {
column: this.source.start.column,
line: this.source.start.line,
offset: sourceOffset(inputString, this.source.start)
}
let end = this.source.end
? {
column: this.source.end.column + 1,
line: this.source.end.line,
offset:
typeof this.source.end.offset === 'number'
? // `source.end.offset` is exclusive, so we don't need to add 1
this.source.end.offset
: // Since line/column in this.source.end is inclusive,
// the `sourceOffset(... , this.source.end)` returns an inclusive offset.
// So, we add 1 to convert it to exclusive.
sourceOffset(inputString, this.source.end) + 1
}
: {
column: start.column + 1,
line: start.line,
offset: start.offset + 1
}
if (opts.word) {
let stringRepresentation = inputString.slice(
sourceOffset(inputString, this.source.start),
sourceOffset(inputString, this.source.end)
)
let index = stringRepresentation.indexOf(opts.word)
if (index !== -1) {
start = this.positionInside(index)
end = this.positionInside(index + opts.word.length)
}
} else {
if (opts.start) {
start = {
column: opts.start.column,
line: opts.start.line,
offset: sourceOffset(inputString, opts.start)
}
} else if (typeof opts.index === 'number') {
start = this.positionInside(opts.index)
}
if (opts.end) {
end = {
column: opts.end.column,
line: opts.end.line,
offset: sourceOffset(inputString, opts.end)
}
} else if (typeof opts.endIndex === 'number') {
end = this.positionInside(opts.endIndex)
} else if (typeof opts.index === 'number') {
end = this.positionInside(opts.index + 1)
}
}
if (
end.line < start.line ||
(end.line === start.line && end.column <= start.column)
) {
end = {
column: start.column + 1,
line: start.line,
offset: start.offset + 1
}
}
return { end, start }
}
raw(prop, defaultType) {
let str = new Stringifier()
return str.raw(this, prop, defaultType)
}
remove() {
if (this.parent) {
this.parent.removeChild(this)
}
this.parent = undefined
return this
}
replaceWith(...nodes) {
if (this.parent) {
let bookmark = this
let foundSelf = false
for (let node of nodes) {
if (node === this) {
foundSelf = true
} else if (foundSelf) {
this.parent.insertAfter(bookmark, node)
bookmark = node
} else {
this.parent.insertBefore(bookmark, node)
}
}
if (!foundSelf) {
this.remove()
}
}
return this
}
root() {
let result = this
while (result.parent && result.parent.type !== 'document') {
result = result.parent
}
return result
}
toJSON(_, inputs) {
let fixed = {}
let emitInputs = inputs == null
inputs = inputs || new Map()
let inputsNextIndex = 0
for (let name in this) {
if (!Object.prototype.hasOwnProperty.call(this, name)) {
/* c8 ignore next 2 */
continue
}
if (name === 'parent' || name === 'proxyCache') continue
let value = this[name]
if (Array.isArray(value)) {
fixed[name] = value.map(i => {
if (typeof i === 'object' && i.toJSON) {
return i.toJSON(null, inputs)
} else {
return i
}
})
} else if (typeof value === 'object' && value.toJSON) {
fixed[name] = value.toJSON(null, inputs)
} else if (name === 'source') {
if (value == null) continue
let inputId = inputs.get(value.input)
if (inputId == null) {
inputId = inputsNextIndex
inputs.set(value.input, inputsNextIndex)
inputsNextIndex++
}
fixed[name] = {
end: value.end,
inputId,
start: value.start
}
} else {
fixed[name] = value
}
}
if (emitInputs) {
fixed.inputs = [...inputs.keys()].map(input => input.toJSON())
}
return fixed
}
toProxy() {
if (!this.proxyCache) {
this.proxyCache = new Proxy(this, this.getProxyProcessor())
}
return this.proxyCache
}
toString(stringifier = stringify) {
if (stringifier.stringify) stringifier = stringifier.stringify
let result = ''
stringifier(this, i => {
result += i
})
return result
}
warn(result, text, opts = {}) {
let data = { node: this }
for (let i in opts) data[i] = opts[i]
return result.warn(text, data)
}
}
module.exports = Node
Node.default = Node