pjx-stimulus 0.1.0__tar.gz
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.
- pjx_stimulus-0.1.0/PKG-INFO +6 -0
- pjx_stimulus-0.1.0/pjx_stimulus/__init__.py +3 -0
- pjx_stimulus-0.1.0/pjx_stimulus/processor.py +183 -0
- pjx_stimulus-0.1.0/pjx_stimulus.egg-info/PKG-INFO +6 -0
- pjx_stimulus-0.1.0/pjx_stimulus.egg-info/SOURCES.txt +10 -0
- pjx_stimulus-0.1.0/pjx_stimulus.egg-info/dependency_links.txt +1 -0
- pjx_stimulus-0.1.0/pjx_stimulus.egg-info/entry_points.txt +2 -0
- pjx_stimulus-0.1.0/pjx_stimulus.egg-info/requires.txt +1 -0
- pjx_stimulus-0.1.0/pjx_stimulus.egg-info/top_level.txt +1 -0
- pjx_stimulus-0.1.0/pyproject.toml +16 -0
- pjx_stimulus-0.1.0/setup.cfg +4 -0
- pjx_stimulus-0.1.0/tests/test_stimulus_processor.py +68 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pjx.core.scanner import Scanner, ScanTokenType
|
|
4
|
+
from pjx.core.tag_utils import format_attr, format_original_attr, rebuild_tag
|
|
5
|
+
from pjx.core.types import ProcessorContext, ProcessorResult
|
|
6
|
+
from pjx.errors import PJXError, SourceLocation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StimulusAliasProcessor:
|
|
10
|
+
"""Processes stimulus:* attribute aliases with controller scope tracking.
|
|
11
|
+
|
|
12
|
+
stimulus:controller="name" -> data-controller="name"
|
|
13
|
+
stimulus:action="..." -> data-action="..."
|
|
14
|
+
stimulus:target="name" -> data-{ctrl}-target="name"
|
|
15
|
+
stimulus:target.{ctrl}="name" -> data-{ctrl}-target="name"
|
|
16
|
+
stimulus:value-{key}="val" -> data-{ctrl}-{key}-value="val"
|
|
17
|
+
stimulus:class-{key}="val" -> data-{ctrl}-{key}-class="val"
|
|
18
|
+
stimulus:outlet-{key}="val" -> data-{ctrl}-{key}-outlet="val"
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
slot = 40 # ProcessorSlot.ALIAS
|
|
22
|
+
|
|
23
|
+
def process(self, source: str, ctx: ProcessorContext) -> ProcessorResult:
|
|
24
|
+
scanner = Scanner(source)
|
|
25
|
+
tokens = scanner.scan()
|
|
26
|
+
result: list[str] = []
|
|
27
|
+
controller_stack: list[tuple[list[str], str]] = []
|
|
28
|
+
|
|
29
|
+
for token in tokens:
|
|
30
|
+
if token.type in (ScanTokenType.TEXT, ScanTokenType.COMMENT):
|
|
31
|
+
result.append(token.value)
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
if token.type == ScanTokenType.CLOSE_TAG:
|
|
35
|
+
tag_name = token.tag_name or ""
|
|
36
|
+
if controller_stack and controller_stack[-1][1] == tag_name:
|
|
37
|
+
controller_stack.pop()
|
|
38
|
+
result.append(token.value)
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
if token.type not in (
|
|
42
|
+
ScanTokenType.OPEN_TAG,
|
|
43
|
+
ScanTokenType.SELF_CLOSING_TAG,
|
|
44
|
+
):
|
|
45
|
+
result.append(token.value)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
tag_name = token.tag_name or ""
|
|
49
|
+
new_attrs: list[str] = []
|
|
50
|
+
entered_scope = False
|
|
51
|
+
current_controllers: list[str] = []
|
|
52
|
+
changed = False
|
|
53
|
+
|
|
54
|
+
for attr in token.attributes:
|
|
55
|
+
if attr.name == "stimulus:controller" and attr.value is not None:
|
|
56
|
+
controllers = [c for c in attr.value.split() if c]
|
|
57
|
+
if not controllers:
|
|
58
|
+
raise PJXError(
|
|
59
|
+
"stimulus:controller vazio",
|
|
60
|
+
SourceLocation(ctx.filename, attr.loc_line, attr.loc_col),
|
|
61
|
+
code="PJX401",
|
|
62
|
+
)
|
|
63
|
+
current_controllers = controllers
|
|
64
|
+
entered_scope = True
|
|
65
|
+
new_attrs.append(format_attr("data-controller", attr.value, attr.is_expression))
|
|
66
|
+
changed = True
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if attr.name == "stimulus:action":
|
|
70
|
+
new_attrs.append(format_attr("data-action", attr.value, attr.is_expression))
|
|
71
|
+
changed = True
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if attr.namespace == "stimulus" and self._is_controller_dependent(attr.name):
|
|
75
|
+
ctrl = self._resolve_controller(
|
|
76
|
+
attr.name,
|
|
77
|
+
controller_stack,
|
|
78
|
+
current_controllers,
|
|
79
|
+
ctx.filename,
|
|
80
|
+
attr.loc_line,
|
|
81
|
+
attr.loc_col,
|
|
82
|
+
)
|
|
83
|
+
html_attr = self._build_stimulus_attr(attr.name, ctrl)
|
|
84
|
+
new_attrs.append(format_attr(html_attr, attr.value, attr.is_expression))
|
|
85
|
+
changed = True
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
new_attrs.append(format_original_attr(attr))
|
|
89
|
+
|
|
90
|
+
if entered_scope and token.type == ScanTokenType.OPEN_TAG:
|
|
91
|
+
controller_stack.append((current_controllers, tag_name))
|
|
92
|
+
|
|
93
|
+
if changed:
|
|
94
|
+
result.append(
|
|
95
|
+
rebuild_tag(
|
|
96
|
+
tag_name,
|
|
97
|
+
new_attrs,
|
|
98
|
+
token.type == ScanTokenType.SELF_CLOSING_TAG,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
result.append(token.value)
|
|
103
|
+
|
|
104
|
+
return ProcessorResult(source="".join(result))
|
|
105
|
+
|
|
106
|
+
def _is_controller_dependent(self, attr_name: str) -> bool:
|
|
107
|
+
base = attr_name.split(".", 1)[0] if "." in attr_name else attr_name
|
|
108
|
+
return (
|
|
109
|
+
base == "stimulus:target"
|
|
110
|
+
or base.startswith("stimulus:value-")
|
|
111
|
+
or base.startswith("stimulus:class-")
|
|
112
|
+
or base.startswith("stimulus:outlet-")
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _resolve_controller(
|
|
116
|
+
self,
|
|
117
|
+
attr_name: str,
|
|
118
|
+
controller_stack: list[tuple[list[str], str]],
|
|
119
|
+
current_controllers: list[str],
|
|
120
|
+
filename: str | None,
|
|
121
|
+
line: int,
|
|
122
|
+
col: int,
|
|
123
|
+
) -> str:
|
|
124
|
+
base_attr, explicit_ctrl = self._split_stimulus_alias(attr_name)
|
|
125
|
+
|
|
126
|
+
all_scopes = [s[0] for s in controller_stack]
|
|
127
|
+
if current_controllers:
|
|
128
|
+
all_scopes.append(current_controllers)
|
|
129
|
+
|
|
130
|
+
if explicit_ctrl is not None:
|
|
131
|
+
for scope in reversed(all_scopes):
|
|
132
|
+
if explicit_ctrl in scope:
|
|
133
|
+
return explicit_ctrl
|
|
134
|
+
raise PJXError(
|
|
135
|
+
f"controller '{explicit_ctrl}' nao esta ativo para {attr_name}",
|
|
136
|
+
SourceLocation(filename, line, col),
|
|
137
|
+
code="PJX402",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if not all_scopes:
|
|
141
|
+
raise PJXError(
|
|
142
|
+
f"{attr_name} fora de stimulus:controller",
|
|
143
|
+
SourceLocation(filename, line, col),
|
|
144
|
+
code="PJX403",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
active_scope = all_scopes[-1]
|
|
148
|
+
if len(active_scope) != 1:
|
|
149
|
+
names = " ".join(active_scope)
|
|
150
|
+
raise PJXError(
|
|
151
|
+
f"{attr_name} e ambiguo com multiplos controllers ativos: {names}",
|
|
152
|
+
SourceLocation(filename, line, col),
|
|
153
|
+
code="PJX404",
|
|
154
|
+
hint=f"use {attr_name}.{active_scope[0]}=...",
|
|
155
|
+
)
|
|
156
|
+
return active_scope[0]
|
|
157
|
+
|
|
158
|
+
def _split_stimulus_alias(self, attr_name: str) -> tuple[str, str | None]:
|
|
159
|
+
if "." not in attr_name:
|
|
160
|
+
return attr_name, None
|
|
161
|
+
base_attr, explicit_ctrl = attr_name.rsplit(".", 1)
|
|
162
|
+
if (
|
|
163
|
+
base_attr == "stimulus:target"
|
|
164
|
+
or base_attr.startswith("stimulus:value-")
|
|
165
|
+
or base_attr.startswith("stimulus:class-")
|
|
166
|
+
or base_attr.startswith("stimulus:outlet-")
|
|
167
|
+
):
|
|
168
|
+
return base_attr, explicit_ctrl
|
|
169
|
+
return attr_name, None
|
|
170
|
+
|
|
171
|
+
def _build_stimulus_attr(self, attr_name: str, ctrl: str) -> str:
|
|
172
|
+
base, _ = self._split_stimulus_alias(attr_name)
|
|
173
|
+
if base == "stimulus:target":
|
|
174
|
+
return f"data-{ctrl}-target"
|
|
175
|
+
for prefix, suffix in [
|
|
176
|
+
("stimulus:value-", "-value"),
|
|
177
|
+
("stimulus:class-", "-class"),
|
|
178
|
+
("stimulus:outlet-", "-outlet"),
|
|
179
|
+
]:
|
|
180
|
+
if base.startswith(prefix):
|
|
181
|
+
key = base[len(prefix) :]
|
|
182
|
+
return f"data-{ctrl}-{key}{suffix}"
|
|
183
|
+
return f"data-{ctrl}-target"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
pjx_stimulus/__init__.py
|
|
3
|
+
pjx_stimulus/processor.py
|
|
4
|
+
pjx_stimulus.egg-info/PKG-INFO
|
|
5
|
+
pjx_stimulus.egg-info/SOURCES.txt
|
|
6
|
+
pjx_stimulus.egg-info/dependency_links.txt
|
|
7
|
+
pjx_stimulus.egg-info/entry_points.txt
|
|
8
|
+
pjx_stimulus.egg-info/requires.txt
|
|
9
|
+
pjx_stimulus.egg-info/top_level.txt
|
|
10
|
+
tests/test_stimulus_processor.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pjx>=0.2.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pjx_stimulus
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=75.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pjx-stimulus"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Stimulus alias processor for PJX"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = ["pjx>=0.2.1"]
|
|
11
|
+
|
|
12
|
+
[project.entry-points."pjx.processors"]
|
|
13
|
+
stimulus = "pjx_stimulus:StimulusAliasProcessor"
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
pjx = { workspace = true, editable = true }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from pjx.core.types import ProcessorContext
|
|
4
|
+
from pjx.errors import PJXError
|
|
5
|
+
from pjx_stimulus import StimulusAliasProcessor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_stimulus_controller():
|
|
9
|
+
proc = StimulusAliasProcessor()
|
|
10
|
+
source = '<div stimulus:controller="dropdown">content</div>'
|
|
11
|
+
result = proc.process(source, ProcessorContext())
|
|
12
|
+
assert 'data-controller="dropdown"' in result.source
|
|
13
|
+
assert "stimulus:" not in result.source
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_stimulus_action():
|
|
17
|
+
proc = StimulusAliasProcessor()
|
|
18
|
+
source = (
|
|
19
|
+
'<div stimulus:controller="dropdown">'
|
|
20
|
+
'<button stimulus:action="click->dropdown#toggle">X</button></div>'
|
|
21
|
+
)
|
|
22
|
+
result = proc.process(source, ProcessorContext())
|
|
23
|
+
assert 'data-action="click->dropdown#toggle"' in result.source
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_stimulus_target_single_controller():
|
|
27
|
+
proc = StimulusAliasProcessor()
|
|
28
|
+
source = '<div stimulus:controller="dropdown"><div stimulus:target="menu">M</div></div>'
|
|
29
|
+
result = proc.process(source, ProcessorContext())
|
|
30
|
+
assert 'data-dropdown-target="menu"' in result.source
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_stimulus_target_explicit_controller():
|
|
34
|
+
proc = StimulusAliasProcessor()
|
|
35
|
+
source = (
|
|
36
|
+
'<div stimulus:controller="dropdown modal">'
|
|
37
|
+
'<div stimulus:target.dropdown="menu">M</div></div>'
|
|
38
|
+
)
|
|
39
|
+
result = proc.process(source, ProcessorContext())
|
|
40
|
+
assert 'data-dropdown-target="menu"' in result.source
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_stimulus_target_ambiguous_multi_controller():
|
|
44
|
+
proc = StimulusAliasProcessor()
|
|
45
|
+
source = '<div stimulus:controller="dropdown modal"><div stimulus:target="menu">M</div></div>'
|
|
46
|
+
with pytest.raises(PJXError, match="ambiguo"):
|
|
47
|
+
proc.process(source, ProcessorContext())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_stimulus_target_outside_controller():
|
|
51
|
+
proc = StimulusAliasProcessor()
|
|
52
|
+
source = '<div stimulus:target="menu">M</div>'
|
|
53
|
+
with pytest.raises(PJXError, match="fora de stimulus:controller"):
|
|
54
|
+
proc.process(source, ProcessorContext())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_stimulus_value():
|
|
58
|
+
proc = StimulusAliasProcessor()
|
|
59
|
+
source = '<div stimulus:controller="editor"><input stimulus:value-content="hello" /></div>'
|
|
60
|
+
result = proc.process(source, ProcessorContext())
|
|
61
|
+
assert 'data-editor-content-value="hello"' in result.source
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_self_closing_with_stimulus():
|
|
65
|
+
proc = StimulusAliasProcessor()
|
|
66
|
+
source = '<div stimulus:controller="tabs"><input stimulus:target="field" /></div>'
|
|
67
|
+
result = proc.process(source, ProcessorContext())
|
|
68
|
+
assert 'data-tabs-target="field"' in result.source
|