ember-scoped-css 2.2.1 → 3.0.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.
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import rewriteHbs from './rewriteHbs.js';
4
+
5
+ const postfix = 'pfx';
6
+
7
+ /** Add the postfix class to elements that carry one of `attributeNames`. */
8
+ function scopeByAttribute(hbs, attributeNames) {
9
+ return rewriteHbs(
10
+ hbs,
11
+ new Set(),
12
+ new Set(),
13
+ postfix,
14
+ new Set(attributeNames),
15
+ );
16
+ }
17
+
18
+ describe('attribute scoping', () => {
19
+ it('adds the postfix class to an element that has a scoped attribute', () => {
20
+ expect(scopeByAttribute('<input type="text">', ['type'])).to.equal(
21
+ '<input type="text" class="pfx">',
22
+ );
23
+ });
24
+
25
+ it('appends to an existing class attribute', () => {
26
+ expect(
27
+ scopeByAttribute('<input type="text" class="foo">', ['type']),
28
+ ).to.equal('<input type="text" class="foo pfx">');
29
+ });
30
+
31
+ it('matches attribute names case-insensitively, like CSS does', () => {
32
+ expect(scopeByAttribute('<input TYPE="text">', ['type'])).to.equal(
33
+ '<input TYPE="text" class="pfx">',
34
+ );
35
+ });
36
+
37
+ it('does not scope elements without the scoped attribute', () => {
38
+ expect(scopeByAttribute('<a href="/x">link</a>', ['type'])).to.equal(
39
+ '<a href="/x">link</a>',
40
+ );
41
+ });
42
+
43
+ it('adds the postfix class only once when tag and attribute both match', () => {
44
+ const out = rewriteHbs(
45
+ '<input type="text">',
46
+ new Set(),
47
+ new Set(['input']),
48
+ postfix,
49
+ new Set(['type']),
50
+ );
51
+
52
+ expect(out).to.equal('<input type="text" class="pfx">');
53
+ });
54
+
55
+ describe('component invocations are scoped too', () => {
56
+ // The postfix class reaches the component's root element through
57
+ // `...attributes`, the same way the matched attribute does.
58
+ it('scopes capitalized components', () => {
59
+ expect(scopeByAttribute('<Foo type="text" />', ['type'])).to.equal(
60
+ '<Foo type="text" class="pfx" />',
61
+ );
62
+ });
63
+
64
+ it('scopes path-based components', () => {
65
+ expect(scopeByAttribute('<foo.bar type="text" />', ['type'])).to.equal(
66
+ '<foo.bar type="text" class="pfx" />',
67
+ );
68
+ });
69
+ });
70
+
71
+ describe('dynamic class values still receive the postfix class', () => {
72
+ it('appends to a mustache class by wrapping it in a concat', () => {
73
+ expect(
74
+ scopeByAttribute('<input type="text" class={{this.cls}}>', ['type']),
75
+ ).to.equal('<input type="text" class="{{this.cls}} pfx">');
76
+ });
77
+
78
+ it('appends to a concat class', () => {
79
+ expect(
80
+ scopeByAttribute('<input type="text" class="foo {{bar}}">', ['type']),
81
+ ).to.equal('<input type="text" class="foo {{bar}} pfx">');
82
+ });
83
+
84
+ it('appends to a mustache class for tag-scoped elements', () => {
85
+ const out = rewriteHbs(
86
+ '<div class={{this.cls}}></div>',
87
+ new Set(),
88
+ new Set(['div']),
89
+ postfix,
90
+ new Set(),
91
+ );
92
+
93
+ expect(out).to.equal('<div class="{{this.cls}} pfx"></div>');
94
+ });
95
+ });
96
+
97
+ describe('...attributes may deliver any scoped attribute at runtime', () => {
98
+ it('scopes elements that spread ...attributes', () => {
99
+ expect(scopeByAttribute('<div ...attributes></div>', ['type'])).to.equal(
100
+ '<div ...attributes class="pfx"></div>',
101
+ );
102
+ });
103
+
104
+ it('does not scope ...attributes when the CSS has no attribute selectors', () => {
105
+ expect(scopeByAttribute('<div ...attributes></div>', [])).to.equal(
106
+ '<div ...attributes></div>',
107
+ );
108
+ });
109
+ });
110
+
111
+ describe('non-attribute bindings are ignored', () => {
112
+ it('does not match named args', () => {
113
+ expect(scopeByAttribute('<Foo @type="text" />', ['type'])).to.equal(
114
+ '<Foo @type="text" />',
115
+ );
116
+ });
117
+ });
118
+ });
@@ -2,7 +2,35 @@ import * as recast from 'ember-template-recast';
2
2
 
3
3
  import { renameClass } from './renameClass.js';
4
4
 
5
- export function templatePlugin({ classes, tags, postfix }) {
5
+ /**
6
+ * Whether an element may carry an attribute that appears in a scoped
7
+ * attribute selector. This includes component invocations: a component
8
+ * receives the postfix class through its `...attributes` the same way it
9
+ * receives the matched attribute, so `[type="text"]` scopes
10
+ * `<Foo type="text">` just as it scopes `<input type="text">`.
11
+ *
12
+ * An element that spreads `...attributes` can receive any attribute from its
13
+ * caller at runtime, so it counts as long as the CSS scopes by attribute at
14
+ * all — the preserved attribute selector still decides which rules actually
15
+ * apply.
16
+ *
17
+ * `attributes` comes from parsing the CSS, so it can only contain valid
18
+ * attribute-selector names — `@args` can never be in it and needs no special
19
+ * handling.
20
+ *
21
+ * Names in `attributes` are lowercased at discovery (CSS matches HTML
22
+ * attribute names case-insensitively), so compare lowercased.
23
+ */
24
+ function elementHasScopedAttribute(node, attributes) {
25
+ if (attributes.size === 0) return false;
26
+
27
+ return node.attributes.some(
28
+ (attr) =>
29
+ attr.name === '...attributes' || attributes.has(attr.name.toLowerCase()),
30
+ );
31
+ }
32
+
33
+ export function templatePlugin({ classes, tags, attributes, postfix }) {
6
34
  let stack = [];
7
35
  // scoped-class is a global we allow in hbs
8
36
  // scopedClass is importable, and we'll error if someone tries to rename it
@@ -46,18 +74,34 @@ export function templatePlugin({ classes, tags, postfix }) {
46
74
  },
47
75
 
48
76
  ElementNode(node) {
49
- if (tags.has(node.tag)) {
50
- // check if class attribute already exists
51
- const classAttr = node.attributes.find((attr) => attr.name === 'class');
52
-
53
- if (classAttr) {
54
- classAttr.value.chars += ' ' + postfix;
55
- } else {
56
- // push class attribute
57
- node.attributes.push(
58
- recast.builders.attr('class', recast.builders.text(postfix)),
59
- );
60
- }
77
+ // An element is in scope if its tag matches a tag selector, or if it
78
+ // carries an attribute named in a scoped attribute selector. We add the
79
+ // postfix class at most once regardless of how many things matched.
80
+ const shouldScope =
81
+ tags.has(node.tag) || elementHasScopedAttribute(node, attributes);
82
+
83
+ if (!shouldScope) return;
84
+
85
+ // check if class attribute already exists
86
+ const classAttr = node.attributes.find((attr) => attr.name === 'class');
87
+
88
+ if (!classAttr) {
89
+ // push class attribute
90
+ node.attributes.push(
91
+ recast.builders.attr('class', recast.builders.text(postfix)),
92
+ );
93
+ } else if (classAttr.value.type === 'TextNode') {
94
+ classAttr.value.chars += ' ' + postfix;
95
+ } else if (classAttr.value.type === 'ConcatStatement') {
96
+ // class="foo {{bar}}"
97
+ classAttr.value.parts.push(recast.builders.text(' ' + postfix));
98
+ } else {
99
+ // class={{this.foo}} — wrap in a concat so we can append the text part
100
+ classAttr.value = recast.builders.concat([
101
+ classAttr.value,
102
+ recast.builders.text(' ' + postfix),
103
+ ]);
104
+ classAttr.quoteType = '"';
61
105
  }
62
106
  },
63
107
 
@@ -133,10 +177,16 @@ function getValue(path) {
133
177
  return path.original;
134
178
  }
135
179
 
136
- export default function rewriteHbs(hbs, classes, tags, postfix) {
180
+ export default function rewriteHbs(
181
+ hbs,
182
+ classes,
183
+ tags,
184
+ postfix,
185
+ attributes = new Set(),
186
+ ) {
137
187
  let ast = recast.parse(hbs);
138
188
 
139
- recast.traverse(ast, templatePlugin({ classes, tags, postfix }));
189
+ recast.traverse(ast, templatePlugin({ classes, tags, attributes, postfix }));
140
190
 
141
191
  let result = recast.print(ast);
142
192