steppy-dev 0.3.0__py3-none-any.whl
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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/build_models.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""Pydantic models for the steppy build command configuration."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Optional, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
7
|
+
|
|
8
|
+
from .locales import normalize_locale, validate_locale_tag
|
|
9
|
+
from .sitemap import SitemapError, normalize_lastmod
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Reserved URL slug used for the onboarding landing page. Guide and group slugs
|
|
13
|
+
# may not collide with this when onboarding is enabled.
|
|
14
|
+
ONBOARDING_SLUG = "getting-started"
|
|
15
|
+
|
|
16
|
+
# URL slug of the whole-site handbook page (`steppy build --handbook`). Checked
|
|
17
|
+
# against guide/group slugs only when that flag is used, so a project that never
|
|
18
|
+
# exports a handbook is free to name a guide "handbook".
|
|
19
|
+
HANDBOOK_SLUG = "handbook"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Either a plain string (treated as the same value for every locale) or a
|
|
23
|
+
# {locale: string} map. Resolved at render time via build.pick_locale.
|
|
24
|
+
LocalizedText = Union[str, dict[str, str]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_localized(value: Any, *, allow_none: bool = False) -> Any:
|
|
28
|
+
if value is None:
|
|
29
|
+
if allow_none:
|
|
30
|
+
return None
|
|
31
|
+
raise ValueError("Localized text must not be null")
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
if not value:
|
|
34
|
+
raise ValueError("Localized text must be a non-empty string")
|
|
35
|
+
return value
|
|
36
|
+
if isinstance(value, dict):
|
|
37
|
+
if not value:
|
|
38
|
+
raise ValueError("Localized text map must not be empty")
|
|
39
|
+
for k, v in value.items():
|
|
40
|
+
if not isinstance(k, str) or not k:
|
|
41
|
+
raise ValueError(f"Localized text map keys must be non-empty strings, got {k!r}")
|
|
42
|
+
if not isinstance(v, str) or not v:
|
|
43
|
+
raise ValueError(f"Localized text map values must be non-empty strings (key '{k}' has value {v!r})")
|
|
44
|
+
return value
|
|
45
|
+
raise ValueError(f"Localized text must be a string or {{locale: string}} map, got {type(value).__name__}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GuideMode(str, Enum):
|
|
49
|
+
"""Supported guide rendering modes.
|
|
50
|
+
|
|
51
|
+
- ``stepbystep`` : the scrollable page of captioned screenshots (always emitted)
|
|
52
|
+
- ``interactive`` : the ``<steppy-guide>`` player, driven by ``guide.json``
|
|
53
|
+
- ``video`` : a narrated MP4 synthesized from the same manifest, per
|
|
54
|
+
(locale x layout). Declaring it is inert until ``steppy build --video`` /
|
|
55
|
+
``steppy guide --video`` arms the render — a render costs minutes of CPU
|
|
56
|
+
and, with a paid TTS provider, real money.
|
|
57
|
+
|
|
58
|
+
Every member needs a ``MODE_LABELS`` row in ``build.py`` (the Angular
|
|
59
|
+
component template indexes it directly); guard:
|
|
60
|
+
``tests/test_guide_mode_sync.py``.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
STEPBYSTEP = "stepbystep"
|
|
64
|
+
INTERACTIVE = "interactive"
|
|
65
|
+
VIDEO = "video"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BuildTarget(str, Enum):
|
|
69
|
+
"""Supported build output targets."""
|
|
70
|
+
|
|
71
|
+
ANGULAR = "angular"
|
|
72
|
+
STATIC = "static"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CursorMode(str, Enum):
|
|
76
|
+
"""Cursor presentation modes for the interactive ``<steppy-guide>`` player.
|
|
77
|
+
|
|
78
|
+
- ``arrow`` : animated arrow + pulsing hotspot (default — current behaviour)
|
|
79
|
+
- ``hotspot`` : no arrow / ripple / kbd overlay; hotspot still pulses and the
|
|
80
|
+
user clicks it to advance (Guideflow-style)
|
|
81
|
+
- ``off`` : no arrow and no hotspot — auto-advance every step after
|
|
82
|
+
``waitAfter`` like a passive video walkthrough
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
ARROW = "arrow"
|
|
86
|
+
HOTSPOT = "hotspot"
|
|
87
|
+
OFF = "off"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TemplateMode(str, Enum):
|
|
91
|
+
"""Presentation templates for the interactive ``<steppy-guide>`` player.
|
|
92
|
+
|
|
93
|
+
- ``overlay`` : full-bleed preview + floating callout pinned to the target
|
|
94
|
+
(the current/default player look)
|
|
95
|
+
- ``sidebar`` : left rail with a vertical step timeline + description, preview
|
|
96
|
+
on the right
|
|
97
|
+
- ``inline`` : full-bleed preview + a slim bottom caption bar
|
|
98
|
+
|
|
99
|
+
Runtime-switchable in the player; this is only the per-guide default.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
SIDEBAR = "sidebar"
|
|
103
|
+
OVERLAY = "overlay"
|
|
104
|
+
INLINE = "inline"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SeoMode(str, Enum):
|
|
108
|
+
"""SEO / indexing mode for the static build target.
|
|
109
|
+
|
|
110
|
+
- ``private`` (default): every page gets a ``noindex`` robots meta and
|
|
111
|
+
``robots.txt`` emits ``Disallow: /`` — nothing is indexable. Safe default
|
|
112
|
+
so an accidentally-exposed client guide site is not crawlable.
|
|
113
|
+
- ``public``: emits ``sitemap.xml``, canonical links, ``hreflang`` alternates
|
|
114
|
+
and optional Open Graph tags. Requires ``baseUrl``.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
PUBLIC = "public"
|
|
118
|
+
PRIVATE = "private"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _validate_slug(value: str, kind: str) -> str:
|
|
122
|
+
if not value or not value.replace("-", "").replace("_", "").isalnum():
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Invalid slug '{value}' ({kind}): must be non-empty and contain only "
|
|
125
|
+
"alphanumeric characters, hyphens, or underscores"
|
|
126
|
+
)
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class GuideEntry(BaseModel):
|
|
131
|
+
"""A single guide entry in the build config."""
|
|
132
|
+
|
|
133
|
+
slug: str
|
|
134
|
+
title: LocalizedText
|
|
135
|
+
scenario: str
|
|
136
|
+
modes: list[GuideMode] = [GuideMode.STEPBYSTEP]
|
|
137
|
+
group: Optional[str] = None
|
|
138
|
+
# Optional per-guide cursor mode override. Falls back to ``BuildConfig.cursor``.
|
|
139
|
+
cursor: Optional[CursorMode] = None
|
|
140
|
+
# Optional per-guide default presentation template. Falls back to
|
|
141
|
+
# ``BuildConfig.default_template``.
|
|
142
|
+
template: Optional[TemplateMode] = None
|
|
143
|
+
# Optional per-guide meta description for public SEO / shared-link
|
|
144
|
+
# snippets. Falls back to ``title`` when unset.
|
|
145
|
+
description: Optional[LocalizedText] = None
|
|
146
|
+
|
|
147
|
+
class Config:
|
|
148
|
+
extra = "forbid"
|
|
149
|
+
|
|
150
|
+
@field_validator("slug")
|
|
151
|
+
@classmethod
|
|
152
|
+
def validate_slug(cls, v: str) -> str:
|
|
153
|
+
return _validate_slug(v, "guide")
|
|
154
|
+
|
|
155
|
+
@field_validator("title", mode="before")
|
|
156
|
+
@classmethod
|
|
157
|
+
def validate_title(cls, v: Any) -> Any:
|
|
158
|
+
return _validate_localized(v)
|
|
159
|
+
|
|
160
|
+
@field_validator("modes")
|
|
161
|
+
@classmethod
|
|
162
|
+
def validate_modes(cls, v: list[GuideMode]) -> list[GuideMode]:
|
|
163
|
+
if not v:
|
|
164
|
+
raise ValueError("At least one mode is required")
|
|
165
|
+
if len(v) != len(set(v)):
|
|
166
|
+
raise ValueError("Duplicate modes are not allowed")
|
|
167
|
+
return v
|
|
168
|
+
|
|
169
|
+
@field_validator("description", mode="before")
|
|
170
|
+
@classmethod
|
|
171
|
+
def validate_description(cls, v: Any) -> Any:
|
|
172
|
+
return _validate_localized(v, allow_none=True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class GroupConfig(BaseModel):
|
|
176
|
+
"""A group of guides surfaced as a navbar section + landing page."""
|
|
177
|
+
|
|
178
|
+
slug: str
|
|
179
|
+
title: LocalizedText
|
|
180
|
+
description: Optional[LocalizedText] = None
|
|
181
|
+
parent: Optional[str] = None
|
|
182
|
+
|
|
183
|
+
class Config:
|
|
184
|
+
extra = "forbid"
|
|
185
|
+
|
|
186
|
+
@field_validator("slug")
|
|
187
|
+
@classmethod
|
|
188
|
+
def validate_slug(cls, v: str) -> str:
|
|
189
|
+
return _validate_slug(v, "group")
|
|
190
|
+
|
|
191
|
+
@field_validator("title", mode="before")
|
|
192
|
+
@classmethod
|
|
193
|
+
def validate_title(cls, v: Any) -> Any:
|
|
194
|
+
return _validate_localized(v)
|
|
195
|
+
|
|
196
|
+
@field_validator("description", mode="before")
|
|
197
|
+
@classmethod
|
|
198
|
+
def validate_description(cls, v: Any) -> Any:
|
|
199
|
+
return _validate_localized(v, allow_none=True)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class OnboardingConfig(BaseModel):
|
|
203
|
+
"""An ordered onboarding path through a subset of guides."""
|
|
204
|
+
|
|
205
|
+
enabled: bool = True
|
|
206
|
+
title: LocalizedText = "Getting Started"
|
|
207
|
+
description: Optional[LocalizedText] = None
|
|
208
|
+
guides: list[str] = Field(default_factory=list)
|
|
209
|
+
|
|
210
|
+
class Config:
|
|
211
|
+
extra = "forbid"
|
|
212
|
+
|
|
213
|
+
@field_validator("title", mode="before")
|
|
214
|
+
@classmethod
|
|
215
|
+
def validate_title(cls, v: Any) -> Any:
|
|
216
|
+
return _validate_localized(v)
|
|
217
|
+
|
|
218
|
+
@field_validator("description", mode="before")
|
|
219
|
+
@classmethod
|
|
220
|
+
def validate_description(cls, v: Any) -> Any:
|
|
221
|
+
return _validate_localized(v, allow_none=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SiteConfig(BaseModel):
|
|
225
|
+
"""Site-level metadata."""
|
|
226
|
+
|
|
227
|
+
title: LocalizedText
|
|
228
|
+
|
|
229
|
+
class Config:
|
|
230
|
+
extra = "forbid"
|
|
231
|
+
|
|
232
|
+
@field_validator("title", mode="before")
|
|
233
|
+
@classmethod
|
|
234
|
+
def validate_title(cls, v: Any) -> Any:
|
|
235
|
+
return _validate_localized(v)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class NavbarConfig(BaseModel):
|
|
239
|
+
"""Navbar ordering configuration."""
|
|
240
|
+
|
|
241
|
+
order: list[str]
|
|
242
|
+
|
|
243
|
+
class Config:
|
|
244
|
+
extra = "forbid"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class SeoConfig(BaseModel):
|
|
248
|
+
"""Static-site SEO / indexing controls.
|
|
249
|
+
|
|
250
|
+
``mode`` defaults to ``private`` (safe default): every static page gets a
|
|
251
|
+
``noindex`` robots meta and ``robots.txt`` emits ``Disallow: /`` so an
|
|
252
|
+
accidentally-exposed client guide site is not indexable. Set
|
|
253
|
+
``mode: 'public'`` (which requires ``baseUrl``) to emit ``sitemap.xml``,
|
|
254
|
+
canonical links, ``hreflang`` alternates and optional Open Graph tags for
|
|
255
|
+
the public showcase / marketing sites. Static target only — the Angular
|
|
256
|
+
host owns its own meta. Indexing controls are defense-in-depth, not access
|
|
257
|
+
control (private sites still sit behind auth).
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
mode: SeoMode = SeoMode.PRIVATE
|
|
261
|
+
base_url: Optional[str] = Field(default=None, alias="baseUrl")
|
|
262
|
+
# Site-wide default meta description (index page + ultimate fallback + OG).
|
|
263
|
+
description: Optional[LocalizedText] = None
|
|
264
|
+
# Opt-in Open Graph / Twitter card tags (public mode only).
|
|
265
|
+
open_graph: bool = Field(default=False, alias="openGraph")
|
|
266
|
+
# Optional site-wide <lastmod> for every sitemap entry. Unset (the default)
|
|
267
|
+
# emits none, which is what keeps a build byte-reproducible; there is
|
|
268
|
+
# deliberately no 'auto' mode, because every source of a timestamp available
|
|
269
|
+
# here is wrong (a CI checkout stamps every file at clone time, and
|
|
270
|
+
# screenshots are not in git at all). Set it from the commit date instead:
|
|
271
|
+
# `git log -1 --format=%cI`.
|
|
272
|
+
lastmod: Optional[str] = None
|
|
273
|
+
|
|
274
|
+
class Config:
|
|
275
|
+
extra = "forbid"
|
|
276
|
+
populate_by_name = True
|
|
277
|
+
|
|
278
|
+
@field_validator("base_url")
|
|
279
|
+
@classmethod
|
|
280
|
+
def validate_base_url(cls, v: Optional[str]) -> Optional[str]:
|
|
281
|
+
if v is None:
|
|
282
|
+
return None
|
|
283
|
+
v = v.strip()
|
|
284
|
+
if not v:
|
|
285
|
+
return None
|
|
286
|
+
if not (v.startswith("http://") or v.startswith("https://")):
|
|
287
|
+
raise ValueError(f"seo.baseUrl must be an absolute http(s) URL, got {v!r}")
|
|
288
|
+
return v.rstrip("/")
|
|
289
|
+
|
|
290
|
+
@field_validator("description", mode="before")
|
|
291
|
+
@classmethod
|
|
292
|
+
def validate_description(cls, v: Any) -> Any:
|
|
293
|
+
return _validate_localized(v, allow_none=True)
|
|
294
|
+
|
|
295
|
+
@field_validator("lastmod")
|
|
296
|
+
@classmethod
|
|
297
|
+
def validate_lastmod(cls, v: Optional[str]) -> Optional[str]:
|
|
298
|
+
"""Grammar lives in ``sitemap.normalize_lastmod``; this only re-labels
|
|
299
|
+
the failure with the config key, like ``validate_base_url`` does."""
|
|
300
|
+
if v is None:
|
|
301
|
+
return None
|
|
302
|
+
if not v.strip():
|
|
303
|
+
return None
|
|
304
|
+
try:
|
|
305
|
+
return normalize_lastmod(v)
|
|
306
|
+
except SitemapError as exc:
|
|
307
|
+
raise ValueError(f"seo.{exc}") from exc
|
|
308
|
+
|
|
309
|
+
@model_validator(mode="after")
|
|
310
|
+
def validate_public_requires_base_url(self) -> "SeoConfig":
|
|
311
|
+
if self.mode == SeoMode.PUBLIC and not self.base_url:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
"seo.baseUrl is required when seo.mode is 'public' — absolute "
|
|
314
|
+
"URLs are needed for sitemap.xml, canonical links and hreflang "
|
|
315
|
+
"alternates"
|
|
316
|
+
)
|
|
317
|
+
return self
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class BuildConfig(BaseModel):
|
|
321
|
+
"""Top-level build configuration, mirrors the TypeScript steppy.guide.config.ts."""
|
|
322
|
+
|
|
323
|
+
scenarios_root: str = Field(alias="scenariosRoot")
|
|
324
|
+
screenshots_dir: str = Field(default="./output/screenshots", alias="screenshotsDir")
|
|
325
|
+
output_dir: str = Field(alias="outputDir")
|
|
326
|
+
assets_dir: str = Field(alias="assetsDir")
|
|
327
|
+
role: str
|
|
328
|
+
locales: list[str] = ["fr"]
|
|
329
|
+
default_locale: str = Field(default="fr", alias="defaultLocale")
|
|
330
|
+
# Optional per-locale display-name overrides for the language switcher.
|
|
331
|
+
# Merged over the built-in LOCALE_LABELS in build.py; unmapped locales fall
|
|
332
|
+
# back to their uppercased code. Lets a project add/rename a language
|
|
333
|
+
# without a code change (FLAWS §4.4).
|
|
334
|
+
locale_labels: dict[str, str] = Field(default_factory=dict, alias="localeLabels")
|
|
335
|
+
# Optional layouts dimension. When non-empty, the build assembles each
|
|
336
|
+
# guide once per (locale, layout) combination and renders an in-page
|
|
337
|
+
# toggle. Empty = single-layout build (backwards-compat).
|
|
338
|
+
layouts: list[str] = Field(default_factory=list)
|
|
339
|
+
default_layout: Optional[str] = Field(default=None, alias="defaultLayout")
|
|
340
|
+
# Default is the framework-agnostic ``static`` site (works with any host,
|
|
341
|
+
# including the React/Vue wrapper packages). Angular projects must set
|
|
342
|
+
# ``target: 'angular'`` explicitly.
|
|
343
|
+
target: BuildTarget = Field(default=BuildTarget.STATIC)
|
|
344
|
+
# Project-wide default cursor mode for the interactive player. Per-guide
|
|
345
|
+
# ``GuideEntry.cursor`` overrides this. Default is ``arrow`` (current
|
|
346
|
+
# behaviour — animated cursor + hotspot).
|
|
347
|
+
cursor: CursorMode = Field(default=CursorMode.ARROW)
|
|
348
|
+
# Project-wide default presentation template for the interactive player.
|
|
349
|
+
# Per-guide ``GuideEntry.template`` overrides this. Default is ``overlay``
|
|
350
|
+
# (the current floating-callout player look).
|
|
351
|
+
default_template: TemplateMode = Field(default=TemplateMode.OVERLAY, alias="defaultTemplate")
|
|
352
|
+
site: SiteConfig
|
|
353
|
+
guides: list[GuideEntry]
|
|
354
|
+
navbar: NavbarConfig
|
|
355
|
+
groups: list[GroupConfig] = Field(default_factory=list)
|
|
356
|
+
onboarding: Optional[OnboardingConfig] = None
|
|
357
|
+
# Static-site SEO / indexing controls. Defaults to a private config
|
|
358
|
+
# (noindex + robots.txt Disallow) so nothing is accidentally indexable.
|
|
359
|
+
seo: SeoConfig = Field(default_factory=SeoConfig)
|
|
360
|
+
|
|
361
|
+
class Config:
|
|
362
|
+
extra = "forbid"
|
|
363
|
+
populate_by_name = True
|
|
364
|
+
|
|
365
|
+
@field_validator("layouts")
|
|
366
|
+
@classmethod
|
|
367
|
+
def validate_layouts(cls, v: list[str]) -> list[str]:
|
|
368
|
+
for item in v:
|
|
369
|
+
if not isinstance(item, str) or not item.strip():
|
|
370
|
+
raise ValueError(f"layout names must be non-empty strings, got {item!r}")
|
|
371
|
+
if len(v) != len(set(v)):
|
|
372
|
+
raise ValueError("Duplicate layout names are not allowed")
|
|
373
|
+
return v
|
|
374
|
+
|
|
375
|
+
@field_validator("guides")
|
|
376
|
+
@classmethod
|
|
377
|
+
def validate_guides_non_empty(cls, v: list[GuideEntry]) -> list[GuideEntry]:
|
|
378
|
+
if not v:
|
|
379
|
+
raise ValueError("At least one guide entry is required")
|
|
380
|
+
return v
|
|
381
|
+
|
|
382
|
+
@field_validator("locales")
|
|
383
|
+
@classmethod
|
|
384
|
+
def validate_locales_non_empty(cls, v: list[str]) -> list[str]:
|
|
385
|
+
# Non-empty first: the message is pinned by tests and by anyone who has
|
|
386
|
+
# seen it, and a format complaint about an absent list reads as noise.
|
|
387
|
+
if not v:
|
|
388
|
+
raise ValueError("At least one locale is required")
|
|
389
|
+
for item in v:
|
|
390
|
+
# Validated, NOT rewritten. The authored spelling is a join key with
|
|
391
|
+
# the capture directories on disk, the host app's i18n language code
|
|
392
|
+
# and every LocalizedText map; only `hreflang` and `<html lang>` get
|
|
393
|
+
# the normalised form. See src/steppy/locales.py. The return value is
|
|
394
|
+
# discarded on purpose.
|
|
395
|
+
validate_locale_tag(item)
|
|
396
|
+
return v
|
|
397
|
+
|
|
398
|
+
@field_validator("default_locale")
|
|
399
|
+
@classmethod
|
|
400
|
+
def validate_default_locale_tag(cls, v: str) -> str:
|
|
401
|
+
validate_locale_tag(v)
|
|
402
|
+
return v
|
|
403
|
+
|
|
404
|
+
@model_validator(mode="after")
|
|
405
|
+
def validate_cross_references(self) -> "BuildConfig":
|
|
406
|
+
guide_slugs = {g.slug for g in self.guides}
|
|
407
|
+
group_slugs = {g.slug for g in self.groups}
|
|
408
|
+
|
|
409
|
+
# Duplicate guide slugs
|
|
410
|
+
if len(guide_slugs) != len(self.guides):
|
|
411
|
+
seen = set()
|
|
412
|
+
dupes = []
|
|
413
|
+
for g in self.guides:
|
|
414
|
+
if g.slug in seen:
|
|
415
|
+
dupes.append(g.slug)
|
|
416
|
+
seen.add(g.slug)
|
|
417
|
+
raise ValueError(f"Duplicate guide slugs: {dupes}")
|
|
418
|
+
|
|
419
|
+
# Duplicate group slugs
|
|
420
|
+
if len(group_slugs) != len(self.groups):
|
|
421
|
+
seen = set()
|
|
422
|
+
dupes = []
|
|
423
|
+
for g in self.groups:
|
|
424
|
+
if g.slug in seen:
|
|
425
|
+
dupes.append(g.slug)
|
|
426
|
+
seen.add(g.slug)
|
|
427
|
+
raise ValueError(f"Duplicate group slugs: {dupes}")
|
|
428
|
+
|
|
429
|
+
# Group/guide slug collisions
|
|
430
|
+
collisions = guide_slugs & group_slugs
|
|
431
|
+
if collisions:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"Slug collision between guides and groups: {sorted(collisions)}. "
|
|
434
|
+
"Guide pages and group landing pages share the same URL space."
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Each guide.group must reference an existing group
|
|
438
|
+
for guide in self.guides:
|
|
439
|
+
if guide.group is not None and guide.group not in group_slugs:
|
|
440
|
+
raise ValueError(
|
|
441
|
+
f"Guide '{guide.slug}' references unknown group "
|
|
442
|
+
f"'{guide.group}'. Available groups: {sorted(group_slugs) or '[]'}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Each group.parent must reference an existing group (no self-ref, no cycles)
|
|
446
|
+
groups_by_slug_map = {g.slug: g for g in self.groups}
|
|
447
|
+
for group in self.groups:
|
|
448
|
+
if group.parent is None:
|
|
449
|
+
continue
|
|
450
|
+
if group.parent not in group_slugs:
|
|
451
|
+
raise ValueError(
|
|
452
|
+
f"Group '{group.slug}' references unknown parent "
|
|
453
|
+
f"'{group.parent}'. Available groups: {sorted(group_slugs)}"
|
|
454
|
+
)
|
|
455
|
+
if group.parent == group.slug:
|
|
456
|
+
raise ValueError(f"Group '{group.slug}' cannot be its own parent")
|
|
457
|
+
for group in self.groups:
|
|
458
|
+
visited: set[str] = set()
|
|
459
|
+
cur = group
|
|
460
|
+
while cur.parent is not None:
|
|
461
|
+
if cur.parent in visited:
|
|
462
|
+
raise ValueError(f"Circular parent reference detected involving group '{group.slug}'")
|
|
463
|
+
visited.add(cur.slug)
|
|
464
|
+
cur = groups_by_slug_map[cur.parent]
|
|
465
|
+
|
|
466
|
+
# Navbar references — accept guide slugs (must be ungrouped) and group slugs
|
|
467
|
+
guide_by_slug = {g.slug: g for g in self.guides}
|
|
468
|
+
for slug in self.navbar.order:
|
|
469
|
+
if slug in group_slugs:
|
|
470
|
+
grp = groups_by_slug_map[slug]
|
|
471
|
+
if grp.parent is not None:
|
|
472
|
+
raise ValueError(
|
|
473
|
+
f"Navbar references '{slug}' which is a sub-group (parent: "
|
|
474
|
+
f"'{grp.parent}'). List the parent group in navbar.order instead."
|
|
475
|
+
)
|
|
476
|
+
continue
|
|
477
|
+
if slug in guide_by_slug:
|
|
478
|
+
if guide_by_slug[slug].group is not None:
|
|
479
|
+
raise ValueError(
|
|
480
|
+
f"Navbar references guide '{slug}' but it belongs to "
|
|
481
|
+
f"group '{guide_by_slug[slug].group}'. Reference the "
|
|
482
|
+
"group slug instead, or remove the guide's `group` field."
|
|
483
|
+
)
|
|
484
|
+
continue
|
|
485
|
+
raise ValueError(
|
|
486
|
+
f"Navbar references unknown slug '{slug}'. "
|
|
487
|
+
f"Available guide slugs: {sorted(guide_slugs)}; "
|
|
488
|
+
f"group slugs: {sorted(group_slugs) or '[]'}"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# default_locale must be in locales
|
|
492
|
+
if self.default_locale not in self.locales:
|
|
493
|
+
raise ValueError(f"defaultLocale '{self.default_locale}' is not in locales {self.locales}")
|
|
494
|
+
|
|
495
|
+
# Two locales that normalise to the same tag would emit two hreflang
|
|
496
|
+
# alternates claiming the same language for two different URLs, which
|
|
497
|
+
# makes search engines discard the whole cluster -- and on NTFS/APFS they
|
|
498
|
+
# may resolve to the same output file (the exclusive.py argument).
|
|
499
|
+
by_normalized: dict[str, str] = {}
|
|
500
|
+
for locale in self.locales:
|
|
501
|
+
normalized = normalize_locale(locale)
|
|
502
|
+
first = by_normalized.setdefault(normalized, locale)
|
|
503
|
+
if first != locale:
|
|
504
|
+
raise ValueError(
|
|
505
|
+
f"locales '{first}' and '{locale}' both mean '{normalized}'. They would emit "
|
|
506
|
+
"two hreflang alternates claiming the same language for two different URLs, "
|
|
507
|
+
"which makes search engines discard the whole alternates cluster — and on "
|
|
508
|
+
"Windows and macOS they may resolve to the same output file."
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# A capture path carries locale and layout as bare sibling segments and
|
|
512
|
+
# `captures.classify_capture_path` tells them apart by set membership, so
|
|
513
|
+
# a name in both sets is genuinely ambiguous.
|
|
514
|
+
shared = sorted(set(self.locales) & set(self.layouts))
|
|
515
|
+
if shared:
|
|
516
|
+
raise ValueError(
|
|
517
|
+
f"{shared} appear in both locales and layouts. Capture paths carry both as bare "
|
|
518
|
+
"directory segments, so a name in both is ambiguous — rename one side."
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# default_layout must be in layouts (when both are set)
|
|
522
|
+
if self.layouts:
|
|
523
|
+
if self.default_layout is None:
|
|
524
|
+
# Default to the first declared layout
|
|
525
|
+
object.__setattr__(self, "default_layout", self.layouts[0])
|
|
526
|
+
elif self.default_layout not in self.layouts:
|
|
527
|
+
raise ValueError(f"defaultLayout '{self.default_layout}' is not in layouts {self.layouts}")
|
|
528
|
+
|
|
529
|
+
# Both targets support interactive mode as of stage 4 of the
|
|
530
|
+
# interactive-player feature: the static target loads the same Lit
|
|
531
|
+
# ``<steppy-guide>`` web component via a plain ``<script type="module">``.
|
|
532
|
+
|
|
533
|
+
# Onboarding validation
|
|
534
|
+
if self.onboarding is not None and self.onboarding.enabled:
|
|
535
|
+
if not self.onboarding.guides:
|
|
536
|
+
raise ValueError("onboarding.guides is empty but onboarding.enabled is true")
|
|
537
|
+
seen: set[str] = set()
|
|
538
|
+
for slug in self.onboarding.guides:
|
|
539
|
+
if slug in seen:
|
|
540
|
+
raise ValueError(f"Duplicate slug '{slug}' in onboarding.guides")
|
|
541
|
+
seen.add(slug)
|
|
542
|
+
if slug not in guide_slugs:
|
|
543
|
+
raise ValueError(
|
|
544
|
+
f"onboarding.guides references unknown guide slug '{slug}'. Available: {sorted(guide_slugs)}"
|
|
545
|
+
)
|
|
546
|
+
if ONBOARDING_SLUG in guide_slugs or ONBOARDING_SLUG in group_slugs:
|
|
547
|
+
raise ValueError(
|
|
548
|
+
f"Slug '{ONBOARDING_SLUG}' is reserved for the onboarding "
|
|
549
|
+
"landing page when onboarding is enabled. Rename the "
|
|
550
|
+
"conflicting guide or group."
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
return self
|
|
554
|
+
|
|
555
|
+
def guides_by_slug(self) -> dict[str, GuideEntry]:
|
|
556
|
+
"""Return guides indexed by slug."""
|
|
557
|
+
return {g.slug: g for g in self.guides}
|
|
558
|
+
|
|
559
|
+
def groups_by_slug(self) -> dict[str, GroupConfig]:
|
|
560
|
+
"""Return groups indexed by slug."""
|
|
561
|
+
return {g.slug: g for g in self.groups}
|
|
562
|
+
|
|
563
|
+
def onboarding_index(self) -> dict[str, int]:
|
|
564
|
+
"""Return guide slug → 0-based onboarding step index, or empty dict."""
|
|
565
|
+
if self.onboarding is None or not self.onboarding.enabled:
|
|
566
|
+
return {}
|
|
567
|
+
return {slug: i for i, slug in enumerate(self.onboarding.guides)}
|
|
568
|
+
|
|
569
|
+
def navigation_tree(self) -> list[dict[str, Any]]:
|
|
570
|
+
"""Compute the effective top-level navigation.
|
|
571
|
+
|
|
572
|
+
Returns a list of dicts shaped as one of:
|
|
573
|
+
- {"kind": "onboarding", "title": str, "path": str, "guides": [slug, ...]}
|
|
574
|
+
- {"kind": "group", "slug": str, "title": str, "path": str,
|
|
575
|
+
"description": str|None, "children": [guide_node, ...]}
|
|
576
|
+
- {"kind": "guide", "slug": str, "title": str, "path": str}
|
|
577
|
+
|
|
578
|
+
Order:
|
|
579
|
+
1. Onboarding entry (if enabled) — always first.
|
|
580
|
+
2. Top-level entries (groups + ungrouped guides) by `navbar.order`,
|
|
581
|
+
then by declaration order.
|
|
582
|
+
"""
|
|
583
|
+
nodes: list[dict[str, Any]] = []
|
|
584
|
+
|
|
585
|
+
if self.onboarding is not None and self.onboarding.enabled:
|
|
586
|
+
nodes.append(
|
|
587
|
+
{
|
|
588
|
+
"kind": "onboarding",
|
|
589
|
+
"title": self.onboarding.title,
|
|
590
|
+
"path": f"/{ONBOARDING_SLUG}",
|
|
591
|
+
"guides": list(self.onboarding.guides),
|
|
592
|
+
}
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
guides_by_slug = self.guides_by_slug()
|
|
596
|
+
groups_by_slug = self.groups_by_slug()
|
|
597
|
+
|
|
598
|
+
# Build per-group child lists in declaration order.
|
|
599
|
+
# Sub-groups are appended first to their parent's list, each carrying
|
|
600
|
+
# the SAME list reference as its own children — so guides appended in
|
|
601
|
+
# the next pass automatically land inside the right nested node.
|
|
602
|
+
group_children: dict[str, list[dict[str, Any]]] = {g.slug: [] for g in self.groups}
|
|
603
|
+
for grp in self.groups:
|
|
604
|
+
if grp.parent is not None:
|
|
605
|
+
group_children[grp.parent].append(_group_node(grp, group_children[grp.slug]))
|
|
606
|
+
for guide in self.guides:
|
|
607
|
+
if guide.group is not None:
|
|
608
|
+
group_children[guide.group].append(_guide_node(guide))
|
|
609
|
+
|
|
610
|
+
# Walk navbar.order to seed top-level placement
|
|
611
|
+
placed_top: set[str] = set() # group/ungrouped-guide slugs at top
|
|
612
|
+
ordered_top: list[dict[str, Any]] = []
|
|
613
|
+
for slug in self.navbar.order:
|
|
614
|
+
if slug in placed_top:
|
|
615
|
+
continue
|
|
616
|
+
if slug in groups_by_slug:
|
|
617
|
+
grp = groups_by_slug[slug]
|
|
618
|
+
ordered_top.append(_group_node(grp, group_children[grp.slug]))
|
|
619
|
+
placed_top.add(slug)
|
|
620
|
+
elif slug in guides_by_slug:
|
|
621
|
+
ordered_top.append(_guide_node(guides_by_slug[slug]))
|
|
622
|
+
placed_top.add(slug)
|
|
623
|
+
|
|
624
|
+
# Append any groups not in navbar.order, in declaration order.
|
|
625
|
+
# Sub-groups (parent is not None) only appear nested under their
|
|
626
|
+
# parent — never at the top level.
|
|
627
|
+
for grp in self.groups:
|
|
628
|
+
if grp.slug in placed_top or grp.parent is not None:
|
|
629
|
+
continue
|
|
630
|
+
ordered_top.append(_group_node(grp, group_children[grp.slug]))
|
|
631
|
+
placed_top.add(grp.slug)
|
|
632
|
+
|
|
633
|
+
# Append any ungrouped guides not in navbar.order, in declaration order
|
|
634
|
+
for guide in self.guides:
|
|
635
|
+
if guide.group is not None:
|
|
636
|
+
continue
|
|
637
|
+
if guide.slug in placed_top:
|
|
638
|
+
continue
|
|
639
|
+
ordered_top.append(_guide_node(guide))
|
|
640
|
+
placed_top.add(guide.slug)
|
|
641
|
+
|
|
642
|
+
nodes.extend(ordered_top)
|
|
643
|
+
return nodes
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _guide_node(guide: GuideEntry) -> dict[str, Any]:
|
|
647
|
+
return {
|
|
648
|
+
"kind": "guide",
|
|
649
|
+
"slug": guide.slug,
|
|
650
|
+
"title": guide.title,
|
|
651
|
+
"path": f"/{guide.slug}",
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _group_node(grp: "GroupConfig", children: list[dict[str, Any]]) -> dict[str, Any]:
|
|
656
|
+
"""Nav node for a group — the twin of :func:`_guide_node`.
|
|
657
|
+
|
|
658
|
+
``children`` is passed by reference: sub-groups are seeded before their
|
|
659
|
+
guides, so the caller relies on later appends to that same list showing up
|
|
660
|
+
inside this node.
|
|
661
|
+
"""
|
|
662
|
+
return {
|
|
663
|
+
"kind": "group",
|
|
664
|
+
"slug": grp.slug,
|
|
665
|
+
"title": grp.title,
|
|
666
|
+
"description": grp.description,
|
|
667
|
+
"path": f"/{grp.slug}",
|
|
668
|
+
"children": children,
|
|
669
|
+
}
|