create-caspian-app 1.3.2 → 1.3.5

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.
@@ -256,6 +256,109 @@ def _iter_files() -> list[Path]:
256
256
  return sorted(files)
257
257
 
258
258
 
259
+ # ---------------------------------------------------------------------------
260
+ # f-string component returns (ratchet)
261
+ # ---------------------------------------------------------------------------
262
+ # `html(...)` is the single markup entrypoint. A component that returns an
263
+ # f-string instead skips it entirely, and the two forms disagree in ways that
264
+ # are invisible at the call site:
265
+ #
266
+ # * The brace dialects are INVERTED. `html()` writes `{{ name }}` for server
267
+ # interpolation and `{count}` for a PulsePoint binding; an f-string writes
268
+ # `{name}` for the server and needs `{{count}}` to emit a binding. Same
269
+ # characters, opposite meanings.
270
+ # * There is no autoescaping. `Component.acall` wraps the returned string in
271
+ # `Markup`, so interpolated request data is emitted raw AND marked trusted.
272
+ # * `<x-*>` scope is not stashed, so a directly-called component
273
+ # (`{{ Card() }}`) cannot resolve nested component tags.
274
+ #
275
+ # The existing returns are recorded in a baseline and allowed; anything new
276
+ # fails the gate. Convert one and delete its baseline line. Regenerate with
277
+ # `python settings/check_templates.py --update-baseline`.
278
+ FSTRING_BASELINE_PATH = PROJECT_ROOT / "settings" / "fstring-components.json"
279
+
280
+ FSTRING_MESSAGE = (
281
+ "Component returns an f-string instead of html(...). The brace dialects are "
282
+ "inverted between the two forms ({{ x }} vs {x}) and an f-string is not "
283
+ "autoescaped, so interpolated data is emitted raw and marked trusted. "
284
+ "Return html(r'''...''', x=x) instead."
285
+ )
286
+
287
+
288
+ def _fstring_component_returns() -> list[tuple[str, str, int, int]]:
289
+ """Every `@component` whose return value is an f-string.
290
+
291
+ Entries are `(rel_path, function_name, line, column)`, sorted.
292
+ """
293
+ import ast
294
+
295
+ found: list[tuple[str, str, int, int]] = []
296
+ for path in _iter_files():
297
+ if path.suffix != ".py":
298
+ continue
299
+ try:
300
+ tree = ast.parse(path.read_text(encoding="utf-8"))
301
+ except (OSError, UnicodeDecodeError, SyntaxError):
302
+ continue
303
+ rel = path.relative_to(PROJECT_ROOT).as_posix()
304
+ for node in ast.walk(tree):
305
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
306
+ continue
307
+ decorators = {
308
+ getattr(d, "id", None) or getattr(d, "attr", None)
309
+ for d in node.decorator_list
310
+ }
311
+ if "component" not in decorators:
312
+ continue
313
+ for stmt in ast.walk(node):
314
+ if isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.JoinedStr):
315
+ found.append((rel, node.name, stmt.lineno, stmt.col_offset + 1))
316
+ break
317
+ return sorted(found)
318
+
319
+
320
+ def _load_fstring_baseline() -> set[str]:
321
+ import json
322
+
323
+ try:
324
+ raw = json.loads(FSTRING_BASELINE_PATH.read_text(encoding="utf-8"))
325
+ except (OSError, ValueError):
326
+ return set()
327
+ return set(raw.get("allowed", []))
328
+
329
+
330
+ def write_fstring_baseline() -> int:
331
+ """Record today's f-string components as the allowed set."""
332
+ import json
333
+
334
+ entries = sorted({f"{rel}::{name}" for rel, name, _, _ in _fstring_component_returns()})
335
+ FSTRING_BASELINE_PATH.write_text(
336
+ json.dumps(
337
+ {
338
+ "_comment": (
339
+ "Components that still return an f-string instead of html(...). "
340
+ "This list may only shrink: converting one means deleting its "
341
+ "line. New entries fail `npm run check`."
342
+ ),
343
+ "allowed": entries,
344
+ },
345
+ indent=2,
346
+ )
347
+ + "\n",
348
+ encoding="utf-8",
349
+ )
350
+ return len(entries)
351
+
352
+
353
+ def lint_fstring_components() -> list[TemplateIssue]:
354
+ allowed = _load_fstring_baseline()
355
+ return [
356
+ TemplateIssue(rel, line, col, "fstring-component", FSTRING_MESSAGE)
357
+ for rel, name, line, col in _fstring_component_returns()
358
+ if f"{rel}::{name}" not in allowed
359
+ ]
360
+
361
+
259
362
  def lint_templates() -> list[TemplateIssue]:
260
363
  """Lint every authored template under `src/`."""
261
364
  issues: list[TemplateIssue] = []
@@ -270,10 +373,21 @@ def lint_templates() -> list[TemplateIssue]:
270
373
  continue
271
374
  rel = path.relative_to(PROJECT_ROOT).as_posix()
272
375
  issues.extend(lint_text(text, rel, is_python=path.suffix == ".py"))
376
+ issues.extend(lint_fstring_components())
273
377
  return issues
274
378
 
275
379
 
276
380
  def main() -> int:
381
+ import sys
382
+
383
+ if "--update-baseline" in sys.argv:
384
+ count = write_fstring_baseline()
385
+ print(
386
+ f"templates: recorded {count} f-string component(s) in "
387
+ f"{FSTRING_BASELINE_PATH.relative_to(PROJECT_ROOT).as_posix()}."
388
+ )
389
+ return 0
390
+
277
391
  issues = lint_templates()
278
392
  if not issues:
279
393
  print("templates: no JSX or unknown directives found.")
@@ -1,5 +1,4 @@
1
1
  from typing import Optional
2
-
3
2
  from casp.component_decorator import html
4
3
  from casp.layout import Metadata
5
4
 
@@ -1,5 +1,8 @@
1
+ from casp.component_decorator import html
2
+
3
+
1
4
  def layout():
2
- return r"""
5
+ return html(r"""
3
6
  <!DOCTYPE html>
4
7
  <html lang="en">
5
8
 
@@ -26,4 +29,4 @@ def layout():
26
29
  </body>
27
30
 
28
31
  </html>
29
- """
32
+ """)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.3.2",
3
+ "version": "1.3.5",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",