create-caspian-app 1.3.6 → 1.3.7

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.
@@ -19,6 +19,7 @@ import re
19
19
  from functools import lru_cache
20
20
  from pathlib import Path
21
21
 
22
+
22
23
  # Mirror of casp.string_helpers.camel_to_kebab. Kept in lockstep so the tag we
23
24
  # look for matches the tag the compiler actually resolves.
24
25
  def camel_to_kebab(name: str) -> str:
@@ -0,0 +1,320 @@
1
+ """Rendering-equivalence oracle for PulsePoint + Jinja markup.
2
+
3
+ Why this exists
4
+ ---------------
5
+ `format.py` runs djLint over authored markup. djLint is Jinja-aware and does not
6
+ reflow text, which makes it the only formatter that survives this project's four
7
+ nested dialects (HTML, Jinja `{{ }}`/`{% %}`, PulsePoint `{ }`, and JS inside
8
+ `<script>`). It is still an HTML formatter, and it will happily make changes that
9
+ are correct for HTML but wrong here -- most notably inserting a newline between
10
+ two `<x-*>` tags, which renders as a visible space because a custom element's
11
+ `display` is unknowable from the markup.
12
+
13
+ So the formatter does not trust djLint. Every block is formatted, then *proved*
14
+ to render identically before it is written back. Anything unprovable is skipped
15
+ and reported. That is what makes a bulk reformat of 500+ templates safe to run.
16
+
17
+ What "equivalent" means here
18
+ ----------------------------
19
+ Each rule below is a real rendering rule, not a heuristic:
20
+
21
+ * whitespace INSIDE a tag (between attributes, before `>`) never renders
22
+ * a whitespace RUN in text renders as a single space, but the presence vs
23
+ absence of whitespace between two inline elements is significant
24
+ * whitespace touching a block-level boundary collapses and never renders
25
+ * a text node's leading/trailing whitespace collapses when its parent is
26
+ block-level, but not when the parent is inline or an unknown `<x-*>` tag
27
+ * `<pre>` / `<textarea>` text renders verbatim
28
+ * `<script>` / `<style>` bodies are code: indentation is irrelevant
29
+ * attribute ORDER never affects rendering; attribute VALUES always do
30
+ * Jinja `{{x}}` and `{{ x }}` are the same expression
31
+
32
+ Anything this module cannot prove, it reports as a difference. False negatives
33
+ (claiming a real change is safe) are the only dangerous errors, so every rule
34
+ errs toward reporting a difference.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from html.parser import HTMLParser
41
+
42
+ LITERAL_TAGS = {"pre", "textarea"}
43
+ CODE_TAGS = {"script", "style"}
44
+
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
+ # fmt: off
49
+ BLOCK_TAGS = {
50
+ "html", "head", "body", "div", "p", "section", "article", "header",
51
+ "footer", "nav", "aside", "main", "form", "fieldset", "legend", "figure",
52
+ "figcaption", "blockquote", "hr", "ul", "ol", "li", "dl", "dt", "dd",
53
+ "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "colgroup",
54
+ "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.
64
+ "svg", "g", "defs", "symbol", "use", "path", "circle", "ellipse", "line",
65
+ "polyline", "polygon", "rect", "clippath", "lineargradient",
66
+ "radialgradient", "stop", "mask", "pattern", "filter", "marker", "desc",
67
+ "animate", "animatetransform", "animatemotion", "switch", "image",
68
+ }
69
+ # fmt: on
70
+
71
+ JINJA = re.compile(r"\{\{\s*(.*?)\s*\}\}|\{%\s*(.*?)\s*%\}", re.S)
72
+
73
+
74
+ def _canon_jinja(text: str) -> str:
75
+ """`{{ x }}` and `{{x}}` are the same expression to Jinja."""
76
+
77
+ def sub(m: re.Match[str]) -> str:
78
+ if m.group(1) is not None:
79
+ return "{{ " + re.sub(r"\s+", " ", m.group(1)) + " }}"
80
+ return "{% " + re.sub(r"\s+", " ", m.group(2)) + " %}"
81
+
82
+ return JINJA.sub(sub, text)
83
+
84
+
85
+ def _canon_attr_value(value: str | None) -> str | None:
86
+ if value is None:
87
+ return None
88
+ return re.sub(r"\s+", " ", _canon_jinja(value)).strip()
89
+
90
+
91
+ class _Tokens(HTMLParser):
92
+ """Reduce markup to a token stream where equality implies equal rendering."""
93
+
94
+ def __init__(self) -> None:
95
+ super().__init__(convert_charrefs=False)
96
+ self.out: list[tuple] = []
97
+ self._open: list[str] = []
98
+ self._raw_stack: list[str] = []
99
+ self._raw_buf: list[str] = []
100
+
101
+ def _in_raw(self) -> str | None:
102
+ return self._raw_stack[-1] if self._raw_stack else None
103
+
104
+ def _attrs(self, attrs) -> tuple:
105
+ return tuple(sorted((k, _canon_attr_value(v)) for k, v in attrs))
106
+
107
+ def handle_starttag(self, tag, attrs):
108
+ if self._in_raw():
109
+ self._raw_buf.append(self.get_starttag_text() or "")
110
+ return
111
+ if tag in LITERAL_TAGS | CODE_TAGS:
112
+ self._raw_stack.append(tag)
113
+ self._raw_buf = []
114
+ self.out.append(("start", tag, self._attrs(attrs)))
115
+ self._open.append(tag)
116
+
117
+ def handle_startendtag(self, tag, attrs):
118
+ if self._in_raw():
119
+ self._raw_buf.append(self.get_starttag_text() or "")
120
+ return
121
+ self.out.append(("start", tag, self._attrs(attrs)))
122
+ self.out.append(("end", tag))
123
+
124
+ def handle_endtag(self, tag):
125
+ raw = self._in_raw()
126
+ if raw:
127
+ if tag != raw:
128
+ self._raw_buf.append(f"</{tag}>")
129
+ return
130
+ body = "".join(self._raw_buf)
131
+ if raw in CODE_TAGS:
132
+ # Code: indentation and blank lines carry no meaning.
133
+ body = "\n".join(ln.strip() for ln in body.splitlines() if ln.strip())
134
+ self.out.append(("raw", raw, body))
135
+ self._raw_stack.pop()
136
+ self._raw_buf = []
137
+ self.out.append(("end", tag))
138
+ if tag in self._open:
139
+ while self._open and self._open.pop() != tag:
140
+ pass
141
+
142
+ def handle_data(self, data):
143
+ if self._in_raw():
144
+ self._raw_buf.append(data)
145
+ return
146
+ collapsed = re.sub(r"\s+", " ", _canon_jinja(data))
147
+ if collapsed == "":
148
+ return
149
+ parent = self._open[-1] if self._open else ""
150
+ 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",))
154
+ return
155
+ self.out.append(
156
+ (
157
+ "text",
158
+ collapsed.strip(),
159
+ collapsed[0].isspace(),
160
+ collapsed[-1].isspace(),
161
+ parent,
162
+ )
163
+ )
164
+
165
+ def handle_comment(self, data):
166
+ if self._in_raw():
167
+ self._raw_buf.append(f"<!--{data}-->")
168
+ return
169
+ self.out.append(("comment", re.sub(r"\s+", " ", data).strip()))
170
+
171
+ def handle_entityref(self, name):
172
+ if self._in_raw():
173
+ self._raw_buf.append(f"&{name};")
174
+ else:
175
+ self.out.append(("entity", name))
176
+
177
+ def handle_charref(self, name):
178
+ if self._in_raw():
179
+ self._raw_buf.append(f"&#{name};")
180
+ else:
181
+ self.out.append(("charref", name))
182
+
183
+ def handle_decl(self, decl):
184
+ self.out.append(("decl", decl))
185
+
186
+
187
+ def _is_block_boundary(token: tuple | None) -> bool:
188
+ if token is None:
189
+ return True # the fragment's own edge
190
+ if token[0] in ("start", "end"):
191
+ return token[1] in BLOCK_TAGS
192
+ return False
193
+
194
+
195
+ def _drop_insignificant_ws(tokens: list[tuple]) -> list[tuple]:
196
+ """Remove whitespace nodes that provably cannot render."""
197
+ out: list[tuple] = []
198
+ for i, tok in enumerate(tokens):
199
+ if tok != ("ws",):
200
+ out.append(tok)
201
+ continue
202
+ prev = out[-1] if out else None
203
+ nxt = next((t for t in tokens[i + 1 :] if t != ("ws",)), None)
204
+ if _is_block_boundary(prev) and _is_block_boundary(nxt):
205
+ continue
206
+ out.append(tok)
207
+ return out
208
+
209
+
210
+ def _canon_text_edges(tokens: list[tuple]) -> list[tuple]:
211
+ """Drop edge-whitespace flags for text inside a block-level parent.
212
+
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.
218
+ """
219
+ 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:
224
+ out.append(tok)
225
+ return out
226
+
227
+
228
+ def _squeeze_jinja(markup: str) -> str:
229
+ """Collapse padding inside Jinja delimiters before the HTML parser runs.
230
+
231
+ A Jinja tag can sit in attribute position -- `<div {{attributes}}>` is how a
232
+ component forwards props onto its root. djLint renders that as
233
+ `<div {{ attributes }}>`, which is the same template but which an HTML parser
234
+ reads as three attributes instead of one. Squeezing both sides first means
235
+ the comparison sees the Jinja tag, not the parser's guess at it.
236
+ """
237
+
238
+ def sub(m: re.Match[str]) -> str:
239
+ if m.group(1) is not None:
240
+ return "{{" + re.sub(r"\s+", " ", m.group(1)) + "}}"
241
+ return "{%" + re.sub(r"\s+", " ", m.group(2)) + "%}"
242
+
243
+ return JINJA.sub(sub, markup)
244
+
245
+
246
+ def tokenize(markup: str) -> list[tuple] | None:
247
+ parser = _Tokens()
248
+ try:
249
+ parser.feed(_squeeze_jinja(markup))
250
+ parser.close()
251
+ except Exception:
252
+ return None
253
+ return _canon_text_edges(_drop_insignificant_ws(parser.out))
254
+
255
+
256
+ def equivalent(before: str, after: str) -> tuple[bool, str]:
257
+ """True when `after` is guaranteed to render exactly like `before`.
258
+
259
+ The second element is a short explanation of the first difference, for the
260
+ skip report.
261
+ """
262
+ ta, tb = tokenize(before), tokenize(after)
263
+ if ta is None or tb is None:
264
+ return False, "markup could not be parsed"
265
+ if ta == tb:
266
+ return True, ""
267
+ for x, y in zip(ta, tb):
268
+ if x != y:
269
+ return False, _diff_reason(x, y)
270
+ extra = ta[len(tb) :] or tb[len(ta) :]
271
+ side = "would remove" if len(ta) > len(tb) else "would add"
272
+ return False, f"{side} {_describe(extra[0]) if extra else 'content'}"
273
+
274
+
275
+ def _diff_reason(x: tuple, y: tuple) -> str:
276
+ """Explain one token difference in terms an author can act on."""
277
+ if x[0] == "text" and y[0] == "text" and x[1] == y[1]:
278
+ return (
279
+ f"would trim whitespace around {_clip(x[1])} inside <{x[4] or '?'}>, "
280
+ f"whose display is not known to be block-level"
281
+ )
282
+ if x[0] == "start" and y[0] == "start" and x[1] == y[1]:
283
+ before, after = dict(x[2]), dict(y[2])
284
+ names = sorted(set(before) | set(after))
285
+ for name in names:
286
+ if before.get(name) != after.get(name):
287
+ if name not in after:
288
+ return f"would drop attribute {name!r} on <{x[1]}>"
289
+ if name not in before:
290
+ return f"would add attribute {name!r} on <{x[1]}>"
291
+ return f"would change attribute {name!r} on <{x[1]}>"
292
+ return f"attributes on <{x[1]}> would change"
293
+ if x == ("ws",):
294
+ return f"would remove whitespace before {_describe(y)}"
295
+ if y == ("ws",):
296
+ return f"would add whitespace before {_describe(x)}"
297
+ if x[0] == "raw" and y[0] == "raw":
298
+ return f"<{x[1]}> body would change"
299
+ return f"{_describe(x)} would become {_describe(y)}"
300
+
301
+
302
+ def _clip(text: str, limit: int = 40) -> str:
303
+ return repr(text if len(text) <= limit else text[:limit] + "…")
304
+
305
+
306
+ def _describe(token: tuple) -> str:
307
+ kind = token[0]
308
+ if kind == "start":
309
+ return f"<{token[1]}>"
310
+ if kind == "end":
311
+ return f"</{token[1]}>"
312
+ if kind == "text":
313
+ return f"text {_clip(token[1])}"
314
+ if kind == "ws":
315
+ return "whitespace"
316
+ if kind == "raw":
317
+ return f"<{token[1]}> body"
318
+ if kind == "comment":
319
+ return "comment"
320
+ return kind
@@ -68,7 +68,7 @@ LOG_FILE = PROJECT_ROOT / ".casp" / "browser-log.jsonl"
68
68
 
69
69
  try:
70
70
  sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
71
- except (AttributeError, ValueError):
71
+ except AttributeError, ValueError:
72
72
  pass
73
73
 
74
74
  _TTY = sys.stdout.isatty()
@@ -254,7 +254,9 @@ def build_report(path: Path = LOG_FILE) -> LogReport:
254
254
 
255
255
  if not loads:
256
256
  routes.append(
257
- RouteStatus(route=route, last_load="", errors=[], warnings=[], healed=0, recheck=recheck)
257
+ RouteStatus(
258
+ route=route, last_load="", errors=[], warnings=[], healed=0, recheck=recheck
259
+ )
258
260
  )
259
261
  continue
260
262
 
@@ -336,8 +338,7 @@ _SESSION_LINES = {
336
338
  ),
337
339
  "ended": (
338
340
  "dev session ended cleanly",
339
- "The dev server has shut down. The results below are from that "
340
- "finished session.",
341
+ "The dev server has shut down. The results below are from that finished session.",
341
342
  ),
342
343
  }
343
344
 
@@ -369,7 +370,9 @@ def print_report(report: LogReport) -> None:
369
370
  if report.session == "live":
370
371
  age = _age(report.started)
371
372
  detail = f"pid {report.pid}, port {report.port}, started {_clock(report.started)}"
372
- print(f" {green('LIVE')} dev session active {gray(f'({detail}{", " + age if age else ""})')}")
373
+ print(
374
+ f" {green('LIVE')} dev session active {gray(f'({detail}{", " + age if age else ""})')}"
375
+ )
373
376
  else:
374
377
  headline, advice = _SESSION_LINES[report.session]
375
378
  mark = yellow("WARN") if report.session != "missing" else gray("NONE")
@@ -456,7 +459,11 @@ def to_json(report: LogReport) -> str:
456
459
  "clean": r.clean,
457
460
  "unconfirmed": r.unconfirmed,
458
461
  "needsRecheck": [
459
- {"message": e.get("message"), "phase": e.get("phase"), "carried": bool(e.get("carried"))}
462
+ {
463
+ "message": e.get("message"),
464
+ "phase": e.get("phase"),
465
+ "carried": bool(e.get("carried")),
466
+ }
460
467
  for e in r.recheck
461
468
  ],
462
469
  "errors": [
@@ -191,9 +191,7 @@ def build() -> int:
191
191
 
192
192
  if resp.status_code in (301, 302, 303, 307, 308):
193
193
  target = resp.headers.get("location", "?")
194
- skipped.append(
195
- (url, f"redirects to {target} -- likely auth-gated, needs the server")
196
- )
194
+ skipped.append((url, f"redirects to {target} -- likely auth-gated, needs the server"))
197
195
  return
198
196
 
199
197
  if resp.status_code != 200:
@@ -232,7 +230,10 @@ def build() -> int:
232
230
  param_sets = await _resolve_static_paths(route)
233
231
  if not param_sets:
234
232
  skipped.append(
235
- (url, "dynamic route -- add static_paths() to its index.py to pre-render (like getStaticPaths)")
233
+ (
234
+ url,
235
+ "dynamic route -- add static_paths() to its index.py to pre-render (like getStaticPaths)",
236
+ )
236
237
  )
237
238
  continue
238
239
  for params in param_sets:
@@ -46,6 +46,7 @@ def _is_component_import_false_positive(issue: Issue) -> bool:
46
46
  return False
47
47
  return ci.is_component_tag_f401(issue.message, issue.path)
48
48
 
49
+
49
50
  # Terminal colors (disabled automatically when output is not a TTY).
50
51
  _TTY = sys.stdout.isatty()
51
52
 
@@ -53,7 +54,7 @@ _TTY = sys.stdout.isatty()
53
54
  # characters; ask for UTF-8 with a safe fallback so output never crashes.
54
55
  try:
55
56
  sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
56
- except (AttributeError, ValueError):
57
+ except AttributeError, ValueError:
57
58
  pass
58
59
 
59
60
 
@@ -288,7 +289,7 @@ def run_pytest() -> Result:
288
289
  for line in (proc.stdout + proc.stderr).splitlines():
289
290
  stripped = line.strip()
290
291
  if stripped.startswith("FAILED "):
291
- body = stripped[len("FAILED "):]
292
+ body = stripped[len("FAILED ") :]
292
293
  nodeid, _, reason = body.partition(" - ")
293
294
  path, _, _ = nodeid.partition("::")
294
295
  issues.append(
@@ -362,7 +363,9 @@ def _execute(label: str, runner, *, streamed: bool) -> Result:
362
363
  elapsed = time.perf_counter() - start
363
364
  mark = green("OK ") if result.ok else red("FAIL")
364
365
  count = len(result.issues)
365
- detail = "" if result.ok else f" ({count} issue(s))" if count else f" ({result.note or 'failed'})"
366
+ detail = (
367
+ "" if result.ok else f" ({count} issue(s))" if count else f" ({result.note or 'failed'})"
368
+ )
366
369
  print(f" {mark} {label} {elapsed:0.1f}s{detail}")
367
370
  return result
368
371
 
@@ -73,8 +73,7 @@ RULES: list[Rule] = [
73
73
  # `{items.map(item => (` and `{items.map((item, i) => (` -- the trailing
74
74
  # `(` is what distinguishes returning markup from a normal value map.
75
75
  re.compile(r"\{[^{}\n]*?\.map\s*\(\s*\(?[\w\s,]*\)?\s*=>\s*\(", re.MULTILINE),
76
- "JSX .map() returning markup. Use <template pp-for=\"item in items\"> "
77
- 'with key="{item.id}".',
76
+ 'JSX .map() returning markup. Use <template pp-for="item in items"> with key="{item.id}".',
78
77
  ),
79
78
  Rule(
80
79
  "jsx-logical",
@@ -86,8 +85,7 @@ RULES: list[Rule] = [
86
85
  "jsx-ternary-element",
87
86
  # `{cond ? <A` -- element directly after a ternary branch.
88
87
  re.compile(r"\{[^{}]*?\?\s*\(?\s*<[a-zA-Z]", re.DOTALL),
89
- "JSX `{cond ? <A/> : <B/>}`. Use two elements with complementary "
90
- 'hidden="{...}" bindings.',
88
+ 'JSX `{cond ? <A/> : <B/>}`. Use two elements with complementary hidden="{...}" bindings.',
91
89
  ),
92
90
  Rule(
93
91
  "unquoted-brace-attr",
@@ -114,7 +112,7 @@ RULES: list[Rule] = [
114
112
  ),
115
113
  "camelCase event prop. PulsePoint binds native lowercase event "
116
114
  'attributes: onclick="{handler()}". A component prop uses kebab-case '
117
- '(on-click), which arrives as pp.props.onClick.',
115
+ "(on-click), which arrives as pp.props.onClick.",
118
116
  ),
119
117
  Rule(
120
118
  "jsx-fragment",
@@ -124,8 +122,7 @@ RULES: list[Rule] = [
124
122
  Rule(
125
123
  "style-object",
126
124
  re.compile(r"style\s*=\s*\{\{"),
127
- "JSX style object. pp-style takes a CSS *string*: "
128
- "pp-style=\"{'color: red'}\".",
125
+ "JSX style object. pp-style takes a CSS *string*: pp-style=\"{'color: red'}\".",
129
126
  ),
130
127
  Rule(
131
128
  "unknown-directive",
@@ -221,9 +218,7 @@ def lint_text(text: str, rel_path: str, *, is_python: bool = False) -> list[Temp
221
218
  for rule in RULES:
222
219
  for match in rule.pattern.finditer(markup):
223
220
  line, column = _position(markup, match.start())
224
- issues.append(
225
- TemplateIssue(rel_path, line, column, rule.code, rule.message)
226
- )
221
+ issues.append(TemplateIssue(rel_path, line, column, rule.code, rule.message))
227
222
 
228
223
  for match in PP_FOR_TAG.finditer(markup):
229
224
  tag = match.group(1).lower()
@@ -284,6 +279,37 @@ FSTRING_MESSAGE = (
284
279
  "Return html(r'''...''', x=x) instead."
285
280
  )
286
281
 
282
+ HTML_FORM_MESSAGE = (
283
+ "html(...) must take a raw triple-quoted literal: html(r'''...'''). It is "
284
+ "the single markup entrypoint, and one form keeps it readable and greppable. "
285
+ "A non-raw string silently rewrites backslashes, so a JS regex or a \\n in a "
286
+ "component script changes meaning between authoring and render; an f-string "
287
+ "additionally inverts the brace dialects and emits interpolated data raw. "
288
+ "Pass server values as context instead: html(r'''...{{ x }}...''', x=x)."
289
+ )
290
+
291
+
292
+ def _own_returns(func):
293
+ """The `return` statements belonging to `func` itself.
294
+
295
+ `ast.walk` would descend into nested `def`s and lambdas and attribute their
296
+ returns to the enclosing function. A `@component` may legitimately define a
297
+ private helper that builds a string fragment, so walking blind reports a
298
+ component that already returns `html(...)` and tells its author to do what
299
+ they have done -- which is how a gate loses its credibility.
300
+ """
301
+ import ast
302
+
303
+ stack = list(func.body)
304
+ while stack:
305
+ node = stack.pop()
306
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
307
+ continue
308
+ if isinstance(node, ast.Return):
309
+ yield node
310
+ continue
311
+ stack.extend(ast.iter_child_nodes(node))
312
+
287
313
 
288
314
  def _fstring_component_returns() -> list[tuple[str, str, int, int]]:
289
315
  """Every `@component` whose return value is an f-string.
@@ -298,20 +324,19 @@ def _fstring_component_returns() -> list[tuple[str, str, int, int]]:
298
324
  continue
299
325
  try:
300
326
  tree = ast.parse(path.read_text(encoding="utf-8"))
301
- except (OSError, UnicodeDecodeError, SyntaxError):
327
+ except OSError, UnicodeDecodeError, SyntaxError:
302
328
  continue
303
329
  rel = path.relative_to(PROJECT_ROOT).as_posix()
304
330
  for node in ast.walk(tree):
305
331
  if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
306
332
  continue
307
333
  decorators = {
308
- getattr(d, "id", None) or getattr(d, "attr", None)
309
- for d in node.decorator_list
334
+ getattr(d, "id", None) or getattr(d, "attr", None) for d in node.decorator_list
310
335
  }
311
336
  if "component" not in decorators:
312
337
  continue
313
- for stmt in ast.walk(node):
314
- if isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.JoinedStr):
338
+ for stmt in _own_returns(node):
339
+ if isinstance(stmt.value, ast.JoinedStr):
315
340
  found.append((rel, node.name, stmt.lineno, stmt.col_offset + 1))
316
341
  break
317
342
  return sorted(found)
@@ -322,7 +347,7 @@ def _load_fstring_baseline() -> set[str]:
322
347
 
323
348
  try:
324
349
  raw = json.loads(FSTRING_BASELINE_PATH.read_text(encoding="utf-8"))
325
- except (OSError, ValueError):
350
+ except OSError, ValueError:
326
351
  return set()
327
352
  return set(raw.get("allowed", []))
328
353
 
@@ -350,6 +375,52 @@ def write_fstring_baseline() -> int:
350
375
  return len(entries)
351
376
 
352
377
 
378
+ def _html_call_template_args():
379
+ """Every `html(...)` call's first argument, with its source text.
380
+
381
+ Yields `(rel_path, lineno, col, source_segment, node)`.
382
+ """
383
+ import ast
384
+
385
+ for path in _iter_files():
386
+ if path.suffix != ".py":
387
+ continue
388
+ try:
389
+ source = path.read_text(encoding="utf-8")
390
+ tree = ast.parse(source)
391
+ except OSError, UnicodeDecodeError, SyntaxError:
392
+ continue
393
+ rel = path.relative_to(PROJECT_ROOT).as_posix()
394
+ for node in ast.walk(tree):
395
+ if not isinstance(node, ast.Call):
396
+ continue
397
+ name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
398
+ if name != "html" or not node.args:
399
+ continue
400
+ arg = node.args[0]
401
+ segment = ast.get_source_segment(source, arg) or ""
402
+ yield rel, arg.lineno, arg.col_offset + 1, segment, arg
403
+
404
+
405
+ def lint_html_call_form() -> list[TemplateIssue]:
406
+ """`html(...)` takes a raw triple-quoted literal -- one form, no exceptions.
407
+
408
+ Two forms drifted apart in this repo once already: 437 calls used
409
+ `html(r'''...''')` and 133 did not, which makes the markup surface
410
+ un-greppable and lets a backslash mean two different things depending on
411
+ which call you are reading.
412
+ """
413
+ import ast
414
+
415
+ issues: list[TemplateIssue] = []
416
+ for rel, line, col, segment, arg in _html_call_template_args():
417
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
418
+ if segment.startswith(('r"""', "r'''", 'R"""', "R'''")):
419
+ continue
420
+ issues.append(TemplateIssue(rel, line, col, "html-form", HTML_FORM_MESSAGE))
421
+ return issues
422
+
423
+
353
424
  def lint_fstring_components() -> list[TemplateIssue]:
354
425
  allowed = _load_fstring_baseline()
355
426
  return [
@@ -365,7 +436,7 @@ def lint_templates() -> list[TemplateIssue]:
365
436
  for path in _iter_files():
366
437
  try:
367
438
  text = path.read_text(encoding="utf-8")
368
- except (OSError, UnicodeDecodeError):
439
+ except OSError, UnicodeDecodeError:
369
440
  continue
370
441
  # Cheap pre-filter: a file with no brace expression and no angle-bracket
371
442
  # markup cannot trip any rule.
@@ -374,6 +445,7 @@ def lint_templates() -> list[TemplateIssue]:
374
445
  rel = path.relative_to(PROJECT_ROOT).as_posix()
375
446
  issues.extend(lint_text(text, rel, is_python=path.suffix == ".py"))
376
447
  issues.extend(lint_fstring_components())
448
+ issues.extend(lint_html_call_form())
377
449
  return issues
378
450
 
379
451
 
@@ -400,10 +472,7 @@ def main() -> int:
400
472
  for path in sorted(by_file):
401
473
  print(path)
402
474
  for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
403
- print(
404
- f" {issue.line}:{issue.column} "
405
- f"[templates:{issue.code}] {issue.message}"
406
- )
475
+ print(f" {issue.line}:{issue.column} [templates:{issue.code}] {issue.message}")
407
476
 
408
477
  print(f"\n{len(issues)} template issue(s) found.")
409
478
  return 1