create-caspian-app 1.3.21 → 1.3.22

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.
@@ -24,6 +24,10 @@ Each rule below is a real rendering rule, not a heuristic:
24
24
  * whitespace touching a block-level boundary collapses and never renders
25
25
  * a text node's leading/trailing whitespace collapses when its parent is
26
26
  block-level, but not when the parent is inline or an unknown `<x-*>` tag
27
+ * in a flex or grid container, an anonymous item holding only white space is
28
+ not rendered at all (CSS Flexbox 4, CSS Grid 6), so whitespace between that
29
+ container's children collapses -- including when the container is an `<x-*>`
30
+ tag whose rendered root is known (see `component_display.py`)
27
31
  * `<pre>` / `<textarea>` text renders verbatim
28
32
  * `<script>` / `<style>` bodies are code: indentation is irrelevant
29
33
  * attribute ORDER never affects rendering; attribute VALUES always do
@@ -39,35 +43,119 @@ from __future__ import annotations
39
43
  import re
40
44
  from html.parser import HTMLParser
41
45
 
46
+
42
47
  LITERAL_TAGS = {"pre", "textarea"}
43
48
  CODE_TAGS = {"script", "style"}
44
49
 
45
- # Whitespace touching one of these collapses away. A custom `<x-*>` tag is
46
- # deliberately absent: its display is set by CSS the formatter cannot see, so it
47
- # is treated as inline and its surrounding whitespace is significant.
48
50
  # fmt: off
51
+ # Block-level boxes. Whitespace touching one of these collapses: it sits either
52
+ # at the edge of the box's own content or on a line that the block already
53
+ # broke, and in both places it is removed.
49
54
  BLOCK_TAGS = {
50
55
  "html", "head", "body", "div", "p", "section", "article", "header",
51
56
  "footer", "nav", "aside", "main", "form", "fieldset", "legend", "figure",
52
57
  "figcaption", "blockquote", "hr", "ul", "ol", "li", "dl", "dt", "dd",
53
58
  "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "colgroup",
54
59
  "col", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "details", "summary",
55
- "dialog", "script", "style", "template", "option", "optgroup", "select",
56
- "textarea", "video", "audio", "source", "track", "canvas", "iframe",
57
- "meta", "link", "title", "address", "hgroup", "menu", "search", "noscript",
58
- "br",
59
- # SVG. Inside an SVG fragment, whitespace between elements is never laid out
60
- # as text, so indenting the children of an inline <svg> cannot change what is
61
- # drawn. `<text>`, `<tspan>` and `<textPath>` are deliberately excluded --
62
- # they do render their content -- as is `<foreignObject>`, whose children are
63
- # HTML again and follow HTML rules.
60
+ "dialog", "script", "style", "template", "address", "hgroup", "menu",
61
+ "search", "noscript", "br", "meta", "link", "title",
62
+ }
63
+
64
+ # Inline-level elements that still lay their *content* out as a block. A
65
+ # `<button>` is `inline-block`: the whitespace at the edges of its content
66
+ # collapses exactly as it would in a div, even though whitespace next to the
67
+ # button itself is significant. Keeping these out of BLOCK_TAGS is the point --
68
+ # two adjacent inline-blocks separated by a newline really do render a space.
69
+ CONTENT_BOX_TAGS = {
70
+ "button", "select", "textarea", "option", "optgroup",
71
+ "video", "audio", "canvas", "iframe", "object", "progress", "meter",
72
+ }
73
+
74
+ # Containers where a child made only of whitespace cannot render at all.
75
+ # * SVG lays out no text, so indenting an svg fragment cannot change what is
76
+ # drawn. `<text>`, `<tspan>`, `<textPath>` and `<foreignObject>` are excluded
77
+ # because they do render their content.
78
+ # * A table or select drops stray text between its structural children.
79
+ # Flex and grid containers join this set at runtime, from their class list.
80
+ WS_DROPPING_TAGS = {
64
81
  "svg", "g", "defs", "symbol", "use", "path", "circle", "ellipse", "line",
65
82
  "polyline", "polygon", "rect", "clippath", "lineargradient",
66
83
  "radialgradient", "stop", "mask", "pattern", "filter", "marker", "desc",
67
84
  "animate", "animatetransform", "animatemotion", "switch", "image",
85
+ "table", "thead", "tbody", "tfoot", "tr", "colgroup", "select", "optgroup",
86
+ "html", "head",
68
87
  }
69
88
  # fmt: on
70
89
 
90
+ # Void elements cannot have children at all. A component written as
91
+ # `<x-input class="..."></x-input>` renders an `<input>`, so anything the author
92
+ # puts between those tags -- including a newline djLint adds -- is discarded.
93
+ VOID_TAGS = {
94
+ "area",
95
+ "base",
96
+ "br",
97
+ "col",
98
+ "embed",
99
+ "hr",
100
+ "img",
101
+ "input",
102
+ "link",
103
+ "meta",
104
+ "param",
105
+ "source",
106
+ "track",
107
+ "wbr",
108
+ }
109
+
110
+ # Tailwind's display utilities that create a flex or grid formatting context.
111
+ # Membership is tested against whitespace-split class tokens, so only the
112
+ # unprefixed form counts: `sm:flex` means the element is *not* flex at every
113
+ # breakpoint, so its whitespace can still render on a narrow screen.
114
+ FLEX_DISPLAY_CLASSES = {"flex", "inline-flex", "grid", "inline-grid"}
115
+ # Display utilities that make an element a block-level box, so whitespace next
116
+ # to it collapses. The `inline-*` forms are deliberately absent: two adjacent
117
+ # inline-blocks separated by a newline really do render a space.
118
+ BLOCK_LEVEL_CLASSES = {"block", "flow-root", "list-item", "table", "flex", "grid"}
119
+ # `display: none` removes the box entirely, so it is not a block-level box that
120
+ # whitespace beside it can collapse against. It says nothing about how its own
121
+ # content is laid out when some state does display it, so it only suppresses
122
+ # `block_level`.
123
+ HIDDEN_CLASS = "hidden"
124
+ # `display: inline` makes an element lay its content out inline, so edge
125
+ # whitespace inside it becomes significant. `display: contents` removes the box
126
+ # and hoists the children into the parent, exactly like `as-child`.
127
+ INLINE_CLASS = "inline"
128
+ CONTENTS_CLASS = "contents"
129
+
130
+ # Display utilities that make an element lay its content out as a block, so the
131
+ # whitespace at the edges of that content collapses. `inline-block` counts: only
132
+ # whitespace *next to* it stays significant, not whitespace inside it.
133
+ BLOCK_CONTAINER_CLASSES = {
134
+ "block",
135
+ "inline-block",
136
+ "flow-root",
137
+ "list-item",
138
+ "table",
139
+ "inline-table",
140
+ "table-cell",
141
+ "table-row",
142
+ "table-caption",
143
+ }
144
+ # Utilities that switch whitespace collapsing off. Every rule here assumes
145
+ # collapsible text, so an element carrying one of these knows nothing.
146
+ PRE_WHITESPACE_CLASSES = {
147
+ "whitespace-pre",
148
+ "whitespace-pre-wrap",
149
+ "whitespace-pre-line",
150
+ "whitespace-break-spaces",
151
+ }
152
+
153
+ # Attributes that make a component transparent: `as-child` renders the child in
154
+ # the component's place, so the child's whitespace lands in the *parent's*
155
+ # formatting context, not in any box the component owns.
156
+ AS_CHILD_ATTRS = ("as-child", "aschild", "as_child")
157
+ _TRUTHY_AS_CHILD = {"", "true", "1", "yes"}
158
+
71
159
  JINJA = re.compile(r"\{\{\s*(.*?)\s*\}\}|\{%\s*(.*?)\s*%\}", re.S)
72
160
 
73
161
 
@@ -88,19 +176,436 @@ def _canon_attr_value(value: str | None) -> str | None:
88
176
  return re.sub(r"\s+", " ", _canon_jinja(value)).strip()
89
177
 
90
178
 
179
+ # ---------------------------------------------------------------------------
180
+ # What an `<x-*>` tag really is
181
+ #
182
+ # A component tag is an ordinary HTML tag with an `x-` prefix: `<x-search />`
183
+ # *is* an `<svg>`, `<x-dialog-close>` *is* a `<button>`. Nothing above needs a
184
+ # custom rule for them -- they only need resolving to the element they render,
185
+ # and then every rule already written for HTML applies unchanged.
186
+ #
187
+ # The mapping is not declared anywhere because it does not have to be: a
188
+ # component is a function returning markup, so rendering it with no props shows
189
+ # its root. That is done once, lazily, the first time a custom tag is seen.
190
+ # ---------------------------------------------------------------------------
191
+
192
+ _ROOT_TAG_RE = re.compile(r"\s*<([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>")
193
+ _CLASS_ATTR_RE = re.compile(r'(?:^|\s)class="([^"]*)"')
194
+ # `merge_classes` renders a live `{twMerge("base classes", ...)}` expression, so
195
+ # a component's own classes are the first quoted literal inside it.
196
+ _TWMERGE_LITERAL_RE = re.compile(r'twMerge\(\s*(?:"|&quot;)(.*?)(?:"|&quot;)')
197
+
198
+ _component_roots: dict[str, dict] | None = None
199
+
200
+
201
+ def _describe_component(component) -> dict | None:
202
+ """Render one component with no props and name its root element."""
203
+ import asyncio
204
+ import html as html_module
205
+
206
+ try:
207
+ markup = str(asyncio.run(component.acall()))
208
+ except Exception:
209
+ # Needs props, or does not render standalone. Unknown is the safe answer.
210
+ return None
211
+ match = _ROOT_TAG_RE.match(markup)
212
+ if match is None:
213
+ return None
214
+ root, attrs_text = match.group(1), match.group(2)
215
+ if root.startswith("x-"):
216
+ # A composition component whose root is another component: the compiler
217
+ # inserts a `display: contents` host, so resolving the chain would mean
218
+ # reasoning about a box that lays out as if it were not there.
219
+ return None
220
+ classes = ""
221
+ class_match = _CLASS_ATTR_RE.search(attrs_text)
222
+ if class_match is not None:
223
+ literal = _TWMERGE_LITERAL_RE.search(class_match.group(1))
224
+ raw = html_module.unescape(class_match.group(1))
225
+ classes = html_module.unescape(literal.group(1)) if literal else ("" if "{" in raw else raw)
226
+ return {"root": root, "classes": classes}
227
+
228
+
229
+ def _load_component_roots() -> dict[str, dict]:
230
+ """Map every `x-*` tag to the element it renders. `{}` if unavailable."""
231
+ import importlib
232
+ import json
233
+ import sys
234
+ from pathlib import Path
235
+
236
+ settings_dir = Path(__file__).resolve().parent
237
+ project_root = settings_dir.parent
238
+ if str(project_root) not in sys.path:
239
+ sys.path.insert(0, str(project_root))
240
+ try:
241
+ entries = json.loads((settings_dir / "component-map.json").read_text(encoding="utf-8"))
242
+ except OSError, ValueError:
243
+ return {}
244
+
245
+ roots: dict[str, dict] = {}
246
+ for entry in entries:
247
+ name = str(entry.get("componentName") or "")
248
+ route = str(entry.get("importRoute") or "")
249
+ if not name or not route:
250
+ continue
251
+ try:
252
+ component = getattr(importlib.import_module(route), name, None)
253
+ except Exception:
254
+ continue
255
+ if component is None or not hasattr(component, "acall"):
256
+ continue
257
+ info = _describe_component(component)
258
+ if info is None:
259
+ continue
260
+ tag = "x-" + re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
261
+ # Two components claiming one tag resolve by import order; unknown is safer.
262
+ if tag in roots and roots[tag] != info:
263
+ roots[tag] = {}
264
+ continue
265
+ roots[tag] = info
266
+ return roots
267
+
268
+
269
+ def component_root(tag: str) -> dict:
270
+ """The rendered root of an `x-*` tag: `{"root": ..., "classes": ...}`."""
271
+ global _component_roots
272
+ if _component_roots is None:
273
+ try:
274
+ _component_roots = _load_component_roots()
275
+ except Exception:
276
+ _component_roots = {}
277
+ return _component_roots.get(tag) or {}
278
+
279
+
280
+ def resolve_tag(tag: str) -> str:
281
+ """The HTML tag an element behaves as. Non-components are themselves."""
282
+ if not tag.startswith("x-"):
283
+ return tag
284
+ return str(component_root(tag).get("root") or "")
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Seeing through `{{ attributes }}`
289
+ #
290
+ # Caspian's props contract forwards a root's attributes as one Jinja value:
291
+ # `<div {{ attributes }}>`. An HTML parser sees an attribute named
292
+ # `{{attributes}}` and no class at all, so the element's display is invisible --
293
+ # which is what froze the last few blocks.
294
+ #
295
+ # The value is built in the same Python file, by the two documented helpers, so
296
+ # an AST walk can recover it. Only a *fully literal* class is accepted:
297
+ # `merge_classes("a b c")`. The moment a props-derived value is merged in
298
+ # (`merge_classes(base, incoming_class)`) the answer is refused, because the
299
+ # call site's class wins at runtime through `twMerge` and the call site is in
300
+ # another file.
301
+ # ---------------------------------------------------------------------------
302
+
303
+ _MAX_RESOLVE_DEPTH = 6
304
+
305
+
306
+ def _call_name(node) -> str:
307
+ func = node.func
308
+ return getattr(func, "id", None) or getattr(func, "attr", None) or ""
309
+
310
+
311
+ def _literal_classes(node, assigned: dict, depth: int = 0) -> str | None:
312
+ """The class list a node evaluates to, or None when it is not fully literal."""
313
+ import ast
314
+
315
+ if depth > _MAX_RESOLVE_DEPTH:
316
+ return None
317
+ if isinstance(node, ast.Constant):
318
+ return node.value if isinstance(node.value, str) else None
319
+ if isinstance(node, ast.Name):
320
+ values = assigned.get(node.id)
321
+ # Reassigned under a branch: which value reaches the template is a
322
+ # runtime decision.
323
+ if not values or len(values) != 1:
324
+ return None
325
+ return _literal_classes(values[0], assigned, depth + 1)
326
+ if isinstance(node, ast.Call):
327
+ name = _call_name(node)
328
+ if name == "merge_classes":
329
+ if node.keywords:
330
+ return None
331
+ parts = []
332
+ for arg in node.args:
333
+ value = _literal_classes(arg, assigned, depth + 1)
334
+ if value is None:
335
+ return None
336
+ parts.append(value)
337
+ return " ".join(parts)
338
+ if name == "get_attributes":
339
+ if not node.args or not isinstance(node.args[0], ast.Dict):
340
+ return None
341
+ for key, value in zip(node.args[0].keys, node.args[0].values):
342
+ if isinstance(key, ast.Constant) and key.value == "class":
343
+ return _literal_classes(value, assigned, depth + 1)
344
+ return "" # a forwarded attribute set with no class of its own
345
+ return None
346
+
347
+
348
+ # A component script may also hold a class list behind a helper:
349
+ # `class="{getIndicatorIconClass()}"`. Only the simplest possible shape is
350
+ # accepted -- `const name = () => "literal";` -- because it takes no arguments,
351
+ # closes over nothing and has a single string body, so the attribute's value is
352
+ # that literal on every render. A helper with parameters or a block body is a
353
+ # runtime decision and stays unknown.
354
+ _SCRIPT_CLASS_FN = re.compile(
355
+ r"""(?<![\w$])const\s+([A-Za-z_$][\w$]*)\s*=\s*\(\s*\)\s*=>\s*(['"])(.*?)\2""",
356
+ re.S,
357
+ )
358
+ # Any other binding of that name means the arrow is not the whole story.
359
+ _SCRIPT_ANY_BINDING = r"(?<![\w$])(?:const|let|var|function)\s+{name}(?![\w$])"
360
+
361
+
362
+ def script_class_literals(source: str) -> dict[str, str]:
363
+ """Map `{name()}` class expressions to the literal they always return."""
364
+ found: dict[str, str] = {}
365
+ for name, _, literal in _SCRIPT_CLASS_FN.findall(source):
366
+ bindings = re.findall(_SCRIPT_ANY_BINDING.format(name=re.escape(name)), source)
367
+ if len(bindings) != 1:
368
+ found.pop(name, None)
369
+ continue
370
+ found[name] = literal
371
+ return {"{" + name + "()}": literal for name, literal in found.items()}
372
+
373
+
374
+ def class_hints(python_source: str) -> dict[str, str]:
375
+ """Every class list this file hides behind an expression.
376
+
377
+ Keyed by the attribute text the markup actually carries: `{{attributes}}`
378
+ for a forwarded Jinja value, `{helper()}` for a script helper.
379
+ """
380
+ hints = jinja_attr_classes(python_source)
381
+ hints.update(script_class_literals(python_source))
382
+ return hints
383
+
384
+
385
+ def jinja_attr_classes(python_source: str) -> dict[str, str]:
386
+ """Map each `{{ name }}` attribute variable to the class list it carries."""
387
+ import ast
388
+
389
+ try:
390
+ tree = ast.parse(python_source)
391
+ except SyntaxError:
392
+ return {}
393
+
394
+ assigned: dict[str, list] = {}
395
+ for node in ast.walk(tree):
396
+ if isinstance(node, ast.Assign):
397
+ for target in node.targets:
398
+ if isinstance(target, ast.Name):
399
+ assigned.setdefault(target.id, []).append(node.value)
400
+
401
+ resolved: dict[str, str] = {}
402
+ for node in ast.walk(tree):
403
+ if not isinstance(node, ast.Call) or _call_name(node) != "html":
404
+ continue
405
+ for keyword in node.keywords:
406
+ if not keyword.arg:
407
+ continue
408
+ classes = _literal_classes(keyword.value, assigned)
409
+ if classes is not None:
410
+ resolved["{{" + keyword.arg.lower() + "}}"] = classes
411
+ return resolved
412
+
413
+
414
+ # Every token that can set an element's display. A component's base classes are
415
+ # merged with whatever the call site passes, and `twMerge` lets the call site
416
+ # win -- so `<x-button class="inline">` really is inline despite the component's
417
+ # `inline-flex` base.
418
+ _DISPLAY_TOKENS = (
419
+ FLEX_DISPLAY_CLASSES
420
+ | BLOCK_LEVEL_CLASSES
421
+ | BLOCK_CONTAINER_CLASSES
422
+ | {HIDDEN_CLASS, INLINE_CLASS, CONTENTS_CLASS}
423
+ )
424
+
425
+
426
+ # Class-like words in an attribute value, including the ones inside a
427
+ # PulsePoint expression such as `{compact ? 'flex' : 'block'}`. Quotes and
428
+ # punctuation are not part of a word, so `'flex'` yields `flex` while the
429
+ # flex-grow utility `flex-1` stays `flex-1`.
430
+ _CLASS_WORD = re.compile(r"[A-Za-z][A-Za-z0-9_/-]*(?::[A-Za-z0-9_/-]+)*")
431
+
432
+
433
+ def _may_override_display(usage_class: str | None) -> bool:
434
+ """Whether a call site's class attribute could change the rendered display.
435
+
436
+ The value may be a PulsePoint expression rather than a plain class list, so
437
+ every class-like word in it is checked, variant prefix stripped: `sm:flex`
438
+ could override at that breakpoint, `flex-1` is flex-grow and cannot. If no
439
+ word can set a display, the call site provably cannot override the
440
+ component's own; if one can, the answer is unknown.
441
+ """
442
+ if not usage_class:
443
+ return False
444
+ for word in _CLASS_WORD.findall(usage_class):
445
+ if word.rsplit(":", 1)[-1] in _DISPLAY_TOKENS:
446
+ return True
447
+ return False
448
+
449
+
450
+ def _preserves_whitespace(class_value: str | None) -> bool:
451
+ """Whether a class list turns off whitespace collapsing.
452
+
453
+ Every rule here assumes collapsible text. Under `white-space: pre` a run of
454
+ spaces renders literally, and even a whitespace-only flex item is drawn, so
455
+ an element carrying one of these utilities has to fall back to knowing
456
+ nothing about its children.
457
+ """
458
+ if not class_value:
459
+ return False
460
+ return any(token in PRE_WHITESPACE_CLASSES for token in class_value.split())
461
+
462
+
463
+ def _as_child_state(attr_map: dict[str, str | None]) -> str:
464
+ """`"yes"` / `"no"` / `"unknown"` for a tag's as-child attribute."""
465
+ for name in AS_CHILD_ATTRS:
466
+ if name not in attr_map:
467
+ continue
468
+ value = (attr_map[name] or "").strip().lower()
469
+ if "{" in value:
470
+ # A runtime expression: the component may or may not be transparent,
471
+ # and guessing either way could drop whitespace that renders.
472
+ return "unknown"
473
+ return "yes" if value in _TRUTHY_AS_CHILD else "no"
474
+ return "no"
475
+
476
+
477
+ class _Frame:
478
+ """One open element, reduced to how it treats its children's whitespace.
479
+
480
+ ``collapses`` -- a text child's edge whitespace cannot render.
481
+ ``drops_ws`` -- a child made only of whitespace cannot render at all.
482
+ ``transparent`` -- the element owns no box (`as-child`); defer to the
483
+ nearest ancestor that does.
484
+ ``opaque`` -- nothing is known; every rule must stay conservative.
485
+ """
486
+
487
+ __slots__ = (
488
+ "tag",
489
+ "collapses",
490
+ "drops_ws",
491
+ "block_level",
492
+ "flex_container",
493
+ "transparent",
494
+ "opaque",
495
+ )
496
+
497
+ def __init__(
498
+ self,
499
+ tag: str,
500
+ attrs: tuple,
501
+ in_flex_parent: bool = False,
502
+ jinja_classes: dict | None = None,
503
+ ) -> None:
504
+ self.tag = tag
505
+ attr_map = dict(attrs)
506
+ state = _as_child_state(attr_map)
507
+ self.transparent = state == "yes"
508
+ self.opaque = state == "unknown"
509
+ self.block_level = False
510
+ self.flex_container = False
511
+ if self.transparent or self.opaque:
512
+ self.collapses = self.drops_ws = False
513
+ return
514
+
515
+ # A component behaves as the element it renders, plus that element's
516
+ # own classes; an ordinary tag is simply itself.
517
+ html_tag = tag
518
+ classes = attr_map.get("class") or ""
519
+ if classes and jinja_classes:
520
+ # `class="{helper()}"` -- a helper with a single literal body.
521
+ classes = jinja_classes.get(classes, classes)
522
+ if not classes and jinja_classes:
523
+ # `<div {{ attributes }}>` -- the class is behind a forwarded value.
524
+ for name in attr_map:
525
+ if name in jinja_classes:
526
+ classes = jinja_classes[name]
527
+ break
528
+ if tag.startswith("x-"):
529
+ html_tag = resolve_tag(tag)
530
+ if _may_override_display(classes):
531
+ # The call site could win the display over the component's base
532
+ # classes, and which one wins is a runtime `twMerge` decision.
533
+ self.collapses = self.drops_ws = False
534
+ return
535
+ classes = str(component_root(tag).get("classes") or "")
536
+
537
+ class_tokens = set(classes.split())
538
+ if CONTENTS_CLASS in class_tokens:
539
+ # No box of its own: the children belong to the parent's context.
540
+ self.transparent = True
541
+ self.collapses = self.drops_ws = False
542
+ return
543
+ if not html_tag or _preserves_whitespace(classes) or INLINE_CLASS in class_tokens:
544
+ self.collapses = self.drops_ws = False
545
+ return
546
+
547
+ flex = bool(class_tokens & FLEX_DISPLAY_CLASSES)
548
+ self.flex_container = flex
549
+ self.drops_ws = flex or html_tag in WS_DROPPING_TAGS or html_tag in VOID_TAGS
550
+ self.collapses = (
551
+ flex
552
+ or self.drops_ws
553
+ or html_tag in BLOCK_TAGS
554
+ or html_tag in CONTENT_BOX_TAGS
555
+ or bool(class_tokens & BLOCK_CONTAINER_CLASSES)
556
+ )
557
+ # An element's own display utility outranks its tag's default: a
558
+ # `<label class="block">` is a block-level box, a `<div class="inline">`
559
+ # is not one any more.
560
+ if HIDDEN_CLASS in class_tokens:
561
+ self.block_level = False
562
+ return
563
+ if in_flex_parent:
564
+ # A flex or grid item is blockified: its computed display becomes
565
+ # the block-level equivalent whatever the element's default was
566
+ # (CSS Display 2.7). So an inline `<label>` inside a grid really is
567
+ # a block box, and the whitespace at its edges collapses.
568
+ self.block_level = True
569
+ self.collapses = True
570
+ return
571
+ self.block_level = bool(class_tokens & BLOCK_LEVEL_CLASSES) or (
572
+ html_tag in BLOCK_TAGS
573
+ and not (class_tokens & BLOCK_CONTAINER_CLASSES - BLOCK_LEVEL_CLASSES)
574
+ )
575
+
576
+
91
577
  class _Tokens(HTMLParser):
92
578
  """Reduce markup to a token stream where equality implies equal rendering."""
93
579
 
94
- def __init__(self) -> None:
580
+ def __init__(self, jinja_classes: dict | None = None) -> None:
95
581
  super().__init__(convert_charrefs=False)
582
+ self.jinja_classes = jinja_classes or {}
96
583
  self.out: list[tuple] = []
97
- self._open: list[str] = []
584
+ self._open: list[_Frame] = []
98
585
  self._raw_stack: list[str] = []
99
586
  self._raw_buf: list[str] = []
100
587
 
101
588
  def _in_raw(self) -> str | None:
102
589
  return self._raw_stack[-1] if self._raw_stack else None
103
590
 
591
+ def _parent_is_flex(self) -> bool:
592
+ """Whether the element about to open becomes a flex or grid item."""
593
+ frame = self._context()
594
+ return frame is not None and frame.flex_container
595
+
596
+ def _context(self) -> "_Frame | None":
597
+ """The nearest open element that actually owns a box.
598
+
599
+ A transparent element (`as-child`) renders its child in its own place,
600
+ so whitespace written inside it belongs to whichever ancestor lays the
601
+ child out. An opaque one stops the walk with nothing known.
602
+ """
603
+ for frame in reversed(self._open):
604
+ if frame.transparent:
605
+ continue
606
+ return frame
607
+ return None
608
+
104
609
  def _attrs(self, attrs) -> tuple:
105
610
  return tuple(sorted((k, _canon_attr_value(v)) for k, v in attrs))
106
611
 
@@ -111,15 +616,19 @@ class _Tokens(HTMLParser):
111
616
  if tag in LITERAL_TAGS | CODE_TAGS:
112
617
  self._raw_stack.append(tag)
113
618
  self._raw_buf = []
114
- self.out.append(("start", tag, self._attrs(attrs)))
115
- self._open.append(tag)
619
+ attrs_tuple = self._attrs(attrs)
620
+ frame = _Frame(tag, attrs_tuple, self._parent_is_flex(), self.jinja_classes)
621
+ self.out.append(("start", tag, attrs_tuple, frame.block_level))
622
+ self._open.append(frame)
116
623
 
117
624
  def handle_startendtag(self, tag, attrs):
118
625
  if self._in_raw():
119
626
  self._raw_buf.append(self.get_starttag_text() or "")
120
627
  return
121
- self.out.append(("start", tag, self._attrs(attrs)))
122
- self.out.append(("end", tag))
628
+ attrs_tuple = self._attrs(attrs)
629
+ frame = _Frame(tag, attrs_tuple, self._parent_is_flex(), self.jinja_classes)
630
+ self.out.append(("start", tag, attrs_tuple, frame.block_level))
631
+ self.out.append(("end", tag, frame.block_level))
123
632
 
124
633
  def handle_endtag(self, tag):
125
634
  raw = self._in_raw()
@@ -134,9 +643,10 @@ class _Tokens(HTMLParser):
134
643
  self.out.append(("raw", raw, body))
135
644
  self._raw_stack.pop()
136
645
  self._raw_buf = []
137
- self.out.append(("end", tag))
138
- if tag in self._open:
139
- while self._open and self._open.pop() != tag:
646
+ closing = next((f for f in reversed(self._open) if f.tag == tag), None)
647
+ self.out.append(("end", tag, closing.block_level if closing else False))
648
+ if any(frame.tag == tag for frame in self._open):
649
+ while self._open and self._open.pop().tag != tag:
140
650
  pass
141
651
 
142
652
  def handle_data(self, data):
@@ -146,18 +656,24 @@ class _Tokens(HTMLParser):
146
656
  collapsed = re.sub(r"\s+", " ", _canon_jinja(data))
147
657
  if collapsed == "":
148
658
  return
149
- parent = self._open[-1] if self._open else ""
659
+ frame = self._context()
660
+ parent = frame.tag if frame is not None else ""
150
661
  if collapsed.strip() == "":
151
- # A pure-whitespace node: its existence can separate two inline
152
- # elements, so it is kept as a token, but its length is irrelevant.
153
- self.out.append(("ws",))
662
+ # A pure-whitespace node. Inside a flex or grid container it becomes
663
+ # an anonymous item holding only white space, which is never
664
+ # rendered; anywhere else its presence can separate two inline
665
+ # elements, so it is kept as a token whose length is irrelevant.
666
+ if frame is not None and frame.drops_ws:
667
+ return
668
+ self.out.append(("ws", frame is not None and frame.collapses))
154
669
  return
670
+ collapses = frame is not None and frame.collapses
155
671
  self.out.append(
156
672
  (
157
673
  "text",
158
674
  collapsed.strip(),
159
- collapsed[0].isspace(),
160
- collapsed[-1].isspace(),
675
+ collapsed[0].isspace() and not collapses,
676
+ collapsed[-1].isspace() and not collapses,
161
677
  parent,
162
678
  )
163
679
  )
@@ -185,10 +701,16 @@ class _Tokens(HTMLParser):
185
701
 
186
702
 
187
703
  def _is_block_boundary(token: tuple | None) -> bool:
704
+ """Whether whitespace touching this token collapses.
705
+
706
+ Only a genuinely block-level box qualifies. An inline-block (`<button>`) or
707
+ an inline replaced element (`<svg>`) does not: two of them separated by a
708
+ newline really do render a space between them.
709
+ """
188
710
  if token is None:
189
711
  return True # the fragment's own edge
190
712
  if token[0] in ("start", "end"):
191
- return token[1] in BLOCK_TAGS
713
+ return bool(token[-1])
192
714
  return False
193
715
 
194
716
 
@@ -196,32 +718,52 @@ def _drop_insignificant_ws(tokens: list[tuple]) -> list[tuple]:
196
718
  """Remove whitespace nodes that provably cannot render."""
197
719
  out: list[tuple] = []
198
720
  for i, tok in enumerate(tokens):
199
- if tok != ("ws",):
721
+ if tok[0] != "ws":
200
722
  out.append(tok)
201
723
  continue
202
724
  prev = out[-1] if out else None
203
- nxt = next((t for t in tokens[i + 1 :] if t != ("ws",)), None)
725
+ nxt = next((t for t in tokens[i + 1 :] if t[0] != "ws"), None)
204
726
  if _is_block_boundary(prev) and _is_block_boundary(nxt):
205
727
  continue
206
- out.append(tok)
728
+ # Whitespace at the first or last position inside a container that lays
729
+ # its content out as a block: a leading space is removed at the start of
730
+ # the first line box, a trailing one at the end of the last.
731
+ at_open = prev is not None and prev[0] == "start"
732
+ at_close = nxt is not None and nxt[0] == "end"
733
+ if tok[1] and (at_open or at_close):
734
+ continue
735
+ out.append(("ws",))
207
736
  return out
208
737
 
209
738
 
210
739
  def _canon_text_edges(tokens: list[tuple]) -> list[tuple]:
211
- """Drop edge-whitespace flags for text inside a block-level parent.
740
+ """Drop edge-whitespace flags that provably cannot render.
741
+
742
+ Three separate reasons a text node's edge whitespace collapses:
212
743
 
213
- `<h1>\\n Title\\n</h1>` -> `<h1>Title</h1>` cannot change rendering, because
214
- whitespace at the edges of a block container always collapses. The same trim
215
- inside a `<span>` or an `<x-*>` tag CAN change rendering (it closes a gap
216
- against an adjacent inline sibling), so those keep their flags and will be
217
- reported as a difference.
744
+ * its parent lays its content out as a block -- `<h1>\n Title\n</h1>`
745
+ is `<h1>Title</h1>`, because whitespace at the edge of a block container
746
+ always collapses;
747
+ * the neighbour on that side is a block-level box -- whitespace next to one
748
+ is removed, even inside an inline parent;
749
+ * both, which is the common case.
750
+
751
+ Inside a `<span>` between two inline siblings none of that applies, and the
752
+ flags survive to be reported as a difference.
218
753
  """
219
754
  out: list[tuple] = []
220
- for tok in tokens:
221
- if tok[0] == "text" and tok[4] in BLOCK_TAGS:
222
- out.append(("text", tok[1], False, False, tok[4]))
223
- else:
755
+ for i, tok in enumerate(tokens):
756
+ if tok[0] != "text":
224
757
  out.append(tok)
758
+ continue
759
+ _, body, lead, trail, parent = tok
760
+ if parent in BLOCK_TAGS:
761
+ lead = trail = False
762
+ if lead and _is_block_boundary(out[-1] if out else None):
763
+ lead = False
764
+ if trail and _is_block_boundary(tokens[i + 1] if i + 1 < len(tokens) else None):
765
+ trail = False
766
+ out.append(("text", body, lead, trail, parent))
225
767
  return out
226
768
 
227
769
 
@@ -243,8 +785,8 @@ def _squeeze_jinja(markup: str) -> str:
243
785
  return JINJA.sub(sub, markup)
244
786
 
245
787
 
246
- def tokenize(markup: str) -> list[tuple] | None:
247
- parser = _Tokens()
788
+ def tokenize(markup: str, jinja_classes: dict | None = None) -> list[tuple] | None:
789
+ parser = _Tokens(jinja_classes)
248
790
  try:
249
791
  parser.feed(_squeeze_jinja(markup))
250
792
  parser.close()
@@ -253,13 +795,17 @@ def tokenize(markup: str) -> list[tuple] | None:
253
795
  return _canon_text_edges(_drop_insignificant_ws(parser.out))
254
796
 
255
797
 
256
- def equivalent(before: str, after: str) -> tuple[bool, str]:
798
+ def equivalent(before: str, after: str, jinja_classes: dict | None = None) -> tuple[bool, str]:
257
799
  """True when `after` is guaranteed to render exactly like `before`.
258
800
 
801
+ `jinja_classes` maps `{{name}}` attribute variables to the class list they
802
+ forward, from `jinja_attr_classes` on the owning Python file. Without it a
803
+ `<div {{ attributes }}>` simply has an unknown display, as before.
804
+
259
805
  The second element is a short explanation of the first difference, for the
260
806
  skip report.
261
807
  """
262
- ta, tb = tokenize(before), tokenize(after)
808
+ ta, tb = tokenize(before, jinja_classes), tokenize(after, jinja_classes)
263
809
  if ta is None or tb is None:
264
810
  return False, "markup could not be parsed"
265
811
  if ta == tb:
@@ -53,6 +53,7 @@ import subprocess
53
53
  import sys
54
54
  import tempfile
55
55
  from dataclasses import dataclass, field
56
+ from functools import lru_cache
56
57
  from pathlib import Path
57
58
 
58
59
  import _markup_equivalence as eq
@@ -345,6 +346,20 @@ def render_literal(block: Block, markup: str) -> str | None:
345
346
  return f"{block.prefix}{body}{block.quote}"
346
347
 
347
348
 
349
+ @lru_cache(maxsize=None)
350
+ def jinja_classes(path: Path) -> dict:
351
+ """Class lists this file hides behind an expression.
352
+
353
+ Recovered from the file's own source so the oracle can see the display of a
354
+ root written as `<div {{ attributes }}>` or `class="{helper()}"`. Cached
355
+ because every block in a file shares one answer.
356
+ """
357
+ try:
358
+ return eq.class_hints(path.read_text(encoding="utf-8"))
359
+ except OSError:
360
+ return {}
361
+
362
+
348
363
  @dataclass
349
364
  class Skip:
350
365
  path: str
@@ -402,7 +417,7 @@ def format_markup_blocks(*, write: bool) -> MarkupReport:
402
417
  if formatted.strip("\n") == block.source.strip("\n"):
403
418
  report.already += 1
404
419
  continue
405
- same, why = eq.equivalent(block.source, formatted)
420
+ same, why = eq.equivalent(block.source, formatted, jinja_classes(path))
406
421
  if not same:
407
422
  report.skips.append(Skip(rel, block.lineno, why))
408
423
  continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.3.21",
3
+ "version": "1.3.22",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",