ember-scoped-css 2.1.0 → 2.2.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/dist/cjs/all.cjs CHANGED
@@ -1,3 +1,3 @@
1
- const require_all = require('./all-Bimr-GZ4.cjs');
1
+ const require_all = require('./all-CFsaG5pM.cjs');
2
2
 
3
3
  exports.scopedCSS = require_all.scopedCSS;
@@ -1,4 +1,4 @@
1
- const require_all = require('./all-Bimr-GZ4.cjs');
1
+ const require_all = require('./all-CFsaG5pM.cjs');
2
2
 
3
3
  //#region src/build/public-exports/babel.js
4
4
  const scopedCSS = require_all.scopedCSS.babel;
@@ -1,4 +1,4 @@
1
- const require_all = require('./all-Bimr-GZ4.cjs');
1
+ const require_all = require('./all-CFsaG5pM.cjs');
2
2
 
3
3
  //#region src/build/public-exports/rollup.js
4
4
  const scopedCSS = require_all.scopedCSS.rollup;
package/dist/cjs/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- const require_all = require('./all-Bimr-GZ4.cjs');
1
+ const require_all = require('./all-CFsaG5pM.cjs');
2
2
 
3
3
  //#region src/build/public-exports/vite.js
4
4
  const scopedCSS = require_all.scopedCSS.vite;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-scoped-css",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "ember-addon"
@@ -62,6 +62,7 @@
62
62
  "ember-template-recast": "^6.1.5",
63
63
  "glob": "^8.1.0",
64
64
  "postcss": "^8.5.3",
65
+ "postcss-scss": "^4.0.9",
65
66
  "postcss-selector-parser": "^6.0.16",
66
67
  "recast": "^0.23.7",
67
68
  "unplugin": "^2.3.10"
@@ -75,7 +76,7 @@
75
76
  "@types/babel__core": "^7.20.5",
76
77
  "@types/common-tags": "^1.8.4",
77
78
  "@types/jscodeshift": "^17.3.0",
78
- "@vitest/coverage-v8": "^3.2.4",
79
+ "@vitest/coverage-v8": "^4.1.0",
79
80
  "babel-plugin-ember-template-compilation": "^3.0.1",
80
81
  "common-tags": "^1.8.2",
81
82
  "concurrently": "^9.2.1",
@@ -86,7 +87,7 @@
86
87
  "prettier": "^3.6.2",
87
88
  "tsdown": "^0.15.12",
88
89
  "typescript": "^5.2.2",
89
- "vitest": "^3.0.6",
90
+ "vitest": "^4.1.0",
90
91
  "webpack": "^5.98.0"
91
92
  },
92
93
  "ember-addon": {
@@ -122,18 +122,32 @@ export function createPlugin(config) {
122
122
 
123
123
  if (hasScopedAttribute(styleTag)) {
124
124
  let css = textContent(styleTag);
125
- let info = getCSSContentInfo(css);
125
+ let lang = getLangAttribute(styleTag);
126
+ let info = getCSSContentInfo(css, lang);
126
127
 
127
128
  addInfo(info);
128
129
 
129
- /**
130
- * This will be handled in ElementNode traversal
131
- */
132
- if (hasInlineAttribute(styleTag)) {
130
+ if (hasInlineAttributeWithoutLang(styleTag)) {
131
+ /**
132
+ * This will be handled in ElementNode traversal
133
+ */
133
134
  return;
134
135
  }
135
136
 
136
- let cssRequest = request.inline.create(info.id, postfix, css);
137
+ if (lang) {
138
+ /**
139
+ * For <style scoped inline lang="..."> we cannot preprocess at Babel-time
140
+ * (preprocessing is async and requires Vite's ResolvedConfig).
141
+ * Remove the tag and inject via virtual CSS module and warn user.
142
+ */
143
+ console.warn(
144
+ `[ember-scoped-css] <style scoped inline lang="${lang}"> is not supported ` +
145
+ `(preprocessing is async and cannot run at Babel-time). ` +
146
+ `Downgrading to non-inline: the style tag will be removed and injected as a virtual CSS module.`,
147
+ );
148
+ }
149
+
150
+ let cssRequest = request.inline.create(info.id, postfix, css, lang);
137
151
 
138
152
  env.meta.jsutils.importForSideEffect(cssRequest);
139
153
  }
@@ -155,7 +169,7 @@ export function createPlugin(config) {
155
169
  );
156
170
  }
157
171
 
158
- if (hasInlineAttribute(node)) {
172
+ if (hasInlineAttributeWithoutLang(node)) {
159
173
  let text = textContent(node);
160
174
  let scopedText = rewriteCss(
161
175
  text,
@@ -176,7 +190,7 @@ export function createPlugin(config) {
176
190
  return null;
177
191
  }
178
192
 
179
- if (hasInlineAttribute(node)) {
193
+ if (hasInlineAttributeWithoutLang(node)) {
180
194
  throw new Error(
181
195
  `<style inline> is not valid. Please add the scoped attribute: <style scoped inline>`,
182
196
  );
@@ -198,6 +212,7 @@ export function createPlugin(config) {
198
212
  */
199
213
  const SCOPED_ATTRIBUTE_NAME = 'scoped';
200
214
  const INLINE_ATTRIBUTE_NAME = 'inline';
215
+ const LANG_ATTRIBUTE_NAME = 'lang';
201
216
 
202
217
  function hasScopedAttribute(node) {
203
218
  if (!node) return;
@@ -209,16 +224,45 @@ function hasScopedAttribute(node) {
209
224
  );
210
225
  }
211
226
 
212
- function hasInlineAttribute(node) {
227
+ function hasInlineAttributeWithoutLang(node) {
213
228
  if (!node) return;
214
229
  if (node.tag !== 'style') return;
215
230
  if (node.type !== 'ElementNode') return;
216
231
 
232
+ if (getLangAttribute(node)) {
233
+ return false;
234
+ }
235
+
217
236
  return node.attributes.some(
218
237
  (attribute) => attribute.name === INLINE_ATTRIBUTE_NAME,
219
238
  );
220
239
  }
221
240
 
241
+ /**
242
+ * Returns the value of the `lang` attribute on a `<style>` node, or null if absent.
243
+ *
244
+ * @param {object} node
245
+ * @returns {string | null}
246
+ */
247
+ function getLangAttribute(node) {
248
+ if (!node) return null;
249
+ if (node.tag !== 'style') return null;
250
+ if (node.type !== 'ElementNode') return null;
251
+
252
+ const attr = node.attributes.find(
253
+ (attribute) => attribute.name === LANG_ATTRIBUTE_NAME,
254
+ );
255
+
256
+ if (!attr) return null;
257
+
258
+ // The attribute value is a TextNode child of the AttrNode's value
259
+ const value = attr.value;
260
+
261
+ if (value?.type === 'TextNode') return value.chars || null;
262
+
263
+ return null;
264
+ }
265
+
222
266
  function textContent(node) {
223
267
  let textChildren = node.children.filter((c) => c.type === 'TextNode');
224
268
 
@@ -2,7 +2,7 @@ import * as babel from '@babel/core';
2
2
  import { stripIndent } from 'common-tags';
3
3
  import { Preprocessor } from 'content-tag';
4
4
  import jscodeshift from 'jscodeshift';
5
- import { describe, expect, it } from 'vitest';
5
+ import { describe, expect, it, vi } from 'vitest';
6
6
 
7
7
  import { createPlugin } from './template-plugin.js';
8
8
 
@@ -28,6 +28,30 @@ async function transform(file: string, config = {}) {
28
28
  return result?.code;
29
29
  }
30
30
 
31
+ function virtualImportUrlsOf(file: string | null | undefined) {
32
+ if (!file) return [];
33
+
34
+ let j = jscodeshift;
35
+
36
+ let result: string[] = [];
37
+
38
+ j(file)
39
+ .find(j.ImportDeclaration, {
40
+ source: {
41
+ value: (value: string) => value.includes('.ember-scoped.css?css='),
42
+ },
43
+ })
44
+ .forEach((path) => {
45
+ let source = path.node.source.value;
46
+
47
+ if (typeof source === 'string') {
48
+ result.push(source);
49
+ }
50
+ });
51
+
52
+ return result;
53
+ }
54
+
31
55
  function templateContentsOf(file: string | null | undefined) {
32
56
  if (!file) return [];
33
57
 
@@ -194,3 +218,118 @@ it('scoped inline transforms correctly', async () => {
194
218
  ]
195
219
  `);
196
220
  });
221
+
222
+ describe('lang attribute (SCSS preprocessor)', () => {
223
+ it('scoped lang="scss" emits virtual import with lang param and rewrites classes', async () => {
224
+ let output = await transform(`
225
+ export const Foo = <template>
226
+ <div class="foo">hi</div>
227
+ <style scoped lang="scss">
228
+ .foo {
229
+ &:hover { color: blue; }
230
+ color: red;
231
+ }
232
+ </style>
233
+ </template>;
234
+ `);
235
+
236
+ // The style tag should be removed (it's a virtual module, not inline)
237
+ expect(templateContentsOf(output)).toMatchInlineSnapshot(`
238
+ [
239
+ "<div class="foo_e65d154a1">hi</div>",
240
+ ]
241
+ `);
242
+
243
+ // The virtual module import should include &lang=scss
244
+ expect(virtualImportUrlsOf(output)).toMatchInlineSnapshot(`
245
+ [
246
+ "./e65d154a1___css-3fbbf8c13a5ef6f5c5395268df4e8f37.ember-scoped.css?css=%0A%20%20%20%20%20%20%20%20%20%20.foo%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%26%3Ahover%20%7B%20color%3A%20blue%3B%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20color%3A%20red%3B%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20&lang=scss",
247
+ ]
248
+ `);
249
+ });
250
+
251
+ it('scoped inline lang="scss" is downgraded to non-inline (warning emitted, style tag removed)', async () => {
252
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
253
+
254
+ let output = await transform(`
255
+ export const Foo = <template>
256
+ <div class="foo">hi</div>
257
+ <style scoped inline lang="scss">
258
+ .foo {
259
+ &:hover { color: blue; }
260
+ color: red;
261
+ }
262
+ </style>
263
+ </template>;
264
+ `);
265
+
266
+ // The style tag should be removed (downgraded to non-inline)
267
+ expect(templateContentsOf(output)).toMatchInlineSnapshot(`
268
+ [
269
+ "<div class="foo_e65d154a1">hi</div>",
270
+ ]
271
+ `);
272
+
273
+ // A virtual module import should have been emitted with lang=scss
274
+ expect(virtualImportUrlsOf(output)).toMatchInlineSnapshot(`
275
+ [
276
+ "./e65d154a1___css-3fbbf8c13a5ef6f5c5395268df4e8f37.ember-scoped.css?css=%0A%20%20%20%20%20%20%20%20%20%20.foo%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%26%3Ahover%20%7B%20color%3A%20blue%3B%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20color%3A%20red%3B%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20&lang=scss",
277
+ ]
278
+ `);
279
+
280
+ // A warning should have been logged
281
+ expect(warnSpy).toHaveBeenCalledWith(
282
+ expect.stringContaining(
283
+ '<style scoped inline lang="scss"> is not supported',
284
+ ),
285
+ );
286
+
287
+ warnSpy.mockRestore();
288
+ });
289
+
290
+ it('handles scoped lang="scss" BEM constructs', async () => {
291
+ let output = await transform(`
292
+ export const Foo = <template>
293
+ <div class="block block--modifier">hi</div>
294
+ <style scoped lang="scss">
295
+ .block {
296
+ &--modifier { color: green; }
297
+ }
298
+ </style>
299
+ </template>;
300
+ `);
301
+
302
+ expect(templateContentsOf(output)).toMatchInlineSnapshot(`
303
+ [
304
+ "<div class="block_e65d154a1 block--modifier_e65d154a1">hi</div>",
305
+ ]
306
+ `);
307
+ });
308
+
309
+ it('handles deeply nested BEM constructs', async () => {
310
+ let output = await transform(`
311
+ export const Foo = <template>
312
+ <div class="block block--modifier block--modifier--modifier block--modifier--modifier--modifier">hi</div>
313
+ <style scoped lang="scss">
314
+ .block {
315
+ &--modifier {
316
+ color: green;
317
+ &--modifier {
318
+ color: green;
319
+ &--modifier {
320
+ color: green;
321
+ }
322
+ }
323
+ }
324
+ }
325
+ </style>
326
+ </template>;
327
+ `);
328
+
329
+ expect(templateContentsOf(output)).toMatchInlineSnapshot(`
330
+ [
331
+ "<div class="block_e65d154a1 block--modifier_e65d154a1 block--modifier--modifier_e65d154a1 block--modifier--modifier--modifier_e65d154a1">hi</div>",
332
+ ]
333
+ `);
334
+ });
335
+ });
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import path from 'node:path';
3
4
 
4
5
  import { rewriteCss } from '../lib/css/rewrite.js';
@@ -6,6 +7,15 @@ import { request } from '../lib/request.js';
6
7
 
7
8
  const META = 'scoped-css:colocated';
8
9
 
10
+ /** File extensions that Vite can preprocess via its CSS preprocessor pipeline */
11
+ const PREPROCESSED_EXTENSIONS = new Set([
12
+ '.scss',
13
+ '.sass',
14
+ '.less',
15
+ '.styl',
16
+ '.stylus',
17
+ ]);
18
+
9
19
  /**
10
20
  * Plugin for supporting colocated styles
11
21
  *
@@ -16,6 +26,12 @@ const META = 'scoped-css:colocated';
16
26
  export function colocated(options = {}) {
17
27
  const CWD = process.cwd();
18
28
 
29
+ /** @type {import('vite').ResolvedConfig | undefined} */
30
+ let viteConfig;
31
+
32
+ /** @type {((code: string, filename: string, config: unknown) => Promise<{ code: string }>) | undefined} */
33
+ let preprocessCSS;
34
+
19
35
  /**
20
36
  *
21
37
  * @param {string} id the request id / what was imported
@@ -73,18 +89,55 @@ export function colocated(options = {}) {
73
89
  }
74
90
  },
75
91
  vite: {
92
+ async configResolved(config) {
93
+ viteConfig = config;
94
+
95
+ // Resolve Vite's preprocessCSS from the app root to ensure we find
96
+ // the correct Vite installation (not a stale or missing one).
97
+ try {
98
+ const require = createRequire(config.root);
99
+ const vitePath = require.resolve('vite');
100
+ const viteModule = await import(vitePath);
101
+
102
+ preprocessCSS = viteModule.preprocessCSS;
103
+ } catch {
104
+ // Vite may not be resolvable from the config root in some setups;
105
+ // preprocessor support for colocated .scss files will throw a clear
106
+ // error at load time if used.
107
+ }
108
+ },
109
+
76
110
  /**
77
111
  * There may not be meta for this request yet.
78
112
  *
79
113
  * @param {*} id
80
114
  */
81
- load(id) {
115
+ async load(id) {
82
116
  if (request.is.colocated(id)) {
83
117
  const parsed = request.colocated.decode(id);
84
118
 
85
119
  let code = readFileSync(parsed.fileName, 'utf-8');
86
120
  let relativeFilePath = path.relative(CWD, parsed.fileName);
87
121
 
122
+ const ext = path.extname(parsed.fileName).toLowerCase();
123
+
124
+ if (PREPROCESSED_EXTENSIONS.has(ext)) {
125
+ if (!viteConfig || !preprocessCSS) {
126
+ throw new Error(
127
+ `[ember-scoped-css] Colocated CSS file with extension '${ext}' requires Vite. ` +
128
+ `CSS preprocessing is only supported in Vite builds.`,
129
+ );
130
+ }
131
+
132
+ const result = await preprocessCSS(
133
+ code,
134
+ parsed.fileName,
135
+ viteConfig,
136
+ );
137
+
138
+ code = result.code;
139
+ }
140
+
88
141
  let css = rewriteCss(
89
142
  code,
90
143
  parsed.postfix,
@@ -1,3 +1,4 @@
1
+ import { createRequire } from 'node:module';
1
2
  import path from 'node:path';
2
3
 
3
4
  import { rewriteCss } from '../lib/css/rewrite.js';
@@ -17,6 +18,12 @@ const META = 'scoped-css:inline';
17
18
  export function inline(options = {}) {
18
19
  const CWD = process.cwd();
19
20
 
21
+ /** @type {import('vite').ResolvedConfig | undefined} */
22
+ let viteConfig;
23
+
24
+ /** @type {((code: string, filename: string, config: unknown) => Promise<{ code: string }>) | undefined} */
25
+ let preprocessCSS;
26
+
20
27
  /**
21
28
  * @param {string} id the request id / what was imported
22
29
  */
@@ -25,22 +32,14 @@ export function inline(options = {}) {
25
32
 
26
33
  const relativeFilePath = path.relative(CWD, filePath);
27
34
 
28
- const css = rewriteCss(
29
- parsed.css,
30
- parsed.postfix,
31
- `<inline>/${relativeFilePath}`,
32
- options.layerName,
33
- );
34
-
35
- const nextId = filePath.split('?')[0];
36
-
37
35
  return {
38
- id: nextId,
36
+ id: filePath.split('?')[0],
39
37
  meta: {
40
38
  [META]: {
41
- css,
39
+ rawCss: parsed.css,
42
40
  postfix: parsed.postfix,
43
41
  fileName: relativeFilePath,
42
+ lang: parsed.lang,
44
43
  },
45
44
  },
46
45
  };
@@ -60,12 +59,53 @@ export function inline(options = {}) {
60
59
  return buildResponse(id, filePath);
61
60
  }
62
61
  },
63
- load(id) {
62
+ async load(id) {
64
63
  const meta = this.getModuleInfo(id)?.meta?.[META];
65
64
 
66
65
  if (meta) {
67
- return meta.css;
66
+ let rawCss = meta.rawCss;
67
+
68
+ if (meta.lang) {
69
+ if (!viteConfig || !preprocessCSS) {
70
+ throw new Error(
71
+ `[ember-scoped-css] <style scoped lang="${meta.lang}"> requires Vite. ` +
72
+ `CSS preprocessing via the 'lang' attribute is only supported in Vite builds.`,
73
+ );
74
+ }
75
+
76
+ const fakeFilename = `${meta.fileName}.${meta.lang}`;
77
+ const result = await preprocessCSS(rawCss, fakeFilename, viteConfig);
78
+
79
+ rawCss = result.code;
80
+ }
81
+
82
+ const css = rewriteCss(
83
+ rawCss,
84
+ meta.postfix,
85
+ `<inline>/${meta.fileName}`,
86
+ options.layerName,
87
+ );
88
+
89
+ return css;
68
90
  }
69
91
  },
92
+ vite: {
93
+ async configResolved(config) {
94
+ viteConfig = config;
95
+
96
+ // Resolve Vite's preprocessCSS from the app root to ensure we find
97
+ // the correct Vite installation (not a stale or missing one).
98
+ try {
99
+ const require = createRequire(config.root);
100
+ const vitePath = require.resolve('vite');
101
+ const viteModule = await import(vitePath);
102
+
103
+ preprocessCSS = viteModule.preprocessCSS;
104
+ } catch {
105
+ // Vite may not be resolvable from the config root in some setups;
106
+ // lang= support will throw a clear error at load time if used.
107
+ }
108
+ },
109
+ },
70
110
  };
71
111
  }
@@ -21,7 +21,7 @@ function isDeclaration(node) {
21
21
  * NOTE: "keyframes" is a singular definition, in that it's a block containing keyframes
22
22
  * using `@keyframes {}` with only one thing on the inside doesn't make sense.
23
23
  */
24
- function rewriteReferencable(node, postfix) {
24
+ function rewriteReferenceable(node, postfix) {
25
25
  let originalName = node.params;
26
26
  let postfixedName = node.params + SEP + postfix;
27
27
 
@@ -90,25 +90,25 @@ export function rewriteCss(css, postfix, fileName, layerName) {
90
90
  * kind => originalName => postfixedName
91
91
  * @type {{ [kind: string]: { [originalName: string]: string }}}
92
92
  */
93
- const referencables = {
93
+ const referenceables = {
94
94
  keyframes: {},
95
95
  'counter-style': {},
96
96
  'position-try': {},
97
97
  property: {},
98
98
  };
99
99
 
100
- const availableReferencables = new Set(Object.keys(referencables));
100
+ const availableReferenceables = new Set(Object.keys(referenceables));
101
101
 
102
- function isReferencable(node) {
102
+ function isReferenceable(node) {
103
103
  if (node.type !== 'atrule') return;
104
104
 
105
- return availableReferencables.has(node.name);
105
+ return availableReferenceables.has(node.name);
106
106
  }
107
107
 
108
108
  function updateDirectReferences(node) {
109
109
  if (!node.value) return;
110
110
 
111
- for (let [, map] of Object.entries(referencables)) {
111
+ for (let [, map] of Object.entries(referenceables)) {
112
112
  if (map[node.value]) {
113
113
  node.value = map[node.value];
114
114
  }
@@ -118,11 +118,11 @@ export function rewriteCss(css, postfix, fileName, layerName) {
118
118
  function updateShorthandContents(node) {
119
119
  if (node.prop === 'animation') {
120
120
  let parts = node.value.split(' ');
121
- let match = parts.filter((x) => referencables.keyframes[x]);
121
+ let match = parts.filter((x) => referenceables.keyframes[x]);
122
122
 
123
123
  if (match.length) {
124
124
  match.forEach((x) => {
125
- let replacement = referencables.keyframes[x];
125
+ let replacement = referenceables.keyframes[x];
126
126
 
127
127
  if (!replacement) return;
128
128
 
@@ -131,7 +131,9 @@ export function rewriteCss(css, postfix, fileName, layerName) {
131
131
  }
132
132
  }
133
133
 
134
- for (let [lookFor, replaceWith] of Object.entries(referencables.property)) {
134
+ for (let [lookFor, replaceWith] of Object.entries(
135
+ referenceables.property,
136
+ )) {
135
137
  let lookForVar = `var(${lookFor})`;
136
138
  let replaceWithVar = `var(${replaceWith})`;
137
139
 
@@ -141,27 +143,27 @@ export function rewriteCss(css, postfix, fileName, layerName) {
141
143
 
142
144
  /**
143
145
  * We have to do two passes:
144
- * 1. postfix all the referencable syntax
146
+ * 1. postfix all the referenceable syntax
145
147
  * 2. postfix as normal, but also checking values of CSS properties
146
- * that could match postfixed referencables from step 1
148
+ * that could match postfixed referenceables from step 1
147
149
  */
148
150
 
149
- // Step 1: find referencables
151
+ // Step 1: find referenceables
150
152
  ast.walk((node) => {
151
153
  /**
152
154
  * @keyframes, @counter-style, etc
153
155
  */
154
- if (isReferencable(node)) {
156
+ if (isReferenceable(node)) {
155
157
  let name = node.name;
156
- let { originalName, postfixedName } = rewriteReferencable(node, postfix);
158
+ let { originalName, postfixedName } = rewriteReferenceable(node, postfix);
157
159
 
158
- referencables[name][originalName] = postfixedName;
160
+ referenceables[name][originalName] = postfixedName;
159
161
 
160
162
  return;
161
163
  }
162
164
  });
163
165
 
164
- // Step 2: postfix and update refenced referencables
166
+ // Step 2: postfix and update referenced referenceables
165
167
  ast.walk((node) => {
166
168
  if (isDeclaration(node)) {
167
169
  updateDirectReferences(node);