@ultimat3/render 20.1.2 → 20.1.4

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.
Files changed (2) hide show
  1. package/package.json +5 -5
  2. package/src/css-modules.ts +46 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "20.1.2",
3
+ "version": "20.1.4",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,10 +36,10 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "20.1.2",
40
- "@ultimat3/core": "20.1.2",
41
- "@ultimat3/i18n": "20.1.2",
42
- "@ultimat3/seo": "20.1.2",
39
+ "@ultimat3/cache": "20.1.4",
40
+ "@ultimat3/core": "20.1.4",
41
+ "@ultimat3/i18n": "20.1.4",
42
+ "@ultimat3/seo": "20.1.4",
43
43
  "sass": "1.102.0"
44
44
  }
45
45
  }
@@ -81,6 +81,47 @@ const CLASS_SELECTOR = /\.(-?[A-Za-z_][\w-]*)/g;
81
81
  const NUL = '\u0000';
82
82
  const MASKED = new RegExp(`${NUL}(\\d+)${NUL}`, 'g');
83
83
 
84
+ const GLOBAL_OPEN = ':global(';
85
+
86
+ /**
87
+ * Unwrap every `:global(<selector>)` to `<selector>`, handing each payload to `keep` so the class
88
+ * rewrite that follows cannot touch it. `:global()` is CSS-MODULES syntax, not CSS: emitted as
89
+ * written it is an unknown pseudo-class, and a browser drops the whole rule. `Table.module.scss`
90
+ * shipped twelve such rules, so the catalog table reached every app with no cell padding, no row
91
+ * borders and no header ground — invisible to every test, because the CSS was never parsed by
92
+ * anything but a browser.
93
+ *
94
+ * A scanner rather than a regex: the payload is a selector and may hold its own parentheses
95
+ * (`tr:nth-child(even)`), so the wrapper closes on ITS parenthesis, found by depth. An unclosed
96
+ * wrapper is left exactly as written — swallowing the rest of the sheet to "repair" a typo would
97
+ * turn one broken rule into every rule after it.
98
+ */
99
+ function unwrapGlobal(css: string, keep: (selector: string) => string): string {
100
+ let out = '';
101
+ let cursor = 0;
102
+ for (;;) {
103
+ const open = css.indexOf(GLOBAL_OPEN, cursor);
104
+ if (open === -1) return out + css.slice(cursor);
105
+ const start = open + GLOBAL_OPEN.length;
106
+ let depth = 1;
107
+ let end = start;
108
+ while (end < css.length && depth > 0) {
109
+ const char = css[end];
110
+ // A `{` means the selector list ended with the wrapper still open: not ours to repair.
111
+ if (char === '{') break;
112
+ if (char === '(') depth += 1;
113
+ else if (char === ')') depth -= 1;
114
+ end += 1;
115
+ }
116
+ if (depth !== 0) return out + css.slice(cursor);
117
+ // The payload is unwrapped FIRST, so `:global(:global(.x))` yields `.x` — a wrapper left inside
118
+ // the mask would be restored verbatim, and the browser would drop the rule after all.
119
+ out +=
120
+ css.slice(cursor, open) + keep(unwrapGlobal(css.slice(start, end - 1), (inner) => inner));
121
+ cursor = end;
122
+ }
123
+ }
124
+
84
125
  /**
85
126
  * Rewrite every class selector to its scoped name and report the map. Done on the compiled CSS
86
127
  * rather than the SCSS source so mixins, `@extend` and interpolation have already produced their
@@ -92,10 +133,13 @@ export function scopeClasses(
92
133
  ): { readonly css: string; readonly classes: Record<string, string> } {
93
134
  const classes: Record<string, string> = {};
94
135
  const literals: string[] = [];
95
- const masked = css.replace(PROTECTED, (match) => {
136
+ const mask = (match: string): string => {
96
137
  literals.push(match);
97
138
  return `${NUL}${literals.length - 1}${NUL}`;
98
- });
139
+ };
140
+ // Strings and `url()` first, so a `:global(` inside a `content:` string is never unwrapped;
141
+ // then each `:global()` payload joins the same mask and comes back unscoped.
142
+ const masked = unwrapGlobal(css.replace(PROTECTED, mask), mask);
99
143
  const scoped = masked.replace(CLASS_SELECTOR, (_match, name: string) => {
100
144
  const local = `${name}_${suffix}`;
101
145
  classes[name] = local;