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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: pjx-stimulus
3
+ Version: 0.1.0
4
+ Summary: Stimulus alias processor for PJX
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pjx>=0.2.1
@@ -0,0 +1,3 @@
1
+ from pjx_stimulus.processor import StimulusAliasProcessor
2
+
3
+ __all__ = ["StimulusAliasProcessor"]
@@ -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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: pjx-stimulus
3
+ Version: 0.1.0
4
+ Summary: Stimulus alias processor for PJX
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pjx>=0.2.1
@@ -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,2 @@
1
+ [pjx.processors]
2
+ stimulus = pjx_stimulus:StimulusAliasProcessor
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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