Skip to content

Commit 7bd1b86

Browse files
authored
feat: new attribute scope style strategy (#7893)
1 parent ba73dea commit 7bd1b86

9 files changed

Lines changed: 101 additions & 29 deletions

File tree

.changeset/neat-suns-search.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'astro': major
3+
---
4+
5+
Implements a new scope style strategy called `"attribute"`. When enabled, styles are applied using `data-*` attributes.
6+
7+
The **default** value of `scopedStyleStrategy` is `"attribute"`.
8+
9+
If you want to use the previous behaviour, you have to use the `"where"` option:
10+
11+
```diff
12+
import { defineConfig } from 'astro/config';
13+
14+
export default defineConfig({
15+
+ scopedStyleStrategy: 'where',
16+
});
17+
```

packages/astro/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@
116116
"test:e2e:match": "playwright test -g"
117117
},
118118
"dependencies": {
119-
"@astrojs/compiler": "^1.8.0",
119+
"@astrojs/compiler": "^1.8.1",
120120
"@astrojs/internal-helpers": "workspace:*",
121121
"@astrojs/markdown-remark": "workspace:*",
122122
"@astrojs/telemetry": "workspace:*",

packages/astro/src/@types/astro.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ import type { AstroConfigSchema } from '../core/config';
2020
import type { AstroTimer } from '../core/config/timer';
2121
import type { AstroCookies } from '../core/cookies';
2222
import type { LogOptions, LoggerLevel } from '../core/logger/core';
23-
import { AstroIntegrationLogger } from '../core/logger/core';
23+
import type { AstroIntegrationLogger } from '../core/logger/core';
2424
import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server';
2525
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
26+
2627
export type {
2728
MarkdownHeading,
2829
MarkdownMetadata,
@@ -609,19 +610,21 @@ export interface AstroUserConfig {
609610
/**
610611
* @docs
611612
* @name scopedStyleStrategy
612-
* @type {('where' | 'class')}
613+
* @type {('where' | 'class' | 'attribute')}
613614
* @default `'where'`
614615
* @version 2.4
615616
* @description
616617
*
617618
* Specify the strategy used for scoping styles within Astro components. Choose from:
618-
* - `'where'` - Use `:where` selectors, causing no specifity increase.
619-
* - `'class'` - Use class-based selectors, causing a +1 specifity increase.
619+
* - `'where'` - Use `:where` selectors, causing no specifity increase.
620+
* - `'class'` - Use class-based selectors, causing a +1 specifity increase.
621+
* - `'attribute'` - Use `data-` attributes, causing no specifity increase.
620622
*
621623
* Using `'class'` is helpful when you want to ensure that element selectors within an Astro component override global style defaults (e.g. from a global stylesheet).
622624
* Using `'where'` gives you more control over specifity, but requires that you use higher-specifity selectors, layers, and other tools to control which selectors are applied.
625+
* Using `'attribute'` is useful in case there's manipulation of the class attributes, so the styling emitted by Astro doesn't go in conflict with the user's business logic.
623626
*/
624-
scopedStyleStrategy?: 'where' | 'class';
627+
scopedStyleStrategy?: 'where' | 'class' | 'attribute';
625628

626629
/**
627630
* @docs

packages/astro/src/core/config/schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ export const AstroConfigSchema = z.object({
8787
.optional()
8888
.default('static'),
8989
scopedStyleStrategy: z
90-
.union([z.literal('where'), z.literal('class')])
90+
.union([z.literal('where'), z.literal('class'), z.literal('attribute')])
9191
.optional()
92-
.default('where'),
92+
.default('attribute'),
9393
adapter: z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }).optional(),
9494
integrations: z.preprocess(
9595
// preprocess

packages/astro/test/0-css.test.js

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,27 @@ describe('CSS', function () {
3939
it('HTML and CSS scoped correctly', async () => {
4040
const el1 = $('#dynamic-class');
4141
const el2 = $('#dynamic-vis');
42-
const classes = $('#class').attr('class').split(' ');
43-
const scopedClass = classes.find((name) => /^astro-[A-Za-z0-9-]+/.test(name));
42+
const classes = $('#class');
43+
let scopedAttribute;
44+
for (const [key] of Object.entries(classes[0].attribs)) {
45+
if (/^data-astro-cid-[A-Za-z0-9-]+/.test(key)) {
46+
// Ema: this is ugly, but for reasons that I don't want to explore, cheerio
47+
// lower case the hash of the attribute
48+
scopedAttribute = key
49+
.toUpperCase()
50+
.replace('data-astro-cid-'.toUpperCase(), 'data-astro-cid-');
51+
}
52+
}
53+
if (!scopedAttribute) {
54+
throw new Error("Couldn't find scoped attribute");
55+
}
4456

4557
// 1. check HTML
46-
expect(el1.attr('class')).to.equal(`blue ${scopedClass}`);
47-
expect(el2.attr('class')).to.equal(`visible ${scopedClass}`);
58+
expect(el1.attr('class')).to.equal(`blue`);
59+
expect(el2.attr('class')).to.equal(`visible`);
4860

4961
// 2. check CSS
50-
const expected = `.blue:where(.${scopedClass}){color:#b0e0e6}.color\\:blue:where(.${scopedClass}){color:#b0e0e6}.visible:where(.${scopedClass}){display:block}`;
62+
const expected = `.blue[${scopedAttribute}],.color\\:blue[${scopedAttribute}]{color:#b0e0e6}.visible[${scopedAttribute}]{display:block}`;
5163
expect(bundledCSS).to.include(expected);
5264
});
5365

@@ -60,8 +72,12 @@ describe('CSS', function () {
6072
expect($('#no-scope').attr('class')).to.equal(undefined);
6173
});
6274

63-
it('Child inheritance', async () => {
64-
expect($('#passed-in').attr('class')).to.match(/outer astro-[A-Z0-9]+ astro-[A-Z0-9]+/);
75+
it('Child inheritance', (done) => {
76+
for (const [key] of Object.entries($('#passed-in')[0].attribs)) {
77+
if (/^data-astro-cid-[A-Za-z0-9-]+/.test(key)) {
78+
done();
79+
}
80+
}
6581
});
6682

6783
it('Using hydrated components adds astro-island styles', async () => {
@@ -70,11 +86,11 @@ describe('CSS', function () {
7086
});
7187

7288
it('<style lang="sass">', async () => {
73-
expect(bundledCSS).to.match(new RegExp('h1\\:where\\(.astro-[^{]*{color:#90ee90}'));
89+
expect(bundledCSS).to.match(new RegExp('h1\\[data-astro-cid-[^{]*{color:#90ee90}'));
7490
});
7591

7692
it('<style lang="scss">', async () => {
77-
expect(bundledCSS).to.match(new RegExp('h1\\:where\\(.astro-[^{]*{color:#ff69b4}'));
93+
expect(bundledCSS).to.match(new RegExp('h1\\[data-astro-cid-[^{]*{color:#ff69b4}'));
7894
});
7995
});
8096

@@ -331,10 +347,10 @@ describe('CSS', function () {
331347
it('resolves Astro styles', async () => {
332348
const allInjectedStyles = $('style').text();
333349

334-
expect(allInjectedStyles).to.contain('.linked-css:where(.astro-');
335-
expect(allInjectedStyles).to.contain('.linked-sass:where(.astro-');
336-
expect(allInjectedStyles).to.contain('.linked-scss:where(.astro-');
337-
expect(allInjectedStyles).to.contain('.wrapper:where(.astro-');
350+
expect(allInjectedStyles).to.contain('.linked-css[data-astro-cid-');
351+
expect(allInjectedStyles).to.contain('.linked-sass[data-astro-cid-');
352+
expect(allInjectedStyles).to.contain('.linked-scss[data-astro-cid-');
353+
expect(allInjectedStyles).to.contain('.wrapper[data-astro-cid-');
338354
});
339355

340356
it('resolves Styles from React', async () => {

packages/astro/test/astro-partial-html.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('Partial HTML', async () => {
2626

2727
// test 2: correct CSS present
2828
const allInjectedStyles = $('style').text();
29-
expect(allInjectedStyles).to.match(/\:where\(\.astro-[^{]+{color:red}/);
29+
expect(allInjectedStyles).to.match(/\[data-astro-cid-[^{]+{color:red}/);
3030
});
3131

3232
it('injects framework styles', async () => {

packages/astro/test/config-vite-css-target.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('CSS', function () {
3232

3333
it('vite.build.cssTarget is respected', async () => {
3434
expect(bundledCSS).to.match(
35-
new RegExp('.class\\:where\\(.astro-[^{]*{top:0;right:0;bottom:0;left:0}')
35+
new RegExp('.class\\[data-astro-[^{]*{top:0;right:0;bottom:0;left:0}')
3636
);
3737
});
3838
});

packages/astro/test/scoped-style-strategy.test.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import * as cheerio from 'cheerio';
33
import { loadFixture } from './test-utils.js';
44

55
describe('scopedStyleStrategy', () => {
6-
describe('default', () => {
6+
describe('scopedStyleStrategy: "where"', () => {
77
/** @type {import('./test-utils').Fixture} */
88
let fixture;
99
let stylesheet;
1010

1111
before(async () => {
1212
fixture = await loadFixture({
1313
root: './fixtures/scoped-style-strategy/',
14+
scopedStyleStrategy: 'where',
1415
});
1516
await fixture.build();
1617

@@ -57,4 +58,35 @@ describe('scopedStyleStrategy', () => {
5758
expect(stylesheet).to.match(/h1\.astro/);
5859
});
5960
});
61+
62+
describe('default', () => {
63+
/** @type {import('./test-utils').Fixture} */
64+
let fixture;
65+
let stylesheet;
66+
67+
before(async () => {
68+
fixture = await loadFixture({
69+
root: './fixtures/scoped-style-strategy/',
70+
});
71+
await fixture.build();
72+
73+
const html = await fixture.readFile('/index.html');
74+
const $ = cheerio.load(html);
75+
const $link = $('link[rel=stylesheet]');
76+
const href = $link.attr('href');
77+
stylesheet = await fixture.readFile(href);
78+
});
79+
80+
it('does not include :where pseudo-selector', () => {
81+
expect(stylesheet).to.not.match(/:where/);
82+
});
83+
84+
it('does not include the class name directly in the selector', () => {
85+
expect(stylesheet).to.not.match(/h1\.astro/);
86+
});
87+
88+
it('includes the data attribute hash', () => {
89+
expect(stylesheet).to.include('h1[data-astro-cid-');
90+
});
91+
});
6092
});

pnpm-lock.yaml

Lines changed: 9 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)