splent-feature-configurator 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,8 @@
1
+ # Compiled frontend bundles
2
+ recursive-include src/splent_io/splent_feature_configurator/assets/dist *.js *.css *.map
3
+
4
+ # Jinja templates (feature views + hook fragments)
5
+ recursive-include src/splent_io/splent_feature_configurator/templates *.html
6
+
7
+ # Alembic migration config and scripts
8
+ recursive-include src/splent_io/splent_feature_configurator/migrations *.py *.ini *.mako
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_configurator
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80.3.1", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "splent_feature_configurator"
7
+ version = "0.1.0"
8
+ readme = "README.md"
9
+ requires-python = ">=3.13"
10
+
11
+ dependencies = [
12
+
13
+ ]
14
+
15
+ [tool.setuptools]
16
+ package-dir = { "" = "src" }
17
+ include-package-data = true
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+ exclude = ["*.tests", "*.tests.*"]
22
+
23
+ [tool.splent]
24
+ cli_version = "1.14.0"
25
+
26
+ # ── Feature Contract (auto-generated) ────────────────────────────────────────
27
+ # Do not edit manually — re-run `splent feature:contract --write` to refresh.
28
+ # Preserved across refreshes: description, category, tags, env, and
29
+ # requires.features_manual (dependencies invisible to static analysis).
30
+ [tool.splent.contract]
31
+ description = "UVL configurator for the SPLENT marketplace. Pick an SPL, select features with live variability validation, and get the exact splent commands to build the product"
32
+ archetype = "light"
33
+
34
+ [tool.splent.contract.provides]
35
+ routes = ["/configurator", "/configurator/<spl_name>", "/configurator/<spl_name>/validate"]
36
+ blueprints = []
37
+ models = []
38
+ commands = []
39
+ hooks = []
40
+ services = ["ConfiguratorService"]
41
+ signals = []
42
+ translations = []
43
+ docker = []
44
+
45
+ [tool.splent.contract.requires]
46
+ features = []
47
+ features_optional = ["marketplace"]
48
+ env_vars = []
49
+ signals = []
50
+
51
+ [tool.splent.contract.extensible]
52
+ services = ["ConfiguratorService"]
53
+ templates = ["configurator/configure.html", "configurator/index.html"]
54
+ models = []
55
+ hooks = []
56
+ routes = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_configurator
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
@@ -0,0 +1,12 @@
1
+ MANIFEST.in
2
+ pyproject.toml
3
+ src/splent_feature_configurator.egg-info/PKG-INFO
4
+ src/splent_feature_configurator.egg-info/SOURCES.txt
5
+ src/splent_feature_configurator.egg-info/dependency_links.txt
6
+ src/splent_feature_configurator.egg-info/top_level.txt
7
+ src/splent_io/splent_feature_configurator/__init__.py
8
+ src/splent_io/splent_feature_configurator/routes.py
9
+ src/splent_io/splent_feature_configurator/services.py
10
+ src/splent_io/splent_feature_configurator/templates/configurator/_macros.html
11
+ src/splent_io/splent_feature_configurator/templates/configurator/configure.html
12
+ src/splent_io/splent_feature_configurator/templates/configurator/index.html
@@ -0,0 +1,19 @@
1
+ from splent_framework.blueprints.base_blueprint import create_blueprint
2
+ from splent_framework.nav.nav_registry import register_nav_item
3
+ from splent_framework.services.service_locator import register_service
4
+
5
+ from splent_io.splent_feature_configurator.services import ConfiguratorService
6
+
7
+ configurator_bp = create_blueprint(__name__)
8
+
9
+
10
+ def init_feature(app):
11
+ register_service(app, "ConfiguratorService", ConfiguratorService)
12
+ # Public storefront navigation entry (composed per-derivation by the theme).
13
+ register_nav_item(
14
+ key="configurator", label="Configurator", href="/configurator", order=30
15
+ )
16
+
17
+
18
+ def inject_context_vars(app):
19
+ return {}
@@ -0,0 +1,83 @@
1
+ from flask import abort, jsonify, render_template, request
2
+ from flask_babel import gettext as _
3
+
4
+ from splent_io.splent_feature_configurator import configurator_bp
5
+ from splent_framework.services.service_locator import service_proxy
6
+
7
+ configurator_service = service_proxy("ConfiguratorService")
8
+
9
+
10
+ def _violation_message(violation: dict) -> str:
11
+ """Translate a structured violation into a human-readable message."""
12
+ code = violation.get("code")
13
+ features = ", ".join(violation.get("features", []))
14
+ if code == "unknown_feature":
15
+ return _(
16
+ "Feature '%(feature)s' does not exist in this SPL",
17
+ feature=violation.get("feature"),
18
+ )
19
+ if code == "unknown_constraint_target":
20
+ return _(
21
+ "'%(required_by)s' requires '%(feature)s', which is not in this SPL model",
22
+ required_by=violation.get("required_by"),
23
+ feature=violation.get("feature"),
24
+ )
25
+ if code == "group_conflict":
26
+ return _(
27
+ "Group '%(group)s' is exclusive: choose only one of %(features)s",
28
+ group=violation.get("group"),
29
+ features=features,
30
+ )
31
+ if code == "group_required":
32
+ return _(
33
+ "Group '%(group)s' needs a choice: select one of %(features)s",
34
+ group=violation.get("group"),
35
+ features=features,
36
+ )
37
+ return str(code)
38
+
39
+
40
+ @configurator_bp.route("/configurator", methods=["GET"])
41
+ def index():
42
+ spls = configurator_service.list_spls()
43
+ return render_template("configurator/index.html", spls=spls)
44
+
45
+
46
+ @configurator_bp.route("/configurator/<spl_name>", methods=["GET"])
47
+ def configure(spl_name):
48
+ spl = configurator_service.get_spl(spl_name)
49
+ model = (spl or {}).get("model") or {}
50
+ if not model.get("features"):
51
+ abort(404)
52
+ # Model features absent from the index render marked as "external".
53
+ tree = configurator_service.feature_tree(
54
+ model, known_shorts=configurator_service.index_feature_shorts()
55
+ )
56
+ return render_template("configurator/configure.html", spl=spl, tree=tree)
57
+
58
+
59
+ def _bad_request(message):
60
+ return jsonify({"error": message}), 400
61
+
62
+
63
+ @configurator_bp.route("/configurator/<spl_name>/validate", methods=["POST"])
64
+ def validate_selection(spl_name):
65
+ # Public anonymous endpoint: malformed JSON must yield a clean 400,
66
+ # never a 500 with a stacktrace.
67
+ payload = request.get_json(silent=True)
68
+ if not isinstance(payload, dict):
69
+ return _bad_request(_("Request body must be a JSON object"))
70
+ selected = payload.get("selected") or []
71
+ if not isinstance(selected, list) or not all(
72
+ isinstance(short, str) for short in selected
73
+ ):
74
+ return _bad_request(_("'selected' must be a list of feature names"))
75
+ product_name = payload.get("product_name") or ""
76
+ if not isinstance(product_name, str):
77
+ return _bad_request(_("'product_name' must be a string"))
78
+ result = configurator_service.validate(spl_name, selected, product_name)
79
+ if result is None:
80
+ abort(404)
81
+ for violation in result["violations"]:
82
+ violation["message"] = _violation_message(violation)
83
+ return jsonify(result)
@@ -0,0 +1,335 @@
1
+ """ConfiguratorService — the UVL configurator behind the marketplace.
2
+
3
+ Given an SPL from the marketplace index, it lets a caller pick a set of
4
+ features and validates the selection against the SPL variability model
5
+ (mandatory features, requires-constraints, alternative groups) without any
6
+ external solver. For a valid selection it produces the exact ``splent``
7
+ commands that build the product.
8
+
9
+ The marketplace index is obtained through a SOFT dependency on the
10
+ marketplace feature (``MarketplaceService`` via the service locator). When
11
+ that feature is absent the service degrades gracefully and reads the index
12
+ itself: local cache first, then ``SPLENT_INDEX_URL``.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ from pathlib import Path
19
+
20
+ INDEX_SCHEMA = 1
21
+ INDEX_URL_ENV = "SPLENT_INDEX_URL"
22
+ WORKSPACE_ENV = "WORKING_DIR"
23
+ DEFAULT_WORKSPACE = "/workspace"
24
+ DEFAULT_ORG = "splent-io"
25
+ DEFAULT_PRODUCT_NAME = "my_app"
26
+
27
+
28
+ class ConfiguratorService:
29
+ """Plain service (no DB): index access + variability validation."""
30
+
31
+ # ── Marketplace index (soft dependency) ───────────────────────────
32
+
33
+ def get_index(self) -> dict | None:
34
+ """Best marketplace index available, or ``None``.
35
+
36
+ Order: marketplace feature → local cache → ``SPLENT_INDEX_URL``.
37
+ """
38
+ for source in (
39
+ self._index_from_marketplace,
40
+ self._index_from_cache,
41
+ self._index_from_url,
42
+ ):
43
+ index = source()
44
+ if index is not None:
45
+ return index
46
+ return None
47
+
48
+ def _index_from_marketplace(self) -> dict | None:
49
+ """Ask the marketplace feature for the index, if it is installed.
50
+
51
+ SOFT dependency: the marketplace feature may not be part of the
52
+ product (or may still be booting), so every failure — service not
53
+ registered, no request context, missing method — degrades to the
54
+ local fallbacks instead of breaking the configurator.
55
+ """
56
+ try:
57
+ from splent_framework.services.service_locator import service_proxy
58
+
59
+ marketplace_service = service_proxy("MarketplaceService")
60
+ for method_name in ("get_index", "index"):
61
+ method = getattr(marketplace_service, method_name, None)
62
+ if callable(method):
63
+ index = method()
64
+ if self._is_index(index):
65
+ return index
66
+ except Exception:
67
+ return None
68
+ return None
69
+
70
+ def _cache_path(self) -> Path:
71
+ workspace = os.getenv(WORKSPACE_ENV, DEFAULT_WORKSPACE)
72
+ return Path(workspace) / ".splent_cache" / "marketplace" / "index.json"
73
+
74
+ def _index_from_cache(self) -> dict | None:
75
+ try:
76
+ data = json.loads(self._cache_path().read_text())
77
+ except (OSError, ValueError):
78
+ return None
79
+ return data if self._is_index(data) else None
80
+
81
+ def _index_from_url(self) -> dict | None:
82
+ url = os.getenv(INDEX_URL_ENV)
83
+ if not url:
84
+ return None
85
+ import urllib.error
86
+ import urllib.request
87
+
88
+ request = urllib.request.Request(
89
+ url, headers={"User-Agent": "splent-feature-configurator"}
90
+ )
91
+ try:
92
+ with urllib.request.urlopen(request, timeout=10) as response:
93
+ data = json.loads(response.read().decode())
94
+ except (urllib.error.URLError, TimeoutError, ValueError):
95
+ return None
96
+ if not self._is_index(data):
97
+ return None
98
+ try: # best-effort: next request is instant and works offline
99
+ path = self._cache_path()
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
102
+ except OSError:
103
+ pass
104
+ return data
105
+
106
+ @staticmethod
107
+ def _is_index(data) -> bool:
108
+ return isinstance(data, dict) and data.get("schema") == INDEX_SCHEMA
109
+
110
+ # ── SPL queries ───────────────────────────────────────────────────
111
+
112
+ def list_spls(self) -> list[dict]:
113
+ """SPL summaries for the chooser page."""
114
+ index = self.get_index() or {}
115
+ summaries = []
116
+ for spl in index.get("spls", []):
117
+ model = spl.get("model") or {}
118
+ summaries.append(
119
+ {
120
+ "name": spl.get("name"),
121
+ "description": spl.get("description", ""),
122
+ "has_model": bool(model.get("features")),
123
+ "feature_count": len(model.get("features") or {}),
124
+ }
125
+ )
126
+ return summaries
127
+
128
+ def get_spl(self, name: str) -> dict | None:
129
+ index = self.get_index() or {}
130
+ for spl in index.get("spls", []):
131
+ if spl.get("name") == name:
132
+ return spl
133
+ return None
134
+
135
+ def index_feature_shorts(self) -> set[str]:
136
+ """Short names of the features published in the marketplace index.
137
+
138
+ An SPL model may reference features that are not published in the
139
+ index (e.g. only available on GitHub/PyPI); callers use this set to
140
+ mark them as *external* instead of linking to a missing detail page.
141
+ """
142
+ index = self.get_index() or {}
143
+ return {
144
+ feature.get("short")
145
+ for feature in index.get("features") or []
146
+ if isinstance(feature, dict) and feature.get("short")
147
+ }
148
+
149
+ def feature_tree(self, model: dict, known_shorts: set[str] | None = None) -> dict:
150
+ """Shape the flat model into what the configurator page renders.
151
+
152
+ Returns ``{"mandatory": [...], "optional": [...], "groups": [...]}``
153
+ where each feature dict carries ``short`` plus its index attributes,
154
+ and each group carries ``owner``, ``kind``, ``members`` and
155
+ ``required`` (True when its members sit under ``mandatory``).
156
+
157
+ When ``known_shorts`` is given, every feature also carries
158
+ ``external``: True for model features absent from the index.
159
+ """
160
+ features = model.get("features") or {}
161
+
162
+ def entry(short: str) -> dict:
163
+ info = {"short": short, **features[short]}
164
+ if known_shorts is not None:
165
+ info["external"] = short not in known_shorts
166
+ return info
167
+
168
+ groups = []
169
+ grouped: set[str] = set()
170
+ for group in model.get("alternative_groups") or []:
171
+ members = [m for m in group.get("members", []) if m in features]
172
+ grouped.update(members)
173
+ groups.append(
174
+ {
175
+ "owner": group.get("owner"),
176
+ "kind": group.get("kind", "alternative"),
177
+ "members": [entry(m) for m in members],
178
+ "required": any(
179
+ features[m].get("presence") == "mandatory" for m in members
180
+ ),
181
+ }
182
+ )
183
+ mandatory, optional = [], []
184
+ for short, info in features.items():
185
+ if short in grouped:
186
+ continue
187
+ bucket = mandatory if info.get("presence") == "mandatory" else optional
188
+ bucket.append(entry(short))
189
+ return {"mandatory": mandatory, "optional": optional, "groups": groups}
190
+
191
+ # ── Variability validation (own logic, no solver, no network) ─────
192
+
193
+ def validate_selection(self, model: dict, selected: list[str]) -> dict:
194
+ """Validate ``selected`` against the SPL model.
195
+
196
+ Applies, in order:
197
+
198
+ 1. Mandatory features outside alternative groups are always part of
199
+ the product (auto-added when missing).
200
+ 2. Transitive closure of the requires-constraints ``a => b``
201
+ (auto-adds every implied feature).
202
+ 3. Alternative groups: an ``alternative`` group admits at most one
203
+ member; a required group (members under ``mandatory``) admits no
204
+ empty choice.
205
+
206
+ Returns ``{"selection_valid", "selection", "auto_added",
207
+ "violations"}`` where violations are structured dicts
208
+ (``code`` + context) so callers can translate them.
209
+ """
210
+ features = model.get("features") or {}
211
+ violations: list[dict] = []
212
+ auto_added: list[str] = []
213
+ chosen: set[str] = set()
214
+
215
+ for short in selected or []:
216
+ if short in features:
217
+ chosen.add(short)
218
+ else:
219
+ violations.append({"code": "unknown_feature", "feature": short})
220
+
221
+ grouped = {
222
+ member
223
+ for group in model.get("alternative_groups") or []
224
+ for member in group.get("members", [])
225
+ }
226
+
227
+ # 1. mandatory features (outside groups) are always included
228
+ for short, info in features.items():
229
+ if (
230
+ info.get("presence") == "mandatory"
231
+ and short not in grouped
232
+ and short not in chosen
233
+ ):
234
+ chosen.add(short)
235
+ auto_added.append(short)
236
+
237
+ # 2. transitive closure of a => b constraints
238
+ constraints = [
239
+ pair for pair in (model.get("constraints") or []) if len(pair) == 2
240
+ ]
241
+ for a, b in constraints:
242
+ if b not in features:
243
+ violations.append(
244
+ {"code": "unknown_constraint_target", "feature": b, "required_by": a}
245
+ )
246
+ applicable = [(a, b) for a, b in constraints if b in features]
247
+ changed = True
248
+ while changed:
249
+ changed = False
250
+ for a, b in applicable:
251
+ if a in chosen and b not in chosen:
252
+ chosen.add(b)
253
+ auto_added.append(b)
254
+ changed = True
255
+
256
+ # 3. alternative groups
257
+ for group in model.get("alternative_groups") or []:
258
+ members = [m for m in group.get("members", []) if m in features]
259
+ chosen_members = [m for m in members if m in chosen]
260
+ required = any(
261
+ features[m].get("presence") == "mandatory" for m in members
262
+ )
263
+ if group.get("kind", "alternative") == "alternative" and len(chosen_members) > 1:
264
+ violations.append(
265
+ {
266
+ "code": "group_conflict",
267
+ "group": group.get("owner"),
268
+ "features": chosen_members,
269
+ }
270
+ )
271
+ if required and not chosen_members:
272
+ violations.append(
273
+ {
274
+ "code": "group_required",
275
+ "group": group.get("owner"),
276
+ "features": members,
277
+ }
278
+ )
279
+
280
+ return {
281
+ "selection_valid": not violations,
282
+ # final selection in model declaration order (mandatory first)
283
+ "selection": [s for s in features if s in chosen],
284
+ "auto_added": auto_added,
285
+ "violations": violations,
286
+ }
287
+
288
+ # ── Command generation ────────────────────────────────────────────
289
+
290
+ def commands_for(
291
+ self, spl_name: str, model: dict, selection: list[str], product_name: str
292
+ ) -> list[str]:
293
+ """The exact splent commands that build the configured product."""
294
+ features = model.get("features") or {}
295
+ name = self.normalize_product_name(product_name)
296
+ commands = [
297
+ f"splent product:create {name} --spl {spl_name}",
298
+ f"splent product:select {name}",
299
+ ]
300
+ for short in selection:
301
+ info = features.get(short) or {}
302
+ package = info.get("package")
303
+ if not package:
304
+ continue
305
+ org = info.get("org") or DEFAULT_ORG
306
+ # feature:install clones the repo when it is not in the workspace
307
+ # yet (feature:add only registers already-cloned editable repos),
308
+ # so the recipe works for marketplace users starting from scratch.
309
+ commands.append(f"splent feature:install {org}/{package}")
310
+ commands.append("splent product:resolve")
311
+ commands.append("splent product:derive --dev")
312
+ return commands
313
+
314
+ @staticmethod
315
+ def normalize_product_name(name: str) -> str:
316
+ slug = re.sub(r"[^a-z0-9_]+", "_", (name or "").strip().lower()).strip("_")
317
+ return slug or DEFAULT_PRODUCT_NAME
318
+
319
+ # ── Entry point used by the routes ────────────────────────────────
320
+
321
+ def validate(
322
+ self, spl_name: str, selected: list[str], product_name: str = ""
323
+ ) -> dict | None:
324
+ """Full validation for one SPL. ``None`` when the SPL is unknown."""
325
+ spl = self.get_spl(spl_name)
326
+ model = (spl or {}).get("model") or {}
327
+ if not model.get("features"):
328
+ return None
329
+ result = self.validate_selection(model, selected)
330
+ result["commands"] = (
331
+ self.commands_for(spl_name, model, result["selection"], product_name)
332
+ if result["selection_valid"]
333
+ else []
334
+ )
335
+ return result
@@ -0,0 +1,24 @@
1
+ {# Configurator building blocks (feather icons as inline SVG).
2
+ All styling lives in skin_store.css. #}
3
+
4
+ {% set ICON_PATHS = {
5
+ "layers": '<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline>',
6
+ "check": '<polyline points="20 6 9 17 4 12"></polyline>',
7
+ "check-circle": '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline>',
8
+ "alert-triangle": '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>',
9
+ "x-circle": '<circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line>',
10
+ "copy": '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>',
11
+ "terminal": '<polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line>',
12
+ "arrow-right": '<line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline>',
13
+ "arrow-left": '<line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline>',
14
+ "git-branch": '<line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path>',
15
+ "sliders": '<line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line>',
16
+ "plus": '<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>',
17
+ "lock": '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
18
+ "zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>',
19
+ } %}
20
+
21
+ {% macro icon(name, size=16) -%}
22
+ <svg class="store-icon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{{ ICON_PATHS.get(name, "") | safe }}</svg>
23
+ {%- endmacro %}
24
+
@@ -0,0 +1,312 @@
1
+ {% extends "public_base.html" %}
2
+ {% import "configurator/_macros.html" as ck %}
3
+
4
+ {% block title %}{{ _("Configurator") }} · {{ spl.name }}{% endblock %}
5
+
6
+ {% block hero %}
7
+ <section class="store-hero store-hero--slim">
8
+ <div class="container">
9
+ <h1 class="store-hero__title">{{ spl.name }}</h1>
10
+ {% if spl.description %}<p class="store-hero__subtitle">{{ spl.description }}</p>{% endif %}
11
+ </div>
12
+ </section>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="container"
17
+ id="configurator"
18
+ data-validate-url="{{ url_for('configurator.validate_selection', spl_name=spl.name) }}">
19
+
20
+ <nav class="store-crumbs">
21
+ <a class="store-backlink" href="{{ url_for('configurator.index') }}">
22
+ {{ ck.icon("arrow-left", 14) }}{{ _("All product lines") }}
23
+ </a>
24
+ </nav>
25
+
26
+ <div class="cfg-cols">
27
+
28
+ {# ── Step 2: feature selection ──────────────────────────────────── #}
29
+ <div>
30
+ <div class="cfg-field">
31
+ <label for="cfg-product-name" class="cfg-field__label">{{ _("Product name") }}</label>
32
+ <input type="text" class="cfg-field__input" id="cfg-product-name"
33
+ value="my_app" autocomplete="off" spellcheck="false"
34
+ aria-describedby="cfg-product-name-help">
35
+ <p id="cfg-product-name-help" class="cfg-field__help">
36
+ {{ _("Lowercase letters, digits and underscores.") }}
37
+ </p>
38
+ </div>
39
+
40
+ {% if tree.mandatory %}
41
+ <section class="cfg-section">
42
+ <h2 class="cfg-section__title">{{ ck.icon("lock") }}{{ _("Core features") }}</h2>
43
+ <p class="cfg-section__hint">{{ _("Always included in every product of this line.") }}</p>
44
+ <div class="cfg-options">
45
+ {% for feature in tree.mandatory %}
46
+ <label class="cfg-option cfg-option--locked" for="cfg-f-{{ feature.short }}">
47
+ <input class="cfg-option__input" type="checkbox" checked disabled
48
+ id="cfg-f-{{ feature.short }}" data-feature="{{ feature.short }}">
49
+ <span class="cfg-option__box">
50
+ <span class="cfg-option__mark">{{ ck.icon("check", 12) }}</span>
51
+ <span>
52
+ <span class="cfg-option__name">{{ feature.short }}{% if feature.external %} <span class="cfg-option__ext">{{ _("external") }}</span>{% endif %}</span>
53
+ <span class="cfg-option__pkg">{{ feature.org }}/{{ feature.package }}</span>
54
+ </span>
55
+ </span>
56
+ </label>
57
+ {% endfor %}
58
+ </div>
59
+ </section>
60
+ {% endif %}
61
+
62
+ {% for group in tree.groups %}
63
+ <section class="cfg-section">
64
+ <h2 class="cfg-section__title">{{ ck.icon("git-branch") }}{{ group.owner }}</h2>
65
+ <p class="cfg-section__hint">
66
+ {% if group.required %}
67
+ {{ _("Exclusive group. Pick exactly one.") }}
68
+ {% else %}
69
+ {{ _("Exclusive group. Pick one at most.") }}
70
+ {% endif %}
71
+ </p>
72
+ <div class="cfg-options">
73
+ {% for member in group.members %}
74
+ <label class="cfg-option cfg-option--radio" for="cfg-f-{{ member.short }}">
75
+ <input class="cfg-option__input" type="radio"
76
+ name="cfg-group-{{ group.owner }}" value="{{ member.short }}"
77
+ id="cfg-f-{{ member.short }}" data-feature="{{ member.short }}"
78
+ {% if group.required and loop.first %}checked{% endif %}>
79
+ <span class="cfg-option__box">
80
+ <span class="cfg-option__mark">{{ ck.icon("check", 12) }}</span>
81
+ <span>
82
+ <span class="cfg-option__name">{{ member.short }}{% if member.external %} <span class="cfg-option__ext">{{ _("external") }}</span>{% endif %}</span>
83
+ <span class="cfg-option__pkg">{{ member.org }}/{{ member.package }}</span>
84
+ </span>
85
+ </span>
86
+ </label>
87
+ {% endfor %}
88
+ {% if not group.required %}
89
+ <label class="cfg-option cfg-option--radio" for="cfg-g-{{ group.owner }}-none">
90
+ <input class="cfg-option__input" type="radio"
91
+ name="cfg-group-{{ group.owner }}" value=""
92
+ id="cfg-g-{{ group.owner }}-none" checked>
93
+ <span class="cfg-option__box">
94
+ <span class="cfg-option__mark">{{ ck.icon("check", 12) }}</span>
95
+ <span>
96
+ <span class="cfg-option__name">{{ _("None") }}</span>
97
+ <span class="cfg-option__pkg">{{ _("skip this group") }}</span>
98
+ </span>
99
+ </span>
100
+ </label>
101
+ {% endif %}
102
+ </div>
103
+ </section>
104
+ {% endfor %}
105
+
106
+ {% if tree.optional %}
107
+ <section class="cfg-section">
108
+ <h2 class="cfg-section__title">{{ ck.icon("plus") }}{{ _("Optional features") }}</h2>
109
+ <p class="cfg-section__hint">{{ _("Dependencies are added automatically as you select.") }}</p>
110
+ <div class="cfg-options">
111
+ {% for feature in tree.optional %}
112
+ <label class="cfg-option" for="cfg-f-{{ feature.short }}" data-auto-label="{{ _('required by your selection') }}">
113
+ <input class="cfg-option__input" type="checkbox"
114
+ id="cfg-f-{{ feature.short }}" data-feature="{{ feature.short }}">
115
+ <span class="cfg-option__box">
116
+ <span class="cfg-option__mark">{{ ck.icon("check", 12) }}</span>
117
+ <span>
118
+ <span class="cfg-option__name">{{ feature.short }}{% if feature.external %} <span class="cfg-option__ext">{{ _("external") }}</span>{% endif %}</span>
119
+ <span class="cfg-option__pkg">{{ feature.org }}/{{ feature.package }}</span>
120
+ </span>
121
+ </span>
122
+ </label>
123
+ {% endfor %}
124
+ </div>
125
+ </section>
126
+ {% endif %}
127
+ </div>
128
+
129
+ {# ── Step 3: live validation + commands (sticky) ────────────────── #}
130
+ <aside class="cfg-panel">
131
+
132
+ <div id="cfg-status-valid" class="cfg-status cfg-status--valid is-hidden" role="status">
133
+ <span class="cfg-status__head">{{ ck.icon("check-circle", 18) }}{{ _("Valid selection") }}</span>
134
+ </div>
135
+
136
+ <div id="cfg-status-invalid" class="cfg-status cfg-status--invalid is-hidden" role="alert">
137
+ <span class="cfg-status__head">{{ ck.icon("x-circle", 18) }}{{ _("Invalid selection") }}</span>
138
+ <ul id="cfg-violations"></ul>
139
+ </div>
140
+
141
+ <div id="cfg-status-error" class="cfg-status cfg-status--error is-hidden" role="alert">
142
+ <span class="cfg-status__head">{{ ck.icon("alert-triangle", 18) }}{{ _("Could not validate the selection. Check your connection and try again.") }}</span>
143
+ </div>
144
+
145
+ <section id="cfg-auto-section" class="cfg-aside-block cfg-aside-block--auto is-hidden">
146
+ <h3 class="cfg-aside-block__title">{{ ck.icon("zap") }}{{ _("Added automatically") }}</h3>
147
+ <p class="cfg-aside-block__hint">{{ _("Required by your selection or by the product line.") }}</p>
148
+ <div id="cfg-auto" class="store-chipset"></div>
149
+ </section>
150
+
151
+ <section id="cfg-commands-section" class="cfg-aside-block is-hidden">
152
+ <h3 class="cfg-aside-block__title">{{ ck.icon("terminal") }}{{ _("Build commands") }}</h3>
153
+ <p class="cfg-aside-block__hint">{{ _("Run these in your SPLENT workspace.") }}</p>
154
+ <div class="store-terminal store-terminal--lines">
155
+ <pre id="cfg-commands"></pre>
156
+ </div>
157
+ <div class="cfg-terminal-actions">
158
+ <button type="button" id="cfg-copy" class="store-copy"
159
+ data-label="{{ _('Copy commands') }}" data-label-copied="{{ _('Copied') }}">
160
+ {{ ck.icon("copy") }}<span id="cfg-copy-label">{{ _("Copy commands") }}</span>
161
+ </button>
162
+ </div>
163
+ </section>
164
+
165
+ </aside>
166
+ </div>
167
+ </div>
168
+ {% endblock %}
169
+
170
+ {% block scripts %}
171
+ <script>
172
+ (function () {
173
+ "use strict";
174
+
175
+ var root = document.getElementById("configurator");
176
+ if (!root) { return; }
177
+
178
+ var validateUrl = root.dataset.validateUrl;
179
+ var nameInput = document.getElementById("cfg-product-name");
180
+ var statusValid = document.getElementById("cfg-status-valid");
181
+ var statusInvalid = document.getElementById("cfg-status-invalid");
182
+ var statusError = document.getElementById("cfg-status-error");
183
+ var violationsList = document.getElementById("cfg-violations");
184
+ var autoSection = document.getElementById("cfg-auto-section");
185
+ var autoBox = document.getElementById("cfg-auto");
186
+ var commandsSection = document.getElementById("cfg-commands-section");
187
+ var commandsPre = document.getElementById("cfg-commands");
188
+ var copyButton = document.getElementById("cfg-copy");
189
+ var copyLabel = document.getElementById("cfg-copy-label");
190
+
191
+ function collectSelected() {
192
+ var selected = [];
193
+ root.querySelectorAll("input[data-feature]").forEach(function (input) {
194
+ if (input.checked && input.dataset.auto !== "1") {
195
+ selected.push(input.dataset.feature);
196
+ }
197
+ });
198
+ return selected;
199
+ }
200
+
201
+ function paintAutoAdded(autoAdded) {
202
+ // Auto-required features show as checked + locked in the tree itself,
203
+ // so "comments pulls in post" is visible where the user is looking.
204
+ root.querySelectorAll("input[data-feature]").forEach(function (input) {
205
+ var card = input.closest("label") || input.parentElement;
206
+ var isAuto = autoAdded.indexOf(input.dataset.feature) !== -1;
207
+ if (isAuto && !input.checked) {
208
+ input.checked = true;
209
+ input.dataset.auto = "1";
210
+ input.disabled = true;
211
+ if (card) { card.classList.add("cfg-option--auto"); }
212
+ } else if (!isAuto && input.dataset.auto === "1") {
213
+ input.checked = false;
214
+ delete input.dataset.auto;
215
+ input.disabled = false;
216
+ if (card) { card.classList.remove("cfg-option--auto"); }
217
+ }
218
+ });
219
+ }
220
+
221
+ function show(el) { el.classList.remove("is-hidden"); }
222
+ function hide(el) { el.classList.add("is-hidden"); }
223
+
224
+ function render(data) {
225
+ hide(statusError);
226
+
227
+ violationsList.textContent = "";
228
+ (data.violations || []).forEach(function (violation) {
229
+ var item = document.createElement("li");
230
+ item.textContent = violation.message || violation.code;
231
+ violationsList.appendChild(item);
232
+ });
233
+
234
+ paintAutoAdded(data.auto_added || []);
235
+
236
+ autoBox.textContent = "";
237
+ (data.auto_added || []).forEach(function (short) {
238
+ var badge = document.createElement("span");
239
+ badge.className = "store-chip store-chip--amber";
240
+ badge.textContent = short;
241
+ autoBox.appendChild(badge);
242
+ });
243
+ if ((data.auto_added || []).length) { show(autoSection); } else { hide(autoSection); }
244
+
245
+ if (data.selection_valid) {
246
+ show(statusValid);
247
+ hide(statusInvalid);
248
+ commandsPre.textContent = (data.commands || []).join("\n");
249
+ show(commandsSection);
250
+ } else {
251
+ hide(statusValid);
252
+ show(statusInvalid);
253
+ hide(commandsSection);
254
+ }
255
+ }
256
+
257
+ var timer = null;
258
+ var requestSeq = 0;
259
+
260
+ function validate() {
261
+ var seq = ++requestSeq;
262
+ fetch(validateUrl, {
263
+ method: "POST",
264
+ headers: { "Content-Type": "application/json" },
265
+ body: JSON.stringify({
266
+ selected: collectSelected(),
267
+ product_name: nameInput ? nameInput.value : ""
268
+ })
269
+ })
270
+ .then(function (response) {
271
+ if (!response.ok) { throw new Error("HTTP " + response.status); }
272
+ return response.json();
273
+ })
274
+ .then(function (data) {
275
+ if (seq === requestSeq) { render(data); }
276
+ })
277
+ .catch(function () {
278
+ if (seq === requestSeq) {
279
+ hide(statusValid);
280
+ hide(statusInvalid);
281
+ hide(commandsSection);
282
+ hide(autoSection);
283
+ show(statusError);
284
+ }
285
+ });
286
+ }
287
+
288
+ function scheduleValidate() {
289
+ clearTimeout(timer);
290
+ timer = setTimeout(validate, 200);
291
+ }
292
+
293
+ root.addEventListener("change", scheduleValidate);
294
+ if (nameInput) { nameInput.addEventListener("input", scheduleValidate); }
295
+
296
+ if (copyButton) {
297
+ copyButton.addEventListener("click", function () {
298
+ var text = commandsPre.textContent;
299
+ if (!navigator.clipboard) { return; }
300
+ navigator.clipboard.writeText(text).then(function () {
301
+ copyLabel.textContent = copyButton.dataset.labelCopied;
302
+ setTimeout(function () {
303
+ copyLabel.textContent = copyButton.dataset.label;
304
+ }, 1500);
305
+ });
306
+ });
307
+ }
308
+
309
+ validate();
310
+ })();
311
+ </script>
312
+ {% endblock %}
@@ -0,0 +1,46 @@
1
+ {% extends "public_base.html" %}
2
+ {% import "configurator/_macros.html" as ck %}
3
+
4
+ {% block title %}{{ _("Configurator") }} · {{ site.name }}{% endblock %}
5
+
6
+ {% block hero %}
7
+ <section class="store-hero store-hero--slim">
8
+ <div class="container">
9
+ <h1 class="store-hero__title">{{ _("Product configurator") }}</h1>
10
+ <p class="store-hero__subtitle">{{ _("Pick a line, select its features with live validation, and copy the exact splent commands.") }}</p>
11
+ </div>
12
+ </section>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="container">
17
+ {% if spls %}
18
+ <div class="cfg-spl-grid">
19
+ {% for spl in spls %}
20
+ <article class="cfg-spl-card">
21
+ <header class="store-spl-card__head">
22
+ {{ ck.icon("layers", 22) }}
23
+ <h2 class="store-spl-card__name">{{ spl.name }}</h2>
24
+ </header>
25
+ <p class="cfg-spl-card__desc">{{ spl.description or _("No description available.") }}</p>
26
+ <footer class="cfg-spl-card__foot">
27
+ {% if spl.has_model %}
28
+ <span class="cfg-spl-card__count">{{ _("%(count)s features", count=spl.feature_count) }}</span>
29
+ <a class="btn btn-primary btn-sm" href="{{ url_for('configurator.configure', spl_name=spl.name) }}">
30
+ {{ _("Configure") }} {{ ck.icon("arrow-right", 14) }}
31
+ </a>
32
+ {% else %}
33
+ <span class="store-badge">{{ _("Model not published") }}</span>
34
+ {% endif %}
35
+ </footer>
36
+ </article>
37
+ {% endfor %}
38
+ </div>
39
+ {% else %}
40
+ <div class="store-empty">
41
+ {{ ck.icon("alert-triangle", 34) }}
42
+ <p>{{ _("The marketplace index is not available right now. Try again once the index has been built or SPLENT_INDEX_URL is configured.") }}</p>
43
+ </div>
44
+ {% endif %}
45
+ </div>
46
+ {% endblock %}