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.
- package/dist/.github/copilot-instructions.md +5 -2
- package/dist/AGENTS.md +5 -1
- package/dist/index.js +1 -1
- package/dist/main.py +152 -156
- package/dist/public/js/pp-reactive-v2.min.js +1 -1
- package/dist/settings/_component_imports.py +1 -0
- package/dist/settings/_markup_equivalence.py +320 -0
- package/dist/settings/browser_log.py +13 -6
- package/dist/settings/build-static.py +5 -4
- package/dist/settings/check.py +6 -3
- package/dist/settings/check_templates.py +90 -21
- package/dist/settings/fix.py +23 -5
- package/dist/settings/format.py +721 -0
- package/dist/settings/serve-static.py +1 -2
- package/dist/src/app/index.py +67 -133
- package/dist/src/app/layout.py +15 -0
- package/dist/tests/README.md +119 -4
- package/dist/tests/conftest.py +1 -0
- package/dist/tests/test_health_route.py +1 -3
- package/dist/tests/test_main_helpers.py +4 -7
- package/package.json +1 -1
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
(
|
|
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:
|
package/dist/settings/check.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
314
|
-
if isinstance(stmt
|
|
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
|
|
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
|
|
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
|