poveste 0.3.1 → 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.
@@ -41,8 +41,10 @@ export function isGlobalImport(id) {
41
41
  // error (#116); the chrome wrapper learned the same lesson in #102.
42
42
  const ROOT_TYPE_SELECTORS = new Set(['html', 'body']);
43
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;
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;
46
48
  function hasRootSelector(css) {
47
49
  return ROOT_SELECTOR_RE.test(css);
48
50
  }
@@ -51,28 +53,44 @@ function hasRootSelector(css) {
51
53
  function isRootTypeSelector(name) {
52
54
  return ROOT_TYPE_SELECTORS.has(name.toLowerCase());
53
55
  }
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).
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
+ }
58
87
  function rewriteRootToScope(css) {
59
- const scope = { type: 'pseudo-class', kind: 'scope' };
60
88
  const processed = lightningcssTransform({
61
89
  filename: 'user.css',
62
90
  code: Buffer.from(css, 'utf8'),
63
91
  minify: false,
64
92
  visitor: {
65
- Selector(selector) {
66
- return selector.map((part) => {
67
- if (part.type === 'pseudo-class' && part.kind === 'root') {
68
- return scope;
69
- }
70
- if (part.type === 'type' && isRootTypeSelector(part.name)) {
71
- return scope;
72
- }
73
- return part;
74
- });
75
- },
93
+ Selector: selector => rewriteSelector(selector),
76
94
  },
77
95
  });
78
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.1",
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.1",
72
- "@poveste/controls": "^0.3.1",
73
- "@poveste/vendors": "^0.3.1",
74
- "@poveste/shared": "^0.3.1"
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",
@@ -45,6 +45,39 @@ describe('wrapUserCss', () => {
45
45
  expect(selectorsIn(out)).toEqual([':scope'])
46
46
  })
47
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)
79
+ })
80
+
48
81
  it('leaves :scope alone (idempotent)', () => {
49
82
  const out = wrapUserCss(':scope { color: red }', {
50
83
  scopeRoot: '.__poveste-render-story',
@@ -54,8 +54,10 @@ export function isGlobalImport(id: string): boolean {
54
54
  const ROOT_TYPE_SELECTORS = new Set(['html', 'body'])
55
55
 
56
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
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
59
61
 
60
62
  function hasRootSelector(css: string): boolean {
61
63
  return ROOT_SELECTOR_RE.test(css)
@@ -67,28 +69,56 @@ function isRootTypeSelector(name: string): boolean {
67
69
  return ROOT_TYPE_SELECTORS.has(name.toLowerCase())
68
70
  }
69
71
 
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).
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
+
74
115
  function rewriteRootToScope(css: string): string {
75
- const scope = { type: 'pseudo-class', kind: 'scope' } as const
76
116
  const processed = lightningcssTransform({
77
117
  filename: 'user.css',
78
118
  code: Buffer.from(css, 'utf8'),
79
119
  minify: false,
80
120
  visitor: {
81
- Selector(selector) {
82
- return selector.map((part) => {
83
- if (part.type === 'pseudo-class' && (part as any).kind === 'root') {
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
88
- }
89
- return part
90
- })
91
- },
121
+ Selector: selector => rewriteSelector(selector as SelectorPart[]) as typeof selector,
92
122
  },
93
123
  })
94
124
  return Buffer.from(processed.code).toString('utf8')