create-caspian-app 1.5.8 → 1.6.0
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 +261 -260
- package/dist/AGENTS.md +445 -434
- package/dist/index.js +2 -2
- package/dist/postcss.config.js +11 -1
- package/dist/pyproject.toml +47 -47
- package/dist/settings/browser_log.py +536 -505
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/bs-config.ts +32 -0
- package/dist/settings/check.py +418 -418
- package/dist/settings/check_templates.py +497 -497
- package/dist/settings/dev-hold.ts +223 -0
- package/dist/settings/fix.py +113 -113
- package/dist/settings/format.py +719 -719
- package/dist/settings/run-postcss.ts +13 -9
- package/dist/settings/utils.ts +28 -0
- package/dist/src/app/globals.css +9 -0
- package/dist/src/app/globals.plain.css +502 -0
- package/dist/tests/README.md +250 -250
- package/dist/tests/test_health_route.py +85 -42
- package/package.json +1 -1
- package/dist/public/css/index.css +0 -1
- package/dist/public/css/styles.css +0 -1
|
@@ -1,497 +1,497 @@
|
|
|
1
|
-
"""Template lint: reject JSX and unknown directives in PulsePoint markup.
|
|
2
|
-
|
|
3
|
-
Why this exists
|
|
4
|
-
---------------
|
|
5
|
-
`npm run
|
|
6
|
-
`{users.map(user => (<tr/>))}`, `class={...}`, `className` -- and the gate stayed
|
|
7
|
-
green, because nothing in the toolchain reads `.html` files. The failure then
|
|
8
|
-
surfaced only in the browser, and the worst variant surfaced nowhere at all: an
|
|
9
|
-
unquoted brace attribute is invalid HTML, so the parser shreds the element, the
|
|
10
|
-
component root never compiles, the runtime's reveal step never clears
|
|
11
|
-
`<body style="opacity: 0">`, and the route serves a blank page with **no console
|
|
12
|
-
error**.
|
|
13
|
-
|
|
14
|
-
PulsePoint borrows React's hook API inside `<script>` and React's component
|
|
15
|
-
decomposition. It borrows none of React's markup syntax. This module enforces
|
|
16
|
-
that boundary at authoring time, where the fix is cheap.
|
|
17
|
-
|
|
18
|
-
Scope
|
|
19
|
-
-----
|
|
20
|
-
Scans authored markup under `src/`:
|
|
21
|
-
|
|
22
|
-
- `**/*.html` -- route, layout, and component templates
|
|
23
|
-
- `**/*.py` -- single-file components that embed markup via `html(\"\"\"...\"\"\")`
|
|
24
|
-
|
|
25
|
-
Regions that legitimately contain JavaScript or sample code are removed before
|
|
26
|
-
matching, so a real `array.map(...)` in a component script and a JSX snippet in
|
|
27
|
-
a docs `<pre>` block are both ignored:
|
|
28
|
-
|
|
29
|
-
- `<script>...</script>` (the component script is JS, `.map()` is correct there)
|
|
30
|
-
- `<pre>` / `<code>` (documentation examples)
|
|
31
|
-
- `<!-- ... -->` (commented-out markup)
|
|
32
|
-
|
|
33
|
-
Usage:
|
|
34
|
-
|
|
35
|
-
python settings/check_templates.py # run standalone
|
|
36
|
-
npm run
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
from __future__ import annotations
|
|
40
|
-
|
|
41
|
-
import re
|
|
42
|
-
from dataclasses import dataclass
|
|
43
|
-
from pathlib import Path
|
|
44
|
-
|
|
45
|
-
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
46
|
-
SCAN_ROOT = PROJECT_ROOT / "src"
|
|
47
|
-
|
|
48
|
-
# Generated or vendored trees that are not hand-authored markup.
|
|
49
|
-
EXCLUDED_PARTS = {"__pycache__", "node_modules", ".venv", "prisma"}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@dataclass
|
|
53
|
-
class TemplateIssue:
|
|
54
|
-
path: str
|
|
55
|
-
line: int
|
|
56
|
-
column: int
|
|
57
|
-
code: str
|
|
58
|
-
message: str
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
@dataclass
|
|
62
|
-
class Rule:
|
|
63
|
-
code: str
|
|
64
|
-
pattern: re.Pattern[str]
|
|
65
|
-
message: str
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# Each rule names the JSX/unsupported construct and the PulsePoint replacement,
|
|
69
|
-
# so the report is directly actionable without opening the docs.
|
|
70
|
-
RULES: list[Rule] = [
|
|
71
|
-
Rule(
|
|
72
|
-
"jsx-map",
|
|
73
|
-
# `{items.map(item => (` and `{items.map((item, i) => (` -- the trailing
|
|
74
|
-
# `(` is what distinguishes returning markup from a normal value map.
|
|
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"> with key="{item.id}".',
|
|
77
|
-
),
|
|
78
|
-
Rule(
|
|
79
|
-
"jsx-logical",
|
|
80
|
-
# `{cond && (<div` -- element after a logical AND.
|
|
81
|
-
re.compile(r"\{[^{}]*?&&\s*\(\s*<", re.DOTALL),
|
|
82
|
-
'JSX `{cond && (<element/>)}`. Use hidden="{!cond}" on the element.',
|
|
83
|
-
),
|
|
84
|
-
Rule(
|
|
85
|
-
"jsx-ternary-element",
|
|
86
|
-
# `{cond ? <A` -- element directly after a ternary branch.
|
|
87
|
-
re.compile(r"\{[^{}]*?\?\s*\(?\s*<[a-zA-Z]", re.DOTALL),
|
|
88
|
-
'JSX `{cond ? <A/> : <B/>}`. Use two elements with complementary hidden="{...}" bindings.',
|
|
89
|
-
),
|
|
90
|
-
Rule(
|
|
91
|
-
"unquoted-brace-attr",
|
|
92
|
-
# `class={...}` / `selected={...}` -- invalid HTML, silently blanks the page.
|
|
93
|
-
#
|
|
94
|
-
# An attribute only exists *inside an opening tag*, and the rule must say
|
|
95
|
-
# so. A bare `\s[\w:.\-]+=\{` also matches `DIR={path}` in a shell script
|
|
96
|
-
# and `ENV PORT={port}` in a Dockerfile -- and this repo embeds both in
|
|
97
|
-
# triple-quoted strings under `src/lib/aws/`, which the Python scan keeps
|
|
98
|
-
# because it cannot tell a heredoc from a template. Requiring the opening
|
|
99
|
-
# tag is not a loosening: a real violation is always inside one.
|
|
100
|
-
#
|
|
101
|
-
# `[^<>]*?` bounds the attribute run to a single tag; it matches newlines
|
|
102
|
-
# (negated classes do), so an attribute on its own line is still caught.
|
|
103
|
-
re.compile(r"<[a-zA-Z][\w:.\-]*(?:[^<>]*?)?\s[\w:.\-]+=\{"),
|
|
104
|
-
"Unquoted brace attribute. This is invalid HTML: the parser splits the "
|
|
105
|
-
"value on spaces, the component root never compiles, and the page "
|
|
106
|
-
'renders blank with no console error. Quote it: attr="{expr}".',
|
|
107
|
-
),
|
|
108
|
-
Rule(
|
|
109
|
-
"react-attribute",
|
|
110
|
-
re.compile(r"\s(className|htmlFor|dangerouslySetInnerHTML)\s*="),
|
|
111
|
-
"React DOM property. Use class=, for=, or server-rendered markup.",
|
|
112
|
-
),
|
|
113
|
-
Rule(
|
|
114
|
-
"camelcase-event",
|
|
115
|
-
# `onClick="…"` / `onClick={…}` in attribute position. The value-start
|
|
116
|
-
# class and the declaration lookbehinds keep ordinary JavaScript out:
|
|
117
|
-
# `const onPointerEnter = () => {` is a valid handler name in a component
|
|
118
|
-
# script or an injected snippet, not a JSX prop.
|
|
119
|
-
re.compile(
|
|
120
|
-
r"(?<!\bconst )(?<!\blet )(?<!\bvar )(?<!\bfunction )"
|
|
121
|
-
r"\son[A-Z][a-zA-Z]*\s*=\s*[\"'{]"
|
|
122
|
-
),
|
|
123
|
-
"camelCase event prop. PulsePoint binds native lowercase event "
|
|
124
|
-
'attributes: onclick="{handler()}". A component prop uses kebab-case '
|
|
125
|
-
"(on-click), which arrives as pp.props.onClick.",
|
|
126
|
-
),
|
|
127
|
-
Rule(
|
|
128
|
-
"jsx-fragment",
|
|
129
|
-
# `</>` is unambiguous, and a well-formed fragment always has one. A bare
|
|
130
|
-
# `<>` is not: it is SQL's not-equals operator, and this repo runs
|
|
131
|
-
# `WHERE pid <> pg_backend_pid()` from a triple-quoted string. So the
|
|
132
|
-
# open tag counts only when an element follows it, which is what a
|
|
133
|
-
# fragment looks like and what `<> value` in SQL never does.
|
|
134
|
-
re.compile(r"</>|<>\s*<"),
|
|
135
|
-
"JSX fragment. A template needs exactly one real root element.",
|
|
136
|
-
),
|
|
137
|
-
Rule(
|
|
138
|
-
"style-object",
|
|
139
|
-
re.compile(r"style\s*=\s*\{\{"),
|
|
140
|
-
"JSX style object. pp-style takes a CSS *string*: pp-style=\"{'color: red'}\".",
|
|
141
|
-
),
|
|
142
|
-
Rule(
|
|
143
|
-
"unknown-directive",
|
|
144
|
-
re.compile(r"\spp-(if|show|else|elif|key|class|text|html|model|bind|on)\s*[=>\s]"),
|
|
145
|
-
"Directive does not exist in PulsePoint. Conditionals use "
|
|
146
|
-
'hidden="{...}", lists use <template pp-for>, keys use plain key="{...}".',
|
|
147
|
-
),
|
|
148
|
-
]
|
|
149
|
-
|
|
150
|
-
# `pp-for` is valid only on <template>. Matching the opening tag it sits in is
|
|
151
|
-
# enough: the attribute cannot appear before its own tag name.
|
|
152
|
-
PP_FOR_TAG = re.compile(r"<\s*([a-zA-Z][\w:-]*)([^>]*?\spp-for\s*=)", re.DOTALL)
|
|
153
|
-
|
|
154
|
-
SCRIPT_BLOCK = re.compile(r"<script\b.*?</script\s*>", re.IGNORECASE | re.DOTALL)
|
|
155
|
-
PRE_BLOCK = re.compile(r"<(pre|code)\b.*?</\1\s*>", re.IGNORECASE | re.DOTALL)
|
|
156
|
-
HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
157
|
-
|
|
158
|
-
# Components are imported with Python imports; an `@import` HTML comment does
|
|
159
|
-
# not import anything and the compiler refuses it. Matched BEFORE comments are
|
|
160
|
-
# blanked out — the construct *is* a comment.
|
|
161
|
-
IMPORT_COMMENT = re.compile(r"<!--\s*@import\b")
|
|
162
|
-
|
|
163
|
-
# In a single-file component the markup lives in a triple-quoted string handed to
|
|
164
|
-
# `html(...)`. Surrounding Python must not be matched: `onValueChange=...` is an
|
|
165
|
-
# ordinary keyword argument, and flagging it as a camelCase event prop would make
|
|
166
|
-
# the gate unusable for every generated maddex component.
|
|
167
|
-
TRIPLE_QUOTED = re.compile(r'"""(?:.|\n)*?"""|\'\'\'(?:.|\n)*?\'\'\'')
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def _blank_out(text: str, pattern: re.Pattern[str]) -> str:
|
|
171
|
-
"""Replace matched regions with same-length whitespace.
|
|
172
|
-
|
|
173
|
-
Offsets stay valid, so reported line/column numbers still point at the real
|
|
174
|
-
location in the original file.
|
|
175
|
-
"""
|
|
176
|
-
|
|
177
|
-
def replace(match: re.Match[str]) -> str:
|
|
178
|
-
return "".join("\n" if ch == "\n" else " " for ch in match.group(0))
|
|
179
|
-
|
|
180
|
-
return pattern.sub(replace, text)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _keep_only(text: str, pattern: re.Pattern[str]) -> str:
|
|
184
|
-
"""Inverse of `_blank_out`: blank everything *outside* the matched regions."""
|
|
185
|
-
kept = ["\n" if ch == "\n" else " " for ch in text]
|
|
186
|
-
for match in pattern.finditer(text):
|
|
187
|
-
for index in range(match.start(), match.end()):
|
|
188
|
-
kept[index] = text[index]
|
|
189
|
-
return "".join(kept)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def _markup_regions(text: str, *, is_python: bool) -> tuple[str, str]:
|
|
193
|
-
"""Reduce a source file to just the markup a template rule may match.
|
|
194
|
-
|
|
195
|
-
Returns ``(markup, markup_with_comments)``: the first has HTML comments
|
|
196
|
-
blanked for the JSX/directive rules; the second keeps them so the
|
|
197
|
-
``@import``-comment rule can still see its target.
|
|
198
|
-
"""
|
|
199
|
-
if is_python:
|
|
200
|
-
# Only triple-quoted regions can hold markup in a single-file component.
|
|
201
|
-
text = _keep_only(text, TRIPLE_QUOTED)
|
|
202
|
-
for pattern in (SCRIPT_BLOCK, PRE_BLOCK):
|
|
203
|
-
text = _blank_out(text, pattern)
|
|
204
|
-
return _blank_out(text, HTML_COMMENT), text
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def _position(text: str, offset: int) -> tuple[int, int]:
|
|
208
|
-
line = text.count("\n", 0, offset) + 1
|
|
209
|
-
line_start = text.rfind("\n", 0, offset) + 1
|
|
210
|
-
return line, offset - line_start + 1
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def lint_text(text: str, rel_path: str, *, is_python: bool = False) -> list[TemplateIssue]:
|
|
214
|
-
"""Return every template issue found in one file's contents."""
|
|
215
|
-
markup, markup_with_comments = _markup_regions(text, is_python=is_python)
|
|
216
|
-
issues: list[TemplateIssue] = []
|
|
217
|
-
|
|
218
|
-
for match in IMPORT_COMMENT.finditer(markup_with_comments):
|
|
219
|
-
line, column = _position(markup_with_comments, match.start())
|
|
220
|
-
issues.append(
|
|
221
|
-
TemplateIssue(
|
|
222
|
-
rel_path,
|
|
223
|
-
line,
|
|
224
|
-
column,
|
|
225
|
-
"import-comment",
|
|
226
|
-
"An '@import' HTML comment does not import a component. Import "
|
|
227
|
-
"it in the owning Python module "
|
|
228
|
-
"(from src.lib.maddex.Button import Button) and keep the "
|
|
229
|
-
"<x-button> tag in the markup.",
|
|
230
|
-
)
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
for rule in RULES:
|
|
234
|
-
for match in rule.pattern.finditer(markup):
|
|
235
|
-
line, column = _position(markup, match.start())
|
|
236
|
-
issues.append(TemplateIssue(rel_path, line, column, rule.code, rule.message))
|
|
237
|
-
|
|
238
|
-
for match in PP_FOR_TAG.finditer(markup):
|
|
239
|
-
tag = match.group(1).lower()
|
|
240
|
-
if tag == "template":
|
|
241
|
-
continue
|
|
242
|
-
line, column = _position(markup, match.start())
|
|
243
|
-
issues.append(
|
|
244
|
-
TemplateIssue(
|
|
245
|
-
rel_path,
|
|
246
|
-
line,
|
|
247
|
-
column,
|
|
248
|
-
"pp-for-placement",
|
|
249
|
-
f"pp-for on <{tag}>. It belongs only on <template>: "
|
|
250
|
-
f'<template pp-for="item in items"><{tag} key="{{item.id}}">…',
|
|
251
|
-
)
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
return issues
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def _iter_files() -> list[Path]:
|
|
258
|
-
if not SCAN_ROOT.exists():
|
|
259
|
-
return []
|
|
260
|
-
files: list[Path] = []
|
|
261
|
-
for pattern in ("**/*.html", "**/*.py"):
|
|
262
|
-
for path in SCAN_ROOT.glob(pattern):
|
|
263
|
-
if EXCLUDED_PARTS.intersection(path.parts):
|
|
264
|
-
continue
|
|
265
|
-
files.append(path)
|
|
266
|
-
return sorted(files)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
# ---------------------------------------------------------------------------
|
|
270
|
-
# f-string component returns (ratchet)
|
|
271
|
-
# ---------------------------------------------------------------------------
|
|
272
|
-
# `html(...)` is the single markup entrypoint. A component that returns an
|
|
273
|
-
# f-string instead skips it entirely, and the two forms disagree in ways that
|
|
274
|
-
# are invisible at the call site:
|
|
275
|
-
#
|
|
276
|
-
# * The brace dialects are INVERTED. `html()` writes `{{ name }}` for server
|
|
277
|
-
# interpolation and `{count}` for a PulsePoint binding; an f-string writes
|
|
278
|
-
# `{name}` for the server and needs `{{count}}` to emit a binding. Same
|
|
279
|
-
# characters, opposite meanings.
|
|
280
|
-
# * There is no autoescaping. `Component.acall` wraps the returned string in
|
|
281
|
-
# `Markup`, so interpolated request data is emitted raw AND marked trusted.
|
|
282
|
-
# * `<x-*>` scope is not stashed, so a directly-called component
|
|
283
|
-
# (`{{ Card() }}`) cannot resolve nested component tags.
|
|
284
|
-
#
|
|
285
|
-
# The existing returns are recorded in a baseline and allowed; anything new
|
|
286
|
-
# fails the gate. Convert one and delete its baseline line. Regenerate with
|
|
287
|
-
# `python settings/check_templates.py --update-baseline`.
|
|
288
|
-
FSTRING_BASELINE_PATH = PROJECT_ROOT / "settings" / "fstring-components.json"
|
|
289
|
-
|
|
290
|
-
FSTRING_MESSAGE = (
|
|
291
|
-
"Component returns an f-string instead of html(...). The brace dialects are "
|
|
292
|
-
"inverted between the two forms ({{ x }} vs {x}) and an f-string is not "
|
|
293
|
-
"autoescaped, so interpolated data is emitted raw and marked trusted. "
|
|
294
|
-
"Return html(r'''...''', x=x) instead."
|
|
295
|
-
)
|
|
296
|
-
|
|
297
|
-
HTML_FORM_MESSAGE = (
|
|
298
|
-
"html(...) must take a raw triple-quoted literal: html(r'''...'''). It is "
|
|
299
|
-
"the single markup entrypoint, and one form keeps it readable and greppable. "
|
|
300
|
-
"A non-raw string silently rewrites backslashes, so a JS regex or a \\n in a "
|
|
301
|
-
"component script changes meaning between authoring and render; an f-string "
|
|
302
|
-
"additionally inverts the brace dialects and emits interpolated data raw. "
|
|
303
|
-
"Pass server values as context instead: html(r'''...{{ x }}...''', x=x)."
|
|
304
|
-
)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def _own_returns(func):
|
|
308
|
-
"""The `return` statements belonging to `func` itself.
|
|
309
|
-
|
|
310
|
-
`ast.walk` would descend into nested `def`s and lambdas and attribute their
|
|
311
|
-
returns to the enclosing function. A `@component` may legitimately define a
|
|
312
|
-
private helper that builds a string fragment, so walking blind reports a
|
|
313
|
-
component that already returns `html(...)` and tells its author to do what
|
|
314
|
-
they have done -- which is how a gate loses its credibility.
|
|
315
|
-
"""
|
|
316
|
-
import ast
|
|
317
|
-
|
|
318
|
-
stack = list(func.body)
|
|
319
|
-
while stack:
|
|
320
|
-
node = stack.pop()
|
|
321
|
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
|
|
322
|
-
continue
|
|
323
|
-
if isinstance(node, ast.Return):
|
|
324
|
-
yield node
|
|
325
|
-
continue
|
|
326
|
-
stack.extend(ast.iter_child_nodes(node))
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def _fstring_component_returns() -> list[tuple[str, str, int, int]]:
|
|
330
|
-
"""Every `@component` whose return value is an f-string.
|
|
331
|
-
|
|
332
|
-
Entries are `(rel_path, function_name, line, column)`, sorted.
|
|
333
|
-
"""
|
|
334
|
-
import ast
|
|
335
|
-
|
|
336
|
-
found: list[tuple[str, str, int, int]] = []
|
|
337
|
-
for path in _iter_files():
|
|
338
|
-
if path.suffix != ".py":
|
|
339
|
-
continue
|
|
340
|
-
try:
|
|
341
|
-
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
342
|
-
except OSError, UnicodeDecodeError, SyntaxError:
|
|
343
|
-
continue
|
|
344
|
-
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
345
|
-
for node in ast.walk(tree):
|
|
346
|
-
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
347
|
-
continue
|
|
348
|
-
decorators = {
|
|
349
|
-
getattr(d, "id", None) or getattr(d, "attr", None) for d in node.decorator_list
|
|
350
|
-
}
|
|
351
|
-
if "component" not in decorators:
|
|
352
|
-
continue
|
|
353
|
-
for stmt in _own_returns(node):
|
|
354
|
-
if isinstance(stmt.value, ast.JoinedStr):
|
|
355
|
-
found.append((rel, node.name, stmt.lineno, stmt.col_offset + 1))
|
|
356
|
-
break
|
|
357
|
-
return sorted(found)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def _load_fstring_baseline() -> set[str]:
|
|
361
|
-
import json
|
|
362
|
-
|
|
363
|
-
try:
|
|
364
|
-
raw = json.loads(FSTRING_BASELINE_PATH.read_text(encoding="utf-8"))
|
|
365
|
-
except OSError, ValueError:
|
|
366
|
-
return set()
|
|
367
|
-
return set(raw.get("allowed", []))
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def write_fstring_baseline() -> int:
|
|
371
|
-
"""Record today's f-string components as the allowed set."""
|
|
372
|
-
import json
|
|
373
|
-
|
|
374
|
-
entries = sorted({f"{rel}::{name}" for rel, name, _, _ in _fstring_component_returns()})
|
|
375
|
-
FSTRING_BASELINE_PATH.write_text(
|
|
376
|
-
json.dumps(
|
|
377
|
-
{
|
|
378
|
-
"_comment": (
|
|
379
|
-
"Components that still return an f-string instead of html(...). "
|
|
380
|
-
"This list may only shrink: converting one means deleting its "
|
|
381
|
-
"line. New entries fail `npm run
|
|
382
|
-
),
|
|
383
|
-
"allowed": entries,
|
|
384
|
-
},
|
|
385
|
-
indent=2,
|
|
386
|
-
)
|
|
387
|
-
+ "\n",
|
|
388
|
-
encoding="utf-8",
|
|
389
|
-
)
|
|
390
|
-
return len(entries)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def _html_call_template_args():
|
|
394
|
-
"""Every `html(...)` call's first argument, with its source text.
|
|
395
|
-
|
|
396
|
-
Yields `(rel_path, lineno, col, source_segment, node)`.
|
|
397
|
-
"""
|
|
398
|
-
import ast
|
|
399
|
-
|
|
400
|
-
for path in _iter_files():
|
|
401
|
-
if path.suffix != ".py":
|
|
402
|
-
continue
|
|
403
|
-
try:
|
|
404
|
-
source = path.read_text(encoding="utf-8")
|
|
405
|
-
tree = ast.parse(source)
|
|
406
|
-
except OSError, UnicodeDecodeError, SyntaxError:
|
|
407
|
-
continue
|
|
408
|
-
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
409
|
-
for node in ast.walk(tree):
|
|
410
|
-
if not isinstance(node, ast.Call):
|
|
411
|
-
continue
|
|
412
|
-
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
|
413
|
-
if name != "html" or not node.args:
|
|
414
|
-
continue
|
|
415
|
-
arg = node.args[0]
|
|
416
|
-
segment = ast.get_source_segment(source, arg) or ""
|
|
417
|
-
yield rel, arg.lineno, arg.col_offset + 1, segment, arg
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def lint_html_call_form() -> list[TemplateIssue]:
|
|
421
|
-
"""`html(...)` takes a raw triple-quoted literal -- one form, no exceptions.
|
|
422
|
-
|
|
423
|
-
Two forms drifted apart in this repo once already: 437 calls used
|
|
424
|
-
`html(r'''...''')` and 133 did not, which makes the markup surface
|
|
425
|
-
un-greppable and lets a backslash mean two different things depending on
|
|
426
|
-
which call you are reading.
|
|
427
|
-
"""
|
|
428
|
-
import ast
|
|
429
|
-
|
|
430
|
-
issues: list[TemplateIssue] = []
|
|
431
|
-
for rel, line, col, segment, arg in _html_call_template_args():
|
|
432
|
-
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
433
|
-
if segment.startswith(('r"""', "r'''", 'R"""', "R'''")):
|
|
434
|
-
continue
|
|
435
|
-
issues.append(TemplateIssue(rel, line, col, "html-form", HTML_FORM_MESSAGE))
|
|
436
|
-
return issues
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
def lint_fstring_components() -> list[TemplateIssue]:
|
|
440
|
-
allowed = _load_fstring_baseline()
|
|
441
|
-
return [
|
|
442
|
-
TemplateIssue(rel, line, col, "fstring-component", FSTRING_MESSAGE)
|
|
443
|
-
for rel, name, line, col in _fstring_component_returns()
|
|
444
|
-
if f"{rel}::{name}" not in allowed
|
|
445
|
-
]
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
def lint_templates() -> list[TemplateIssue]:
|
|
449
|
-
"""Lint every authored template under `src/`."""
|
|
450
|
-
issues: list[TemplateIssue] = []
|
|
451
|
-
for path in _iter_files():
|
|
452
|
-
try:
|
|
453
|
-
text = path.read_text(encoding="utf-8")
|
|
454
|
-
except OSError, UnicodeDecodeError:
|
|
455
|
-
continue
|
|
456
|
-
# Cheap pre-filter: a file with no brace expression and no angle-bracket
|
|
457
|
-
# markup cannot trip any rule.
|
|
458
|
-
if "{" not in text and "<" not in text:
|
|
459
|
-
continue
|
|
460
|
-
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
461
|
-
issues.extend(lint_text(text, rel, is_python=path.suffix == ".py"))
|
|
462
|
-
issues.extend(lint_fstring_components())
|
|
463
|
-
issues.extend(lint_html_call_form())
|
|
464
|
-
return issues
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
def main() -> int:
|
|
468
|
-
import sys
|
|
469
|
-
|
|
470
|
-
if "--update-baseline" in sys.argv:
|
|
471
|
-
count = write_fstring_baseline()
|
|
472
|
-
print(
|
|
473
|
-
f"templates: recorded {count} f-string component(s) in "
|
|
474
|
-
f"{FSTRING_BASELINE_PATH.relative_to(PROJECT_ROOT).as_posix()}."
|
|
475
|
-
)
|
|
476
|
-
return 0
|
|
477
|
-
|
|
478
|
-
issues = lint_templates()
|
|
479
|
-
if not issues:
|
|
480
|
-
print("templates: no JSX or unknown directives found.")
|
|
481
|
-
return 0
|
|
482
|
-
|
|
483
|
-
by_file: dict[str, list[TemplateIssue]] = {}
|
|
484
|
-
for issue in issues:
|
|
485
|
-
by_file.setdefault(issue.path, []).append(issue)
|
|
486
|
-
|
|
487
|
-
for path in sorted(by_file):
|
|
488
|
-
print(path)
|
|
489
|
-
for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
|
|
490
|
-
print(f" {issue.line}:{issue.column} [templates:{issue.code}] {issue.message}")
|
|
491
|
-
|
|
492
|
-
print(f"\n{len(issues)} template issue(s) found.")
|
|
493
|
-
return 1
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if __name__ == "__main__":
|
|
497
|
-
raise SystemExit(main())
|
|
1
|
+
"""Template lint: reject JSX and unknown directives in PulsePoint markup.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
`npm run test` validated Python only. A route template could contain JSX --
|
|
6
|
+
`{users.map(user => (<tr/>))}`, `class={...}`, `className` -- and the gate stayed
|
|
7
|
+
green, because nothing in the toolchain reads `.html` files. The failure then
|
|
8
|
+
surfaced only in the browser, and the worst variant surfaced nowhere at all: an
|
|
9
|
+
unquoted brace attribute is invalid HTML, so the parser shreds the element, the
|
|
10
|
+
component root never compiles, the runtime's reveal step never clears
|
|
11
|
+
`<body style="opacity: 0">`, and the route serves a blank page with **no console
|
|
12
|
+
error**.
|
|
13
|
+
|
|
14
|
+
PulsePoint borrows React's hook API inside `<script>` and React's component
|
|
15
|
+
decomposition. It borrows none of React's markup syntax. This module enforces
|
|
16
|
+
that boundary at authoring time, where the fix is cheap.
|
|
17
|
+
|
|
18
|
+
Scope
|
|
19
|
+
-----
|
|
20
|
+
Scans authored markup under `src/`:
|
|
21
|
+
|
|
22
|
+
- `**/*.html` -- route, layout, and component templates
|
|
23
|
+
- `**/*.py` -- single-file components that embed markup via `html(\"\"\"...\"\"\")`
|
|
24
|
+
|
|
25
|
+
Regions that legitimately contain JavaScript or sample code are removed before
|
|
26
|
+
matching, so a real `array.map(...)` in a component script and a JSX snippet in
|
|
27
|
+
a docs `<pre>` block are both ignored:
|
|
28
|
+
|
|
29
|
+
- `<script>...</script>` (the component script is JS, `.map()` is correct there)
|
|
30
|
+
- `<pre>` / `<code>` (documentation examples)
|
|
31
|
+
- `<!-- ... -->` (commented-out markup)
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
|
|
35
|
+
python settings/check_templates.py # run standalone
|
|
36
|
+
npm run test # runs as part of the gate
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import re
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
46
|
+
SCAN_ROOT = PROJECT_ROOT / "src"
|
|
47
|
+
|
|
48
|
+
# Generated or vendored trees that are not hand-authored markup.
|
|
49
|
+
EXCLUDED_PARTS = {"__pycache__", "node_modules", ".venv", "prisma"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TemplateIssue:
|
|
54
|
+
path: str
|
|
55
|
+
line: int
|
|
56
|
+
column: int
|
|
57
|
+
code: str
|
|
58
|
+
message: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Rule:
|
|
63
|
+
code: str
|
|
64
|
+
pattern: re.Pattern[str]
|
|
65
|
+
message: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Each rule names the JSX/unsupported construct and the PulsePoint replacement,
|
|
69
|
+
# so the report is directly actionable without opening the docs.
|
|
70
|
+
RULES: list[Rule] = [
|
|
71
|
+
Rule(
|
|
72
|
+
"jsx-map",
|
|
73
|
+
# `{items.map(item => (` and `{items.map((item, i) => (` -- the trailing
|
|
74
|
+
# `(` is what distinguishes returning markup from a normal value map.
|
|
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"> with key="{item.id}".',
|
|
77
|
+
),
|
|
78
|
+
Rule(
|
|
79
|
+
"jsx-logical",
|
|
80
|
+
# `{cond && (<div` -- element after a logical AND.
|
|
81
|
+
re.compile(r"\{[^{}]*?&&\s*\(\s*<", re.DOTALL),
|
|
82
|
+
'JSX `{cond && (<element/>)}`. Use hidden="{!cond}" on the element.',
|
|
83
|
+
),
|
|
84
|
+
Rule(
|
|
85
|
+
"jsx-ternary-element",
|
|
86
|
+
# `{cond ? <A` -- element directly after a ternary branch.
|
|
87
|
+
re.compile(r"\{[^{}]*?\?\s*\(?\s*<[a-zA-Z]", re.DOTALL),
|
|
88
|
+
'JSX `{cond ? <A/> : <B/>}`. Use two elements with complementary hidden="{...}" bindings.',
|
|
89
|
+
),
|
|
90
|
+
Rule(
|
|
91
|
+
"unquoted-brace-attr",
|
|
92
|
+
# `class={...}` / `selected={...}` -- invalid HTML, silently blanks the page.
|
|
93
|
+
#
|
|
94
|
+
# An attribute only exists *inside an opening tag*, and the rule must say
|
|
95
|
+
# so. A bare `\s[\w:.\-]+=\{` also matches `DIR={path}` in a shell script
|
|
96
|
+
# and `ENV PORT={port}` in a Dockerfile -- and this repo embeds both in
|
|
97
|
+
# triple-quoted strings under `src/lib/aws/`, which the Python scan keeps
|
|
98
|
+
# because it cannot tell a heredoc from a template. Requiring the opening
|
|
99
|
+
# tag is not a loosening: a real violation is always inside one.
|
|
100
|
+
#
|
|
101
|
+
# `[^<>]*?` bounds the attribute run to a single tag; it matches newlines
|
|
102
|
+
# (negated classes do), so an attribute on its own line is still caught.
|
|
103
|
+
re.compile(r"<[a-zA-Z][\w:.\-]*(?:[^<>]*?)?\s[\w:.\-]+=\{"),
|
|
104
|
+
"Unquoted brace attribute. This is invalid HTML: the parser splits the "
|
|
105
|
+
"value on spaces, the component root never compiles, and the page "
|
|
106
|
+
'renders blank with no console error. Quote it: attr="{expr}".',
|
|
107
|
+
),
|
|
108
|
+
Rule(
|
|
109
|
+
"react-attribute",
|
|
110
|
+
re.compile(r"\s(className|htmlFor|dangerouslySetInnerHTML)\s*="),
|
|
111
|
+
"React DOM property. Use class=, for=, or server-rendered markup.",
|
|
112
|
+
),
|
|
113
|
+
Rule(
|
|
114
|
+
"camelcase-event",
|
|
115
|
+
# `onClick="…"` / `onClick={…}` in attribute position. The value-start
|
|
116
|
+
# class and the declaration lookbehinds keep ordinary JavaScript out:
|
|
117
|
+
# `const onPointerEnter = () => {` is a valid handler name in a component
|
|
118
|
+
# script or an injected snippet, not a JSX prop.
|
|
119
|
+
re.compile(
|
|
120
|
+
r"(?<!\bconst )(?<!\blet )(?<!\bvar )(?<!\bfunction )"
|
|
121
|
+
r"\son[A-Z][a-zA-Z]*\s*=\s*[\"'{]"
|
|
122
|
+
),
|
|
123
|
+
"camelCase event prop. PulsePoint binds native lowercase event "
|
|
124
|
+
'attributes: onclick="{handler()}". A component prop uses kebab-case '
|
|
125
|
+
"(on-click), which arrives as pp.props.onClick.",
|
|
126
|
+
),
|
|
127
|
+
Rule(
|
|
128
|
+
"jsx-fragment",
|
|
129
|
+
# `</>` is unambiguous, and a well-formed fragment always has one. A bare
|
|
130
|
+
# `<>` is not: it is SQL's not-equals operator, and this repo runs
|
|
131
|
+
# `WHERE pid <> pg_backend_pid()` from a triple-quoted string. So the
|
|
132
|
+
# open tag counts only when an element follows it, which is what a
|
|
133
|
+
# fragment looks like and what `<> value` in SQL never does.
|
|
134
|
+
re.compile(r"</>|<>\s*<"),
|
|
135
|
+
"JSX fragment. A template needs exactly one real root element.",
|
|
136
|
+
),
|
|
137
|
+
Rule(
|
|
138
|
+
"style-object",
|
|
139
|
+
re.compile(r"style\s*=\s*\{\{"),
|
|
140
|
+
"JSX style object. pp-style takes a CSS *string*: pp-style=\"{'color: red'}\".",
|
|
141
|
+
),
|
|
142
|
+
Rule(
|
|
143
|
+
"unknown-directive",
|
|
144
|
+
re.compile(r"\spp-(if|show|else|elif|key|class|text|html|model|bind|on)\s*[=>\s]"),
|
|
145
|
+
"Directive does not exist in PulsePoint. Conditionals use "
|
|
146
|
+
'hidden="{...}", lists use <template pp-for>, keys use plain key="{...}".',
|
|
147
|
+
),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# `pp-for` is valid only on <template>. Matching the opening tag it sits in is
|
|
151
|
+
# enough: the attribute cannot appear before its own tag name.
|
|
152
|
+
PP_FOR_TAG = re.compile(r"<\s*([a-zA-Z][\w:-]*)([^>]*?\spp-for\s*=)", re.DOTALL)
|
|
153
|
+
|
|
154
|
+
SCRIPT_BLOCK = re.compile(r"<script\b.*?</script\s*>", re.IGNORECASE | re.DOTALL)
|
|
155
|
+
PRE_BLOCK = re.compile(r"<(pre|code)\b.*?</\1\s*>", re.IGNORECASE | re.DOTALL)
|
|
156
|
+
HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
157
|
+
|
|
158
|
+
# Components are imported with Python imports; an `@import` HTML comment does
|
|
159
|
+
# not import anything and the compiler refuses it. Matched BEFORE comments are
|
|
160
|
+
# blanked out — the construct *is* a comment.
|
|
161
|
+
IMPORT_COMMENT = re.compile(r"<!--\s*@import\b")
|
|
162
|
+
|
|
163
|
+
# In a single-file component the markup lives in a triple-quoted string handed to
|
|
164
|
+
# `html(...)`. Surrounding Python must not be matched: `onValueChange=...` is an
|
|
165
|
+
# ordinary keyword argument, and flagging it as a camelCase event prop would make
|
|
166
|
+
# the gate unusable for every generated maddex component.
|
|
167
|
+
TRIPLE_QUOTED = re.compile(r'"""(?:.|\n)*?"""|\'\'\'(?:.|\n)*?\'\'\'')
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _blank_out(text: str, pattern: re.Pattern[str]) -> str:
|
|
171
|
+
"""Replace matched regions with same-length whitespace.
|
|
172
|
+
|
|
173
|
+
Offsets stay valid, so reported line/column numbers still point at the real
|
|
174
|
+
location in the original file.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def replace(match: re.Match[str]) -> str:
|
|
178
|
+
return "".join("\n" if ch == "\n" else " " for ch in match.group(0))
|
|
179
|
+
|
|
180
|
+
return pattern.sub(replace, text)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _keep_only(text: str, pattern: re.Pattern[str]) -> str:
|
|
184
|
+
"""Inverse of `_blank_out`: blank everything *outside* the matched regions."""
|
|
185
|
+
kept = ["\n" if ch == "\n" else " " for ch in text]
|
|
186
|
+
for match in pattern.finditer(text):
|
|
187
|
+
for index in range(match.start(), match.end()):
|
|
188
|
+
kept[index] = text[index]
|
|
189
|
+
return "".join(kept)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _markup_regions(text: str, *, is_python: bool) -> tuple[str, str]:
|
|
193
|
+
"""Reduce a source file to just the markup a template rule may match.
|
|
194
|
+
|
|
195
|
+
Returns ``(markup, markup_with_comments)``: the first has HTML comments
|
|
196
|
+
blanked for the JSX/directive rules; the second keeps them so the
|
|
197
|
+
``@import``-comment rule can still see its target.
|
|
198
|
+
"""
|
|
199
|
+
if is_python:
|
|
200
|
+
# Only triple-quoted regions can hold markup in a single-file component.
|
|
201
|
+
text = _keep_only(text, TRIPLE_QUOTED)
|
|
202
|
+
for pattern in (SCRIPT_BLOCK, PRE_BLOCK):
|
|
203
|
+
text = _blank_out(text, pattern)
|
|
204
|
+
return _blank_out(text, HTML_COMMENT), text
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _position(text: str, offset: int) -> tuple[int, int]:
|
|
208
|
+
line = text.count("\n", 0, offset) + 1
|
|
209
|
+
line_start = text.rfind("\n", 0, offset) + 1
|
|
210
|
+
return line, offset - line_start + 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def lint_text(text: str, rel_path: str, *, is_python: bool = False) -> list[TemplateIssue]:
|
|
214
|
+
"""Return every template issue found in one file's contents."""
|
|
215
|
+
markup, markup_with_comments = _markup_regions(text, is_python=is_python)
|
|
216
|
+
issues: list[TemplateIssue] = []
|
|
217
|
+
|
|
218
|
+
for match in IMPORT_COMMENT.finditer(markup_with_comments):
|
|
219
|
+
line, column = _position(markup_with_comments, match.start())
|
|
220
|
+
issues.append(
|
|
221
|
+
TemplateIssue(
|
|
222
|
+
rel_path,
|
|
223
|
+
line,
|
|
224
|
+
column,
|
|
225
|
+
"import-comment",
|
|
226
|
+
"An '@import' HTML comment does not import a component. Import "
|
|
227
|
+
"it in the owning Python module "
|
|
228
|
+
"(from src.lib.maddex.Button import Button) and keep the "
|
|
229
|
+
"<x-button> tag in the markup.",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
for rule in RULES:
|
|
234
|
+
for match in rule.pattern.finditer(markup):
|
|
235
|
+
line, column = _position(markup, match.start())
|
|
236
|
+
issues.append(TemplateIssue(rel_path, line, column, rule.code, rule.message))
|
|
237
|
+
|
|
238
|
+
for match in PP_FOR_TAG.finditer(markup):
|
|
239
|
+
tag = match.group(1).lower()
|
|
240
|
+
if tag == "template":
|
|
241
|
+
continue
|
|
242
|
+
line, column = _position(markup, match.start())
|
|
243
|
+
issues.append(
|
|
244
|
+
TemplateIssue(
|
|
245
|
+
rel_path,
|
|
246
|
+
line,
|
|
247
|
+
column,
|
|
248
|
+
"pp-for-placement",
|
|
249
|
+
f"pp-for on <{tag}>. It belongs only on <template>: "
|
|
250
|
+
f'<template pp-for="item in items"><{tag} key="{{item.id}}">…',
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return issues
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _iter_files() -> list[Path]:
|
|
258
|
+
if not SCAN_ROOT.exists():
|
|
259
|
+
return []
|
|
260
|
+
files: list[Path] = []
|
|
261
|
+
for pattern in ("**/*.html", "**/*.py"):
|
|
262
|
+
for path in SCAN_ROOT.glob(pattern):
|
|
263
|
+
if EXCLUDED_PARTS.intersection(path.parts):
|
|
264
|
+
continue
|
|
265
|
+
files.append(path)
|
|
266
|
+
return sorted(files)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# f-string component returns (ratchet)
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# `html(...)` is the single markup entrypoint. A component that returns an
|
|
273
|
+
# f-string instead skips it entirely, and the two forms disagree in ways that
|
|
274
|
+
# are invisible at the call site:
|
|
275
|
+
#
|
|
276
|
+
# * The brace dialects are INVERTED. `html()` writes `{{ name }}` for server
|
|
277
|
+
# interpolation and `{count}` for a PulsePoint binding; an f-string writes
|
|
278
|
+
# `{name}` for the server and needs `{{count}}` to emit a binding. Same
|
|
279
|
+
# characters, opposite meanings.
|
|
280
|
+
# * There is no autoescaping. `Component.acall` wraps the returned string in
|
|
281
|
+
# `Markup`, so interpolated request data is emitted raw AND marked trusted.
|
|
282
|
+
# * `<x-*>` scope is not stashed, so a directly-called component
|
|
283
|
+
# (`{{ Card() }}`) cannot resolve nested component tags.
|
|
284
|
+
#
|
|
285
|
+
# The existing returns are recorded in a baseline and allowed; anything new
|
|
286
|
+
# fails the gate. Convert one and delete its baseline line. Regenerate with
|
|
287
|
+
# `python settings/check_templates.py --update-baseline`.
|
|
288
|
+
FSTRING_BASELINE_PATH = PROJECT_ROOT / "settings" / "fstring-components.json"
|
|
289
|
+
|
|
290
|
+
FSTRING_MESSAGE = (
|
|
291
|
+
"Component returns an f-string instead of html(...). The brace dialects are "
|
|
292
|
+
"inverted between the two forms ({{ x }} vs {x}) and an f-string is not "
|
|
293
|
+
"autoescaped, so interpolated data is emitted raw and marked trusted. "
|
|
294
|
+
"Return html(r'''...''', x=x) instead."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
HTML_FORM_MESSAGE = (
|
|
298
|
+
"html(...) must take a raw triple-quoted literal: html(r'''...'''). It is "
|
|
299
|
+
"the single markup entrypoint, and one form keeps it readable and greppable. "
|
|
300
|
+
"A non-raw string silently rewrites backslashes, so a JS regex or a \\n in a "
|
|
301
|
+
"component script changes meaning between authoring and render; an f-string "
|
|
302
|
+
"additionally inverts the brace dialects and emits interpolated data raw. "
|
|
303
|
+
"Pass server values as context instead: html(r'''...{{ x }}...''', x=x)."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _own_returns(func):
|
|
308
|
+
"""The `return` statements belonging to `func` itself.
|
|
309
|
+
|
|
310
|
+
`ast.walk` would descend into nested `def`s and lambdas and attribute their
|
|
311
|
+
returns to the enclosing function. A `@component` may legitimately define a
|
|
312
|
+
private helper that builds a string fragment, so walking blind reports a
|
|
313
|
+
component that already returns `html(...)` and tells its author to do what
|
|
314
|
+
they have done -- which is how a gate loses its credibility.
|
|
315
|
+
"""
|
|
316
|
+
import ast
|
|
317
|
+
|
|
318
|
+
stack = list(func.body)
|
|
319
|
+
while stack:
|
|
320
|
+
node = stack.pop()
|
|
321
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
|
|
322
|
+
continue
|
|
323
|
+
if isinstance(node, ast.Return):
|
|
324
|
+
yield node
|
|
325
|
+
continue
|
|
326
|
+
stack.extend(ast.iter_child_nodes(node))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _fstring_component_returns() -> list[tuple[str, str, int, int]]:
|
|
330
|
+
"""Every `@component` whose return value is an f-string.
|
|
331
|
+
|
|
332
|
+
Entries are `(rel_path, function_name, line, column)`, sorted.
|
|
333
|
+
"""
|
|
334
|
+
import ast
|
|
335
|
+
|
|
336
|
+
found: list[tuple[str, str, int, int]] = []
|
|
337
|
+
for path in _iter_files():
|
|
338
|
+
if path.suffix != ".py":
|
|
339
|
+
continue
|
|
340
|
+
try:
|
|
341
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
342
|
+
except OSError, UnicodeDecodeError, SyntaxError:
|
|
343
|
+
continue
|
|
344
|
+
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
345
|
+
for node in ast.walk(tree):
|
|
346
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
347
|
+
continue
|
|
348
|
+
decorators = {
|
|
349
|
+
getattr(d, "id", None) or getattr(d, "attr", None) for d in node.decorator_list
|
|
350
|
+
}
|
|
351
|
+
if "component" not in decorators:
|
|
352
|
+
continue
|
|
353
|
+
for stmt in _own_returns(node):
|
|
354
|
+
if isinstance(stmt.value, ast.JoinedStr):
|
|
355
|
+
found.append((rel, node.name, stmt.lineno, stmt.col_offset + 1))
|
|
356
|
+
break
|
|
357
|
+
return sorted(found)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _load_fstring_baseline() -> set[str]:
|
|
361
|
+
import json
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
raw = json.loads(FSTRING_BASELINE_PATH.read_text(encoding="utf-8"))
|
|
365
|
+
except OSError, ValueError:
|
|
366
|
+
return set()
|
|
367
|
+
return set(raw.get("allowed", []))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def write_fstring_baseline() -> int:
|
|
371
|
+
"""Record today's f-string components as the allowed set."""
|
|
372
|
+
import json
|
|
373
|
+
|
|
374
|
+
entries = sorted({f"{rel}::{name}" for rel, name, _, _ in _fstring_component_returns()})
|
|
375
|
+
FSTRING_BASELINE_PATH.write_text(
|
|
376
|
+
json.dumps(
|
|
377
|
+
{
|
|
378
|
+
"_comment": (
|
|
379
|
+
"Components that still return an f-string instead of html(...). "
|
|
380
|
+
"This list may only shrink: converting one means deleting its "
|
|
381
|
+
"line. New entries fail `npm run test`."
|
|
382
|
+
),
|
|
383
|
+
"allowed": entries,
|
|
384
|
+
},
|
|
385
|
+
indent=2,
|
|
386
|
+
)
|
|
387
|
+
+ "\n",
|
|
388
|
+
encoding="utf-8",
|
|
389
|
+
)
|
|
390
|
+
return len(entries)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _html_call_template_args():
|
|
394
|
+
"""Every `html(...)` call's first argument, with its source text.
|
|
395
|
+
|
|
396
|
+
Yields `(rel_path, lineno, col, source_segment, node)`.
|
|
397
|
+
"""
|
|
398
|
+
import ast
|
|
399
|
+
|
|
400
|
+
for path in _iter_files():
|
|
401
|
+
if path.suffix != ".py":
|
|
402
|
+
continue
|
|
403
|
+
try:
|
|
404
|
+
source = path.read_text(encoding="utf-8")
|
|
405
|
+
tree = ast.parse(source)
|
|
406
|
+
except OSError, UnicodeDecodeError, SyntaxError:
|
|
407
|
+
continue
|
|
408
|
+
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
409
|
+
for node in ast.walk(tree):
|
|
410
|
+
if not isinstance(node, ast.Call):
|
|
411
|
+
continue
|
|
412
|
+
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
|
413
|
+
if name != "html" or not node.args:
|
|
414
|
+
continue
|
|
415
|
+
arg = node.args[0]
|
|
416
|
+
segment = ast.get_source_segment(source, arg) or ""
|
|
417
|
+
yield rel, arg.lineno, arg.col_offset + 1, segment, arg
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def lint_html_call_form() -> list[TemplateIssue]:
|
|
421
|
+
"""`html(...)` takes a raw triple-quoted literal -- one form, no exceptions.
|
|
422
|
+
|
|
423
|
+
Two forms drifted apart in this repo once already: 437 calls used
|
|
424
|
+
`html(r'''...''')` and 133 did not, which makes the markup surface
|
|
425
|
+
un-greppable and lets a backslash mean two different things depending on
|
|
426
|
+
which call you are reading.
|
|
427
|
+
"""
|
|
428
|
+
import ast
|
|
429
|
+
|
|
430
|
+
issues: list[TemplateIssue] = []
|
|
431
|
+
for rel, line, col, segment, arg in _html_call_template_args():
|
|
432
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
433
|
+
if segment.startswith(('r"""', "r'''", 'R"""', "R'''")):
|
|
434
|
+
continue
|
|
435
|
+
issues.append(TemplateIssue(rel, line, col, "html-form", HTML_FORM_MESSAGE))
|
|
436
|
+
return issues
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def lint_fstring_components() -> list[TemplateIssue]:
|
|
440
|
+
allowed = _load_fstring_baseline()
|
|
441
|
+
return [
|
|
442
|
+
TemplateIssue(rel, line, col, "fstring-component", FSTRING_MESSAGE)
|
|
443
|
+
for rel, name, line, col in _fstring_component_returns()
|
|
444
|
+
if f"{rel}::{name}" not in allowed
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def lint_templates() -> list[TemplateIssue]:
|
|
449
|
+
"""Lint every authored template under `src/`."""
|
|
450
|
+
issues: list[TemplateIssue] = []
|
|
451
|
+
for path in _iter_files():
|
|
452
|
+
try:
|
|
453
|
+
text = path.read_text(encoding="utf-8")
|
|
454
|
+
except OSError, UnicodeDecodeError:
|
|
455
|
+
continue
|
|
456
|
+
# Cheap pre-filter: a file with no brace expression and no angle-bracket
|
|
457
|
+
# markup cannot trip any rule.
|
|
458
|
+
if "{" not in text and "<" not in text:
|
|
459
|
+
continue
|
|
460
|
+
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
461
|
+
issues.extend(lint_text(text, rel, is_python=path.suffix == ".py"))
|
|
462
|
+
issues.extend(lint_fstring_components())
|
|
463
|
+
issues.extend(lint_html_call_form())
|
|
464
|
+
return issues
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def main() -> int:
|
|
468
|
+
import sys
|
|
469
|
+
|
|
470
|
+
if "--update-baseline" in sys.argv:
|
|
471
|
+
count = write_fstring_baseline()
|
|
472
|
+
print(
|
|
473
|
+
f"templates: recorded {count} f-string component(s) in "
|
|
474
|
+
f"{FSTRING_BASELINE_PATH.relative_to(PROJECT_ROOT).as_posix()}."
|
|
475
|
+
)
|
|
476
|
+
return 0
|
|
477
|
+
|
|
478
|
+
issues = lint_templates()
|
|
479
|
+
if not issues:
|
|
480
|
+
print("templates: no JSX or unknown directives found.")
|
|
481
|
+
return 0
|
|
482
|
+
|
|
483
|
+
by_file: dict[str, list[TemplateIssue]] = {}
|
|
484
|
+
for issue in issues:
|
|
485
|
+
by_file.setdefault(issue.path, []).append(issue)
|
|
486
|
+
|
|
487
|
+
for path in sorted(by_file):
|
|
488
|
+
print(path)
|
|
489
|
+
for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
|
|
490
|
+
print(f" {issue.line}:{issue.column} [templates:{issue.code}] {issue.message}")
|
|
491
|
+
|
|
492
|
+
print(f"\n{len(issues)} template issue(s) found.")
|
|
493
|
+
return 1
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
if __name__ == "__main__":
|
|
497
|
+
raise SystemExit(main())
|