create-caspian-app 1.0.0 → 1.0.2
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/caspian.js +1 -1
- package/dist/index.js +1 -1
- package/dist/public/js/main.js +12 -12
- package/dist/settings/_component_imports.py +70 -70
- package/dist/settings/browser_log.py +498 -498
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/build-static.py +290 -290
- package/dist/settings/check.py +415 -415
- package/dist/settings/check_templates.py +274 -274
- package/dist/settings/component-map.ts +6 -6
- package/dist/settings/dev-log-bridge.ts +585 -585
- package/dist/settings/fix.py +95 -95
- package/dist/settings/python-server.ts +31 -31
- package/dist/settings/run-postcss.ts +317 -317
- package/dist/settings/serve-static.py +188 -188
- package/dist/tests/README.md +135 -135
- package/dist/tests/conftest.py +89 -89
- package/dist/tests/test_health_route.py +44 -44
- package/dist/tests/test_main_helpers.py +112 -112
- package/package.json +1 -1
|
@@ -1,274 +1,274 @@
|
|
|
1
|
-
"""Template lint: reject JSX and unknown directives in PulsePoint markup.
|
|
2
|
-
|
|
3
|
-
Why this exists
|
|
4
|
-
---------------
|
|
5
|
-
`npm run check` 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 check # 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\"> "
|
|
77
|
-
'with key="{item.id}".',
|
|
78
|
-
),
|
|
79
|
-
Rule(
|
|
80
|
-
"jsx-logical",
|
|
81
|
-
# `{cond && (<div` -- element after a logical AND.
|
|
82
|
-
re.compile(r"\{[^{}]*?&&\s*\(\s*<", re.DOTALL),
|
|
83
|
-
'JSX `{cond && (<element/>)}`. Use hidden="{!cond}" on the element.',
|
|
84
|
-
),
|
|
85
|
-
Rule(
|
|
86
|
-
"jsx-ternary-element",
|
|
87
|
-
# `{cond ? <A` -- element directly after a ternary branch.
|
|
88
|
-
re.compile(r"\{[^{}]*?\?\s*\(?\s*<[a-zA-Z]", re.DOTALL),
|
|
89
|
-
"JSX `{cond ? <A/> : <B/>}`. Use two elements with complementary "
|
|
90
|
-
'hidden="{...}" bindings.',
|
|
91
|
-
),
|
|
92
|
-
Rule(
|
|
93
|
-
"unquoted-brace-attr",
|
|
94
|
-
# `class={...}` / `selected={...}` -- invalid HTML, silently blanks the page.
|
|
95
|
-
re.compile(r"\s[\w:.\-]+=\{"),
|
|
96
|
-
"Unquoted brace attribute. This is invalid HTML: the parser splits the "
|
|
97
|
-
"value on spaces, the component root never compiles, and the page "
|
|
98
|
-
'renders blank with no console error. Quote it: attr="{expr}".',
|
|
99
|
-
),
|
|
100
|
-
Rule(
|
|
101
|
-
"react-attribute",
|
|
102
|
-
re.compile(r"\s(className|htmlFor|dangerouslySetInnerHTML)\s*="),
|
|
103
|
-
"React DOM property. Use class=, for=, or server-rendered markup.",
|
|
104
|
-
),
|
|
105
|
-
Rule(
|
|
106
|
-
"camelcase-event",
|
|
107
|
-
# `onClick="…"` / `onClick={…}` in attribute position. The value-start
|
|
108
|
-
# class and the declaration lookbehinds keep ordinary JavaScript out:
|
|
109
|
-
# `const onPointerEnter = () => {` is a valid handler name in a component
|
|
110
|
-
# script or an injected snippet, not a JSX prop.
|
|
111
|
-
re.compile(
|
|
112
|
-
r"(?<!\bconst )(?<!\blet )(?<!\bvar )(?<!\bfunction )"
|
|
113
|
-
r"\son[A-Z][a-zA-Z]*\s*=\s*[\"'{]"
|
|
114
|
-
),
|
|
115
|
-
"camelCase event prop. PulsePoint binds native lowercase event "
|
|
116
|
-
'attributes: onclick="{handler()}". A component prop uses kebab-case '
|
|
117
|
-
'(on-click), which arrives as pp.props.onClick.',
|
|
118
|
-
),
|
|
119
|
-
Rule(
|
|
120
|
-
"jsx-fragment",
|
|
121
|
-
re.compile(r"<>|</>"),
|
|
122
|
-
"JSX fragment. A template needs exactly one real root element.",
|
|
123
|
-
),
|
|
124
|
-
Rule(
|
|
125
|
-
"style-object",
|
|
126
|
-
re.compile(r"style\s*=\s*\{\{"),
|
|
127
|
-
"JSX style object. pp-style takes a CSS *string*: "
|
|
128
|
-
"pp-style=\"{'color: red'}\".",
|
|
129
|
-
),
|
|
130
|
-
Rule(
|
|
131
|
-
"unknown-directive",
|
|
132
|
-
re.compile(r"\spp-(if|show|else|elif|key|class|text|html|model|bind|on)\s*[=>\s]"),
|
|
133
|
-
"Directive does not exist in PulsePoint. Conditionals use "
|
|
134
|
-
'hidden="{...}", lists use <template pp-for>, keys use plain key="{...}".',
|
|
135
|
-
),
|
|
136
|
-
]
|
|
137
|
-
|
|
138
|
-
# `pp-for` is valid only on <template>. Matching the opening tag it sits in is
|
|
139
|
-
# enough: the attribute cannot appear before its own tag name.
|
|
140
|
-
PP_FOR_TAG = re.compile(r"<\s*([a-zA-Z][\w:-]*)([^>]*?\spp-for\s*=)", re.DOTALL)
|
|
141
|
-
|
|
142
|
-
SCRIPT_BLOCK = re.compile(r"<script\b.*?</script\s*>", re.IGNORECASE | re.DOTALL)
|
|
143
|
-
PRE_BLOCK = re.compile(r"<(pre|code)\b.*?</\1\s*>", re.IGNORECASE | re.DOTALL)
|
|
144
|
-
HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
145
|
-
|
|
146
|
-
# In a single-file component the markup lives in a triple-quoted string handed to
|
|
147
|
-
# `html(...)`. Surrounding Python must not be matched: `onValueChange=...` is an
|
|
148
|
-
# ordinary keyword argument, and flagging it as a camelCase event prop would make
|
|
149
|
-
# the gate unusable for every generated maddex component.
|
|
150
|
-
TRIPLE_QUOTED = re.compile(r'"""(?:.|\n)*?"""|\'\'\'(?:.|\n)*?\'\'\'')
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _blank_out(text: str, pattern: re.Pattern[str]) -> str:
|
|
154
|
-
"""Replace matched regions with same-length whitespace.
|
|
155
|
-
|
|
156
|
-
Offsets stay valid, so reported line/column numbers still point at the real
|
|
157
|
-
location in the original file.
|
|
158
|
-
"""
|
|
159
|
-
|
|
160
|
-
def replace(match: re.Match[str]) -> str:
|
|
161
|
-
return "".join("\n" if ch == "\n" else " " for ch in match.group(0))
|
|
162
|
-
|
|
163
|
-
return pattern.sub(replace, text)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _keep_only(text: str, pattern: re.Pattern[str]) -> str:
|
|
167
|
-
"""Inverse of `_blank_out`: blank everything *outside* the matched regions."""
|
|
168
|
-
kept = ["\n" if ch == "\n" else " " for ch in text]
|
|
169
|
-
for match in pattern.finditer(text):
|
|
170
|
-
for index in range(match.start(), match.end()):
|
|
171
|
-
kept[index] = text[index]
|
|
172
|
-
return "".join(kept)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def _markup_only(text: str, *, is_python: bool) -> str:
|
|
176
|
-
"""Reduce a source file to just the markup a template rule may match."""
|
|
177
|
-
if is_python:
|
|
178
|
-
# Only triple-quoted regions can hold markup in a single-file component.
|
|
179
|
-
text = _keep_only(text, TRIPLE_QUOTED)
|
|
180
|
-
for pattern in (SCRIPT_BLOCK, PRE_BLOCK, HTML_COMMENT):
|
|
181
|
-
text = _blank_out(text, pattern)
|
|
182
|
-
return text
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def _position(text: str, offset: int) -> tuple[int, int]:
|
|
186
|
-
line = text.count("\n", 0, offset) + 1
|
|
187
|
-
line_start = text.rfind("\n", 0, offset) + 1
|
|
188
|
-
return line, offset - line_start + 1
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def lint_text(text: str, rel_path: str, *, is_python: bool = False) -> list[TemplateIssue]:
|
|
192
|
-
"""Return every template issue found in one file's contents."""
|
|
193
|
-
markup = _markup_only(text, is_python=is_python)
|
|
194
|
-
issues: list[TemplateIssue] = []
|
|
195
|
-
|
|
196
|
-
for rule in RULES:
|
|
197
|
-
for match in rule.pattern.finditer(markup):
|
|
198
|
-
line, column = _position(markup, match.start())
|
|
199
|
-
issues.append(
|
|
200
|
-
TemplateIssue(rel_path, line, column, rule.code, rule.message)
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
for match in PP_FOR_TAG.finditer(markup):
|
|
204
|
-
tag = match.group(1).lower()
|
|
205
|
-
if tag == "template":
|
|
206
|
-
continue
|
|
207
|
-
line, column = _position(markup, match.start())
|
|
208
|
-
issues.append(
|
|
209
|
-
TemplateIssue(
|
|
210
|
-
rel_path,
|
|
211
|
-
line,
|
|
212
|
-
column,
|
|
213
|
-
"pp-for-placement",
|
|
214
|
-
f"pp-for on <{tag}>. It belongs only on <template>: "
|
|
215
|
-
f'<template pp-for="item in items"><{tag} key="{{item.id}}">…',
|
|
216
|
-
)
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
return issues
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def _iter_files() -> list[Path]:
|
|
223
|
-
if not SCAN_ROOT.exists():
|
|
224
|
-
return []
|
|
225
|
-
files: list[Path] = []
|
|
226
|
-
for pattern in ("**/*.html", "**/*.py"):
|
|
227
|
-
for path in SCAN_ROOT.glob(pattern):
|
|
228
|
-
if EXCLUDED_PARTS.intersection(path.parts):
|
|
229
|
-
continue
|
|
230
|
-
files.append(path)
|
|
231
|
-
return sorted(files)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def lint_templates() -> list[TemplateIssue]:
|
|
235
|
-
"""Lint every authored template under `src/`."""
|
|
236
|
-
issues: list[TemplateIssue] = []
|
|
237
|
-
for path in _iter_files():
|
|
238
|
-
try:
|
|
239
|
-
text = path.read_text(encoding="utf-8")
|
|
240
|
-
except (OSError, UnicodeDecodeError):
|
|
241
|
-
continue
|
|
242
|
-
# Cheap pre-filter: a file with no brace expression and no angle-bracket
|
|
243
|
-
# markup cannot trip any rule.
|
|
244
|
-
if "{" not in text and "<" not in text:
|
|
245
|
-
continue
|
|
246
|
-
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
247
|
-
issues.extend(lint_text(text, rel, is_python=path.suffix == ".py"))
|
|
248
|
-
return issues
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def main() -> int:
|
|
252
|
-
issues = lint_templates()
|
|
253
|
-
if not issues:
|
|
254
|
-
print("templates: no JSX or unknown directives found.")
|
|
255
|
-
return 0
|
|
256
|
-
|
|
257
|
-
by_file: dict[str, list[TemplateIssue]] = {}
|
|
258
|
-
for issue in issues:
|
|
259
|
-
by_file.setdefault(issue.path, []).append(issue)
|
|
260
|
-
|
|
261
|
-
for path in sorted(by_file):
|
|
262
|
-
print(path)
|
|
263
|
-
for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
|
|
264
|
-
print(
|
|
265
|
-
f" {issue.line}:{issue.column} "
|
|
266
|
-
f"[templates:{issue.code}] {issue.message}"
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
print(f"\n{len(issues)} template issue(s) found.")
|
|
270
|
-
return 1
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if __name__ == "__main__":
|
|
274
|
-
raise SystemExit(main())
|
|
1
|
+
"""Template lint: reject JSX and unknown directives in PulsePoint markup.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
`npm run check` 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 check # 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\"> "
|
|
77
|
+
'with key="{item.id}".',
|
|
78
|
+
),
|
|
79
|
+
Rule(
|
|
80
|
+
"jsx-logical",
|
|
81
|
+
# `{cond && (<div` -- element after a logical AND.
|
|
82
|
+
re.compile(r"\{[^{}]*?&&\s*\(\s*<", re.DOTALL),
|
|
83
|
+
'JSX `{cond && (<element/>)}`. Use hidden="{!cond}" on the element.',
|
|
84
|
+
),
|
|
85
|
+
Rule(
|
|
86
|
+
"jsx-ternary-element",
|
|
87
|
+
# `{cond ? <A` -- element directly after a ternary branch.
|
|
88
|
+
re.compile(r"\{[^{}]*?\?\s*\(?\s*<[a-zA-Z]", re.DOTALL),
|
|
89
|
+
"JSX `{cond ? <A/> : <B/>}`. Use two elements with complementary "
|
|
90
|
+
'hidden="{...}" bindings.',
|
|
91
|
+
),
|
|
92
|
+
Rule(
|
|
93
|
+
"unquoted-brace-attr",
|
|
94
|
+
# `class={...}` / `selected={...}` -- invalid HTML, silently blanks the page.
|
|
95
|
+
re.compile(r"\s[\w:.\-]+=\{"),
|
|
96
|
+
"Unquoted brace attribute. This is invalid HTML: the parser splits the "
|
|
97
|
+
"value on spaces, the component root never compiles, and the page "
|
|
98
|
+
'renders blank with no console error. Quote it: attr="{expr}".',
|
|
99
|
+
),
|
|
100
|
+
Rule(
|
|
101
|
+
"react-attribute",
|
|
102
|
+
re.compile(r"\s(className|htmlFor|dangerouslySetInnerHTML)\s*="),
|
|
103
|
+
"React DOM property. Use class=, for=, or server-rendered markup.",
|
|
104
|
+
),
|
|
105
|
+
Rule(
|
|
106
|
+
"camelcase-event",
|
|
107
|
+
# `onClick="…"` / `onClick={…}` in attribute position. The value-start
|
|
108
|
+
# class and the declaration lookbehinds keep ordinary JavaScript out:
|
|
109
|
+
# `const onPointerEnter = () => {` is a valid handler name in a component
|
|
110
|
+
# script or an injected snippet, not a JSX prop.
|
|
111
|
+
re.compile(
|
|
112
|
+
r"(?<!\bconst )(?<!\blet )(?<!\bvar )(?<!\bfunction )"
|
|
113
|
+
r"\son[A-Z][a-zA-Z]*\s*=\s*[\"'{]"
|
|
114
|
+
),
|
|
115
|
+
"camelCase event prop. PulsePoint binds native lowercase event "
|
|
116
|
+
'attributes: onclick="{handler()}". A component prop uses kebab-case '
|
|
117
|
+
'(on-click), which arrives as pp.props.onClick.',
|
|
118
|
+
),
|
|
119
|
+
Rule(
|
|
120
|
+
"jsx-fragment",
|
|
121
|
+
re.compile(r"<>|</>"),
|
|
122
|
+
"JSX fragment. A template needs exactly one real root element.",
|
|
123
|
+
),
|
|
124
|
+
Rule(
|
|
125
|
+
"style-object",
|
|
126
|
+
re.compile(r"style\s*=\s*\{\{"),
|
|
127
|
+
"JSX style object. pp-style takes a CSS *string*: "
|
|
128
|
+
"pp-style=\"{'color: red'}\".",
|
|
129
|
+
),
|
|
130
|
+
Rule(
|
|
131
|
+
"unknown-directive",
|
|
132
|
+
re.compile(r"\spp-(if|show|else|elif|key|class|text|html|model|bind|on)\s*[=>\s]"),
|
|
133
|
+
"Directive does not exist in PulsePoint. Conditionals use "
|
|
134
|
+
'hidden="{...}", lists use <template pp-for>, keys use plain key="{...}".',
|
|
135
|
+
),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
# `pp-for` is valid only on <template>. Matching the opening tag it sits in is
|
|
139
|
+
# enough: the attribute cannot appear before its own tag name.
|
|
140
|
+
PP_FOR_TAG = re.compile(r"<\s*([a-zA-Z][\w:-]*)([^>]*?\spp-for\s*=)", re.DOTALL)
|
|
141
|
+
|
|
142
|
+
SCRIPT_BLOCK = re.compile(r"<script\b.*?</script\s*>", re.IGNORECASE | re.DOTALL)
|
|
143
|
+
PRE_BLOCK = re.compile(r"<(pre|code)\b.*?</\1\s*>", re.IGNORECASE | re.DOTALL)
|
|
144
|
+
HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
145
|
+
|
|
146
|
+
# In a single-file component the markup lives in a triple-quoted string handed to
|
|
147
|
+
# `html(...)`. Surrounding Python must not be matched: `onValueChange=...` is an
|
|
148
|
+
# ordinary keyword argument, and flagging it as a camelCase event prop would make
|
|
149
|
+
# the gate unusable for every generated maddex component.
|
|
150
|
+
TRIPLE_QUOTED = re.compile(r'"""(?:.|\n)*?"""|\'\'\'(?:.|\n)*?\'\'\'')
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _blank_out(text: str, pattern: re.Pattern[str]) -> str:
|
|
154
|
+
"""Replace matched regions with same-length whitespace.
|
|
155
|
+
|
|
156
|
+
Offsets stay valid, so reported line/column numbers still point at the real
|
|
157
|
+
location in the original file.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def replace(match: re.Match[str]) -> str:
|
|
161
|
+
return "".join("\n" if ch == "\n" else " " for ch in match.group(0))
|
|
162
|
+
|
|
163
|
+
return pattern.sub(replace, text)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _keep_only(text: str, pattern: re.Pattern[str]) -> str:
|
|
167
|
+
"""Inverse of `_blank_out`: blank everything *outside* the matched regions."""
|
|
168
|
+
kept = ["\n" if ch == "\n" else " " for ch in text]
|
|
169
|
+
for match in pattern.finditer(text):
|
|
170
|
+
for index in range(match.start(), match.end()):
|
|
171
|
+
kept[index] = text[index]
|
|
172
|
+
return "".join(kept)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _markup_only(text: str, *, is_python: bool) -> str:
|
|
176
|
+
"""Reduce a source file to just the markup a template rule may match."""
|
|
177
|
+
if is_python:
|
|
178
|
+
# Only triple-quoted regions can hold markup in a single-file component.
|
|
179
|
+
text = _keep_only(text, TRIPLE_QUOTED)
|
|
180
|
+
for pattern in (SCRIPT_BLOCK, PRE_BLOCK, HTML_COMMENT):
|
|
181
|
+
text = _blank_out(text, pattern)
|
|
182
|
+
return text
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _position(text: str, offset: int) -> tuple[int, int]:
|
|
186
|
+
line = text.count("\n", 0, offset) + 1
|
|
187
|
+
line_start = text.rfind("\n", 0, offset) + 1
|
|
188
|
+
return line, offset - line_start + 1
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def lint_text(text: str, rel_path: str, *, is_python: bool = False) -> list[TemplateIssue]:
|
|
192
|
+
"""Return every template issue found in one file's contents."""
|
|
193
|
+
markup = _markup_only(text, is_python=is_python)
|
|
194
|
+
issues: list[TemplateIssue] = []
|
|
195
|
+
|
|
196
|
+
for rule in RULES:
|
|
197
|
+
for match in rule.pattern.finditer(markup):
|
|
198
|
+
line, column = _position(markup, match.start())
|
|
199
|
+
issues.append(
|
|
200
|
+
TemplateIssue(rel_path, line, column, rule.code, rule.message)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
for match in PP_FOR_TAG.finditer(markup):
|
|
204
|
+
tag = match.group(1).lower()
|
|
205
|
+
if tag == "template":
|
|
206
|
+
continue
|
|
207
|
+
line, column = _position(markup, match.start())
|
|
208
|
+
issues.append(
|
|
209
|
+
TemplateIssue(
|
|
210
|
+
rel_path,
|
|
211
|
+
line,
|
|
212
|
+
column,
|
|
213
|
+
"pp-for-placement",
|
|
214
|
+
f"pp-for on <{tag}>. It belongs only on <template>: "
|
|
215
|
+
f'<template pp-for="item in items"><{tag} key="{{item.id}}">…',
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return issues
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _iter_files() -> list[Path]:
|
|
223
|
+
if not SCAN_ROOT.exists():
|
|
224
|
+
return []
|
|
225
|
+
files: list[Path] = []
|
|
226
|
+
for pattern in ("**/*.html", "**/*.py"):
|
|
227
|
+
for path in SCAN_ROOT.glob(pattern):
|
|
228
|
+
if EXCLUDED_PARTS.intersection(path.parts):
|
|
229
|
+
continue
|
|
230
|
+
files.append(path)
|
|
231
|
+
return sorted(files)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def lint_templates() -> list[TemplateIssue]:
|
|
235
|
+
"""Lint every authored template under `src/`."""
|
|
236
|
+
issues: list[TemplateIssue] = []
|
|
237
|
+
for path in _iter_files():
|
|
238
|
+
try:
|
|
239
|
+
text = path.read_text(encoding="utf-8")
|
|
240
|
+
except (OSError, UnicodeDecodeError):
|
|
241
|
+
continue
|
|
242
|
+
# Cheap pre-filter: a file with no brace expression and no angle-bracket
|
|
243
|
+
# markup cannot trip any rule.
|
|
244
|
+
if "{" not in text and "<" not in text:
|
|
245
|
+
continue
|
|
246
|
+
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
247
|
+
issues.extend(lint_text(text, rel, is_python=path.suffix == ".py"))
|
|
248
|
+
return issues
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def main() -> int:
|
|
252
|
+
issues = lint_templates()
|
|
253
|
+
if not issues:
|
|
254
|
+
print("templates: no JSX or unknown directives found.")
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
by_file: dict[str, list[TemplateIssue]] = {}
|
|
258
|
+
for issue in issues:
|
|
259
|
+
by_file.setdefault(issue.path, []).append(issue)
|
|
260
|
+
|
|
261
|
+
for path in sorted(by_file):
|
|
262
|
+
print(path)
|
|
263
|
+
for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
|
|
264
|
+
print(
|
|
265
|
+
f" {issue.line}:{issue.column} "
|
|
266
|
+
f"[templates:{issue.code}] {issue.message}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
print(f"\n{len(issues)} template issue(s) found.")
|
|
270
|
+
return 1
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
raise SystemExit(main())
|
|
@@ -981,7 +981,7 @@ function analyzeFile(filePath: string, rootDir: string): ComponentMetadata[] {
|
|
|
981
981
|
|
|
982
982
|
function loadConfig(): CaspianConfig {
|
|
983
983
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
984
|
-
console.error(`Error: Configuration file not found at: ${CONFIG_PATH}`);
|
|
984
|
+
console.error(`Error: Configuration file not found at: ${CONFIG_PATH}`);
|
|
985
985
|
process.exit(1);
|
|
986
986
|
}
|
|
987
987
|
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
@@ -1024,7 +1024,7 @@ function walkDirectory(
|
|
|
1024
1024
|
*/
|
|
1025
1025
|
|
|
1026
1026
|
export async function componentMap() {
|
|
1027
|
-
console.log("Starting Component Analysis (Lezer AST-first)...");
|
|
1027
|
+
console.log("Starting Component Analysis (Lezer AST-first)...");
|
|
1028
1028
|
const config = loadConfig();
|
|
1029
1029
|
let allFiles: string[] = [];
|
|
1030
1030
|
|
|
@@ -1036,7 +1036,7 @@ export async function componentMap() {
|
|
|
1036
1036
|
);
|
|
1037
1037
|
});
|
|
1038
1038
|
|
|
1039
|
-
console.log(`Found ${allFiles.length} Python files.`);
|
|
1039
|
+
console.log(`Found ${allFiles.length} Python files.`);
|
|
1040
1040
|
const componentRegistry: ComponentMetadata[] = [];
|
|
1041
1041
|
|
|
1042
1042
|
allFiles.forEach((file) => {
|
|
@@ -1044,15 +1044,15 @@ export async function componentMap() {
|
|
|
1044
1044
|
const foundComponents = analyzeFile(file, PROJECT_ROOT);
|
|
1045
1045
|
componentRegistry.push(...foundComponents);
|
|
1046
1046
|
} catch (e) {
|
|
1047
|
-
console.warn(`Warning: Failed to parse ${file}:`, e);
|
|
1047
|
+
console.warn(`Warning: Failed to parse ${file}:`, e);
|
|
1048
1048
|
}
|
|
1049
1049
|
});
|
|
1050
1050
|
|
|
1051
|
-
console.log(`Discovered ${componentRegistry.length} Components.`);
|
|
1051
|
+
console.log(`Discovered ${componentRegistry.length} Components.`);
|
|
1052
1052
|
|
|
1053
1053
|
const outputPath = path.join(__dirname, "component-map.json");
|
|
1054
1054
|
if (componentRegistry.length > 0 || !fs.existsSync(outputPath)) {
|
|
1055
1055
|
fs.writeFileSync(outputPath, JSON.stringify(componentRegistry, null, 2));
|
|
1056
|
-
console.log(`Component map written to: ${outputPath}`);
|
|
1056
|
+
console.log(`Component map written to: ${outputPath}`);
|
|
1057
1057
|
}
|
|
1058
1058
|
}
|