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.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. 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
+ }