poveste 0.3.0 → 0.3.2
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.
|
@@ -6,7 +6,7 @@ export function wrapUserCss(css, opts) {
|
|
|
6
6
|
return css;
|
|
7
7
|
}
|
|
8
8
|
const { hoisted, remainder } = extractHoistableAtRules(css);
|
|
9
|
-
const body = remainder
|
|
9
|
+
const body = hasRootSelector(remainder)
|
|
10
10
|
? rewriteRootToScope(remainder)
|
|
11
11
|
: remainder;
|
|
12
12
|
const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : '';
|
|
@@ -18,10 +18,10 @@ export function wrapChromeCss(css, opts) {
|
|
|
18
18
|
return css;
|
|
19
19
|
}
|
|
20
20
|
const { hoisted, remainder } = extractHoistableAtRules(css);
|
|
21
|
-
// Rewrite
|
|
21
|
+
// Rewrite root selectors to `:scope` so source-level `@scope (:root) to (...)`
|
|
22
22
|
// rules nest correctly under the outer scope; without this, the inner
|
|
23
23
|
// `:root` resolves to the document root which is outside the outer scope.
|
|
24
|
-
const body = remainder
|
|
24
|
+
const body = hasRootSelector(remainder)
|
|
25
25
|
? rewriteRootToScope(remainder)
|
|
26
26
|
: remainder;
|
|
27
27
|
const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : '';
|
|
@@ -34,21 +34,63 @@ export function isGlobalImport(id) {
|
|
|
34
34
|
const parts = id.slice(q + 1).split('&');
|
|
35
35
|
return parts.includes('global') || parts.some(p => p.startsWith('global='));
|
|
36
36
|
}
|
|
37
|
+
// `html` and `body` sit above the scoping root once the sheet is wrapped, so a
|
|
38
|
+
// rule targeting either can never match. Inside a story they mean the same
|
|
39
|
+
// thing `:root` does — the one root there is — which is what `:scope`
|
|
40
|
+
// resolves to. Leaving them out made `body { font-size: 14px }` inert with no
|
|
41
|
+
// error (#116); the chrome wrapper learned the same lesson in #102.
|
|
42
|
+
const ROOT_TYPE_SELECTORS = new Set(['html', 'body']);
|
|
43
|
+
// Only a gate on the lightningcss round-trip, so a false positive costs a
|
|
44
|
+
// reparse and nothing else. Word-bounded to skip `.sidebar-body` and friends;
|
|
45
|
+
// `(` is a boundary too, or a sheet whose only root is `:is(html)` would skip
|
|
46
|
+
// the pass that exists to rewrite it.
|
|
47
|
+
const ROOT_SELECTOR_RE = /:root|(?:^|[\s,{}>+~(])(?:html|body)\b/i;
|
|
48
|
+
function hasRootSelector(css) {
|
|
49
|
+
return ROOT_SELECTOR_RE.test(css);
|
|
50
|
+
}
|
|
51
|
+
// Type selectors are ASCII case-insensitive against HTML and lightningcss
|
|
52
|
+
// reports the name as authored, so `BODY { … }` arrives spelled `BODY`.
|
|
53
|
+
function isRootTypeSelector(name) {
|
|
54
|
+
return ROOT_TYPE_SELECTORS.has(name.toLowerCase());
|
|
55
|
+
}
|
|
56
|
+
// Matching pseudo-classes we descend into. `:is()` and `:where()` are plain
|
|
57
|
+
// selector lists, so a root inside one still means the root.
|
|
58
|
+
//
|
|
59
|
+
// `:not()` and `:has()` are deliberately absent, and the recursion stops at
|
|
60
|
+
// them entirely rather than skipping one level (#124). Negation inverts the
|
|
61
|
+
// rewrite instead of fixing it: `.card:not(body)` today excludes nothing inside
|
|
62
|
+
// a story, while `.card:not(:scope)` would start excluding the story root — a
|
|
63
|
+
// behaviour change, silent in exactly the way the original bug was. `:has()` is
|
|
64
|
+
// relational and has the same problem. `:nth-child(… of S)` keeps its list
|
|
65
|
+
// under `of` rather than `selectors`, so it is untouched for free.
|
|
66
|
+
const DESCENDABLE_PSEUDO_CLASSES = new Set(['is', 'where']);
|
|
67
|
+
const SCOPE_PART = { type: 'pseudo-class', kind: 'scope' };
|
|
68
|
+
function rewriteSelector(selector) {
|
|
69
|
+
return selector.map((part, index) => {
|
|
70
|
+
if (part.type === 'pseudo-class' && part.kind === 'root') {
|
|
71
|
+
return SCOPE_PART;
|
|
72
|
+
}
|
|
73
|
+
// A namespace is its own part, immediately before the type selector it
|
|
74
|
+
// qualifies. Rewriting past it emits `*|:scope`, which is invalid — a
|
|
75
|
+
// prefix cannot qualify a pseudo-class — and the browser drops the rule.
|
|
76
|
+
// `svg|body` also means a body in the SVG namespace, which is not the
|
|
77
|
+
// document root, so leaving these alone is right on both counts.
|
|
78
|
+
if (part.type === 'type' && isRootTypeSelector(part.name ?? '')) {
|
|
79
|
+
return selector[index - 1]?.type === 'namespace' ? part : SCOPE_PART;
|
|
80
|
+
}
|
|
81
|
+
if (part.type === 'pseudo-class' && DESCENDABLE_PSEUDO_CLASSES.has(part.kind ?? '') && part.selectors) {
|
|
82
|
+
return { ...part, selectors: part.selectors.map(rewriteSelector) };
|
|
83
|
+
}
|
|
84
|
+
return part;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
37
87
|
function rewriteRootToScope(css) {
|
|
38
88
|
const processed = lightningcssTransform({
|
|
39
89
|
filename: 'user.css',
|
|
40
90
|
code: Buffer.from(css, 'utf8'),
|
|
41
91
|
minify: false,
|
|
42
92
|
visitor: {
|
|
43
|
-
Selector(selector)
|
|
44
|
-
for (const part of selector) {
|
|
45
|
-
if (part.type === 'pseudo-class' && part.kind === 'root') {
|
|
46
|
-
;
|
|
47
|
-
part.kind = 'scope';
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return selector;
|
|
51
|
-
},
|
|
93
|
+
Selector: selector => rewriteSelector(selector),
|
|
52
94
|
},
|
|
53
95
|
});
|
|
54
96
|
return Buffer.from(processed.code).toString('utf8');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "poveste",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"description": "A fast story book powered by Vite",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Sorin Gitlan"
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
"sirv": "^3.0.0",
|
|
69
69
|
"unhead": "^3.1.0",
|
|
70
70
|
"vite-node": "^6.0.0",
|
|
71
|
-
"@poveste/app": "^0.3.
|
|
72
|
-
"@poveste/controls": "^0.3.
|
|
73
|
-
"@poveste/shared": "^0.3.
|
|
74
|
-
"@poveste/vendors": "^0.3.
|
|
71
|
+
"@poveste/app": "^0.3.2",
|
|
72
|
+
"@poveste/controls": "^0.3.2",
|
|
73
|
+
"@poveste/shared": "^0.3.2",
|
|
74
|
+
"@poveste/vendors": "^0.3.2"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@types/fs-extra": "^11.0.4",
|
|
@@ -1,21 +1,81 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { isGlobalImport, wrapChromeCss, wrapUserCss } from '../transforms.js'
|
|
3
3
|
|
|
4
|
+
/** Selectors of every rule inside the wrap, so a test can assert on them whole. */
|
|
5
|
+
function selectorsIn(css: string): string[] {
|
|
6
|
+
return css.split('{')
|
|
7
|
+
.slice(0, -1)
|
|
8
|
+
.map(chunk => chunk.slice(chunk.lastIndexOf('}') + 1).trim())
|
|
9
|
+
.filter(selector => selector.length > 0 && !selector.startsWith('@'))
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
describe('wrapUserCss', () => {
|
|
5
13
|
it('wraps a single rule in @scope (.scope-root)', () => {
|
|
6
|
-
const out = wrapUserCss('
|
|
14
|
+
const out = wrapUserCss('main { color: red }', { scopeRoot: '.__poveste-render-story' })
|
|
7
15
|
expect(out).toContain('@scope (.__poveste-render-story)')
|
|
8
|
-
expect(out).toContain('
|
|
16
|
+
expect(out).toContain('main')
|
|
9
17
|
expect(out).toContain('color: red')
|
|
10
18
|
})
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
// Every root spelling has to land on :scope. A rule left as `html`/`body`
|
|
21
|
+
// targets an ancestor of the scoping root and can never match — silently,
|
|
22
|
+
// which is what made #116 survive this long. Asserting the whole selector
|
|
23
|
+
// rather than "contains :scope": the failure mode is a leftover `html`, and
|
|
24
|
+
// a substring check passes right over it.
|
|
25
|
+
it.each([
|
|
26
|
+
[':root', ':root { --brand: rebeccapurple }', ':scope'],
|
|
27
|
+
['html', 'html { --brand: rebeccapurple }', ':scope'],
|
|
28
|
+
['body', 'body { --brand: rebeccapurple }', ':scope'],
|
|
29
|
+
['BODY', 'BODY { --brand: rebeccapurple }', ':scope'],
|
|
30
|
+
['body .card', 'body .card { --brand: rebeccapurple }', ':scope .card'],
|
|
31
|
+
['body.dark .card', 'body.dark .card { --brand: rebeccapurple }', ':scope.dark .card'],
|
|
32
|
+
['.body-copy', '.body-copy { --brand: rebeccapurple }', '.body-copy'],
|
|
33
|
+
['.page-html', '.page-html { --brand: rebeccapurple }', '.page-html'],
|
|
34
|
+
])('compiles %s to a selector that can still match', (_input, css, expected) => {
|
|
35
|
+
const out = wrapUserCss(css, { scopeRoot: '.__poveste-render-story' })
|
|
36
|
+
|
|
37
|
+
expect(selectorsIn(out)).toEqual([expected])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('rewrites root selectors nested in at-rules', () => {
|
|
41
|
+
const out = wrapUserCss('@media (min-width: 700px) { body { --brand: rebeccapurple } }', {
|
|
14
42
|
scopeRoot: '.__poveste-render-story',
|
|
15
43
|
})
|
|
16
|
-
|
|
17
|
-
expect(out).
|
|
18
|
-
|
|
44
|
+
|
|
45
|
+
expect(selectorsIn(out)).toEqual([':scope'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// `:is()` and `:where()` are plain selector lists, so a root inside one still
|
|
49
|
+
// means the root (#124).
|
|
50
|
+
it.each([
|
|
51
|
+
[':is(html, body)', ':is(html, body) { --brand: rebeccapurple }', ':is(:scope, :scope)'],
|
|
52
|
+
[':where(:root)', ':where(:root) { --brand: rebeccapurple }', ':where(:scope)'],
|
|
53
|
+
// lightningcss collapses a single-argument `:is()`; same specificity.
|
|
54
|
+
[':is(html)', ':is(html) { --brand: rebeccapurple }', ':scope'],
|
|
55
|
+
[':is(.a, body) .card', ':is(.a, body) .card { --brand: rebeccapurple }', ':is(.a, :scope) .card'],
|
|
56
|
+
[':is(.a, :where(body))', ':is(.a, :where(body)) { --brand: rebeccapurple }', ':is(.a, :where(:scope))'],
|
|
57
|
+
])('rewrites a root inside %s', (_input, css, expected) => {
|
|
58
|
+
const out = wrapUserCss(css, { scopeRoot: '.__poveste-render-story' })
|
|
59
|
+
|
|
60
|
+
expect(selectorsIn(out)).toEqual([expected])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Rewriting inside a negation would change what the rule matches rather than
|
|
64
|
+
// fix it, so the walk stops at `:not()`/`:has()` — including an `:is()` nested
|
|
65
|
+
// inside one. A namespaced root is skipped for a different reason: the prefix
|
|
66
|
+
// is its own part, and `*|:scope` is invalid CSS the browser drops outright.
|
|
67
|
+
it.each([
|
|
68
|
+
[':not()', '.card:not(body) { --brand: rebeccapurple }', '.card:not(body)'],
|
|
69
|
+
[':has()', '.card:has(body) { --brand: rebeccapurple }', '.card:has(body)'],
|
|
70
|
+
['an :is() nested in :not()', '.card:not(:is(html)) { --brand: rebeccapurple }', '.card:not(:is(html))'],
|
|
71
|
+
['nth-child(… of S)', 'li:nth-child(1 of body) { --brand: rebeccapurple }', 'li:nth-child(1 of body)'],
|
|
72
|
+
['any namespace', 'body { color: red }\n*|body { --brand: rebeccapurple }', '*|body'],
|
|
73
|
+
['a named namespace', 'body { color: red }\nsvg|body { --brand: rebeccapurple }', 'svg|body'],
|
|
74
|
+
['the empty namespace', 'body { color: red }\n|body { --brand: rebeccapurple }', '|body'],
|
|
75
|
+
])('leaves a root inside %s alone', (_input, css, expected) => {
|
|
76
|
+
const out = wrapUserCss(css, { scopeRoot: '.__poveste-render-story' })
|
|
77
|
+
|
|
78
|
+
expect(selectorsIn(out)).toContain(expected)
|
|
19
79
|
})
|
|
20
80
|
|
|
21
81
|
it('leaves :scope alone (idempotent)', () => {
|
|
@@ -47,7 +107,18 @@ describe('wrapChromeCss', () => {
|
|
|
47
107
|
scopeLower: '.__poveste-render-story',
|
|
48
108
|
})
|
|
49
109
|
expect(out).toContain('@scope (.poveste-app-root) to (.__poveste-render-story)')
|
|
50
|
-
expect(out).toContain('
|
|
110
|
+
expect(out).toContain('color: red')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Same rule as the user side: inside the wrap, `body` means the app's root
|
|
114
|
+
// container, which is what :scope resolves to (#102).
|
|
115
|
+
it('rewrites html and body to :scope', () => {
|
|
116
|
+
const out = wrapChromeCss('body { font-size: .875rem }\nhtml { color-scheme: dark }', {
|
|
117
|
+
scopeRoot: '.poveste-app-root',
|
|
118
|
+
scopeLower: '.__poveste-render-story',
|
|
119
|
+
})
|
|
120
|
+
expect(out).toContain(':scope')
|
|
121
|
+
expect(out).not.toMatch(/(?:^|[\s,{])(?:html|body)\b/m)
|
|
51
122
|
})
|
|
52
123
|
|
|
53
124
|
it('hoists @import to the top, outside @scope', () => {
|
|
@@ -16,7 +16,7 @@ export function wrapUserCss(css: string, opts: WrapOptions): string {
|
|
|
16
16
|
return css
|
|
17
17
|
}
|
|
18
18
|
const { hoisted, remainder } = extractHoistableAtRules(css)
|
|
19
|
-
const body = remainder
|
|
19
|
+
const body = hasRootSelector(remainder)
|
|
20
20
|
? rewriteRootToScope(remainder)
|
|
21
21
|
: remainder
|
|
22
22
|
const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : ''
|
|
@@ -29,10 +29,10 @@ export function wrapChromeCss(css: string, opts: WrapWithLowerOptions): string {
|
|
|
29
29
|
return css
|
|
30
30
|
}
|
|
31
31
|
const { hoisted, remainder } = extractHoistableAtRules(css)
|
|
32
|
-
// Rewrite
|
|
32
|
+
// Rewrite root selectors to `:scope` so source-level `@scope (:root) to (...)`
|
|
33
33
|
// rules nest correctly under the outer scope; without this, the inner
|
|
34
34
|
// `:root` resolves to the document root which is outside the outer scope.
|
|
35
|
-
const body = remainder
|
|
35
|
+
const body = hasRootSelector(remainder)
|
|
36
36
|
? rewriteRootToScope(remainder)
|
|
37
37
|
: remainder
|
|
38
38
|
const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : ''
|
|
@@ -46,20 +46,79 @@ export function isGlobalImport(id: string): boolean {
|
|
|
46
46
|
return parts.includes('global') || parts.some(p => p.startsWith('global='))
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// `html` and `body` sit above the scoping root once the sheet is wrapped, so a
|
|
50
|
+
// rule targeting either can never match. Inside a story they mean the same
|
|
51
|
+
// thing `:root` does — the one root there is — which is what `:scope`
|
|
52
|
+
// resolves to. Leaving them out made `body { font-size: 14px }` inert with no
|
|
53
|
+
// error (#116); the chrome wrapper learned the same lesson in #102.
|
|
54
|
+
const ROOT_TYPE_SELECTORS = new Set(['html', 'body'])
|
|
55
|
+
|
|
56
|
+
// Only a gate on the lightningcss round-trip, so a false positive costs a
|
|
57
|
+
// reparse and nothing else. Word-bounded to skip `.sidebar-body` and friends;
|
|
58
|
+
// `(` is a boundary too, or a sheet whose only root is `:is(html)` would skip
|
|
59
|
+
// the pass that exists to rewrite it.
|
|
60
|
+
const ROOT_SELECTOR_RE = /:root|(?:^|[\s,{}>+~(])(?:html|body)\b/i
|
|
61
|
+
|
|
62
|
+
function hasRootSelector(css: string): boolean {
|
|
63
|
+
return ROOT_SELECTOR_RE.test(css)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Type selectors are ASCII case-insensitive against HTML and lightningcss
|
|
67
|
+
// reports the name as authored, so `BODY { … }` arrives spelled `BODY`.
|
|
68
|
+
function isRootTypeSelector(name: string): boolean {
|
|
69
|
+
return ROOT_TYPE_SELECTORS.has(name.toLowerCase())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Matching pseudo-classes we descend into. `:is()` and `:where()` are plain
|
|
73
|
+
// selector lists, so a root inside one still means the root.
|
|
74
|
+
//
|
|
75
|
+
// `:not()` and `:has()` are deliberately absent, and the recursion stops at
|
|
76
|
+
// them entirely rather than skipping one level (#124). Negation inverts the
|
|
77
|
+
// rewrite instead of fixing it: `.card:not(body)` today excludes nothing inside
|
|
78
|
+
// a story, while `.card:not(:scope)` would start excluding the story root — a
|
|
79
|
+
// behaviour change, silent in exactly the way the original bug was. `:has()` is
|
|
80
|
+
// relational and has the same problem. `:nth-child(… of S)` keeps its list
|
|
81
|
+
// under `of` rather than `selectors`, so it is untouched for free.
|
|
82
|
+
const DESCENDABLE_PSEUDO_CLASSES = new Set(['is', 'where'])
|
|
83
|
+
|
|
84
|
+
const SCOPE_PART = { type: 'pseudo-class', kind: 'scope' }
|
|
85
|
+
|
|
86
|
+
// Structural stand-in for lightningcss's selector component union, which is not
|
|
87
|
+
// exported in a form that survives the nesting below.
|
|
88
|
+
interface SelectorPart {
|
|
89
|
+
type: string
|
|
90
|
+
kind?: string
|
|
91
|
+
name?: string
|
|
92
|
+
selectors?: SelectorPart[][]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rewriteSelector(selector: SelectorPart[]): SelectorPart[] {
|
|
96
|
+
return selector.map((part, index) => {
|
|
97
|
+
if (part.type === 'pseudo-class' && part.kind === 'root') {
|
|
98
|
+
return SCOPE_PART
|
|
99
|
+
}
|
|
100
|
+
// A namespace is its own part, immediately before the type selector it
|
|
101
|
+
// qualifies. Rewriting past it emits `*|:scope`, which is invalid — a
|
|
102
|
+
// prefix cannot qualify a pseudo-class — and the browser drops the rule.
|
|
103
|
+
// `svg|body` also means a body in the SVG namespace, which is not the
|
|
104
|
+
// document root, so leaving these alone is right on both counts.
|
|
105
|
+
if (part.type === 'type' && isRootTypeSelector(part.name ?? '')) {
|
|
106
|
+
return selector[index - 1]?.type === 'namespace' ? part : SCOPE_PART
|
|
107
|
+
}
|
|
108
|
+
if (part.type === 'pseudo-class' && DESCENDABLE_PSEUDO_CLASSES.has(part.kind ?? '') && part.selectors) {
|
|
109
|
+
return { ...part, selectors: part.selectors.map(rewriteSelector) }
|
|
110
|
+
}
|
|
111
|
+
return part
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
49
115
|
function rewriteRootToScope(css: string): string {
|
|
50
116
|
const processed = lightningcssTransform({
|
|
51
117
|
filename: 'user.css',
|
|
52
118
|
code: Buffer.from(css, 'utf8'),
|
|
53
119
|
minify: false,
|
|
54
120
|
visitor: {
|
|
55
|
-
Selector(selector)
|
|
56
|
-
for (const part of selector) {
|
|
57
|
-
if (part.type === 'pseudo-class' && (part as any).kind === 'root') {
|
|
58
|
-
;(part as any).kind = 'scope'
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return selector
|
|
62
|
-
},
|
|
121
|
+
Selector: selector => rewriteSelector(selector as SelectorPart[]) as typeof selector,
|
|
63
122
|
},
|
|
64
123
|
})
|
|
65
124
|
return Buffer.from(processed.code).toString('utf8')
|