poveste 0.3.0 → 0.3.1

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.includes(':root')
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 `:root` to `:scope` so source-level `@scope (:root) to (...)`
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.includes(':root')
24
+ const body = hasRootSelector(remainder)
25
25
  ? rewriteRootToScope(remainder)
26
26
  : remainder;
27
27
  const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : '';
@@ -34,20 +34,44 @@ 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
+ const ROOT_SELECTOR_RE = /:root|(?:^|[\s,{}>+~])(?:html|body)\b/i;
46
+ function hasRootSelector(css) {
47
+ return ROOT_SELECTOR_RE.test(css);
48
+ }
49
+ // Type selectors are ASCII case-insensitive against HTML and lightningcss
50
+ // reports the name as authored, so `BODY { … }` arrives spelled `BODY`.
51
+ function isRootTypeSelector(name) {
52
+ return ROOT_TYPE_SELECTORS.has(name.toLowerCase());
53
+ }
54
+ // Top level of the selector only: lightningcss hands `:is()`/`:where()`/`:not()`
55
+ // over as one part with the inner selectors nested inside, so a root spelled
56
+ // `:is(html, body)` is left alone. Descending needs a decision on `:not()`,
57
+ // where rewriting would change what the rule matches rather than fix it (#124).
37
58
  function rewriteRootToScope(css) {
59
+ const scope = { type: 'pseudo-class', kind: 'scope' };
38
60
  const processed = lightningcssTransform({
39
61
  filename: 'user.css',
40
62
  code: Buffer.from(css, 'utf8'),
41
63
  minify: false,
42
64
  visitor: {
43
65
  Selector(selector) {
44
- for (const part of selector) {
66
+ return selector.map((part) => {
45
67
  if (part.type === 'pseudo-class' && part.kind === 'root') {
46
- ;
47
- part.kind = 'scope';
68
+ return scope;
69
+ }
70
+ if (part.type === 'type' && isRootTypeSelector(part.name)) {
71
+ return scope;
48
72
  }
49
- }
50
- return selector;
73
+ return part;
74
+ });
51
75
  },
52
76
  },
53
77
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poveste",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
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.0",
72
- "@poveste/controls": "^0.3.0",
73
- "@poveste/shared": "^0.3.0",
74
- "@poveste/vendors": "^0.3.0"
71
+ "@poveste/app": "^0.3.1",
72
+ "@poveste/controls": "^0.3.1",
73
+ "@poveste/vendors": "^0.3.1",
74
+ "@poveste/shared": "^0.3.1"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@types/fs-extra": "^11.0.4",
@@ -1,21 +1,48 @@
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('body { color: red }', { scopeRoot: '.__poveste-render-story' })
14
+ const out = wrapUserCss('main { color: red }', { scopeRoot: '.__poveste-render-story' })
7
15
  expect(out).toContain('@scope (.__poveste-render-story)')
8
- expect(out).toContain('body')
16
+ expect(out).toContain('main')
9
17
  expect(out).toContain('color: red')
10
18
  })
11
19
 
12
- it('rewrites :root → :scope inside the scope', () => {
13
- const out = wrapUserCss(':root { --color-primary: blue }', {
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
- expect(out).toContain(':scope')
17
- expect(out).not.toContain(':root')
18
- expect(out).toContain('--color-primary: blue')
44
+
45
+ expect(selectorsIn(out)).toEqual([':scope'])
19
46
  })
20
47
 
21
48
  it('leaves :scope alone (idempotent)', () => {
@@ -47,7 +74,18 @@ describe('wrapChromeCss', () => {
47
74
  scopeLower: '.__poveste-render-story',
48
75
  })
49
76
  expect(out).toContain('@scope (.poveste-app-root) to (.__poveste-render-story)')
50
- expect(out).toContain('body')
77
+ expect(out).toContain('color: red')
78
+ })
79
+
80
+ // Same rule as the user side: inside the wrap, `body` means the app's root
81
+ // container, which is what :scope resolves to (#102).
82
+ it('rewrites html and body to :scope', () => {
83
+ const out = wrapChromeCss('body { font-size: .875rem }\nhtml { color-scheme: dark }', {
84
+ scopeRoot: '.poveste-app-root',
85
+ scopeLower: '.__poveste-render-story',
86
+ })
87
+ expect(out).toContain(':scope')
88
+ expect(out).not.toMatch(/(?:^|[\s,{])(?:html|body)\b/m)
51
89
  })
52
90
 
53
91
  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.includes(':root')
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 `:root` to `:scope` so source-level `@scope (:root) to (...)`
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.includes(':root')
35
+ const body = hasRootSelector(remainder)
36
36
  ? rewriteRootToScope(remainder)
37
37
  : remainder
38
38
  const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : ''
@@ -46,19 +46,48 @@ 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
+ const ROOT_SELECTOR_RE = /:root|(?:^|[\s,{}>+~])(?:html|body)\b/i
59
+
60
+ function hasRootSelector(css: string): boolean {
61
+ return ROOT_SELECTOR_RE.test(css)
62
+ }
63
+
64
+ // Type selectors are ASCII case-insensitive against HTML and lightningcss
65
+ // reports the name as authored, so `BODY { … }` arrives spelled `BODY`.
66
+ function isRootTypeSelector(name: string): boolean {
67
+ return ROOT_TYPE_SELECTORS.has(name.toLowerCase())
68
+ }
69
+
70
+ // Top level of the selector only: lightningcss hands `:is()`/`:where()`/`:not()`
71
+ // over as one part with the inner selectors nested inside, so a root spelled
72
+ // `:is(html, body)` is left alone. Descending needs a decision on `:not()`,
73
+ // where rewriting would change what the rule matches rather than fix it (#124).
49
74
  function rewriteRootToScope(css: string): string {
75
+ const scope = { type: 'pseudo-class', kind: 'scope' } as const
50
76
  const processed = lightningcssTransform({
51
77
  filename: 'user.css',
52
78
  code: Buffer.from(css, 'utf8'),
53
79
  minify: false,
54
80
  visitor: {
55
81
  Selector(selector) {
56
- for (const part of selector) {
82
+ return selector.map((part) => {
57
83
  if (part.type === 'pseudo-class' && (part as any).kind === 'root') {
58
- ;(part as any).kind = 'scope'
84
+ return scope as unknown as typeof part
85
+ }
86
+ if (part.type === 'type' && isRootTypeSelector(part.name)) {
87
+ return scope as unknown as typeof part
59
88
  }
60
- }
61
- return selector
89
+ return part
90
+ })
62
91
  },
63
92
  },
64
93
  })