Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/yummy-streets-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stylelint": patch
---

Fixed: `no-invalid-position-declaration` false positives for embedded styles
36 changes: 36 additions & 0 deletions lib/rules/no-invalid-position-declaration/__tests__/index.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { stripIndent } from 'common-tags';

import naiveCssInJs from '../../../__tests__/fixtures/postcss-naive-css-in-js.cjs';
import rule from '../index.mjs';
const { messages, ruleName } = rule;

Expand Down Expand Up @@ -316,3 +317,38 @@ testRule({
},
],
});

testRule({
ruleName,
config: true,
customSyntax: 'postcss-html',
accept: [
{
code: '<a style="color: red"></a>',
description: 'HTML style attribute',
},
],
reject: [
{
skip: true,
code: '<style>color: red</style>',
message: messages.rejected,
line: 1,
column: 6,
endLine: 1,
endColumn: 10,
},
],
});

testRule({
ruleName,
config: true,
customSyntax: naiveCssInJs,
accept: [
{
code: 'css` color: red; `;',
description: 'CSS-in-JS template literal',
},
],
});
4 changes: 4 additions & 0 deletions lib/rules/no-invalid-position-declaration/index.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions lib/rules/no-invalid-position-declaration/index.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isAtRule, isRule } from '../../utils/typeGuards.mjs';
import findNodeUpToRoot from '../../utils/findNodeUpToRoot.mjs';
import isInDocument from '../../utils/isInDocument.mjs';
import isStandardSyntaxDeclaration from '../../utils/isStandardSyntaxDeclaration.mjs';
import { nestingSupportedAtKeywords } from '../../reference/atKeywords.mjs';
import report from '../../utils/report.mjs';
Expand All @@ -24,6 +25,9 @@ const rule = (primary) => {
if (!validOptions) return;

root.walkDecls((decl) => {
// Skip checking declarations in embedded styles
if (isInDocument(decl)) return;

if (!isStandardSyntaxDeclaration(decl)) return;

const { parent } = decl;
Expand Down
234 changes: 234 additions & 0 deletions lib/utils/__tests__/isInDocument.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import postcss from 'postcss';
import postcssHtml from 'postcss-html';

import isInDocument from '../isInDocument.mjs';
import naiveCssInJs from '../../__tests__/fixtures/postcss-naive-css-in-js.cjs';

describe('isInDocument', () => {
it('returns false for nodes not in a document', () => {
const root = postcss.parse('a { color: red; }');
const {
first: { first: decl },
} = root;

expect(isInDocument(decl)).toBe(false);
});

it('returns false for root nodes', () => {
const root = postcss.parse('a { color: red; }');

expect(isInDocument(root)).toBe(false);
});

it('returns true for nodes directly in a document', () => {
const document = postcss.document();
const root = postcss.parse('a { color: red; }');

document.append(root);

expect(isInDocument(root)).toBe(true);
});

it('returns true for nested nodes in a document', () => {
const document = postcss.document();
const root = postcss.parse('a { color: red; --custom: blue; }');

document.append(root);

const decls = [];

root.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(2);
expect(isInDocument(decls[0])).toBe(true); // color: red
expect(isInDocument(decls[1])).toBe(true); // --custom: blue
});

it('returns true for deeply nested nodes in a document', () => {
const document = postcss.document();
const root = postcss.parse(`
@media (min-width: 600px) {
a {
color: red;
@supports (display: grid) {
display: grid;
}
}
}
`);

document.append(root);

const decls = [];

root.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(2);
expect(isInDocument(decls[0])).toBe(true); // color: red
expect(isInDocument(decls[1])).toBe(true); // display: grid
});

it('returns true for CSS-in-JS with naiveCssInJs parser', () => {
const document = naiveCssInJs.parse('css` color: red; `;');
const decls = [];

document.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(1);
expect(isInDocument(decls[0])).toBe(true);
});

it('returns true for multiple CSS-in-JS blocks', () => {
const document = naiveCssInJs.parse(`
css\` color: red; \`;
css\` font-size: 14px; --custom: blue; \`;
`);
const decls = [];

document.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(3);
expect(isInDocument(decls[0])).toBe(true); // color: red
expect(isInDocument(decls[1])).toBe(true); // font-size: 14px
expect(isInDocument(decls[2])).toBe(true); // --custom: blue
});

it('returns false for nodes without parent', () => {
const decl = postcss.decl({ prop: 'color', value: 'red' });

expect(isInDocument(decl)).toBe(false);
});

it('returns true for nodes with document property', () => {
const document = postcss.document();
const mockNode = postcss.decl({ prop: 'color', value: 'blue' });

// Mock a node that has a document property
mockNode.document = document;

expect(isInDocument(mockNode)).toBe(true);
});

it('returns false for nodes with non-document document property', () => {
const mockNode = postcss.decl({ prop: 'color', value: 'blue' });

// Mock a node that has a document property but it's not a Document
mockNode.document = 'not-a-document';

expect(isInDocument(mockNode)).toBe(false);
});

it('returns true for document nodes themselves', () => {
const document = postcss.document();

expect(isInDocument(document)).toBe(true);
});

it('handles complex nested document structures', () => {
const outerDocument = postcss.document();
const innerDocument = postcss.document();
const root = postcss.parse('a { color: red; }');

innerDocument.append(root);
outerDocument.append(innerDocument);

const decl = root.first.first;

expect(isInDocument(decl)).toBe(true);
});

describe('with postcss-html parser', () => {
it('returns true for declarations in HTML style tags', () => {
const document = postcssHtml.parse('<style>a { color: red; --custom: blue; }</style>');
const decls = [];

document.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(2);
expect(isInDocument(decls[0])).toBe(true); // color: red
expect(isInDocument(decls[1])).toBe(true); // --custom: blue
});

it('returns true for declarations in HTML style attributes', () => {
const document = postcssHtml.parse('<div style="color: red; font-size: 14px;">content</div>');
const decls = [];

document.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(2);
expect(isInDocument(decls[0])).toBe(true); // color: red
expect(isInDocument(decls[1])).toBe(true); // font-size: 14px
});

it('returns true for declarations in multiple HTML style sources', () => {
const document = postcssHtml.parse(`
<style>body { margin: 0; }</style>
<div style="color: red;">content</div>
<style>.class { padding: 10px; }</style>
`);
const decls = [];

document.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(3);
expect(isInDocument(decls[0])).toBe(true); // margin: 0
expect(isInDocument(decls[1])).toBe(true); // color: red
expect(isInDocument(decls[2])).toBe(true); // padding: 10px
});

it('returns true for deeply nested declarations in HTML', () => {
const document = postcssHtml.parse(`
<style>
@media (min-width: 600px) {
.container {
display: grid;
@supports (display: grid) {
grid-template-columns: 1fr 1fr;
}
}
}
</style>
`);
const decls = [];

document.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(2);
expect(isInDocument(decls[0])).toBe(true); // display: grid
expect(isInDocument(decls[1])).toBe(true); // grid-template-columns
});

it('returns true for custom properties in HTML style attributes', () => {
const document = postcssHtml.parse(
'<div style="--theme-color: #ff0000; color: var(--theme-color);">content</div>',
);
const decls = [];

document.walkDecls((decl) => decls.push(decl));

expect(decls).toHaveLength(2);
expect(isInDocument(decls[0])).toBe(true); // --theme-color
expect(isInDocument(decls[1])).toBe(true); // color
});

it('handles edge case where root.parent might be undefined', () => {
// Test cases where HTML parsers may not properly set parent relationships
// Some parsers create Document nodes but don't always link Root.parent to Document
const document = postcssHtml.parse('<style>a { color: red; }</style>');
const root = document.first;
const decl = root.first.first;

// Verify the expected AST structure: Document -> Root -> Rule -> Declaration
expect(document.type).toBe('document');
expect(root.type).toBe('root');

// In some HTML parsing scenarios, root.parent may be undefined
// despite the root being contained within a document
expect(root.parent).toBeUndefined();

// Our function should still correctly detect that the declaration
// is within a document context through alternative means
expect(isInDocument(decl)).toBe(true);
});
});
});
33 changes: 33 additions & 0 deletions lib/utils/isInDocument.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions lib/utils/isInDocument.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { isDocument } from './typeGuards.mjs';

/**
* @param {import('postcss').Node} node
* @returns {boolean}
*/
export default function isInDocument(node) {
let current = node;

while (current) {
if (isDocument(current)) return true;

// Check for unofficial 'document' property from parsers like postcss-html
if (
'document' in current &&
current.document &&
isDocument(/** @type {import('postcss').Document} */ (current.document))
)
return true;

if (!current.parent) break;

current = current.parent;
}

return false;
}
Loading