@tsrx/core 0.1.65 → 0.1.66
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.
- package/README.md +26 -1
- package/package.json +9 -5
- package/src/analyze/css-analyze.js +44 -6
- package/src/analyze/index.js +14 -1
- package/src/analyze/style-analyze.js +467 -0
- package/src/analyze/validation.js +57 -0
- package/src/diagnostics.js +22 -0
- package/src/index.js +17 -0
- package/src/parse/style.js +97 -7
- package/src/plugin.js +145 -47
- package/src/scope.js +1 -1
- package/src/transform/jsx/index.js +94 -417
- package/src/transform/jsx/style-scopes.js +842 -0
- package/src/transform/scoping.js +129 -79
- package/src/transform/segments.js +16 -4
- package/src/transform/style-ref.js +74 -13
- package/src/transform/stylesheet.js +2 -1
- package/src/utils/is-reference.js +59 -0
- package/tests/fixtures/scoped-styles/README.md +71 -0
- package/tests/fixtures/scoped-styles/apply-forms.expected.json +18 -0
- package/tests/fixtures/scoped-styles/apply-forms.tsrx +69 -0
- package/tests/fixtures/scoped-styles/assigned-positions.expected.json +29 -0
- package/tests/fixtures/scoped-styles/assigned-positions.tsrx +90 -0
- package/tests/fixtures/scoped-styles/class-opt-in.expected.json +13 -0
- package/tests/fixtures/scoped-styles/class-opt-in.tsrx +39 -0
- package/tests/fixtures/scoped-styles/control-flow-else-if.expected.json +11 -0
- package/tests/fixtures/scoped-styles/control-flow-else-if.tsrx +26 -0
- package/tests/fixtures/scoped-styles/control-flow.expected.json +17 -0
- package/tests/fixtures/scoped-styles/control-flow.tsrx +102 -0
- package/tests/fixtures/scoped-styles/cross-module-apply.expected.json +14 -0
- package/tests/fixtures/scoped-styles/cross-module-apply.tsrx +48 -0
- package/tests/fixtures/scoped-styles/element-rooted-templates.expected.json +12 -0
- package/tests/fixtures/scoped-styles/element-rooted-templates.tsrx +33 -0
- package/tests/fixtures/scoped-styles/precedence.expected.json +11 -0
- package/tests/fixtures/scoped-styles/precedence.tsrx +45 -0
- package/tests/fixtures/scoped-styles/rfc-opening-example/panel.expected.json +12 -0
- package/tests/fixtures/scoped-styles/rfc-opening-example/panel.tsrx +46 -0
- package/tests/fixtures/scoped-styles/rfc-opening-example/theme.expected.json +9 -0
- package/tests/fixtures/scoped-styles/rfc-opening-example/theme.tsrx +24 -0
- package/tests/fixtures/scoped-styles/search-panel.expected.json +11 -0
- package/tests/fixtures/scoped-styles/search-panel.tsrx +48 -0
- package/tests/fixtures/scoped-styles/sibling-scope.expected.json +11 -0
- package/tests/fixtures/scoped-styles/sibling-scope.tsrx +44 -0
- package/tests/fixtures/scoped-styles/sibling-scopes.expected.json +12 -0
- package/tests/fixtures/scoped-styles/sibling-scopes.tsrx +51 -0
- package/tests/fixtures/scoped-styles/theme-composition.expected.json +14 -0
- package/tests/fixtures/scoped-styles/theme-composition.tsrx +45 -0
- package/tests/fixtures/scoped-styles/theme-diamond.expected.json +10 -0
- package/tests/fixtures/scoped-styles/theme-diamond.tsrx +18 -0
- package/tests/shared/scoped-styles-fixtures.js +67 -0
- package/tests/utils/fixtures/style-syntax.js +519 -0
- package/types/index.d.ts +85 -0
package/src/transform/scoping.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
/** @import * as AST from 'estree' */
|
|
9
9
|
/** @import * as ESTreeJSX from 'estree-jsx' */
|
|
10
|
-
/** @import { Visitors } from '../../types/index' */
|
|
10
|
+
/** @import { ScopeClassParts, StyleRenderMode, Visitors } from '../../types/index' */
|
|
11
11
|
|
|
12
12
|
import { walk } from 'zimmerframe';
|
|
13
13
|
import * as b from '../utils/builders.js';
|
|
@@ -18,27 +18,35 @@ export { is_style_element };
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Mark selectors inside the stylesheet as "used" so `renderStylesheets` does
|
|
21
|
-
* not comment them out
|
|
21
|
+
* not comment them out, per render mode (D4):
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
* selector-pruning because component boundaries can be dynamic — any
|
|
25
|
-
* authored inside
|
|
23
|
+
* - `scope`: a free-standing `<style>` block. Every selector is marked; we
|
|
24
|
+
* skip selector-pruning because component boundaries can be dynamic — any
|
|
25
|
+
* selector authored inside a scope's `<style>` block is considered
|
|
26
|
+
* intentional.
|
|
27
|
+
* - `class-map`: a block assigned to a local, unexported, unapplied variable.
|
|
28
|
+
* The only selectors reachable through the generated class map are
|
|
29
|
+
* standalone class selectors — scoped (`.x`) or global-wrapped
|
|
30
|
+
* (`:global(.x)`). Anything else at the top level — element selectors,
|
|
31
|
+
* compound selectors, descendant chains, global tag selectors — never ends
|
|
32
|
+
* up in the class map and is marked unused for `renderStylesheets` to
|
|
33
|
+
* comment out. Selectors of nested rules ride along with their parent: they
|
|
34
|
+
* apply where the parent's class matched, and the whole rule is pruned when
|
|
35
|
+
* the parent itself is unreachable.
|
|
36
|
+
* - `theme`: an exported or applied block (D5). Every selector is kept and
|
|
37
|
+
* hash-scoped, because appliers stamp `$class` on arbitrary elements.
|
|
26
38
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* class selectors — scoped (`.x`) or global-wrapped (`:global(.x)`). Anything
|
|
30
|
-
* else at the top level — element selectors, compound selectors, descendant
|
|
31
|
-
* chains, global tag selectors — never ends up in the class map and is marked
|
|
32
|
-
* unused for `renderStylesheets` to comment out. Selectors of nested rules ride
|
|
33
|
-
* along with their parent: they apply where the parent's class matched, and the
|
|
34
|
-
* whole rule is pruned when the parent itself is unreachable.
|
|
39
|
+
* The boolean form (`true` → `class-map`, `false` → `scope`) is kept for one
|
|
40
|
+
* release for consumers compiled against the previous signature.
|
|
35
41
|
*
|
|
36
42
|
* @param {AST.CSS.StyleSheet} stylesheet
|
|
37
|
-
* @param {boolean} [
|
|
43
|
+
* @param {StyleRenderMode | boolean} [mode]
|
|
38
44
|
* @returns {AST.CSS.StyleSheet}
|
|
39
45
|
*/
|
|
40
|
-
export function prepare_stylesheet_for_render(stylesheet,
|
|
41
|
-
|
|
46
|
+
export function prepare_stylesheet_for_render(stylesheet, mode = 'scope') {
|
|
47
|
+
const render_mode = mode === true ? 'class-map' : mode === false ? 'scope' : mode;
|
|
48
|
+
const is_class_map = render_mode === 'class-map';
|
|
49
|
+
if (is_class_map) {
|
|
42
50
|
mark_class_map_selectors(stylesheet);
|
|
43
51
|
}
|
|
44
52
|
walk(
|
|
@@ -47,7 +55,7 @@ export function prepare_stylesheet_for_render(stylesheet, is_style_expression =
|
|
|
47
55
|
/** @type {Visitors<AST.CSS.Node, null>} */ ({
|
|
48
56
|
_(node, { next, path }) {
|
|
49
57
|
if (node.type === 'ComplexSelector') {
|
|
50
|
-
if (
|
|
58
|
+
if (is_class_map && is_unreachable_via_class_map(node, path)) {
|
|
51
59
|
// Not in the generated class map. The analyzer pre-marks global
|
|
52
60
|
// selectors as used, so reset, and leave the subtree untouched —
|
|
53
61
|
// no `scoped` marks that would splice the hash into pruned output.
|
|
@@ -149,7 +157,7 @@ export function annotate_with_hash(
|
|
|
149
157
|
if (node.type === 'JSXElement') {
|
|
150
158
|
const element = /** @type {AST.TSRXJSXElement} */ (node);
|
|
151
159
|
if (!is_composite_jsx_element(element) || element.metadata?.dynamicElement) {
|
|
152
|
-
|
|
160
|
+
add_hash_class(element, hash, jsx_class_attr_name);
|
|
153
161
|
}
|
|
154
162
|
element.children = element.children
|
|
155
163
|
.map((child) => annotate_with_hash(child, hash, jsx_class_attr_name, preserve_style_elements))
|
|
@@ -221,85 +229,127 @@ function is_class_attribute(attr) {
|
|
|
221
229
|
}
|
|
222
230
|
|
|
223
231
|
/**
|
|
224
|
-
*
|
|
225
|
-
* the
|
|
232
|
+
* The authored class value of an attribute as an expression, or `null` when
|
|
233
|
+
* the attribute has no usable value (`<div class>`, `class={}`).
|
|
226
234
|
*
|
|
227
|
-
* @param {ESTreeJSX.JSXAttribute
|
|
228
|
-
* @
|
|
229
|
-
* @returns {boolean} whether the hash was merged into the literal in place
|
|
235
|
+
* @param {ESTreeJSX.JSXAttribute | undefined} attr
|
|
236
|
+
* @returns {AST.Expression | null}
|
|
230
237
|
*/
|
|
231
|
-
function
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
value.value
|
|
235
|
-
|
|
236
|
-
return
|
|
238
|
+
function class_attribute_base(attr) {
|
|
239
|
+
const value = attr?.value;
|
|
240
|
+
if (!value) return null;
|
|
241
|
+
const expression = value.type === 'JSXExpressionContainer' ? value.expression : value;
|
|
242
|
+
if (expression.type === 'JSXEmptyExpression') return null;
|
|
243
|
+
return /** @type {AST.Expression} */ (expression);
|
|
237
244
|
}
|
|
238
245
|
|
|
239
246
|
/**
|
|
240
|
-
*
|
|
247
|
+
* Build one attribute value from the accumulated parts — `authored hashes…
|
|
248
|
+
* applied…` — as a single string literal when everything is static,
|
|
249
|
+
* otherwise as one template literal (never a template literal nested in
|
|
250
|
+
* another across repeated stamping).
|
|
241
251
|
*
|
|
242
|
-
* @param {
|
|
243
|
-
* @
|
|
244
|
-
* @param {'class' | 'className'} [class_attr_name='class']
|
|
245
|
-
* @returns {void}
|
|
252
|
+
* @param {ScopeClassParts} parts
|
|
253
|
+
* @returns {AST.Expression}
|
|
246
254
|
*/
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
const
|
|
255
|
+
function build_scope_class_value(parts) {
|
|
256
|
+
const { base, hashes, applied } = parts;
|
|
257
|
+
const base_literal = base?.type === 'Literal' && typeof base.value === 'string' ? base : null;
|
|
258
|
+
/** @type {Array<string | AST.Expression>} */
|
|
259
|
+
const sequence = [];
|
|
260
|
+
if (base_literal) sequence.push(/** @type {string} */ (base_literal.value));
|
|
261
|
+
else if (base) sequence.push(base);
|
|
262
|
+
sequence.push(...hashes, ...applied);
|
|
250
263
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
264
|
+
/** @type {AST.TemplateElement[]} */
|
|
265
|
+
const quasis = [];
|
|
266
|
+
/** @type {AST.Expression[]} */
|
|
267
|
+
const expressions = [];
|
|
268
|
+
/** @type {string} */
|
|
269
|
+
let text = '';
|
|
270
|
+
for (const part of sequence) {
|
|
271
|
+
if (typeof part === 'string') {
|
|
272
|
+
if (part) text = text ? `${text} ${part}` : part;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const between = expressions.length > 0;
|
|
276
|
+
quasis.push(b.quasi(text ? (between ? ` ${text} ` : `${text} `) : between ? ' ' : '', false));
|
|
277
|
+
expressions.push(part);
|
|
278
|
+
text = '';
|
|
254
279
|
}
|
|
255
280
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
281
|
+
if (expressions.length === 0) {
|
|
282
|
+
// Keep the authored literal's position so editor mappings survive.
|
|
283
|
+
return base_literal
|
|
284
|
+
? { ...base_literal, value: text, raw: JSON.stringify(text) }
|
|
285
|
+
: b.literal(text, JSON.stringify(text));
|
|
261
286
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// Dynamic expression. Concatenate at runtime via template literal.
|
|
266
|
-
existing.value = b.jsx_expression_container(
|
|
267
|
-
b.template([b.quasi('', false), b.quasi(` ${hash}`, true)], [value]),
|
|
268
|
-
);
|
|
287
|
+
quasis.push(b.quasi(text ? ` ${text}` : '', true));
|
|
288
|
+
return b.template(quasis, expressions);
|
|
269
289
|
}
|
|
270
290
|
|
|
271
291
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
292
|
+
* Stamp a scope's classes on an element, copy-on-write. The parts live on the
|
|
293
|
+
* element's (shared) metadata so an enclosing scope's stamp and a nested
|
|
294
|
+
* scope's stamp accumulate into one value: `authored hashes… applied…`, with
|
|
295
|
+
* every applied theme after every scope hash regardless of which scope
|
|
296
|
+
* applied it.
|
|
297
|
+
*
|
|
298
|
+
* @template {AST.TSRXJSXElement} T
|
|
299
|
+
* @param {T} element
|
|
300
|
+
* @param {string[]} hashes scope hashes to add
|
|
301
|
+
* @param {Array<string | AST.Expression>} applied theme classes: literals or `theme.$class` reads
|
|
302
|
+
* @param {'class' | 'className'} [class_attr_name='class']
|
|
303
|
+
* @returns {T}
|
|
276
304
|
*/
|
|
277
|
-
function
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
305
|
+
export function add_scope_classes(element, hashes, applied, class_attr_name = 'class') {
|
|
306
|
+
if (hashes.length === 0 && applied.length === 0) return element;
|
|
307
|
+
const attrs = element.openingElement.attributes ?? [];
|
|
308
|
+
const index = attrs.findIndex(is_class_attribute);
|
|
309
|
+
const existing = index === -1 ? undefined : /** @type {ESTreeJSX.JSXAttribute} */ (attrs[index]);
|
|
310
|
+
const metadata = element.metadata || (element.metadata = { path: [] });
|
|
311
|
+
const parts =
|
|
312
|
+
metadata.tsrx_scope_class ||
|
|
313
|
+
(metadata.tsrx_scope_class = {
|
|
314
|
+
base: class_attribute_base(existing),
|
|
315
|
+
hashes: [],
|
|
316
|
+
applied: [],
|
|
317
|
+
});
|
|
318
|
+
for (const hash of hashes) {
|
|
319
|
+
if (!parts.hashes.includes(hash)) parts.hashes.push(hash);
|
|
286
320
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (!value) {
|
|
290
|
-
existing.value = b.literal(hash, JSON.stringify(hash));
|
|
291
|
-
return;
|
|
321
|
+
for (const part of applied) {
|
|
322
|
+
if (typeof part !== 'string' || !parts.applied.includes(part)) parts.applied.push(part);
|
|
292
323
|
}
|
|
293
324
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
325
|
+
const value = build_scope_class_value(parts);
|
|
326
|
+
const attr_value =
|
|
327
|
+
value.type === 'Literal' && typeof value.value === 'string'
|
|
328
|
+
? value
|
|
329
|
+
: b.jsx_expression_container(value);
|
|
330
|
+
const next_attrs = attrs.slice();
|
|
331
|
+
if (existing) {
|
|
332
|
+
next_attrs[index] = { ...existing, value: attr_value };
|
|
333
|
+
} else {
|
|
334
|
+
next_attrs.push(b.jsx_attribute(b.jsx_id(class_attr_name), attr_value));
|
|
300
335
|
}
|
|
336
|
+
return {
|
|
337
|
+
...element,
|
|
338
|
+
openingElement: { ...element.openingElement, attributes: next_attrs },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
301
341
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
342
|
+
/**
|
|
343
|
+
* Ensure the element carries a class attribute containing the scoping hash,
|
|
344
|
+
* in place. Kept for consumers that stamp one hash at a time; the scope
|
|
345
|
+
* pre-pass uses {@link add_scope_classes}.
|
|
346
|
+
*
|
|
347
|
+
* @param {AST.TSRXJSXElement} element
|
|
348
|
+
* @param {string} hash
|
|
349
|
+
* @param {'class' | 'className'} [class_attr_name='class']
|
|
350
|
+
* @returns {void}
|
|
351
|
+
*/
|
|
352
|
+
export function add_hash_class(element, hash, class_attr_name = 'class') {
|
|
353
|
+
const stamped = add_scope_classes(element, [hash], [], class_attr_name);
|
|
354
|
+
element.openingElement = stamped.openingElement;
|
|
305
355
|
}
|
|
@@ -158,10 +158,15 @@ function visit_source_ast(ast, src_line_offsets, { regions, css_element_info, sc
|
|
|
158
158
|
).loc;
|
|
159
159
|
const cssStart = loc_to_offset(openLoc.end.line, openLoc.end.column, src_line_offsets);
|
|
160
160
|
|
|
161
|
-
const closeLoc =
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
const closeLoc =
|
|
162
|
+
/** @type {(ESTreeJSX.JSXClosingElement & AST.NodeWithLocation) | null} */ (
|
|
163
|
+
node.closingElement
|
|
164
|
+
)?.loc;
|
|
165
|
+
// An unclosed block recovered in loose mode has no closing tag; its
|
|
166
|
+
// body ends where the captured CSS ends.
|
|
167
|
+
const cssEnd = closeLoc
|
|
168
|
+
? loc_to_offset(closeLoc.start.line, closeLoc.start.column, src_line_offsets)
|
|
169
|
+
: cssStart + node.css.length;
|
|
165
170
|
|
|
166
171
|
regions.push({
|
|
167
172
|
start: cssStart,
|
|
@@ -659,6 +664,13 @@ export function convert_source_map_to_mappings(
|
|
|
659
664
|
if (node.metadata?.disable_verification) {
|
|
660
665
|
token.mappingData = { ...mapping_data, verification: false };
|
|
661
666
|
}
|
|
667
|
+
// A synthesized identifier that borrows an authored span so
|
|
668
|
+
// diagnostics land on it (e.g. the `$class` read of a type-only
|
|
669
|
+
// `apply` target): map for verification only, so hover and
|
|
670
|
+
// navigation on the authored token are not polluted by it.
|
|
671
|
+
if (node.metadata?.verify_only) {
|
|
672
|
+
token.mappingData = mapping_data_verify_only;
|
|
673
|
+
}
|
|
662
674
|
// A generated identifier whose source span sits inside a string
|
|
663
675
|
// literal (e.g. a server-module lowering's namespace reference
|
|
664
676
|
// carrying the authored `'server'` import specifier): serve
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @import * as AST from 'estree' */
|
|
2
2
|
/** @import * as ESTreeJSX from 'estree-jsx' */
|
|
3
|
-
/** @import { ClassMapCollectionState, StyleRefOptions, TopScopedClasses, Visitors } from '../../types/index' */
|
|
3
|
+
/** @import { ClassMapCollectionState, StyleClassMapOptions, StyleRefOptions, TopScopedClasses, Visitors } from '../../types/index' */
|
|
4
4
|
|
|
5
5
|
import { walk } from 'zimmerframe';
|
|
6
6
|
import * as b from '../utils/builders.js';
|
|
@@ -14,40 +14,101 @@ import { clone_ast_node, clone_identifier } from './jsx/ast-builders.js';
|
|
|
14
14
|
const regex_backslash_and_following_character = /\\(.)/g;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* @param {AST.Node} component
|
|
18
|
-
* @param {AST.CSS.StyleSheet} css
|
|
17
|
+
* @param {AST.Node} component the node whose metadata carries the scope's `topScopedClasses`
|
|
18
|
+
* @param {AST.CSS.StyleSheet | null} css
|
|
19
|
+
* @param {StyleClassMapOptions} [options]
|
|
19
20
|
* @returns {AST.ObjectExpression}
|
|
20
21
|
*/
|
|
21
|
-
export function create_style_class_map(component, css) {
|
|
22
|
+
export function create_style_class_map(component, css, options = {}) {
|
|
22
23
|
return build_style_class_map(
|
|
23
|
-
component.metadata?.topScopedClasses ??
|
|
24
|
-
|
|
24
|
+
component.metadata?.topScopedClasses ??
|
|
25
|
+
(css ? collect_style_class_map_entries(css) : new Map()),
|
|
26
|
+
options.hash ?? css?.hash ?? null,
|
|
27
|
+
options,
|
|
25
28
|
);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* @param {AST.CSS.StyleSheet} css
|
|
33
|
+
* @param {StyleClassMapOptions} [options]
|
|
30
34
|
* @returns {AST.ObjectExpression}
|
|
31
35
|
*/
|
|
32
|
-
export function create_style_class_map_from_stylesheet(css) {
|
|
33
|
-
return build_style_class_map(collect_style_class_map_entries(css), css.hash);
|
|
36
|
+
export function create_style_class_map_from_stylesheet(css, options = {}) {
|
|
37
|
+
return build_style_class_map(collect_style_class_map_entries(css), css.hash, options);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
* `{ foo: 'hash foo', … }` for every class the
|
|
41
|
+
* `{ $class: '<applied…> <hash>', foo: 'hash foo', … }` for every class the
|
|
42
|
+
* style expression exposes. `$class` comes first and is the block's own scope
|
|
43
|
+
* hash preceded by the `$class` of every applied theme (D6): adjacent static
|
|
44
|
+
* parts fold into one literal, runtime parts join with `+`.
|
|
38
45
|
*
|
|
39
46
|
* @param {TopScopedClasses} top_scoped_classes
|
|
40
47
|
* @param {string | null} hash
|
|
48
|
+
* @param {StyleClassMapOptions} [options]
|
|
41
49
|
* @returns {AST.ObjectExpression}
|
|
42
50
|
*/
|
|
43
|
-
function build_style_class_map(top_scoped_classes, hash) {
|
|
51
|
+
export function build_style_class_map(top_scoped_classes, hash, options = {}) {
|
|
44
52
|
const class_names = [...top_scoped_classes.keys()].sort();
|
|
53
|
+
/** @type {Array<string | AST.Expression>} */
|
|
54
|
+
const parts = [...(options.applied ?? [])];
|
|
55
|
+
if (hash) parts.push(hash);
|
|
45
56
|
|
|
46
|
-
return b.object(
|
|
47
|
-
|
|
57
|
+
return b.object([
|
|
58
|
+
b.prop('init', b.literal('$class'), build_class_expression(parts)),
|
|
59
|
+
...class_names.map((class_name) =>
|
|
48
60
|
b.prop('init', b.literal(class_name), b.literal(hash ? `${hash} ${class_name}` : class_name)),
|
|
49
61
|
),
|
|
50
|
-
);
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Join class parts into one expression: a literal when every part is
|
|
67
|
+
* static, else a `+` chain with static runs folded together.
|
|
68
|
+
*
|
|
69
|
+
* @param {Array<string | AST.Expression>} parts
|
|
70
|
+
* @returns {AST.Expression}
|
|
71
|
+
*/
|
|
72
|
+
function build_class_expression(parts) {
|
|
73
|
+
/** @type {AST.Expression | null} */
|
|
74
|
+
let result = null;
|
|
75
|
+
let pending = '';
|
|
76
|
+
/** @type {Set<string>} */
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
/** @param {AST.Expression} expression */
|
|
79
|
+
const append = (expression) => {
|
|
80
|
+
result = result ? b.binary('+', result, expression) : expression;
|
|
81
|
+
};
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
if (typeof part === 'string') {
|
|
84
|
+
// Statically known hashes appear once even when applied through
|
|
85
|
+
// several themes (D12, static dedupe).
|
|
86
|
+
if (seen.has(part)) continue;
|
|
87
|
+
seen.add(part);
|
|
88
|
+
pending = pending ? `${pending} ${part}` : part;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (result) {
|
|
92
|
+
append(b.literal(pending ? ` ${pending} ` : ' '));
|
|
93
|
+
} else if (pending) {
|
|
94
|
+
append(b.literal(`${pending} `));
|
|
95
|
+
}
|
|
96
|
+
pending = '';
|
|
97
|
+
append(clone_ast_node(part, false));
|
|
98
|
+
}
|
|
99
|
+
if (!result) return b.literal(pending);
|
|
100
|
+
if (pending) append(b.literal(` ${pending}`));
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The class names an assigned block's class map exposes, in source order.
|
|
106
|
+
*
|
|
107
|
+
* @param {AST.CSS.StyleSheet} css
|
|
108
|
+
* @returns {string[]}
|
|
109
|
+
*/
|
|
110
|
+
export function get_style_class_map_names(css) {
|
|
111
|
+
return [...collect_style_class_map_entries(css).keys()];
|
|
51
112
|
}
|
|
52
113
|
|
|
53
114
|
/**
|
|
@@ -543,6 +543,7 @@ export function render_css_result(stylesheets, minify = false) {
|
|
|
543
543
|
}
|
|
544
544
|
return {
|
|
545
545
|
css: render_stylesheets(stylesheets, minify),
|
|
546
|
-
|
|
546
|
+
// One hash per scope: every sheet of a scope carries the scope's hash.
|
|
547
|
+
cssHash: [...new Set(stylesheets.map((s) => s.hash))].join(' '),
|
|
547
548
|
};
|
|
548
549
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an identifier (or a non-computed member chain rooted at one) is a
|
|
3
|
+
* reference to a binding rather than a property name, label, or specifier
|
|
4
|
+
* alias. Inlined from `is-reference@3.0.3` (MIT, Rich Harris,
|
|
5
|
+
* https://github.com/Rich-Harris/is-reference) so consumers that bundle the
|
|
6
|
+
* analyzer need no extra license notice for a 40-line helper.
|
|
7
|
+
*
|
|
8
|
+
* @import * as AST from 'estree'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {AST.Node} node
|
|
13
|
+
* @param {AST.Node | null | undefined} parent
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
export function is_reference(node, parent) {
|
|
17
|
+
if (node.type === 'MemberExpression') {
|
|
18
|
+
return !node.computed && is_reference(node.object, node);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (node.type !== 'Identifier') return false;
|
|
22
|
+
|
|
23
|
+
switch (parent?.type) {
|
|
24
|
+
// disregard `bar` in `foo.bar`
|
|
25
|
+
case 'MemberExpression':
|
|
26
|
+
return parent.computed || node === parent.object;
|
|
27
|
+
|
|
28
|
+
// disregard the `foo` in `class {foo(){}}` but keep it in `class {[foo](){}}`
|
|
29
|
+
case 'MethodDefinition':
|
|
30
|
+
return parent.computed;
|
|
31
|
+
|
|
32
|
+
// disregard the `meta` in `import.meta`
|
|
33
|
+
case 'MetaProperty':
|
|
34
|
+
return parent.meta === node;
|
|
35
|
+
|
|
36
|
+
// disregard the `foo` in `class {foo=bar}` but keep it in `class {[foo]=bar}` and `class {bar=foo}`
|
|
37
|
+
case 'PropertyDefinition':
|
|
38
|
+
return parent.computed || node === parent.value;
|
|
39
|
+
|
|
40
|
+
// disregard the `bar` in `{ bar: foo }`, but keep it in `{ [bar]: foo }`
|
|
41
|
+
case 'Property':
|
|
42
|
+
return parent.computed || node === parent.value;
|
|
43
|
+
|
|
44
|
+
// disregard the `bar` in `export { foo as bar }` or
|
|
45
|
+
// the foo in `import { foo as bar }`
|
|
46
|
+
case 'ExportSpecifier':
|
|
47
|
+
case 'ImportSpecifier':
|
|
48
|
+
return node === parent.local;
|
|
49
|
+
|
|
50
|
+
// disregard the `foo` in `foo: while (...) { ... break foo; ... continue foo;}`
|
|
51
|
+
case 'LabeledStatement':
|
|
52
|
+
case 'BreakStatement':
|
|
53
|
+
case 'ContinueStatement':
|
|
54
|
+
return false;
|
|
55
|
+
|
|
56
|
+
default:
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Scoped style conformance fixtures
|
|
2
|
+
|
|
3
|
+
Target-neutral fixtures for sibling-scoped `<style>` blocks, `$class`, and `apply`
|
|
4
|
+
([RFC tsrx-org/RFCs#1](https://github.com/tsrx-org/RFCs/discussions/1)). Each
|
|
5
|
+
`<name>.tsrx` compiles as one module (imports are never resolved) and its sibling
|
|
6
|
+
`<name>.expected.json` states what the output must contain. A subdirectory groups
|
|
7
|
+
the modules of a multi-file example; every `.tsrx` inside is still compiled on its
|
|
8
|
+
own.
|
|
9
|
+
|
|
10
|
+
The TSRX runner lives in
|
|
11
|
+
`packages/tsrx/tests/shared/scoped-styles-conformance.js`. Other compilers vendor
|
|
12
|
+
this directory and implement the same checks.
|
|
13
|
+
|
|
14
|
+
## Labels
|
|
15
|
+
|
|
16
|
+
Hashes are position-derived and never appear in the JSON. Every scope and every
|
|
17
|
+
assigned block is named by a **label**, and the label resolves to a hash by
|
|
18
|
+
reading the marker selector `.<label>.<hash>` back from the compiled CSS.
|
|
19
|
+
|
|
20
|
+
Fixture conventions that make this work:
|
|
21
|
+
|
|
22
|
+
- Every `<style>` block with a body **starts with its label's marker rule**,
|
|
23
|
+
`.<label> { --label: <label>; }`. Blocks that share a scope repeat the scope's
|
|
24
|
+
label, so the sequence of marker rules in the CSS is the sheet emission order.
|
|
25
|
+
- A standalone block's marker must match an element, or it is pruned and has no
|
|
26
|
+
hash: put the label in the class list of some element the scope reaches
|
|
27
|
+
(`<div class="e1 scopeA">`). Assigned blocks keep their class selectors, so they
|
|
28
|
+
need no element.
|
|
29
|
+
- An assigned block's label is its variable name.
|
|
30
|
+
- A label of the form `import:<expr>` is not resolved; it stands for the runtime
|
|
31
|
+
read `<expr>.$class` of a block the compiler cannot see (an import, or any block
|
|
32
|
+
whose `$class` is not fully static).
|
|
33
|
+
|
|
34
|
+
## `expected.json`
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"elements": {
|
|
39
|
+
"e1 scopeA": ["scopeA", "import:theme"],
|
|
40
|
+
"{theme.dark}": ["scopeA"]
|
|
41
|
+
},
|
|
42
|
+
"cssOrder": ["theme", "scopeA", "scopeA", "scopeB"],
|
|
43
|
+
"pruned": ["p", "div"],
|
|
44
|
+
"classMaps": { "theme": ["base", "theme"], "bundle": ["import:a", "import:b"] },
|
|
45
|
+
"knownFailure": "optional: why the compiler currently disagrees"
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- **`elements`** — authored `class` value → labels the element must carry, in
|
|
50
|
+
order: enclosing scope hashes outer → inner, then applied themes. Static labels
|
|
51
|
+
are appended to the authored literal (`class="e1 scopeA <hashA>"`). A runtime
|
|
52
|
+
label makes it a template literal (``class={`e1 <hashA> ${theme.$class}`}``). A
|
|
53
|
+
key wrapped in braces is an expression-valued authored class and matches
|
|
54
|
+
``class={`${theme.dark} <hashA>`}`` (or the untouched `class={theme.dark}` when
|
|
55
|
+
the chain is empty). Both `class=` and `className=` are accepted.
|
|
56
|
+
- **`cssOrder`** — labels of the marker rules in emission order: a scope's sheets
|
|
57
|
+
contiguous in source order, nested scopes after their parent, siblings in source
|
|
58
|
+
order, assigned blocks at their declaration position. `cssHash` must equal the
|
|
59
|
+
distinct hashes of this list, in order.
|
|
60
|
+
- **`pruned`** — selector texts expected inside `/* (unused) … */` comments, in
|
|
61
|
+
order. Standalone blocks prune selectors that match nothing in their reach;
|
|
62
|
+
assigned blocks that are neither exported nor applied prune every non-class
|
|
63
|
+
selector; exported or applied blocks prune nothing.
|
|
64
|
+
- **`classMaps`** — assigned-block variable → `$class` composition: applied
|
|
65
|
+
blocks' compositions first (transitively), the block's own label last. `own` may
|
|
66
|
+
stand for the variable's label. Adjacent static hashes share one string literal
|
|
67
|
+
and runtime labels are concatenated (`'<a> ' + x.$class + ' <own>'`). When the
|
|
68
|
+
composition includes the block's own label, the map must also carry
|
|
69
|
+
`'<label>': '<own hash> <label>'`.
|
|
70
|
+
- **`knownFailure`** — when present, the fixture documents a discrepancy between
|
|
71
|
+
the RFC and the compiler and is expected to fail until it is fixed.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"elements": {
|
|
3
|
+
"a1": ["one"],
|
|
4
|
+
"a2": ["one"],
|
|
5
|
+
"a3": ["one"],
|
|
6
|
+
"b1": ["one", "two"],
|
|
7
|
+
"c1 withBody": ["withBody", "one"],
|
|
8
|
+
"c2": ["withBody", "one"],
|
|
9
|
+
"d1": ["one", "two"]
|
|
10
|
+
},
|
|
11
|
+
"cssOrder": ["one", "two", "withBody", "withBody"],
|
|
12
|
+
"pruned": [],
|
|
13
|
+
"classMaps": {
|
|
14
|
+
"one": ["one"],
|
|
15
|
+
"two": ["two"],
|
|
16
|
+
"both": ["one", "two"]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Every `apply` shape: self-closed, array, with a body, two blocks in one
|
|
2
|
+
// scope, and a body-less assigned bundle.
|
|
3
|
+
const one = <style>
|
|
4
|
+
.one {
|
|
5
|
+
--label: one;
|
|
6
|
+
}
|
|
7
|
+
div {
|
|
8
|
+
color: red;
|
|
9
|
+
}
|
|
10
|
+
</style>;
|
|
11
|
+
|
|
12
|
+
const two = <style>
|
|
13
|
+
.two {
|
|
14
|
+
--label: two;
|
|
15
|
+
}
|
|
16
|
+
div {
|
|
17
|
+
color: blue;
|
|
18
|
+
}
|
|
19
|
+
</style>;
|
|
20
|
+
|
|
21
|
+
export const both = <style apply={[one, two]} />;
|
|
22
|
+
|
|
23
|
+
export function SelfClosed() @{
|
|
24
|
+
<>
|
|
25
|
+
<style apply={one} />
|
|
26
|
+
<div class="a1">{'a'}</div>
|
|
27
|
+
<p class="a2">
|
|
28
|
+
<span class="a3">{'c'}</span>
|
|
29
|
+
</p>
|
|
30
|
+
</>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ArrayForm() @{
|
|
34
|
+
<>
|
|
35
|
+
<style apply={[one, two]} />
|
|
36
|
+
<div class="b1">{'b'}</div>
|
|
37
|
+
</>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function WithBody() @{
|
|
41
|
+
<>
|
|
42
|
+
<style apply={one}>
|
|
43
|
+
.withBody {
|
|
44
|
+
--label: withBody;
|
|
45
|
+
}
|
|
46
|
+
div {
|
|
47
|
+
color: black;
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
50
|
+
<style>
|
|
51
|
+
.withBody {
|
|
52
|
+
--label: withBody;
|
|
53
|
+
}
|
|
54
|
+
p {
|
|
55
|
+
margin: 0;
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
58
|
+
<div class="c1 withBody">{'c'}</div>
|
|
59
|
+
<p class="c2">{'p'}</p>
|
|
60
|
+
</>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function TwoApplies() @{
|
|
64
|
+
<>
|
|
65
|
+
<style apply={one} />
|
|
66
|
+
<style apply={two} />
|
|
67
|
+
<div class="d1">{'d'}</div>
|
|
68
|
+
</>
|
|
69
|
+
}
|