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.py ADDED
@@ -0,0 +1,2798 @@
1
+ """Build assembler for Steppy — generates Angular or static guide artifacts from scenarios."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+ from urllib.parse import urlsplit
11
+
12
+ import jinja2
13
+
14
+ from ._player_bundle import (
15
+ get_cursor_bundle_path,
16
+ get_legacy_cursor_iife,
17
+ get_player_bundle_path,
18
+ )
19
+ from ._templating import bundled_templates_dir, make_template_env
20
+ from .captures import DEFAULT_SCREENSHOTS_DIR, resolve_output_dir
21
+ from .build_models import (
22
+ HANDBOOK_SLUG,
23
+ ONBOARDING_SLUG,
24
+ BuildConfig,
25
+ BuildTarget,
26
+ CursorMode,
27
+ GuideEntry,
28
+ GuideMode,
29
+ LocalizedText,
30
+ SeoMode,
31
+ )
32
+ from .guide import GuideAssembler, register_jinja_filters, step_stem
33
+ from .locales import base_language, normalize_locale, og_locale
34
+ from .models import SteppyConfig
35
+ from .naming import GUIDE_SITE_TOURS_DIR
36
+ from .sitemap import Alternate, SitemapUrl, limit_warnings, render_sitemap
37
+ from .parser import parse_scenario_file
38
+
39
+
40
+ class BuildError(Exception):
41
+ """Raised when the build process encounters an error."""
42
+
43
+
44
+ def _warn(message: str) -> None:
45
+ """Emit a build warning to stderr, like ``config._emit_warnings``.
46
+
47
+ build.py has no rich console of its own — the CLI owns that — so anything
48
+ the build wants to say outside its results dict goes here.
49
+ """
50
+ print(f"WARNING: {message}", file=sys.stderr)
51
+
52
+
53
+ # ── Locale display labels ────────────────────────────────────────────────────
54
+
55
+ # Built-in display names, endonyms where practical. A project can add or
56
+ # override any label via ``locale_labels`` in its build config (FLAWS §4.4);
57
+ # an unmapped locale falls back to its uppercased code (e.g. "PT-BR").
58
+ LOCALE_LABELS: dict[str, str] = {
59
+ "fr": "Français",
60
+ "nl": "Nederlands",
61
+ "en": "English",
62
+ "de": "Deutsch",
63
+ "es": "Español",
64
+ "it": "Italiano",
65
+ "pt": "Português",
66
+ "ca": "Català",
67
+ "da": "Dansk",
68
+ "sv": "Svenska",
69
+ "no": "Norsk",
70
+ "fi": "Suomi",
71
+ "pl": "Polski",
72
+ "cs": "Čeština",
73
+ "ro": "Română",
74
+ "hu": "Magyar",
75
+ "el": "Ελληνικά",
76
+ "tr": "Türkçe",
77
+ "ru": "Русский",
78
+ "uk": "Українська",
79
+ "ar": "العربية",
80
+ "ja": "日本語",
81
+ "ko": "한국어",
82
+ "zh": "中文",
83
+ }
84
+
85
+ # One row per GuideMode member. `templates/angular/component.ts.j2` indexes this
86
+ # directly (`mode_labels[mode]`) while looping over a guide's declared modes, so
87
+ # a missing row is a Jinja UndefinedError, not a cosmetic gap.
88
+ # Guard: tests/test_guide_mode_sync.py.
89
+ MODE_LABELS: dict[str, str] = {
90
+ "stepbystep": "Step by Step",
91
+ "interactive": "Interactive",
92
+ "video": "Video",
93
+ }
94
+
95
+
96
+ # Default UI strings emitted into every locale's i18n JSON. The host app's
97
+ # translation team edits the generated files post-build; we ship English
98
+ # defaults so an unedited build is at least usable in English. ngx-translate
99
+ # placeholder syntax (``{{var}}``) is preserved untouched.
100
+ UI_LABEL_DEFAULTS: dict[str, dict[str, str]] = {
101
+ "onboarding": {
102
+ "start": "Start onboarding →",
103
+ "stepLabel": "step {{position}} of {{total}}",
104
+ "previousLabel": "Start",
105
+ "doneLabel": "Finish",
106
+ },
107
+ "ui": {
108
+ "groupEmpty": "No guides in this group yet.",
109
+ },
110
+ }
111
+
112
+
113
+ # ── Localized text resolution ────────────────────────────────────────────────
114
+
115
+
116
+ def pick_locale(
117
+ value: Optional[LocalizedText],
118
+ locale: str,
119
+ default_locale: str,
120
+ ) -> Optional[str]:
121
+ """Resolve a LocalizedText value for a target locale.
122
+
123
+ - Plain string → returned as-is (treated as locale-agnostic).
124
+ - Dict → returns ``value[locale]`` if present, else the entry for the bare
125
+ language subtag, else ``value[default_locale]``, else ``None``.
126
+ - ``None`` → ``None`` (used for optional description fields).
127
+
128
+ The language-subtag rung is what keeps a regional locale from silently
129
+ serving another language's text. A site on ``['fr-BE', 'nl-BE']`` whose maps
130
+ are written ``{fr: …, nl: …}`` — the overwhelmingly likely authoring —
131
+ would otherwise fall straight through to the default locale, so every
132
+ ``nl-BE`` page would carry the **French** meta description and og:title
133
+ while declaring ``lang="nl-BE"``. Two URLs with identical text claiming
134
+ different languages is the textbook hreflang-cluster rejection.
135
+
136
+ Only the *query* is truncated, never widened: ``fr`` must not match a map
137
+ that has only ``fr-BE``, because with both ``fr-BE`` and ``fr-CA`` present
138
+ the choice would be arbitrary and dict-order dependent.
139
+ """
140
+ if value is None:
141
+ return None
142
+ if isinstance(value, str):
143
+ return value
144
+ if locale in value:
145
+ return value[locale]
146
+ bare = base_language(locale)
147
+ if bare != locale and bare in value:
148
+ return value[bare]
149
+ return value.get(default_locale)
150
+
151
+
152
+ # ── Config loading ───────────────────────────────────────────────────────────
153
+
154
+
155
+ def load_ts_config(config_path: Path) -> dict:
156
+ """Evaluate a TypeScript config file via Node subprocess and return the parsed dict.
157
+
158
+ Tries ``npx tsx`` to evaluate the config. The target Angular app is expected
159
+ to have Node.js available.
160
+
161
+ Raises:
162
+ BuildError: If the config cannot be loaded (Node missing, syntax error, etc.)
163
+ """
164
+ resolved = config_path.resolve().as_posix()
165
+ # Single-line script — embedded newlines break stdout capture when npx.CMD
166
+ # is invoked via subprocess on Windows.
167
+ # ``json.dumps`` emits a properly-escaped JS string literal so a path with a
168
+ # quote/backslash can't break out of (or inject into) the import (FLAWS §5.2).
169
+ script = (
170
+ f"import config from {json.dumps(resolved)}; process.stdout.write(JSON.stringify(config.default ?? config));"
171
+ )
172
+ # NOTE: shell=(os.name == "nt") is required to invoke npx.CMD on Windows.
173
+ # It stays safe because ``script`` always contains spaces, so Python's
174
+ # list2cmdline double-quotes it — inside double quotes cmd.exe does not treat
175
+ # & | ^ as metacharacters, so a path with those characters can't inject.
176
+ npx = shutil.which("npx") or "npx"
177
+ try:
178
+ result = subprocess.run(
179
+ [npx, "tsx", "-e", script],
180
+ capture_output=True,
181
+ text=True,
182
+ encoding="utf-8",
183
+ errors="replace",
184
+ cwd=str(config_path.parent),
185
+ timeout=30,
186
+ shell=(os.name == "nt"),
187
+ )
188
+ except FileNotFoundError:
189
+ raise BuildError(
190
+ "Node.js / npx not found. The build command requires Node.js "
191
+ "to evaluate the TypeScript config file. Install Node.js and "
192
+ "ensure npx is on your PATH."
193
+ )
194
+ except subprocess.TimeoutExpired:
195
+ raise BuildError(f"Timed out evaluating config {config_path}. Check that the config file is valid TypeScript.")
196
+
197
+ if result.returncode != 0:
198
+ raise BuildError(f"Failed to evaluate config {config_path}:\n{result.stderr.strip()}")
199
+
200
+ try:
201
+ return json.loads(result.stdout)
202
+ except json.JSONDecodeError as exc:
203
+ raise BuildError(f"Config {config_path} did not produce valid JSON: {exc}")
204
+
205
+
206
+ def load_build_config(config_path: Path) -> BuildConfig:
207
+ """Load and validate a build config from a TypeScript file.
208
+
209
+ Returns a validated ``BuildConfig`` instance.
210
+
211
+ Raises:
212
+ BuildError: On load or validation failure.
213
+ """
214
+ if not config_path.exists():
215
+ raise BuildError(f"Config file not found: {config_path}")
216
+
217
+ raw = load_ts_config(config_path)
218
+ try:
219
+ return BuildConfig(**raw)
220
+ except Exception as exc:
221
+ raise BuildError(f"Invalid config: {exc}")
222
+
223
+
224
+ # ── Helpers ──────────────────────────────────────────────────────────────────
225
+
226
+
227
+ def _slug_to_class_name(slug: str) -> str:
228
+ """Convert a slug like ``my-guide`` to a PascalCase class name ``MyGuide``."""
229
+ return "".join(part.capitalize() for part in slug.replace("_", "-").split("-"))
230
+
231
+
232
+ def _role_namespace(role: str) -> str:
233
+ """Normalize a role to the uppercase identifier used as the i18n key
234
+ prefix and the routes-export const stem (``editor`` → ``EDITOR``,
235
+ ``field-agent`` → ``FIELD_AGENT``)."""
236
+ return role.upper().replace("-", "_")
237
+
238
+
239
+ # ── Build assembler ──────────────────────────────────────────────────────────
240
+
241
+
242
+ class BuildAssembler:
243
+ """Generates guide artifacts from a build config and scenario files.
244
+
245
+ Supports two targets: ``angular`` (default) emits standalone components,
246
+ routes, and a navbar consumable by an Angular host app. ``static`` emits
247
+ a self-contained static site (full HTML pages, index, manifest.json, copied
248
+ screenshots) suitable for any plain static host.
249
+ """
250
+
251
+ def __init__(
252
+ self,
253
+ build_config: BuildConfig,
254
+ project_root: Path,
255
+ steppy_config: Optional[SteppyConfig] = None,
256
+ templates_dir: Optional[Path] = None,
257
+ ):
258
+ self.build_config = build_config
259
+ self.project_root = project_root
260
+
261
+ # Deduped (guide, locale, layout) keys already warned about (see
262
+ # _warn_layout_capture_missing).
263
+ self._warned_missing_layouts: set[tuple[str, str, str]] = set()
264
+
265
+ # Effective layouts per guide slug, filled by _build_guide (see
266
+ # _layouts_for_guide). Read by the static manifest writer.
267
+ self._guide_layouts: dict[str, list[str]] = {}
268
+
269
+ # PDF export state, armed by build_all(pdf=…, handbook=…). Static
270
+ # target only; every other build leaves these untouched and inert.
271
+ self._pdf_enabled = False
272
+ self._handbook_enabled = False
273
+ # Emitted (page html, title) pairs to print — filled by _emit_static_guide.
274
+ self._pdf_pages: list[tuple[Path, str]] = []
275
+ # locale -> {guide slug: rendered fragment} for the handbook, reusing
276
+ # the fragments _build_guide already rendered.
277
+ self._handbook_fragments: dict[str, dict[str, str]] = {}
278
+
279
+ # Video export state, armed by build_all(video=True). Jobs are collected
280
+ # during the normal pass and rendered once at the end, sharing a single
281
+ # browser — the same shape as the PDF pass above.
282
+ self._video_enabled = False
283
+ self._video_jobs: list = []
284
+ # slug -> {locale: [relative media paths]}, for the page templates and
285
+ # the site manifest.
286
+ self._video_media: dict[str, dict[str, dict[str, str]]] = {}
287
+ self.steppy_config = steppy_config
288
+
289
+ # In-app tours to republish (the tours ``guide`` target), armed by
290
+ # build_all before the wipe; None when nothing is published.
291
+ self._tours_publish = None
292
+
293
+ # Effective locale display names: built-in map, overridden per-project
294
+ # by the build config's ``locale_labels`` (FLAWS §4.4).
295
+ self.locale_labels: dict[str, str] = {
296
+ **LOCALE_LABELS,
297
+ **build_config.locale_labels,
298
+ }
299
+
300
+ # The *only* place the normalised spelling of a locale lives. Everything
301
+ # else -- filenames, capture directories, LocalizedText keys, the host
302
+ # app's i18n code -- keeps the authored tag verbatim, because those are
303
+ # identities in systems Steppy does not own (see src/steppy/locales.py).
304
+ # Holding the second form in one dict is what stops a third sprouting.
305
+ self.html_lang: dict[str, str] = {loc: normalize_locale(loc) for loc in build_config.locales}
306
+
307
+ self.env = make_template_env(templates_dir, project_root)
308
+ register_jinja_filters(self.env)
309
+ self.env.globals["pick_locale"] = pick_locale
310
+ self.env.globals["default_locale"] = build_config.default_locale
311
+
312
+ # Pre-build the GuideAssembler for step preparation
313
+ self._guide_assembler = self._create_guide_assembler()
314
+
315
+ def _create_guide_assembler(self) -> GuideAssembler:
316
+ """Create a GuideAssembler with a minimal config if no steppy_config provided.
317
+
318
+ ``project_root`` is threaded through deliberately: without it the
319
+ assembler would resolve local templates from the current working
320
+ directory while this build resolves them from the project root, so a
321
+ single build could consult two different ``templates/`` trees.
322
+ """
323
+ if self.steppy_config:
324
+ return GuideAssembler(self.steppy_config, project_root=self.project_root)
325
+
326
+ # Build a minimal SteppyConfig sufficient for prepare_guide_steps
327
+ minimal_config = SteppyConfig(
328
+ apps={},
329
+ auth={},
330
+ )
331
+ return GuideAssembler(minimal_config, project_root=self.project_root)
332
+
333
+ def _resolve_path(self, relative: str) -> Path:
334
+ """Resolve a path relative to the project root."""
335
+ return (self.project_root / relative).resolve()
336
+
337
+ def _screenshots_root(self) -> Path:
338
+ """Where this build reads captures from (FLAWS §4.6).
339
+
340
+ Precedence: an explicit ``screenshotsDir`` in the TypeScript build
341
+ config → ``config.output.screenshots_dir`` from ``config.yaml`` (what
342
+ the generated conftest actually wrote to) → the ``BuildConfig``
343
+ default. ``model_fields_set`` is what separates "explicitly set" from
344
+ "defaulted" — without it a build could never fall back, since the field
345
+ carries a default of its own.
346
+
347
+ ``steppy_config`` is optional (``cli.build`` tolerates an unloadable
348
+ ``config.yaml``), in which case only the first and last rungs exist.
349
+ """
350
+ if "screenshots_dir" in self.build_config.model_fields_set or self.steppy_config is None:
351
+ return self._resolve_path(self.build_config.screenshots_dir)
352
+ return resolve_output_dir(self.project_root, self.steppy_config.output.screenshots_dir, DEFAULT_SCREENSHOTS_DIR)
353
+
354
+ def _is_static(self) -> bool:
355
+ return self.build_config.target == BuildTarget.STATIC
356
+
357
+ def _lang_switcher_links(
358
+ self,
359
+ href_for: "callable[[str], str]",
360
+ current_locale: str,
361
+ ) -> list[dict]:
362
+ """Build language-switcher link descriptors for a static page.
363
+
364
+ ``href_for(locale)`` returns the target URL (relative to the rendered
365
+ page) for the given locale. The active locale renders as a span; others
366
+ render as links.
367
+ """
368
+ return [
369
+ {
370
+ "locale": loc,
371
+ "label": self.locale_labels.get(loc, loc.upper()),
372
+ "href": href_for(loc),
373
+ "active": loc == current_locale,
374
+ }
375
+ for loc in self.build_config.locales
376
+ ]
377
+
378
+ def _redirect_html(
379
+ self,
380
+ target_url: str,
381
+ *,
382
+ lang: str,
383
+ canonical: Optional[str] = None,
384
+ noindex: bool = False,
385
+ ) -> str:
386
+ """Tiny meta-refresh HTML used to point bare directory URLs at a
387
+ default-locale page. Kept inline (no template) since it's trivial.
388
+
389
+ ``noindex`` adds a robots noindex meta (private seo mode) so redirect
390
+ stubs are not indexable either. Public builds deliberately do **not**
391
+ get one: a ``noindex`` page canonicalling into an indexable cluster is a
392
+ known footgun, and a zero-delay meta refresh already reads as a
393
+ redirect, so the stub's own URL is not indexed either way.
394
+
395
+ ``canonical`` defaults to ``target_url``; public builds pass the
396
+ absolute form so every canonical on the site has the same shape."""
397
+ robots = '<meta name="robots" content="noindex,nofollow,noarchive">\n' if noindex else ""
398
+ return (
399
+ f'<!doctype html>\n<html lang="{lang}">\n<meta charset="utf-8">\n'
400
+ f"{robots}"
401
+ f'<meta http-equiv="refresh" content="0; url={target_url}">\n'
402
+ f'<link rel="canonical" href="{canonical or target_url}">\n'
403
+ f"<title>Redirecting…</title>\n"
404
+ f'<p><a href="{target_url}">Redirecting…</a></p>\n'
405
+ )
406
+
407
+ def _dir_redirect_stub(self, dir_path: str) -> str:
408
+ """The ``<dir>/index.html`` meta-refresh stub a section gets so its bare
409
+ directory URL resolves instead of 404ing.
410
+
411
+ One helper rather than four call sites each deciding independently what a
412
+ stub's lang, canonical and robots directive should be."""
413
+ default_locale = self.build_config.default_locale
414
+ private = self._is_private_seo()
415
+ return self._redirect_html(
416
+ f"./{default_locale}.html",
417
+ lang=self._html_lang(default_locale),
418
+ canonical=(None if private else self._abs_url(self._page_site_rel(dir_path, default_locale))),
419
+ noindex=private,
420
+ )
421
+
422
+ # ── SEO / indexing (static target) ───────────────────────────────────
423
+
424
+ def _is_private_seo(self) -> bool:
425
+ """True when the static site should be non-indexable (the default)."""
426
+ return self.build_config.seo.mode != SeoMode.PUBLIC
427
+
428
+ def _abs_url(self, site_rel: str) -> str:
429
+ """Absolute URL for a site-root-relative path (public seo mode)."""
430
+ base = self.build_config.seo.base_url or ""
431
+ return f"{base}/{site_rel.lstrip('/')}"
432
+
433
+ def _html_lang(self, locale: str) -> str:
434
+ """The BCP-47 spelling of ``locale``, for ``<html lang>`` / ``hreflang``.
435
+
436
+ Falls back to the authored tag for anything outside the configured set
437
+ (nothing is, today) rather than raising in the middle of a render."""
438
+ return self.html_lang.get(locale, locale)
439
+
440
+ def _page_site_rel(self, dir_path: str, locale: str, is_index: bool = False) -> str:
441
+ """Site-root-relative path for a page in a given locale.
442
+
443
+ The default-locale index resolves to ``""`` — the bare site root —
444
+ rather than ``index.html``. Both serve the same bytes, so a canonical
445
+ (and a sitemap ``<loc>``, and an hreflang href) naming the filename has
446
+ the home page indexed as two URLs. The file is still *written* as
447
+ ``index.html`` by ``_index_filename``; only the URL differs.
448
+
449
+ Directory stubs elsewhere are deliberately not treated this way:
450
+ ``<group>/index.html`` is a meta-refresh stub rather than the content,
451
+ so ``<group>/`` must never become a canonical."""
452
+ if is_index:
453
+ if locale == self.build_config.default_locale:
454
+ return ""
455
+ return self._index_filename(locale)
456
+ fname = f"{locale}.html"
457
+ return f"{dir_path}/{fname}" if dir_path else fname
458
+
459
+ def _has_video(self, entry: GuideEntry) -> bool:
460
+ """Did this build actually produce video for ``entry``?
461
+
462
+ ``bool(...get(slug))`` rather than ``slug in ...``: ``_emit_video_guide``
463
+ assigns the key unconditionally, so a guide whose media dict came out
464
+ empty would answer ``True`` to a membership test.
465
+ """
466
+ return bool(self._video_media.get(entry.slug))
467
+
468
+ def _emitted_modes(self, entry: GuideEntry) -> list[str]:
469
+ """The guide's modes, narrowed to the ones that have a page behind them.
470
+
471
+ A guide can declare ``video`` without ``--video`` having armed the
472
+ render, and offering a "Video" tab with no file behind it is worse than
473
+ not offering it. The component toggle and the guide page's mode row
474
+ already applied that rule; ``manifest.json`` and ``<role>.nav.json`` did
475
+ not, so both advertised a mode whose page was never written and any
476
+ consumer building a switcher from them constructed a 404. One helper, so
477
+ there is no fifth spelling to get wrong.
478
+ """
479
+ has_video = self._has_video(entry)
480
+ return [m.value for m in entry.modes if m is not GuideMode.VIDEO or has_video]
481
+
482
+ def _mode_links(self, entry: GuideEntry, current: str, locale: str) -> list[dict]:
483
+ """The "other renderings of this guide" row, for every page that is one.
484
+
485
+ Returns ``[]`` when there is only one rendering — a single-mode guide
486
+ gets no row rather than a row of one.
487
+
488
+ The row exists because these pages canonical to each other: the
489
+ interactive and video pages point ``rel=canonical`` at the step-by-step
490
+ page (see ``_guide_primary_site_rel``), so a crawler is told where the
491
+ real version is while a reader was given no way to get there. The
492
+ interactive page had no outbound link to it at all, and the video page
493
+ had a hand-rolled one whose label did not even match ``MODE_LABELS``.
494
+ """
495
+ candidates = [("stepbystep", f"./{locale}.html")]
496
+ if GuideMode.INTERACTIVE in entry.modes:
497
+ candidates.append(("interactive", "./interactive.html"))
498
+ if self._has_video(entry):
499
+ candidates.append(("video", "./video.html"))
500
+ if len(candidates) < 2:
501
+ return []
502
+ return [{"label": MODE_LABELS[mode], "href": href, "active": mode == current} for mode, href in candidates]
503
+
504
+ def _guide_primary_site_rel(self, entry: GuideEntry) -> str:
505
+ """The one URL a guide's other renderings canonical to.
506
+
507
+ ``interactive.html`` and ``video.html`` present the *same* steps as the
508
+ per-locale step-by-step pages, so leaving them self-canonical gave every
509
+ guide three or four near-identical indexable URLs. They point here
510
+ instead, and drop out of the sitemap.
511
+
512
+ There is deliberately no "unless the guide has no step-by-step page"
513
+ branch, because there is no such guide: ``_build_guide`` calls
514
+ ``_emit_static_guide`` unconditionally and ``GuideMode``'s docstring says
515
+ ``stepbystep`` is *always emitted*. A branch that cannot fire would read
516
+ like a safety net while protecting nothing."""
517
+ return self._page_site_rel(entry.slug, self.build_config.default_locale)
518
+
519
+ def _hreflang_alternates(self, dir_path: str, is_index: bool = False) -> list[dict]:
520
+ """Absolute per-locale sibling URLs for ``<link rel="alternate">`` and
521
+ sitemap ``<xhtml:link>``. Empty for single-locale sites."""
522
+ locales = self.build_config.locales
523
+ if len(locales) <= 1:
524
+ return []
525
+ alts = [
526
+ {
527
+ "hreflang": self._html_lang(loc),
528
+ "href": self._abs_url(self._page_site_rel(dir_path, loc, is_index)),
529
+ }
530
+ for loc in locales
531
+ ]
532
+ alts.append(
533
+ {
534
+ "hreflang": "x-default",
535
+ "href": self._abs_url(self._page_site_rel(dir_path, self.build_config.default_locale, is_index)),
536
+ }
537
+ )
538
+ return alts
539
+
540
+ def _first_screenshot_site_rel(self, steps_by_layout: dict, default_layout: Optional[str]) -> Optional[str]:
541
+ """Site-root-relative URL of a guide's first screenshot, for
542
+ ``og:image``. Strips the page-relative ``../`` prefix baked into the
543
+ step data (``../assets/...`` → ``assets/...``)."""
544
+ if not steps_by_layout:
545
+ return None
546
+ if default_layout and default_layout in steps_by_layout:
547
+ key = default_layout
548
+ elif "default" in steps_by_layout:
549
+ key = "default"
550
+ else:
551
+ key = next(iter(steps_by_layout), None)
552
+ if key is None:
553
+ return None
554
+ steps = steps_by_layout.get(key) or []
555
+ if not steps:
556
+ return None
557
+ url = steps[0].get("screenshot")
558
+ if not url:
559
+ return None
560
+ while url.startswith("../"):
561
+ url = url[3:]
562
+ return url.lstrip("/")
563
+
564
+ def _seo_for_page(
565
+ self,
566
+ *,
567
+ locale: str,
568
+ title,
569
+ description,
570
+ dir_path: Optional[str] = None,
571
+ is_index: bool = False,
572
+ site_rel: Optional[str] = None,
573
+ alternates: Optional[list] = None,
574
+ og_image_site_rel: Optional[str] = None,
575
+ og_type: str = "article",
576
+ ) -> dict:
577
+ """Per-page SEO context for the ``static/_seo_head.html.j2`` partial.
578
+
579
+ Private mode (default) → only a ``noindex`` robots directive. Public
580
+ mode → canonical + hreflang alternates + meta description + optional
581
+ Open Graph. ``title``/``description`` are ``LocalizedText`` — callers
582
+ pre-apply the title fallback for descriptions."""
583
+ if self._is_private_seo():
584
+ return {"robots": "noindex,nofollow,noarchive"}
585
+ default_locale = self.build_config.default_locale
586
+ if site_rel is None:
587
+ site_rel = self._page_site_rel(dir_path or "", locale, is_index)
588
+ canonical = self._abs_url(site_rel)
589
+ desc = pick_locale(description, locale, default_locale)
590
+ if alternates is None:
591
+ alternates = self._hreflang_alternates(dir_path or "", is_index)
592
+ ctx: dict = {
593
+ "robots": None,
594
+ "canonical": canonical,
595
+ "description": desc,
596
+ "alternates": alternates,
597
+ }
598
+ if self.build_config.seo.open_graph:
599
+ ctx["og"] = {
600
+ "type": og_type,
601
+ "title": pick_locale(title, locale, default_locale),
602
+ "description": desc,
603
+ "url": canonical,
604
+ "site_name": pick_locale(self.build_config.site.title, locale, default_locale),
605
+ # None (and therefore omitted) unless the tag has a valid Open
606
+ # Graph spelling -- see locales.og_locale.
607
+ "locale": og_locale(self._html_lang(locale)),
608
+ "image": (self._abs_url(og_image_site_rel) if og_image_site_rel else None),
609
+ }
610
+ return ctx
611
+
612
+ # ── Per-guide layout dimension ───────────────────────────────────────
613
+
614
+ def _layouts_for_guide(self, entry: GuideEntry, scenario) -> list[str]:
615
+ """The layouts this guide is actually built for.
616
+
617
+ ``BuildConfig.layouts`` is the site-wide dimension; a scenario may
618
+ *declare* the viewports it covers via frontmatter ``layouts:`` (the
619
+ same declaration that makes ``steppy run`` parametrize over them).
620
+ Intersect the two, keeping build order, so a desktop-only flow stops
621
+ shipping a "Mobile" toggle that swaps between identical screenshots.
622
+
623
+ A scenario that declares nothing keeps the full build dimension —
624
+ output is byte-identical to the pre-feature build. Only the
625
+ *declaration* narrows: a missing capture still falls back with a
626
+ warning (``_warn_layout_capture_missing``), because a failed capture
627
+ must not silently shrink the published site.
628
+ """
629
+ build_layouts = list(self.build_config.layouts)
630
+ declared = getattr(scenario, "layouts", None)
631
+ if not build_layouts or not declared:
632
+ return build_layouts
633
+ narrowed = [layout for layout in build_layouts if layout in declared]
634
+ if not narrowed:
635
+ raise BuildError(
636
+ f"Guide '{entry.slug}': scenario '{entry.scenario}' declares layouts "
637
+ f"{sorted(declared)} but the build declares {build_layouts} — no overlap, "
638
+ f"so there is nothing to render. Align the scenario's frontmatter `layouts:` "
639
+ f"with the build config's `layouts`."
640
+ )
641
+ return narrowed
642
+
643
+ def _default_layout_for(self, layouts: list[str]) -> Optional[str]:
644
+ """The build's default layout when it survived the per-guide
645
+ intersection, else this guide's first layout (``None`` with no layouts)."""
646
+ if not layouts:
647
+ return None
648
+ configured = self.build_config.default_layout
649
+ if configured and configured in layouts:
650
+ return configured
651
+ return layouts[0]
652
+
653
+ def _warn_layout_capture_missing(self, guide_slug: str, scenario_slug: str, locale: str, layout: str) -> None:
654
+ """Warn once per (guide, locale, layout) that a configured layout has no capture.
655
+
656
+ Emitted to stderr like ``config._emit_warnings`` — build.py has no
657
+ console. Deduped because the message would otherwise repeat per guide
658
+ per locale on every build of a project that simply has not recaptured.
659
+ """
660
+ key = (guide_slug, locale, layout)
661
+ if key in self._warned_missing_layouts:
662
+ return
663
+ self._warned_missing_layouts.add(key)
664
+ print(
665
+ f"WARNING: guide '{guide_slug}' declares layout '{layout}' but no capture exists at "
666
+ f"screenshots/{scenario_slug}/{locale}/{layout}/ — falling back to the coarser capture. "
667
+ f"The '{layout}' view will show the same screenshots as the others. "
668
+ f"Recapture with: steppy run --guide --layouts {layout}",
669
+ file=sys.stderr,
670
+ )
671
+
672
+ def _rel(self, path: Path) -> str:
673
+ """Path key for the results dict — project-relative when possible."""
674
+ try:
675
+ return str(path.relative_to(self.project_root))
676
+ except ValueError:
677
+ return str(path)
678
+
679
+ def _copy_binary(self, src: Path, dest: Path, results: dict[str, str], dry_run: bool) -> None:
680
+ """Copy a vendored binary asset, recording the same
681
+ ``planned|unchanged|updated|generated`` status ``_write_file`` uses.
682
+
683
+ NOTE: the ``unchanged``/``updated`` branches are currently unreachable on
684
+ a real build. ``build_all`` rmtrees both ``output_dir`` and
685
+ ``assets_dir`` before any writer runs, and every writer targets a path
686
+ under one of them, so ``dest.exists()`` is always False and every build
687
+ reports ``N generated, 0 updated, 0 unchanged``. The byte-compare is
688
+ kept because it costs one read of a file we were about to read anyway
689
+ and it is what makes this helper correct if incremental builds are ever
690
+ introduced — but do not read it as evidence that they exist today.
691
+ """
692
+ rel = self._rel(dest)
693
+ if dry_run:
694
+ results[rel] = "planned"
695
+ return
696
+ dest.parent.mkdir(parents=True, exist_ok=True)
697
+ existed = dest.exists()
698
+ if existed and dest.read_bytes() == src.read_bytes():
699
+ results[rel] = "unchanged"
700
+ else:
701
+ shutil.copy2(src, dest)
702
+ results[rel] = "updated" if existed else "generated"
703
+
704
+ def _resolve_assets_dir(self, output_dir: Path) -> Path:
705
+ """Pick the assets directory based on target.
706
+
707
+ Static target writes screenshots into ``outputDir/assets/`` so the
708
+ build is self-contained and pages can use relative URLs that work on
709
+ any deploy root. Angular target uses the configured ``assetsDir``
710
+ (typically the host app's ``public/`` folder).
711
+ """
712
+ if self._is_static():
713
+ return output_dir / "assets"
714
+ return self._resolve_path(self.build_config.assets_dir)
715
+
716
+ def validate(self) -> None:
717
+ """Validate that all referenced files exist and config is consistent.
718
+
719
+ Raises:
720
+ BuildError: On any validation failure.
721
+ """
722
+ scenarios_root = self._resolve_path(self.build_config.scenarios_root)
723
+ screenshots_dir = self._screenshots_root()
724
+
725
+ if not scenarios_root.exists():
726
+ raise BuildError(f"Scenarios root not found: {scenarios_root}")
727
+
728
+ if not screenshots_dir.exists():
729
+ raise BuildError(f"Screenshots directory not found: {screenshots_dir}")
730
+
731
+ for entry in self.build_config.guides:
732
+ scenario_path = scenarios_root / entry.scenario
733
+ if not scenario_path.exists():
734
+ raise BuildError(f"Scenario file not found for guide '{entry.slug}': {scenario_path}")
735
+
736
+ def build_all(
737
+ self,
738
+ dry_run: bool = False,
739
+ *,
740
+ pdf: bool = False,
741
+ handbook: bool = False,
742
+ video: bool = False,
743
+ ) -> dict[str, str]:
744
+ """Build all artifacts for the configured target.
745
+
746
+ Args:
747
+ dry_run: If True, compute what would be written but don't write.
748
+ pdf: Also print every emitted guide page to PDF and link it from
749
+ the page (static target only).
750
+ handbook: Also assemble ``handbook/<locale>.html`` + ``.pdf`` — the
751
+ whole site as one document — and link it from the index.
752
+ video: Also render a narrated MP4 per (locale, layout) for each
753
+ guide declaring ``modes: ['video']``. Both gates are required:
754
+ the flag is the cost decision, the mode is the content one.
755
+
756
+ Returns:
757
+ Dict mapping file paths (relative to project root) to status
758
+ ("generated", "updated", "unchanged", "planned").
759
+ """
760
+ self.validate()
761
+ self._arm_pdf_export(pdf=pdf, handbook=handbook)
762
+ self._arm_video_export(video=video)
763
+ results: dict[str, str] = {}
764
+
765
+ output_dir = self._resolve_path(self.build_config.output_dir)
766
+ assets_dir = self._resolve_assets_dir(output_dir)
767
+ # Before the wipe: a missing or stale tour tree fails the build while
768
+ # the previous site is still intact.
769
+ self._arm_tours(output_dir, assets_dir)
770
+
771
+ if not dry_run:
772
+ # Clean output directory
773
+ if output_dir.exists():
774
+ shutil.rmtree(output_dir)
775
+ output_dir.mkdir(parents=True, exist_ok=True)
776
+
777
+ # Clean assets directory
778
+ if assets_dir.exists():
779
+ shutil.rmtree(assets_dir)
780
+ assets_dir.mkdir(parents=True, exist_ok=True)
781
+
782
+ # Build each guide (target-aware: writes every per-guide artifact inline).
783
+ for entry in self.build_config.guides:
784
+ self._build_guide(entry, output_dir, assets_dir, results, dry_run)
785
+
786
+ # Videos are rendered before the shared files so the index/manifest can
787
+ # report what actually exists, and after every guide so one browser
788
+ # covers the whole batch.
789
+ if self._video_enabled:
790
+ self._render_build_videos(results, dry_run)
791
+
792
+ # Write shared site files (target-specific)
793
+ if self._is_static():
794
+ self._emit_static_tours(output_dir, results, dry_run)
795
+ self._emit_static_shared(output_dir, results, dry_run)
796
+ if self._handbook_enabled:
797
+ self._write_static_handbooks(output_dir, results, dry_run)
798
+ if self._pdf_enabled or self._handbook_enabled:
799
+ self._render_build_pdfs(output_dir, results, dry_run)
800
+ else:
801
+ self._emit_angular_shared(output_dir, results, dry_run)
802
+
803
+ return results
804
+
805
+ def _onboarding_strip_for(self, entry: GuideEntry) -> Optional[dict]:
806
+ """Return per-guide onboarding navigation context, or None.
807
+
808
+ Shape: {prev, next, position, total, slug, title (entry's), title (onboarding's)…}
809
+ Only emitted when the guide participates in the onboarding path.
810
+ """
811
+ onb = self.build_config.onboarding
812
+ if onb is None or not onb.enabled or entry.slug not in onb.guides:
813
+ return None
814
+ index = onb.guides.index(entry.slug)
815
+ total = len(onb.guides)
816
+ guides_by_slug = self.build_config.guides_by_slug()
817
+ prev_slug = onb.guides[index - 1] if index > 0 else None
818
+ next_slug = onb.guides[index + 1] if index < total - 1 else None
819
+ return {
820
+ "title": onb.title,
821
+ "path_slug": ONBOARDING_SLUG,
822
+ # Integer position/total feed the Angular template's
823
+ # ngx-translate placeholder substitution. The pre-formatted
824
+ # *_label fields are retained for the static target template.
825
+ "position": index + 1,
826
+ "total": total,
827
+ "position_label": f"step {index + 1} of {total}",
828
+ "prev_label": "Start",
829
+ "done_label": "Finish",
830
+ "prev": ({"slug": prev_slug, "title": guides_by_slug[prev_slug].title} if prev_slug else None),
831
+ "next": ({"slug": next_slug, "title": guides_by_slug[next_slug].title} if next_slug else None),
832
+ }
833
+
834
+ def _build_guide(
835
+ self,
836
+ entry: GuideEntry,
837
+ output_dir: Path,
838
+ assets_dir: Path,
839
+ results: dict[str, str],
840
+ dry_run: bool,
841
+ ) -> None:
842
+ """Build artifacts for a single guide entry.
843
+
844
+ Renders the per-locale HTML fragment(s) (target-agnostic), copies
845
+ screenshots, then emits target-specific per-guide files (Angular
846
+ component TS files, or static full-page HTML files).
847
+
848
+ Writes every per-guide artifact inline and returns nothing — the shared
849
+ emitters re-derive whatever they need from ``build_config``.
850
+ """
851
+ guide_dir = output_dir / entry.slug
852
+ if not dry_run:
853
+ guide_dir.mkdir(parents=True, exist_ok=True)
854
+
855
+ scenarios_root = self._resolve_path(self.build_config.scenarios_root)
856
+ screenshots_dir = self._screenshots_root()
857
+ scenario_path = scenarios_root / entry.scenario
858
+
859
+ # Parse the scenario
860
+ scenario = parse_scenario_file(scenario_path)
861
+ role = self.build_config.role
862
+
863
+ # Build target-aware screenshot URL resolver.
864
+ # - Angular: absolute URL ``/assets/<role>/...`` so [innerHTML]-injected
865
+ # images resolve from any route depth. The host app exports the
866
+ # generated assets folder into its ``public/assets/<role>/``.
867
+ # - Static: relative URL from <slug>/<locale>.html → ../assets/<slug>/<locale>/[<layout>/]step-NN.png,
868
+ # so the build is portable across any deploy root (root domain or subpath).
869
+ # When per-locale or per-layout subdirs aren't on disk we degrade to
870
+ # the next coarser layout so single-language / single-layout projects
871
+ # keep building without recapture.
872
+ is_static = self._is_static()
873
+
874
+ def make_url_fn(locale_segment: Optional[str], layout_segment: Optional[str]):
875
+ segs = []
876
+ if locale_segment:
877
+ segs.append(locale_segment)
878
+ if layout_segment:
879
+ segs.append(layout_segment)
880
+ seg = "/".join(segs) + "/" if segs else ""
881
+ if is_static:
882
+
883
+ def fn(slug: str, step_index: int, frame_key: Optional[str]) -> str:
884
+ stem = step_stem(step_index, frame_key)
885
+ return f"../assets/{entry.slug}/{seg}{stem}.png"
886
+ else:
887
+
888
+ def fn(slug: str, step_index: int, frame_key: Optional[str]) -> str:
889
+ stem = step_stem(step_index, frame_key)
890
+ return f"/assets/{role}/{entry.slug}/{seg}{stem}.png"
891
+
892
+ return fn
893
+
894
+ # Layouts dimension, narrowed to what this guide's scenario declares.
895
+ # Empty list = single iteration with layout=None (back-compat);
896
+ # non-empty = render once per (locale, layout) pair and let the
897
+ # page/component template stitch with an in-page toggle.
898
+ guide_layouts = self._layouts_for_guide(entry, scenario)
899
+ guide_default_layout = self._default_layout_for(guide_layouts)
900
+ layouts_or_default: list[Optional[str]] = list(guide_layouts) if guide_layouts else [None]
901
+
902
+ # Render the scoped HTML fragment per (locale, layout) (shared between
903
+ # targets). For static target we suppress the fragment's inline <style>
904
+ # block since the static page links the shared assets/guides.css.
905
+ fragment_tpl = self.env.get_template("guide.fragment.html.j2")
906
+ # Nested dict[locale][layout_key] -> fragment / steps. The inner key is
907
+ # the layout name, or the literal "default" when no layouts are
908
+ # configured. This keeps a uniform shape downstream.
909
+ fragments_by_locale: dict[str, dict[str, str]] = {}
910
+ steps_by_locale: dict[str, dict[str, list[dict]]] = {}
911
+ include_styles = not self._is_static()
912
+ for locale in self.build_config.locales:
913
+ fragments_by_locale[locale] = {}
914
+ steps_by_locale[locale] = {}
915
+ for layout in layouts_or_default:
916
+ layout_key = layout or "default"
917
+ # Same resolver the copier and the read side use, so the URL can
918
+ # never name a directory nothing wrote (see
919
+ # GuideAssembler.resolve_capture_segments).
920
+ locale_segment, layout_segment = self._guide_assembler.resolve_capture_segments(
921
+ scenario.slug, screenshots_dir, locale, layout
922
+ )
923
+ # The resolver falling back past a configured layout is not an
924
+ # error (single-layout projects rely on it), but it IS silent
925
+ # data loss: every layout renders the same screenshots, so the
926
+ # published page ships a layout toggle that swaps between
927
+ # identical images, and a step restricted to one layout is shown
928
+ # with another layout's capture. Say so once per (guide, layout).
929
+ if layout is not None and layout_segment is None:
930
+ self._warn_layout_capture_missing(entry.slug, scenario.slug, locale, layout)
931
+ guide_steps = self._guide_assembler.prepare_guide_steps(
932
+ scenario,
933
+ role,
934
+ locale,
935
+ screenshots_dir,
936
+ screenshot_url_fn=make_url_fn(locale_segment, layout_segment),
937
+ layout=layout,
938
+ )
939
+ html_content = fragment_tpl.render(
940
+ title=pick_locale(
941
+ entry.title,
942
+ locale,
943
+ self.build_config.default_locale,
944
+ ),
945
+ role=role.title(),
946
+ steps=guide_steps,
947
+ include_styles=include_styles,
948
+ )
949
+ fragments_by_locale[locale][layout_key] = html_content
950
+ steps_by_locale[locale][layout_key] = guide_steps
951
+ # The handbook is a one-document read: it takes each guide at
952
+ # its default layout rather than stacking every layout's copy.
953
+ if self._handbook_enabled and layout_key == (guide_default_layout or "default"):
954
+ self._handbook_fragments.setdefault(locale, {})[entry.slug] = html_content
955
+
956
+ cursor_timings = scenario.guide.cursor.model_dump() if scenario.guide else {}
957
+
958
+ # Resolve the effective cursor mode for this guide. Per-guide override
959
+ # wins; otherwise fall through to the project-wide default. We keep
960
+ # ``None`` when the resolved value is ``arrow`` so the generated host
961
+ # templates stay byte-identical with the pre-feature default.
962
+ effective_cursor = entry.cursor or self.build_config.cursor
963
+ cursor_mode = effective_cursor.value if effective_cursor != CursorMode.ARROW else None
964
+
965
+ # Resolve the effective presentation template (per-guide override wins).
966
+ # Always a concrete string — unlike cursor there is no legacy host
967
+ # output to keep byte-identical, and an always-present field is simpler
968
+ # for the player and clearer in the emitted artifacts.
969
+ template = (entry.template or self.build_config.default_template).value
970
+
971
+ # Copy screenshots (shared between targets — different destination only)
972
+ self._copy_screenshots(
973
+ entry,
974
+ scenario,
975
+ screenshots_dir,
976
+ assets_dir,
977
+ results,
978
+ dry_run,
979
+ layouts=guide_layouts,
980
+ )
981
+
982
+ # Video job collection runs before the page emitters so they can link
983
+ # what is about to be rendered (same shape as pdf_url). Nothing is
984
+ # rendered here — `_render_build_videos` does the batch at the end.
985
+ if self._video_enabled and GuideMode.VIDEO in entry.modes:
986
+ self._emit_video_guide(
987
+ entry,
988
+ scenario,
989
+ role,
990
+ guide_dir,
991
+ assets_dir,
992
+ cursor_timings,
993
+ results,
994
+ dry_run,
995
+ cursor_mode=cursor_mode,
996
+ template=template,
997
+ layouts=guide_layouts,
998
+ default_layout=guide_default_layout,
999
+ )
1000
+
1001
+ # Per-target per-guide writes
1002
+ if self._is_static():
1003
+ self._emit_static_guide(
1004
+ entry,
1005
+ guide_dir,
1006
+ fragments_by_locale,
1007
+ steps_by_locale,
1008
+ cursor_timings,
1009
+ results,
1010
+ dry_run,
1011
+ layouts=guide_layouts,
1012
+ default_layout=guide_default_layout,
1013
+ )
1014
+ else:
1015
+ self._emit_angular_guide(
1016
+ entry,
1017
+ guide_dir,
1018
+ fragments_by_locale,
1019
+ steps_by_locale,
1020
+ cursor_timings,
1021
+ results,
1022
+ dry_run,
1023
+ cursor_mode=cursor_mode,
1024
+ template=template,
1025
+ layouts=guide_layouts,
1026
+ default_layout=guide_default_layout,
1027
+ )
1028
+
1029
+ # Interactive manifest + per-guide page (when the guide opts in via
1030
+ # modes: ['interactive']). Stage 4: both targets are wired.
1031
+ # Video mode does NOT need this: it builds its own manifest with
1032
+ # disk-relative screenshot URLs (see _build_video_manifest).
1033
+ if GuideMode.INTERACTIVE in entry.modes:
1034
+ self._emit_interactive_guide(
1035
+ entry,
1036
+ scenario,
1037
+ role,
1038
+ guide_dir,
1039
+ assets_dir,
1040
+ cursor_timings,
1041
+ results,
1042
+ dry_run,
1043
+ cursor_mode=cursor_mode,
1044
+ template=template,
1045
+ layouts=guide_layouts,
1046
+ default_layout=guide_default_layout,
1047
+ )
1048
+
1049
+ # Remember the effective layout set so the site manifest can report it
1050
+ # per guide (the top-level `layouts` stays the site-wide dimension).
1051
+ self._guide_layouts[entry.slug] = list(guide_layouts)
1052
+
1053
+ # ── Per-guide emitters ───────────────────────────────────────────────
1054
+
1055
+ def _emit_angular_guide(
1056
+ self,
1057
+ entry: GuideEntry,
1058
+ guide_dir: Path,
1059
+ fragments_by_locale: dict[str, dict[str, str]],
1060
+ steps_by_locale: dict[str, dict[str, list[dict]]],
1061
+ cursor_timings: dict,
1062
+ results: dict[str, str],
1063
+ dry_run: bool,
1064
+ *,
1065
+ cursor_mode: Optional[str] = None,
1066
+ template: str = "overlay",
1067
+ layouts: Optional[list[str]] = None,
1068
+ default_layout: Optional[str] = None,
1069
+ ) -> None:
1070
+ """Write per-guide Angular files. ``fragments_by_locale`` is a nested
1071
+ dict keyed first by locale, then by layout (or ``"default"`` when no
1072
+ layouts are configured). ``steps_by_locale`` is the matching nested
1073
+ per-step frame data — the stepbystep cursor driver consumes the slim
1074
+ ``{index, frames: [{viewport, cursor}]}`` shape baked into the content
1075
+ module."""
1076
+ # Write standalone fragment HTML files (handy for browser preview / debugging)
1077
+ for locale, fragments_by_layout in fragments_by_locale.items():
1078
+ for layout_key, html_content in fragments_by_layout.items():
1079
+ # Suffix only when multiple layouts so single-layout output keeps
1080
+ # the existing filename shape.
1081
+ layout_suffix = f"-{layout_key}" if len(fragments_by_layout) > 1 else ""
1082
+ html_filename = f"{entry.slug}-stepbystep-{locale}{layout_suffix}.html"
1083
+ self._write_file(guide_dir / html_filename, html_content, results, dry_run)
1084
+
1085
+ class_name = _slug_to_class_name(entry.slug)
1086
+ has_video = self._has_video(entry)
1087
+ modes = self._emitted_modes(entry)
1088
+ has_interactive = GuideMode.INTERACTIVE in entry.modes
1089
+ has_multiple_modes = len(modes) > 1
1090
+ guide_layouts = list(layouts if layouts is not None else self.build_config.layouts)
1091
+ has_multiple_layouts = len(guide_layouts) > 1
1092
+ onboarding_strip = self._onboarding_strip_for(entry)
1093
+ role_namespace = _role_namespace(self.build_config.role)
1094
+ # Initial value for the component's local layout signal. With layouts
1095
+ # configured, use this guide's default; otherwise the literal
1096
+ # "default" key which the content map collapses to.
1097
+ layout_signal_default = default_layout or (guide_layouts[0] if guide_layouts else "default")
1098
+
1099
+ # Content module — shape is content[locale][layout_key] = fragment,
1100
+ # plus steps[locale][layout_key] = slim per-step frame data
1101
+ # ({index, frames: [{viewport, cursor}]}) that the stepbystep cursor
1102
+ # driver animates over the [innerHTML]-injected DOM. The Angular
1103
+ # component picks the active locale via ngx-translate's TranslateService
1104
+ # and the active layout via a local signal; single-layout configs
1105
+ # collapse to a single "default" key.
1106
+ steps_by_locale_slim = {
1107
+ locale: {
1108
+ layout_key: [
1109
+ {
1110
+ "index": step["index"],
1111
+ "frames": [
1112
+ {
1113
+ "viewport": frame.get("viewport"),
1114
+ "cursor": frame.get("cursor"),
1115
+ }
1116
+ for frame in step.get("frames", [])
1117
+ ],
1118
+ }
1119
+ for step in steps
1120
+ ]
1121
+ for layout_key, steps in by_layout.items()
1122
+ }
1123
+ for locale, by_layout in steps_by_locale.items()
1124
+ }
1125
+ content_tpl = self.env.get_template("angular/content.ts.j2")
1126
+ content_ts = content_tpl.render(
1127
+ content_by_locale=fragments_by_locale,
1128
+ steps_by_locale=steps_by_locale_slim,
1129
+ cursor_timings=cursor_timings,
1130
+ )
1131
+ self._write_file(guide_dir / f"{entry.slug}.content.ts", content_ts, results, dry_run)
1132
+
1133
+ # Component
1134
+ component_tpl = self.env.get_template("angular/component.ts.j2")
1135
+ component_ts = component_tpl.render(
1136
+ slug=entry.slug,
1137
+ class_name=class_name,
1138
+ role=self.build_config.role,
1139
+ role_namespace=role_namespace,
1140
+ default_locale=self.build_config.default_locale,
1141
+ layouts=guide_layouts,
1142
+ default_layout=layout_signal_default,
1143
+ modes=modes,
1144
+ mode_labels=MODE_LABELS,
1145
+ default_mode=modes[0],
1146
+ has_interactive=has_interactive,
1147
+ has_video=has_video,
1148
+ has_multiple_modes=has_multiple_modes,
1149
+ has_multiple_layouts=has_multiple_layouts,
1150
+ onboarding_strip=onboarding_strip,
1151
+ )
1152
+ self._write_file(guide_dir / f"{entry.slug}.component.ts", component_ts, results, dry_run)
1153
+
1154
+ # Interactive placeholder component (if needed)
1155
+ if has_interactive:
1156
+ interactive_tpl = self.env.get_template("angular/component-interactive.ts.j2")
1157
+ interactive_ts = interactive_tpl.render(
1158
+ slug=entry.slug,
1159
+ class_name=class_name,
1160
+ role=self.build_config.role,
1161
+ role_namespace=role_namespace,
1162
+ default_locale=self.build_config.default_locale,
1163
+ default_layout=default_layout,
1164
+ has_multiple_layouts=has_multiple_layouts,
1165
+ cursor_mode=cursor_mode,
1166
+ default_template=template,
1167
+ )
1168
+ self._write_file(
1169
+ guide_dir / f"{entry.slug}-interactive.component.ts",
1170
+ interactive_ts,
1171
+ results,
1172
+ dry_run,
1173
+ )
1174
+
1175
+ # Video host component (only when a video was actually rendered).
1176
+ if has_video:
1177
+ media = self._video_media[entry.slug]
1178
+ videos = {
1179
+ key: {
1180
+ "file": Path(item["src"]).name,
1181
+ "poster_file": Path(item["poster"]).name,
1182
+ "captions_file": Path(item["captions"]).name,
1183
+ }
1184
+ for key, item in media.items()
1185
+ }
1186
+ video_ts = self.env.get_template("angular/component-video.ts.j2").render(
1187
+ slug=entry.slug,
1188
+ class_name=class_name,
1189
+ role=self.build_config.role,
1190
+ default_locale=self.build_config.default_locale,
1191
+ default_layout=default_layout,
1192
+ has_multiple_layouts=has_multiple_layouts,
1193
+ videos=videos,
1194
+ default_key=next(iter(videos)),
1195
+ )
1196
+ self._write_file(
1197
+ guide_dir / f"{entry.slug}-video.component.ts",
1198
+ video_ts,
1199
+ results,
1200
+ dry_run,
1201
+ )
1202
+
1203
+ def _emit_static_guide(
1204
+ self,
1205
+ entry: GuideEntry,
1206
+ guide_dir: Path,
1207
+ fragments_by_locale: dict[str, dict[str, str]],
1208
+ steps_by_locale: dict[str, dict[str, list[dict]]],
1209
+ cursor_timings: dict,
1210
+ results: dict[str, str],
1211
+ dry_run: bool,
1212
+ *,
1213
+ layouts: Optional[list[str]] = None,
1214
+ default_layout: Optional[str] = None,
1215
+ ) -> None:
1216
+ """Write per-guide static HTML pages — one full page per locale.
1217
+
1218
+ When multiple layouts are configured, each page bundles all layouts'
1219
+ fragments under ``data-layout="..."`` wrappers so the in-page toggle
1220
+ can swap between them client-side.
1221
+ """
1222
+ page_tpl = self.env.get_template("static/page.html.j2")
1223
+ groups_by_slug = self.build_config.groups_by_slug()
1224
+ group = groups_by_slug.get(entry.group) if entry.group else None
1225
+ onboarding_strip = self._onboarding_strip_for(entry)
1226
+ layout_keys = list(layouts if layouts is not None else self.build_config.layouts)
1227
+ if default_layout is None:
1228
+ default_layout = self._default_layout_for(layout_keys)
1229
+ for locale, fragments_by_layout in fragments_by_locale.items():
1230
+ lang_links = self._lang_switcher_links(
1231
+ href_for=lambda loc: f"./{loc}.html",
1232
+ current_locale=locale,
1233
+ )
1234
+ steps_by_layout = steps_by_locale.get(locale, {})
1235
+ seo = self._seo_for_page(
1236
+ locale=locale,
1237
+ dir_path=entry.slug,
1238
+ title=entry.title,
1239
+ description=entry.description or entry.title,
1240
+ og_image_site_rel=self._first_screenshot_site_rel(steps_by_layout, default_layout),
1241
+ )
1242
+ # `fragments_by_layout` uses key "default" when no layouts are
1243
+ # configured (back-compat). The page template detects multi-layout
1244
+ # mode by checking len(layout_keys) > 1.
1245
+ page_html = page_tpl.render(
1246
+ entry=entry,
1247
+ site_title=self.build_config.site.title,
1248
+ locale=locale,
1249
+ html_lang=self._html_lang(locale),
1250
+ index_href=self._index_href(locale),
1251
+ current_locale=locale,
1252
+ locales=self.build_config.locales,
1253
+ default_locale=self.build_config.default_locale,
1254
+ locale_labels=self.locale_labels,
1255
+ lang_links=lang_links,
1256
+ fragments_by_layout=fragments_by_layout,
1257
+ steps_by_layout=steps_by_layout,
1258
+ layouts=layout_keys,
1259
+ default_layout=default_layout,
1260
+ cursor_timings=cursor_timings,
1261
+ group=group,
1262
+ onboarding_strip=onboarding_strip,
1263
+ seo=seo,
1264
+ # Sibling of the page itself; None unless --pdf armed the export.
1265
+ pdf_url=f"./{locale}.pdf" if self._pdf_enabled else None,
1266
+ handbook_url=(f"../{self._handbook_href(locale)}" if self._handbook_enabled else None),
1267
+ mode_links=self._mode_links(entry, "stepbystep", locale),
1268
+ )
1269
+ page_path = guide_dir / f"{locale}.html"
1270
+ self._write_file(page_path, page_html, results, dry_run)
1271
+ if self._pdf_enabled:
1272
+ self._pdf_pages.append(
1273
+ (page_path, pick_locale(entry.title, locale, self.build_config.default_locale) or entry.slug)
1274
+ )
1275
+
1276
+ # Bare `<slug>/` used to 404: groups, onboarding and the handbook all got
1277
+ # a stub and guides did not. Excluded from the sitemap like the others.
1278
+ self._write_file(
1279
+ guide_dir / "index.html",
1280
+ self._dir_redirect_stub(entry.slug),
1281
+ results,
1282
+ dry_run,
1283
+ )
1284
+
1285
+ if entry.slug in self._video_media:
1286
+ self._emit_static_video_page(entry, guide_dir, results, dry_run)
1287
+
1288
+ def _emit_static_video_page(
1289
+ self,
1290
+ entry: GuideEntry,
1291
+ guide_dir: Path,
1292
+ results: dict[str, str],
1293
+ dry_run: bool,
1294
+ ) -> None:
1295
+ """One ``<slug>/video.html`` playing every locale/layout variant.
1296
+
1297
+ A sibling page per mode, matching how ``interactive.html`` works — the
1298
+ static target has no in-page mode toggle.
1299
+ """
1300
+ media = self._video_media.get(entry.slug) or {}
1301
+ if not media:
1302
+ return
1303
+ variants = [
1304
+ {
1305
+ "key": key,
1306
+ "label": self._variant_label(item),
1307
+ "locale": item["locale"],
1308
+ "layout": item["layout"],
1309
+ "src": item["src"],
1310
+ "poster": item["poster"],
1311
+ "captions": item["captions"],
1312
+ }
1313
+ for key, item in media.items()
1314
+ ]
1315
+ page_html = self.env.get_template("static/video.html.j2").render(
1316
+ entry=entry,
1317
+ site_title=self.build_config.site.title,
1318
+ default_locale=self.build_config.default_locale,
1319
+ html_lang=self._html_lang(self.build_config.default_locale),
1320
+ index_href=self._index_href(self.build_config.default_locale),
1321
+ mode_links=self._mode_links(entry, "video", self.build_config.default_locale),
1322
+ variants=variants,
1323
+ # Canonical to the step-by-step page for the same reason the
1324
+ # interactive page does -- see _guide_primary_site_rel.
1325
+ seo=self._seo_for_page(
1326
+ locale=self.build_config.default_locale,
1327
+ site_rel=self._guide_primary_site_rel(entry),
1328
+ title=entry.title,
1329
+ description=entry.description or entry.title,
1330
+ alternates=[],
1331
+ ),
1332
+ )
1333
+ self._write_file(guide_dir / "video.html", page_html, results, dry_run)
1334
+
1335
+ def _variant_label(self, item: dict) -> str:
1336
+ locale = self.locale_labels.get(item["locale"], item["locale"])
1337
+ layout = item.get("layout")
1338
+ return f"{locale} · {layout.capitalize()}" if layout else locale
1339
+
1340
+ def _emit_interactive_guide(
1341
+ self,
1342
+ entry: GuideEntry,
1343
+ scenario,
1344
+ role: str,
1345
+ guide_dir: Path,
1346
+ assets_dir: Path,
1347
+ cursor_timings: dict,
1348
+ results: dict[str, str],
1349
+ dry_run: bool,
1350
+ *,
1351
+ cursor_mode: Optional[str] = None,
1352
+ template: str = "overlay",
1353
+ layouts: Optional[list[str]] = None,
1354
+ default_layout: Optional[str] = None,
1355
+ ) -> None:
1356
+ """Emit per-guide interactive artifacts.
1357
+
1358
+ Both targets:
1359
+ * Render the guide manifest (``guide.json``).
1360
+
1361
+ Static target additionally emits an ``interactive.html`` page that
1362
+ loads the player bundle and mounts ``<steppy-guide>``. The bundle
1363
+ itself is copied once-per-build by ``_copy_player_bundle_if_needed``.
1364
+
1365
+ Angular target emits only the manifest — the per-guide
1366
+ ``<slug>-interactive.component.ts`` (rendered by ``_emit_angular_guide``)
1367
+ mounts ``<steppy-guide>`` against the manifest URL.
1368
+ """
1369
+ is_static = self._is_static()
1370
+ screenshots_dir = self._screenshots_root()
1371
+ guide_layouts = list(layouts if layouts is not None else self.build_config.layouts)
1372
+ if default_layout is None:
1373
+ default_layout = self._default_layout_for(guide_layouts)
1374
+
1375
+ def screenshot_url(
1376
+ slug: str,
1377
+ step_index: int,
1378
+ frame_key: Optional[str],
1379
+ locale: str,
1380
+ layout: Optional[str],
1381
+ ) -> str:
1382
+ stem = step_stem(step_index, frame_key)
1383
+ # Same resolver as the copier and the stepbystep URL builder. This
1384
+ # closure used to hardcode `segs = [locale]`, so a project with flat
1385
+ # (locale-less) captures got a manifest pointing at
1386
+ # assets/<slug>/<locale>/… that the copier never wrote: the
1387
+ # stepbystep page rendered fine while the interactive page showed a
1388
+ # blank stage on every step, and the build reported success.
1389
+ segs = [
1390
+ seg
1391
+ for seg in self._guide_assembler.resolve_capture_segments(
1392
+ scenario.slug, screenshots_dir, locale, layout
1393
+ )
1394
+ if seg
1395
+ ]
1396
+ seg = "/".join(segs) + "/" if segs else ""
1397
+ if is_static:
1398
+ # Manifest at output_dir/<slug>/guide.json → screenshots at
1399
+ # output_dir/assets/<slug>/<locale>/<layout>/step-NN.png.
1400
+ return f"../assets/{entry.slug}/{seg}{stem}.png"
1401
+ # Angular: absolute root-relative URL served from host's public/.
1402
+ return f"/assets/{role}/{entry.slug}/{seg}{stem}.png"
1403
+
1404
+ # Title is a LocalizedText. Normalize to {locale: str} for every
1405
+ # declared locale, falling back to default_locale (then to the raw
1406
+ # string for the plain-string LocalizedText form).
1407
+ resolved_title: dict[str, str] = {}
1408
+ for locale in self.build_config.locales:
1409
+ value = pick_locale(
1410
+ entry.title,
1411
+ locale,
1412
+ self.build_config.default_locale,
1413
+ )
1414
+ if value is None:
1415
+ raise BuildError(f"Interactive manifest for '{entry.slug}': title is empty for locale '{locale}'.")
1416
+ resolved_title[locale] = value
1417
+
1418
+ try:
1419
+ manifest = self._guide_assembler.prepare_interactive_manifest(
1420
+ scenario=scenario,
1421
+ role=role,
1422
+ screenshots_dir=self._screenshots_root(),
1423
+ locales=list(self.build_config.locales),
1424
+ layouts=guide_layouts,
1425
+ default_locale=self.build_config.default_locale,
1426
+ default_layout=default_layout,
1427
+ title=resolved_title,
1428
+ cursor_timings=cursor_timings,
1429
+ screenshot_url_fn=screenshot_url,
1430
+ cursor_mode=cursor_mode,
1431
+ default_template=template,
1432
+ )
1433
+ except ValueError as exc:
1434
+ raise BuildError(str(exc)) from exc
1435
+
1436
+ # Manifest destination:
1437
+ # - Static: output_dir/<slug>/guide.json (sibling to the per-locale HTML pages).
1438
+ # - Angular: assets_dir/<slug>/guide.json (alongside the screenshots, so
1439
+ # the host's public/assets/<role>/<slug>/ contains both).
1440
+ if is_static:
1441
+ manifest_path = guide_dir / "guide.json"
1442
+ else:
1443
+ manifest_path = assets_dir / entry.slug / "guide.json"
1444
+
1445
+ manifest_json = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
1446
+ self._write_file(manifest_path, manifest_json, results, dry_run)
1447
+
1448
+ if is_static:
1449
+ # Static target: render an interactive.html alongside the per-locale
1450
+ # stepbystep pages. The bundle is copied once-per-build via
1451
+ # ``_copy_player_bundle_if_needed``.
1452
+ page_tpl = self.env.get_template("static/interactive.html.j2")
1453
+ # Canonical to the step-by-step page, not to itself: this renders
1454
+ # the same steps in a different presentation, and a self-canonical
1455
+ # here is what had every guide occupying three or four indexable
1456
+ # URLs. No hreflang -- a page that canonicals elsewhere must not
1457
+ # carry alternates, and this one serves every locale at one URL.
1458
+ interactive_seo = self._seo_for_page(
1459
+ locale=self.build_config.default_locale,
1460
+ site_rel=self._guide_primary_site_rel(entry),
1461
+ title=entry.title,
1462
+ description=entry.description or entry.title,
1463
+ alternates=[],
1464
+ )
1465
+ page_html = page_tpl.render(
1466
+ entry=entry,
1467
+ site_title=self.build_config.site.title,
1468
+ default_locale=self.build_config.default_locale,
1469
+ html_lang=self._html_lang(self.build_config.default_locale),
1470
+ index_href=self._index_href(self.build_config.default_locale),
1471
+ mode_links=self._mode_links(entry, "interactive", self.build_config.default_locale),
1472
+ default_layout=default_layout,
1473
+ layouts=guide_layouts,
1474
+ pick_locale=pick_locale,
1475
+ cursor_mode=cursor_mode,
1476
+ default_template=template,
1477
+ seo=interactive_seo,
1478
+ )
1479
+ self._write_file(
1480
+ guide_dir / "interactive.html",
1481
+ page_html,
1482
+ results,
1483
+ dry_run,
1484
+ )
1485
+
1486
+ def _copy_player_bundle_if_needed(
1487
+ self,
1488
+ output_dir: Path,
1489
+ results: dict[str, str],
1490
+ dry_run: bool,
1491
+ ) -> None:
1492
+ """Copy the ESM player bundle into the build output once, when at
1493
+ least one guide opts into interactive mode.
1494
+
1495
+ Destination depends on the target:
1496
+ * Angular: ``assets_dir/steppy-player.js`` so the host serves it at
1497
+ ``/assets/<role>/steppy-player.js`` (matching the URL the
1498
+ generated component fetches).
1499
+ * Static: ``output_dir/assets/steppy-player.js``, referenced from
1500
+ ``<slug>/interactive.html`` via the relative path
1501
+ ``../assets/steppy-player.js``.
1502
+ """
1503
+ has_interactive = any(GuideMode.INTERACTIVE in g.modes for g in self.build_config.guides)
1504
+ if not has_interactive:
1505
+ return
1506
+ # _resolve_assets_dir already returns output_dir/"assets" for the static
1507
+ # target, so both targets resolve through it.
1508
+ self._copy_binary(
1509
+ get_player_bundle_path(),
1510
+ self._resolve_assets_dir(output_dir) / "steppy-player.js",
1511
+ results,
1512
+ dry_run,
1513
+ )
1514
+
1515
+ def _copy_cursor_bundle(
1516
+ self,
1517
+ output_dir: Path,
1518
+ results: dict[str, str],
1519
+ dry_run: bool,
1520
+ ) -> None:
1521
+ """Copy the legacy cursor IIFE bundle into the Angular assets dir as
1522
+ ``steppy-cursor.js`` so the host serves it at
1523
+ ``/assets/<role>/steppy-cursor.js``.
1524
+
1525
+ Every Angular guide has a stepbystep view, and ``<script>`` tags
1526
+ injected via Angular's ``[innerHTML]`` never execute — so the cursor
1527
+ driver can't ship inside the rendered fragment. The generated component
1528
+ loads this bundle once and calls ``window.__STEPPY_CURSOR__.mount(...)``
1529
+ after each render. Static target has no equivalent: it inlines the same
1530
+ source via ``_write_static_cursor_js`` (``get_legacy_cursor_iife``).
1531
+ """
1532
+ self._copy_binary(
1533
+ get_cursor_bundle_path(),
1534
+ self._resolve_assets_dir(output_dir) / "steppy-cursor.js",
1535
+ results,
1536
+ dry_run,
1537
+ )
1538
+
1539
+ # ── Video (both targets) ─────────────────────────────────────────────
1540
+
1541
+ def _arm_video_export(self, *, video: bool) -> None:
1542
+ """Validate and record the video-export request for this build.
1543
+
1544
+ Fails early on purpose. A render is minutes of CPU per (guide, locale,
1545
+ layout) and, with a paid TTS provider, real money — discovering that
1546
+ ffmpeg is missing at the end of that is the worst possible time.
1547
+ """
1548
+ self._video_jobs = []
1549
+ self._video_media = {}
1550
+ self._video_enabled = video
1551
+ if not video:
1552
+ return
1553
+
1554
+ declared = [g.slug for g in self.build_config.guides if GuideMode.VIDEO in g.modes]
1555
+ if not declared:
1556
+ _warn(
1557
+ "--video was passed but no guide declares modes: ['video'] — nothing to render. "
1558
+ "Add the mode to the guides you want videos for in the build config."
1559
+ )
1560
+ self._video_enabled = False
1561
+ return
1562
+
1563
+ if self.steppy_config is None:
1564
+ # build tolerates a missing config.yaml, but silently defaulting here
1565
+ # would ship a paid-TTS project a set of silent videos with no clue why.
1566
+ _warn(
1567
+ "--video was passed but no config.yaml was found; using default video settings. "
1568
+ "Narration will be captions-only (video.tts.provider defaults to 'none')."
1569
+ )
1570
+
1571
+ from .video import find_ffmpeg
1572
+
1573
+ find_ffmpeg(self._video_config())
1574
+
1575
+ def _video_config(self):
1576
+ """Video settings from ``config.yaml``, or the defaults.
1577
+
1578
+ ``steppy build`` treats a missing config as non-fatal (it only needs it
1579
+ for role display names), so mirror ``_pdf_config`` rather than failing —
1580
+ ``_arm_video_export`` has already warned about the consequence.
1581
+ """
1582
+ from .models import VideoConfig
1583
+
1584
+ return self.steppy_config.video if self.steppy_config else VideoConfig()
1585
+
1586
+ def _emit_video_guide(
1587
+ self,
1588
+ entry: GuideEntry,
1589
+ scenario,
1590
+ role: str,
1591
+ guide_dir: Path,
1592
+ assets_dir: Path,
1593
+ cursor_timings: dict,
1594
+ results: dict[str, str],
1595
+ dry_run: bool,
1596
+ *,
1597
+ cursor_mode: Optional[str] = None,
1598
+ template: str = "overlay",
1599
+ layouts: Optional[list[str]] = None,
1600
+ default_layout: Optional[str] = None,
1601
+ ) -> None:
1602
+ """Collect the render jobs for one guide; render nothing yet.
1603
+
1604
+ Rendering is deferred to ``_render_build_videos`` so a whole build shares
1605
+ one browser, exactly as the PDF pass does.
1606
+
1607
+ The manifest built here is **not** the published one: screenshot URLs are
1608
+ resolved relative to the harness on disk rather than to the deployed site.
1609
+ The Angular target's published URLs are root-absolute (``/assets/<role>/…``)
1610
+ and would resolve to the filesystem root over ``file://``; making the
1611
+ renderer own its own URL shape means it does not care which target it is
1612
+ serving, and Angular gets video for free.
1613
+ """
1614
+ options = self._video_config()
1615
+ screenshots_dir = self._screenshots_root()
1616
+ guide_layouts = list(layouts if layouts is not None else self.build_config.layouts)
1617
+ if default_layout is None:
1618
+ default_layout = self._default_layout_for(guide_layouts)
1619
+ media: dict[str, dict[str, str]] = {}
1620
+
1621
+ # One video per (locale, layout) — but two layouts that resolve to the
1622
+ # same captures would render the same footage twice, at full price.
1623
+ seen_segments: dict[tuple[str, tuple], str] = {}
1624
+
1625
+ for locale in self.build_config.locales:
1626
+ for layout in guide_layouts or [None]:
1627
+ segments = self._guide_assembler.resolve_capture_segments(
1628
+ scenario.slug, screenshots_dir, locale, layout
1629
+ )
1630
+ stem = locale if not layout or len(guide_layouts) <= 1 else f"{locale}-{layout}"
1631
+ out = guide_dir / f"{stem}.mp4" if self._is_static() else assets_dir / entry.slug / f"{stem}.mp4"
1632
+
1633
+ duplicate_of = seen_segments.get((locale, tuple(segments)))
1634
+ if duplicate_of is not None:
1635
+ _warn(
1636
+ f"guide '{entry.slug}' ({locale}): layout '{layout}' resolves to the same "
1637
+ f"captures as '{duplicate_of}', so its video would be identical. Rendering "
1638
+ f"once. Capture the layout (steppy run --guide --layouts …) or drop it from "
1639
+ f"the guide's layouts."
1640
+ )
1641
+ media[stem] = dict(media[duplicate_of])
1642
+ continue
1643
+ seen_segments[(locale, tuple(segments))] = stem
1644
+
1645
+ manifest = self._build_video_manifest(
1646
+ entry,
1647
+ scenario,
1648
+ role,
1649
+ locale=locale,
1650
+ layout=layout,
1651
+ page_dir=guide_dir,
1652
+ assets_dir=assets_dir,
1653
+ screenshots_dir=screenshots_dir,
1654
+ cursor_timings=cursor_timings,
1655
+ cursor_mode=cursor_mode,
1656
+ template=template,
1657
+ default_layout=default_layout,
1658
+ guide_layouts=guide_layouts,
1659
+ )
1660
+
1661
+ job = self._video_job(entry, manifest, guide_dir, out, locale, layout, options)
1662
+ self._video_jobs.append(job)
1663
+ for path in (job.out, job.vtt, job.poster):
1664
+ self._record_artifact(path, results, dry_run)
1665
+ media[stem] = {
1666
+ "locale": locale,
1667
+ "layout": layout or "",
1668
+ "src": self._media_href(out, guide_dir),
1669
+ "poster": self._media_href(job.poster, guide_dir),
1670
+ "captions": self._media_href(job.vtt, guide_dir),
1671
+ }
1672
+
1673
+ self._video_media[entry.slug] = media
1674
+
1675
+ def _media_href(self, path: Path, page_dir: Path) -> str:
1676
+ """URL of a media file relative to the page that embeds it."""
1677
+ return Path(os.path.relpath(path, page_dir)).as_posix()
1678
+
1679
+ def _build_video_manifest(
1680
+ self,
1681
+ entry: GuideEntry,
1682
+ scenario,
1683
+ role: str,
1684
+ *,
1685
+ locale: str,
1686
+ layout: Optional[str],
1687
+ page_dir: Path,
1688
+ assets_dir: Path,
1689
+ screenshots_dir: Path,
1690
+ cursor_timings: dict,
1691
+ cursor_mode: Optional[str],
1692
+ template: str,
1693
+ default_layout: Optional[str],
1694
+ guide_layouts: list[str],
1695
+ ) -> dict:
1696
+ """Manifest for one (locale, layout), with disk-relative screenshot URLs."""
1697
+
1698
+ def screenshot_url(slug, step_index, frame_key, url_locale, url_layout):
1699
+ stem = step_stem(step_index, frame_key)
1700
+ segs = [
1701
+ seg
1702
+ for seg in self._guide_assembler.resolve_capture_segments(
1703
+ scenario.slug, screenshots_dir, url_locale, url_layout
1704
+ )
1705
+ if seg
1706
+ ]
1707
+ target = (assets_dir / entry.slug).joinpath(*segs) / f"{stem}.png"
1708
+ return self._media_href(target, page_dir)
1709
+
1710
+ title = pick_locale(entry.title, locale, self.build_config.default_locale)
1711
+ if title is None:
1712
+ raise BuildError(f"Video for '{entry.slug}': title is empty for locale '{locale}'.")
1713
+
1714
+ try:
1715
+ return self._guide_assembler.prepare_interactive_manifest(
1716
+ scenario=scenario,
1717
+ role=role,
1718
+ screenshots_dir=screenshots_dir,
1719
+ locales=[locale],
1720
+ layouts=[layout] if layout else [],
1721
+ default_locale=locale,
1722
+ default_layout=layout,
1723
+ title={locale: title},
1724
+ cursor_timings=cursor_timings,
1725
+ screenshot_url_fn=screenshot_url,
1726
+ cursor_mode=cursor_mode,
1727
+ default_template=template,
1728
+ )
1729
+ except ValueError as exc:
1730
+ raise BuildError(f"Video for '{entry.slug}' ({locale}): {exc}") from exc
1731
+
1732
+ def _video_job(self, entry, manifest, page_dir, out, locale, layout, options):
1733
+ from .video import VideoJob
1734
+
1735
+ default_locale = self.build_config.default_locale
1736
+ return VideoJob(
1737
+ manifest=manifest,
1738
+ page_dir=page_dir,
1739
+ out=out,
1740
+ locale=locale,
1741
+ layout=layout,
1742
+ title=pick_locale(entry.title, locale, default_locale) or entry.slug,
1743
+ subtitle=pick_locale(self.build_config.site.title, locale, default_locale) or "",
1744
+ outro=pick_locale(self.build_config.site.title, locale, default_locale) or "",
1745
+ stamp=self._video_stamp(entry, options, locale, layout),
1746
+ logo_data_uri=self._logo_data_uri(options),
1747
+ )
1748
+
1749
+ def _video_stamp(self, entry: GuideEntry, options, locale: str, layout: Optional[str]) -> str:
1750
+ """Render the burned-in provenance line, or "" when disabled."""
1751
+ if not options.stamp.enabled:
1752
+ return ""
1753
+ from datetime import date
1754
+
1755
+ return options.stamp.text.format(
1756
+ version=options.stamp.version,
1757
+ date=date.today().isoformat(),
1758
+ locale=locale,
1759
+ layout=layout or "",
1760
+ slug=entry.slug,
1761
+ )
1762
+
1763
+ def _logo_data_uri(self, options) -> str:
1764
+ """Embed the branding logo as a data URI.
1765
+
1766
+ The render page has no network and loads over ``file://``, so a path
1767
+ would only sometimes resolve; inlining always does.
1768
+ """
1769
+ if not options.branding.logo:
1770
+ return ""
1771
+ path = self._resolve_path(options.branding.logo)
1772
+ if not path.is_file():
1773
+ _warn(f"video.branding.logo not found: {path}. The watermark will be omitted.")
1774
+ return ""
1775
+ import base64
1776
+ import mimetypes
1777
+
1778
+ mime = mimetypes.guess_type(path.name)[0] or "image/png"
1779
+ return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode("ascii")
1780
+
1781
+ def _render_build_videos(self, results: dict[str, str], dry_run: bool) -> None:
1782
+ """Render every collected job, sharing one browser for the batch.
1783
+
1784
+ A dry run has already recorded what *would* be written and must never
1785
+ launch a browser or an encoder.
1786
+ """
1787
+ if dry_run or not self._video_jobs:
1788
+ return
1789
+
1790
+ from .tts import assemble_track
1791
+ from .video import render_videos
1792
+
1793
+ options = self._video_config()
1794
+ jobs = self._synthesize_narration(options)
1795
+
1796
+ failures = [
1797
+ r
1798
+ for r in render_videos(
1799
+ jobs,
1800
+ options,
1801
+ assemble_audio=assemble_track,
1802
+ cache_root=self._resolve_path(options.cache_dir),
1803
+ on_warning=_warn,
1804
+ )
1805
+ if not r.ok
1806
+ ]
1807
+ if failures:
1808
+ detail = "\n".join(f" {r.job.out.name}: {r.error}" for r in failures)
1809
+ raise BuildError(f"{len(failures)} video(s) failed to render:\n{detail}")
1810
+
1811
+ def _synthesize_narration(self, options) -> list:
1812
+ """Voice every job's steps before any browser opens.
1813
+
1814
+ Thin wrapper over the shared ``tts.voice_jobs`` — the only thing a build
1815
+ adds is resolving the cache against the project root, so a build
1816
+ launched from a subdirectory hits the same warm cache instead of
1817
+ re-billing against an empty one.
1818
+ """
1819
+ from .tts import voice_jobs
1820
+ from .video import find_ffmpeg
1821
+
1822
+ if options.tts.provider == "none":
1823
+ return list(self._video_jobs)
1824
+
1825
+ cache_root = self._resolve_path(options.tts.cache_dir)
1826
+ return voice_jobs(
1827
+ self._video_jobs,
1828
+ options.tts,
1829
+ ffmpeg=find_ffmpeg(options),
1830
+ cache_root=cache_root,
1831
+ work_dir=cache_root / "work",
1832
+ on_warning=_warn,
1833
+ )
1834
+
1835
+ # ── Screenshots (shared) ─────────────────────────────────────────────
1836
+
1837
+ def _copy_screenshots(
1838
+ self,
1839
+ entry: GuideEntry,
1840
+ scenario,
1841
+ screenshots_dir: Path,
1842
+ assets_dir: Path,
1843
+ results: dict[str, str],
1844
+ dry_run: bool,
1845
+ *,
1846
+ layouts: Optional[list[str]] = None,
1847
+ ) -> None:
1848
+ """Copy screenshots for this guide to the assets directory.
1849
+
1850
+ For each configured locale, prefers ``screenshots_dir/<slug>/<locale>/``
1851
+ and copies into ``assets_dir/<entry.slug>/<locale>/``. Falls back to
1852
+ the flat ``screenshots_dir/<slug>/`` → ``assets_dir/<entry.slug>/``
1853
+ layout when the per-locale subdir doesn't exist (single-language
1854
+ projects, or projects that haven't recaptured after enabling locales).
1855
+ """
1856
+ scenario_root = screenshots_dir / scenario.slug
1857
+ if not scenario_root.exists():
1858
+ return
1859
+
1860
+ copied_dirs: set[Path] = set()
1861
+
1862
+ def _copy_from(src_dir: Path, dest_dir: Path) -> None:
1863
+ if dest_dir in copied_dirs:
1864
+ return
1865
+ copied_dirs.add(dest_dir)
1866
+ if not dry_run:
1867
+ dest_dir.mkdir(parents=True, exist_ok=True)
1868
+ # JSON sidecars carry bbox + viewport metadata that the runtime
1869
+ # cursor animation reads — they must travel with the PNGs.
1870
+ for src_file in sorted([*src_dir.glob("step-*.png"), *src_dir.glob("step-*.json")]):
1871
+ dest = dest_dir / src_file.name
1872
+ rel_dest = self._rel(dest)
1873
+ if dry_run:
1874
+ results[rel_dest] = "planned"
1875
+ else:
1876
+ shutil.copy2(src_file, dest)
1877
+ results[rel_dest] = "generated"
1878
+
1879
+ guide_layouts = list(layouts if layouts is not None else self.build_config.layouts)
1880
+ layouts_or_default: list[Optional[str]] = guide_layouts or [None]
1881
+ for locale in self.build_config.locales:
1882
+ for layout in layouts_or_default:
1883
+ # Same resolver the URL builder and the read side use. Mirroring
1884
+ # the resolved segments into the assets tree is what guarantees
1885
+ # the emitted <img src> resolves (see
1886
+ # GuideAssembler.resolve_capture_segments).
1887
+ segments = [
1888
+ seg
1889
+ for seg in self._guide_assembler.resolve_capture_segments(
1890
+ scenario.slug, screenshots_dir, locale, layout
1891
+ )
1892
+ if seg
1893
+ ]
1894
+ _copy_from(
1895
+ scenario_root.joinpath(*segments),
1896
+ (assets_dir / entry.slug).joinpath(*segments),
1897
+ )
1898
+
1899
+ # ── Shared site files ────────────────────────────────────────────────
1900
+
1901
+ def _emit_angular_shared(
1902
+ self,
1903
+ output_dir: Path,
1904
+ results: dict[str, str],
1905
+ dry_run: bool,
1906
+ ) -> None:
1907
+ self._write_routes(output_dir, results, dry_run)
1908
+ self._write_nav_json(output_dir, results, dry_run)
1909
+ self._write_i18n_json(output_dir, results, dry_run)
1910
+ self._write_angular_group_components(output_dir, results, dry_run)
1911
+ self._write_angular_onboarding_component(output_dir, results, dry_run)
1912
+ self._copy_cursor_bundle(output_dir, results, dry_run)
1913
+ self._copy_player_bundle_if_needed(output_dir, results, dry_run)
1914
+ self._write_gitignore(output_dir, results, dry_run)
1915
+
1916
+ # ── In-app tours: the ``guide`` target ───────────────────────────────
1917
+
1918
+ def _arm_tours(self, output_dir: Path, assets_dir: Path) -> None:
1919
+ """Verify what the tours ``guide`` target will publish, before the wipe.
1920
+
1921
+ Inert unless ``config.yaml`` enables tours. The tree is the one
1922
+ ``steppy generate`` / ``steppy tours`` wrote; the build only
1923
+ copies it (see ``steppy.tours.publish``). Lazily imported, like the
1924
+ rest of the tours package.
1925
+ """
1926
+ self._tours_publish = None
1927
+ tours = getattr(self.steppy_config, "tours", None)
1928
+ if tours is None or not tours.enabled:
1929
+ return
1930
+ from .tours.publish import TourPublishError, check_build_overlaps, plan_guide_publish
1931
+ from .tours.settings import ToursSettings
1932
+
1933
+ settings = ToursSettings.from_config(self.steppy_config, self.project_root)
1934
+ try:
1935
+ check_build_overlaps(settings, output_dir, assets_dir)
1936
+ if not settings.publishes_guide:
1937
+ return
1938
+ if not self._is_static():
1939
+ _warn("tours: the guide target publishes only with target: 'static'; skipped")
1940
+ return
1941
+ taken = {g.slug for g in self.build_config.guides} | {g.slug for g in self.build_config.groups}
1942
+ if GUIDE_SITE_TOURS_DIR in taken:
1943
+ raise BuildError(
1944
+ f"slug {GUIDE_SITE_TOURS_DIR!r} is reserved for the in-app tours this site publishes; "
1945
+ "rename that guide or group"
1946
+ )
1947
+ plan = plan_guide_publish(
1948
+ settings,
1949
+ guide_slugs=[g.slug for g in self.build_config.guides],
1950
+ build_locales=self.build_config.locales,
1951
+ )
1952
+ except TourPublishError as exc:
1953
+ raise BuildError(str(exc)) from exc
1954
+ for warning in plan.warnings:
1955
+ _warn(warning)
1956
+ self._tours_publish = plan
1957
+
1958
+ def _emit_static_tours(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
1959
+ """Copy the verified tour tree to ``<site>/tours/<build-id>/``, byte for byte."""
1960
+ plan = self._tours_publish
1961
+ if plan is None:
1962
+ return
1963
+ target = output_dir / plan.site_dir
1964
+ for name, data in plan.files: # tour files first, the manifest last
1965
+ self._write_bytes(target / name, data, results, dry_run)
1966
+
1967
+ def _emit_static_shared(
1968
+ self,
1969
+ output_dir: Path,
1970
+ results: dict[str, str],
1971
+ dry_run: bool,
1972
+ ) -> None:
1973
+ self._write_static_index(output_dir, results, dry_run)
1974
+ self._write_static_group_pages(output_dir, results, dry_run)
1975
+ self._write_static_onboarding_page(output_dir, results, dry_run)
1976
+ self._write_static_manifest(output_dir, results, dry_run)
1977
+ self._write_static_css(output_dir, results, dry_run)
1978
+ self._write_static_cursor_js(output_dir, results, dry_run)
1979
+ self._copy_player_bundle_if_needed(output_dir, results, dry_run)
1980
+ self._warn_root_only_files()
1981
+ self._write_static_robots(output_dir, results, dry_run)
1982
+ self._write_static_sitemap(output_dir, results, dry_run)
1983
+ self._write_nojekyll(output_dir, results, dry_run)
1984
+ self._write_static_headers(output_dir, results, dry_run)
1985
+
1986
+ # ── Angular shared writers (unchanged behavior) ──────────────────────
1987
+
1988
+ def _default_route_slug(self) -> str:
1989
+ """Pick the default route. Onboarding wins when present; otherwise the
1990
+ first navbar entry that resolves to a real route (group or guide); else
1991
+ the first declared guide."""
1992
+ if self.build_config.onboarding is not None and self.build_config.onboarding.enabled:
1993
+ return ONBOARDING_SLUG
1994
+ groups_by_slug = self.build_config.groups_by_slug()
1995
+ guides_by_slug = self.build_config.guides_by_slug()
1996
+ for slug in self.build_config.navbar.order:
1997
+ if slug in groups_by_slug or slug in guides_by_slug:
1998
+ return slug
1999
+ return self.build_config.guides[0].slug
2000
+
2001
+ def _write_routes(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2002
+ role = self.build_config.role
2003
+ role_ns = _role_namespace(role)
2004
+ guides_data = [{"slug": g.slug, "class_name": _slug_to_class_name(g.slug)} for g in self.build_config.guides]
2005
+ groups_data = [
2006
+ {"slug": grp.slug, "class_name": _slug_to_class_name(grp.slug)} for grp in self.build_config.groups
2007
+ ]
2008
+ has_onboarding = self.build_config.onboarding is not None and self.build_config.onboarding.enabled
2009
+ tpl = self.env.get_template("angular/routes.ts.j2")
2010
+ content = tpl.render(
2011
+ role=role,
2012
+ routes_const=f"{role_ns}_ROUTES",
2013
+ guides=guides_data,
2014
+ groups=groups_data,
2015
+ has_onboarding=has_onboarding,
2016
+ onboarding_slug=ONBOARDING_SLUG,
2017
+ default_slug=self._default_route_slug(),
2018
+ )
2019
+ self._write_file(output_dir / f"{role}.routes.ts", content, results, dry_run)
2020
+
2021
+ def _write_nav_json(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2022
+ """Emit the navigation tree as JSON with translation-key references
2023
+ instead of inlined ``LocalizedText`` maps. The host app feeds the keys
2024
+ through ngx-translate to render the navbar.
2025
+ """
2026
+ role = self.build_config.role
2027
+ role_ns = _role_namespace(role)
2028
+ guides_by_slug = self.build_config.guides_by_slug()
2029
+ onb_index = self.build_config.onboarding_index()
2030
+
2031
+ def transform(node: dict) -> dict:
2032
+ kind = node["kind"]
2033
+ if kind == "onboarding":
2034
+ return {
2035
+ "kind": "onboarding",
2036
+ "titleKey": f"{role_ns}.onboarding.title",
2037
+ "path": node["path"],
2038
+ "guides": list(node["guides"]),
2039
+ }
2040
+ if kind == "group":
2041
+ entry: dict = {
2042
+ "kind": "group",
2043
+ "slug": node["slug"],
2044
+ "titleKey": f"{role_ns}.groups.{node['slug']}.title",
2045
+ "path": node["path"],
2046
+ "children": [transform(child) for child in node["children"]],
2047
+ }
2048
+ if node.get("description") is not None:
2049
+ entry["descriptionKey"] = f"{role_ns}.groups.{node['slug']}.description"
2050
+ return entry
2051
+ if kind == "guide":
2052
+ slug = node["slug"]
2053
+ guide_entry = guides_by_slug[slug]
2054
+ return {
2055
+ "kind": "guide",
2056
+ "slug": slug,
2057
+ "titleKey": f"{role_ns}.guides.{slug}.title",
2058
+ "path": node["path"],
2059
+ "group": guide_entry.group,
2060
+ "modes": self._emitted_modes(guide_entry),
2061
+ "onboardingIndex": onb_index.get(slug),
2062
+ }
2063
+ raise BuildError(f"Unknown navigation node kind: {kind!r}")
2064
+
2065
+ transformed = [transform(n) for n in self.build_config.navigation_tree()]
2066
+ content = json.dumps(transformed, indent=2, ensure_ascii=False) + "\n"
2067
+ self._write_file(output_dir / f"{role}.nav.json", content, results, dry_run)
2068
+
2069
+ def _write_i18n_json(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2070
+ """Emit one ``i18n/<role>/<locale>.json`` per configured locale.
2071
+
2072
+ Each file holds a single top-level ``<ROLE>`` namespace whose value is
2073
+ a tree of translation strings (site title, per-guide titles, per-group
2074
+ titles/descriptions, onboarding text, hardcoded UI labels). Hardcoded
2075
+ UI labels (``ui.groupEmpty``, ``onboarding.start``…) ship as English
2076
+ defaults in every locale; translators edit the generated files.
2077
+ """
2078
+ role = self.build_config.role
2079
+ role_ns = _role_namespace(role)
2080
+ cfg = self.build_config
2081
+ default_locale = cfg.default_locale
2082
+ onb = cfg.onboarding
2083
+
2084
+ for locale in cfg.locales:
2085
+ guides_section = {g.slug: {"title": pick_locale(g.title, locale, default_locale)} for g in cfg.guides}
2086
+ groups_section: dict[str, dict[str, str]] = {}
2087
+ for grp in cfg.groups:
2088
+ bucket: dict[str, str] = {
2089
+ "title": pick_locale(grp.title, locale, default_locale),
2090
+ }
2091
+ if grp.description is not None:
2092
+ bucket["description"] = pick_locale(grp.description, locale, default_locale)
2093
+ groups_section[grp.slug] = bucket
2094
+
2095
+ onboarding_section: dict[str, str] = dict(UI_LABEL_DEFAULTS["onboarding"])
2096
+ if onb is not None and onb.enabled:
2097
+ onboarding_section["title"] = pick_locale(onb.title, locale, default_locale)
2098
+ if onb.description is not None:
2099
+ onboarding_section["description"] = pick_locale(onb.description, locale, default_locale)
2100
+
2101
+ bundle = {
2102
+ role_ns: {
2103
+ "site": {
2104
+ "title": pick_locale(cfg.site.title, locale, default_locale),
2105
+ },
2106
+ "guides": guides_section,
2107
+ "groups": groups_section,
2108
+ "onboarding": onboarding_section,
2109
+ "ui": dict(UI_LABEL_DEFAULTS["ui"]),
2110
+ }
2111
+ }
2112
+ content = json.dumps(bundle, indent=2, ensure_ascii=False) + "\n"
2113
+ self._write_file(
2114
+ output_dir / "i18n" / role / f"{locale}.json",
2115
+ content,
2116
+ results,
2117
+ dry_run,
2118
+ )
2119
+
2120
+ def _write_angular_group_components(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2121
+ if not self.build_config.groups:
2122
+ return
2123
+ role_ns = _role_namespace(self.build_config.role)
2124
+ tpl = self.env.get_template("angular/group.component.ts.j2")
2125
+ for group in self.build_config.groups:
2126
+ children = [{"slug": guide.slug} for guide in self.build_config.guides if guide.group == group.slug]
2127
+ content = tpl.render(
2128
+ role_namespace=role_ns,
2129
+ group=group,
2130
+ class_name=_slug_to_class_name(group.slug),
2131
+ children=children,
2132
+ has_description=group.description is not None,
2133
+ )
2134
+ self._write_file(
2135
+ output_dir / "_groups" / f"{group.slug}.group.component.ts",
2136
+ content,
2137
+ results,
2138
+ dry_run,
2139
+ )
2140
+
2141
+ def _write_angular_onboarding_component(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2142
+ onb = self.build_config.onboarding
2143
+ if onb is None or not onb.enabled:
2144
+ return
2145
+ role_ns = _role_namespace(self.build_config.role)
2146
+ tpl = self.env.get_template("angular/onboarding.component.ts.j2")
2147
+ steps = [{"slug": slug} for slug in onb.guides]
2148
+ content = tpl.render(
2149
+ role_namespace=role_ns,
2150
+ steps=steps,
2151
+ has_description=onb.description is not None,
2152
+ )
2153
+ self._write_file(
2154
+ output_dir / "_onboarding" / "onboarding.component.ts",
2155
+ content,
2156
+ results,
2157
+ dry_run,
2158
+ )
2159
+
2160
+ def _write_gitignore(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2161
+ tpl = self.env.get_template("angular/gitignore.j2")
2162
+ content = tpl.render()
2163
+ self._write_file(output_dir / ".gitignore", content, results, dry_run)
2164
+
2165
+ # ── Static shared writers ────────────────────────────────────────────
2166
+
2167
+ def _guides_in_order(self) -> list[GuideEntry]:
2168
+ """Return guides ordered by navbar.order, with declaration-order fallback
2169
+ for any slug not explicitly listed (mirrors the Angular navbar logic)."""
2170
+ by_slug = self.build_config.guides_by_slug()
2171
+ ordered: list[GuideEntry] = []
2172
+ seen: set[str] = set()
2173
+ for slug in self.build_config.navbar.order:
2174
+ if slug in by_slug and slug not in seen:
2175
+ ordered.append(by_slug[slug])
2176
+ seen.add(slug)
2177
+ for guide in self.build_config.guides:
2178
+ if guide.slug not in seen:
2179
+ ordered.append(guide)
2180
+ seen.add(guide.slug)
2181
+ return ordered
2182
+
2183
+ def _index_filename(self, locale: str) -> str:
2184
+ """Filename for a locale-specific top-level index page."""
2185
+ if locale == self.build_config.default_locale:
2186
+ return "index.html"
2187
+ return f"index.{locale}.html"
2188
+
2189
+ def _index_href(self, locale: str) -> str:
2190
+ """Link back to the site index from a page one directory deep.
2191
+
2192
+ Every static page except the index itself sits at ``<dir>/<name>.html``,
2193
+ so they all share this one href. It exists because the templates used to
2194
+ re-implement ``_index_filename`` inline — three different ways, keyed on
2195
+ ``locale``, on ``current_locale``, and (on the interactive and video
2196
+ pages) on ``default_locale`` compared with itself, which was a ternary
2197
+ whose else-branch could never be taken. Four spellings of one rule.
2198
+
2199
+ Note this is the *file* href, not the canonical URL: ``_page_site_rel``
2200
+ deliberately answers ``""`` for the default-locale index so the home page
2201
+ is canonical at the bare site root rather than at ``/index.html``. The
2202
+ two must stay separate.
2203
+ """
2204
+ return f"../{self._index_filename(locale)}"
2205
+
2206
+ def _write_static_index(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2207
+ """Write one index page per locale at the project root.
2208
+
2209
+ - Default locale → ``index.html``
2210
+ - Other locales → ``index.<locale>.html``
2211
+
2212
+ Lang switcher links between them. Default-locale lives at
2213
+ ``index.html`` directly so the canonical URL stays clean.
2214
+ """
2215
+ tpl = self.env.get_template("static/index.html.j2")
2216
+ for locale in self.build_config.locales:
2217
+ lang_links = self._lang_switcher_links(
2218
+ href_for=lambda loc: f"./{self._index_filename(loc)}",
2219
+ current_locale=locale,
2220
+ )
2221
+ seo = self._seo_for_page(
2222
+ locale=locale,
2223
+ is_index=True,
2224
+ title=self.build_config.site.title,
2225
+ description=self.build_config.seo.description or self.build_config.site.title,
2226
+ og_type="website",
2227
+ )
2228
+ content = tpl.render(
2229
+ site_title=self.build_config.site.title,
2230
+ current_locale=locale,
2231
+ locale=locale,
2232
+ html_lang=self._html_lang(locale),
2233
+ default_locale=self.build_config.default_locale,
2234
+ locales=self.build_config.locales,
2235
+ locale_labels=self.locale_labels,
2236
+ lang_links=lang_links,
2237
+ navigation=self.build_config.navigation_tree(),
2238
+ onboarding_slug=ONBOARDING_SLUG,
2239
+ onboarding_eyebrow="Getting Started",
2240
+ group_eyebrow="Module",
2241
+ steps_label="steps",
2242
+ guides_label="guides",
2243
+ seo=seo,
2244
+ # None unless --handbook armed the export.
2245
+ handbook_url=(f"./{self._handbook_href(locale)}" if self._handbook_enabled else None),
2246
+ handbook_pdf_url=(f"./{HANDBOOK_SLUG}/{locale}.pdf" if self._handbook_enabled else None),
2247
+ handbook_label="Full handbook",
2248
+ )
2249
+ self._write_file(
2250
+ output_dir / self._index_filename(locale),
2251
+ content,
2252
+ results,
2253
+ dry_run,
2254
+ )
2255
+
2256
+ def _write_static_group_pages(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2257
+ """Per-locale group landing pages at ``<group-slug>/<locale>.html``,
2258
+ plus a ``<group-slug>/index.html`` redirect to the default locale."""
2259
+ if not self.build_config.groups:
2260
+ return
2261
+ tpl = self.env.get_template("static/group.html.j2")
2262
+ guide_children: dict[str, list[dict]] = {g.slug: [] for g in self.build_config.groups}
2263
+ for guide in self.build_config.guides:
2264
+ if guide.group is not None:
2265
+ guide_children[guide.group].append({"kind": "guide", "slug": guide.slug, "title": guide.title})
2266
+ subgroup_children: dict[str, list[dict]] = {g.slug: [] for g in self.build_config.groups}
2267
+ for grp in self.build_config.groups:
2268
+ if grp.parent is not None:
2269
+ subgroup_children[grp.parent].append(
2270
+ {
2271
+ "kind": "group",
2272
+ "slug": grp.slug,
2273
+ "title": grp.title,
2274
+ "description": grp.description,
2275
+ "child_count": len(guide_children[grp.slug]),
2276
+ }
2277
+ )
2278
+ children_by_group: dict[str, list[dict]] = {
2279
+ g.slug: subgroup_children[g.slug] + guide_children[g.slug] for g in self.build_config.groups
2280
+ }
2281
+ default_locale = self.build_config.default_locale
2282
+ for group in self.build_config.groups:
2283
+ for locale in self.build_config.locales:
2284
+ lang_links = self._lang_switcher_links(
2285
+ href_for=lambda loc: f"./{loc}.html",
2286
+ current_locale=locale,
2287
+ )
2288
+ seo = self._seo_for_page(
2289
+ locale=locale,
2290
+ dir_path=group.slug,
2291
+ title=group.title,
2292
+ description=group.description or group.title,
2293
+ og_type="website",
2294
+ )
2295
+ content = tpl.render(
2296
+ site_title=self.build_config.site.title,
2297
+ current_locale=locale,
2298
+ locale=locale,
2299
+ html_lang=self._html_lang(locale),
2300
+ index_href=self._index_href(locale),
2301
+ default_locale=default_locale,
2302
+ locales=self.build_config.locales,
2303
+ locale_labels=self.locale_labels,
2304
+ lang_links=lang_links,
2305
+ group=group,
2306
+ children=children_by_group[group.slug],
2307
+ empty_label="No guides in this group yet.",
2308
+ seo=seo,
2309
+ )
2310
+ self._write_file(
2311
+ output_dir / group.slug / f"{locale}.html",
2312
+ content,
2313
+ results,
2314
+ dry_run,
2315
+ )
2316
+ # index.html redirect to default locale
2317
+ self._write_file(
2318
+ output_dir / group.slug / "index.html",
2319
+ self._dir_redirect_stub(group.slug),
2320
+ results,
2321
+ dry_run,
2322
+ )
2323
+
2324
+ def _write_static_onboarding_page(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2325
+ """Per-locale onboarding pages at ``<onboarding-slug>/<locale>.html``,
2326
+ plus a ``<onboarding-slug>/index.html`` redirect to the default locale."""
2327
+ onb = self.build_config.onboarding
2328
+ if onb is None or not onb.enabled:
2329
+ return
2330
+ tpl = self.env.get_template("static/onboarding.html.j2")
2331
+ guides_by_slug = self.build_config.guides_by_slug()
2332
+ steps = [guides_by_slug[slug] for slug in onb.guides]
2333
+ default_locale = self.build_config.default_locale
2334
+ for locale in self.build_config.locales:
2335
+ lang_links = self._lang_switcher_links(
2336
+ href_for=lambda loc: f"./{loc}.html",
2337
+ current_locale=locale,
2338
+ )
2339
+ seo = self._seo_for_page(
2340
+ locale=locale,
2341
+ dir_path=ONBOARDING_SLUG,
2342
+ title=onb.title,
2343
+ description=onb.description or onb.title,
2344
+ og_type="website",
2345
+ )
2346
+ content = tpl.render(
2347
+ site_title=self.build_config.site.title,
2348
+ current_locale=locale,
2349
+ locale=locale,
2350
+ html_lang=self._html_lang(locale),
2351
+ index_href=self._index_href(locale),
2352
+ default_locale=default_locale,
2353
+ locales=self.build_config.locales,
2354
+ locale_labels=self.locale_labels,
2355
+ lang_links=lang_links,
2356
+ onboarding=onb,
2357
+ steps=steps,
2358
+ start_label="Start onboarding →",
2359
+ seo=seo,
2360
+ )
2361
+ self._write_file(
2362
+ output_dir / ONBOARDING_SLUG / f"{locale}.html",
2363
+ content,
2364
+ results,
2365
+ dry_run,
2366
+ )
2367
+ self._write_file(
2368
+ output_dir / ONBOARDING_SLUG / "index.html",
2369
+ self._dir_redirect_stub(ONBOARDING_SLUG),
2370
+ results,
2371
+ dry_run,
2372
+ )
2373
+
2374
+ def _write_static_manifest(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2375
+ onb = self.build_config.onboarding
2376
+ onb_index = self.build_config.onboarding_index()
2377
+ manifest = {
2378
+ "site": {"title": self.build_config.site.title},
2379
+ "locales": list(self.build_config.locales),
2380
+ "defaultLocale": self.build_config.default_locale,
2381
+ "layouts": list(self.build_config.layouts),
2382
+ "defaultLayout": self.build_config.default_layout,
2383
+ "guides": [
2384
+ {
2385
+ "slug": g.slug,
2386
+ "title": g.title,
2387
+ "modes": self._emitted_modes(g),
2388
+ "scenario": g.scenario,
2389
+ "group": g.group,
2390
+ "onboardingIndex": onb_index.get(g.slug),
2391
+ # Effective layouts for THIS guide — the site-wide
2392
+ # ``layouts`` above narrowed by the scenario's frontmatter
2393
+ # declaration (see _layouts_for_guide).
2394
+ "layouts": self._guide_layouts.get(g.slug, list(self.build_config.layouts)),
2395
+ "pages": {loc: f"{g.slug}/{loc}.html" for loc in self.build_config.locales},
2396
+ }
2397
+ for g in self._guides_in_order()
2398
+ ],
2399
+ "groups": [
2400
+ {
2401
+ "slug": grp.slug,
2402
+ "title": grp.title,
2403
+ "description": grp.description,
2404
+ "guides": [guide.slug for guide in self.build_config.guides if guide.group == grp.slug],
2405
+ "pages": {loc: f"{grp.slug}/{loc}.html" for loc in self.build_config.locales},
2406
+ }
2407
+ for grp in self.build_config.groups
2408
+ ],
2409
+ "onboarding": (
2410
+ {
2411
+ "enabled": True,
2412
+ "title": onb.title,
2413
+ "description": onb.description,
2414
+ "guides": list(onb.guides),
2415
+ "pages": {loc: f"{ONBOARDING_SLUG}/{loc}.html" for loc in self.build_config.locales},
2416
+ }
2417
+ if onb is not None and onb.enabled
2418
+ else None
2419
+ ),
2420
+ "navigation": self.build_config.navigation_tree(),
2421
+ }
2422
+ plan = self._tours_publish
2423
+ if plan is not None:
2424
+ manifest["tours"] = {
2425
+ "buildId": plan.build_id,
2426
+ "manifest": f"{plan.site_dir}/tours.manifest.json",
2427
+ "ids": list(plan.tour_ids),
2428
+ }
2429
+ content = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
2430
+ self._write_file(output_dir / "manifest.json", content, results, dry_run)
2431
+
2432
+ # ── PDF export (static target) ───────────────────────────────────────
2433
+
2434
+ def _arm_pdf_export(self, *, pdf: bool, handbook: bool) -> None:
2435
+ """Validate and record the PDF-export request for this build.
2436
+
2437
+ Angular is rejected outright: that target emits components for a host
2438
+ app to route and render, so there is no page here to print — the host
2439
+ owns its own document.
2440
+ """
2441
+ self._pdf_pages = []
2442
+ self._handbook_fragments = {}
2443
+ self._pdf_enabled = pdf
2444
+ self._handbook_enabled = handbook
2445
+ if not (pdf or handbook):
2446
+ return
2447
+ if not self._is_static():
2448
+ raise BuildError(
2449
+ "PDF export requires target: 'static'. The angular target emits components "
2450
+ "for a host app to render, so there is no page for Steppy to print."
2451
+ )
2452
+ if handbook:
2453
+ taken = {g.slug for g in self.build_config.guides} | {g.slug for g in self.build_config.groups}
2454
+ if HANDBOOK_SLUG in taken:
2455
+ raise BuildError(
2456
+ f"--handbook writes to '{HANDBOOK_SLUG}/', but a guide or group already uses that "
2457
+ f"slug. Rename it, or drop --handbook."
2458
+ )
2459
+
2460
+ def _record_artifact(self, path: Path, results: dict[str, str], dry_run: bool) -> None:
2461
+ """Record a **binary** artifact in the results dict.
2462
+
2463
+ ``_write_file`` owns text output (and its unchanged/updated diffing);
2464
+ PDFs are written by Chromium, so they only need to be counted.
2465
+ """
2466
+ results[self._rel(path)] = "planned" if dry_run else "generated"
2467
+
2468
+ def _handbook_href(self, locale: str) -> str:
2469
+ """Site-root-relative path of the handbook page for ``locale``."""
2470
+ return f"{HANDBOOK_SLUG}/{locale}.html"
2471
+
2472
+ def _write_static_handbooks(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2473
+ """One ``handbook/<locale>.html`` per locale: the whole site as a single
2474
+ document (cover, contents, every guide) in navigation order.
2475
+
2476
+ Deliberately written one directory deep, exactly like ``<slug>/<locale>.html``:
2477
+ the guide fragments already carry ``../assets/...`` screenshot URLs, so
2478
+ they can be reused verbatim instead of re-rendered against a new depth.
2479
+ """
2480
+ tpl = self.env.get_template("guide.book.html.j2")
2481
+ css = self.env.get_template("static/guides.css").render()
2482
+ groups_by_slug = self.build_config.groups_by_slug()
2483
+ default_locale = self.build_config.default_locale
2484
+ for locale in self.build_config.locales:
2485
+ fragments = self._handbook_fragments.get(locale, {})
2486
+ entries: list[dict] = []
2487
+ current_group: Optional[str] = None
2488
+ for entry in self._guides_in_order():
2489
+ html_content = fragments.get(entry.slug)
2490
+ if html_content is None:
2491
+ continue
2492
+ section = None
2493
+ if entry.group and entry.group != current_group:
2494
+ group = groups_by_slug.get(entry.group)
2495
+ if group is not None:
2496
+ section = pick_locale(group.title, locale, default_locale)
2497
+ current_group = entry.group
2498
+ entries.append(
2499
+ {
2500
+ "slug": entry.slug,
2501
+ "title": pick_locale(entry.title, locale, default_locale),
2502
+ "section": section,
2503
+ "html": html_content,
2504
+ }
2505
+ )
2506
+ if not entries:
2507
+ continue
2508
+ content = tpl.render(
2509
+ title=pick_locale(self.build_config.site.title, locale, default_locale),
2510
+ subtitle=None,
2511
+ role=None,
2512
+ locale=locale,
2513
+ html_lang=self._html_lang(locale),
2514
+ guides=entries,
2515
+ guides_css=css,
2516
+ )
2517
+ self._write_file(output_dir / HANDBOOK_SLUG / f"{locale}.html", content, results, dry_run)
2518
+ # Bare-directory URL → default locale, like every other section.
2519
+ if self._handbook_fragments:
2520
+ self._write_file(
2521
+ output_dir / HANDBOOK_SLUG / "index.html",
2522
+ self._redirect_html(
2523
+ f"./{default_locale}.html",
2524
+ lang=self._html_lang(default_locale),
2525
+ noindex=True,
2526
+ ),
2527
+ results,
2528
+ dry_run,
2529
+ )
2530
+
2531
+ def _render_build_pdfs(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2532
+ """Print the emitted pages (and handbooks) to PDF.
2533
+
2534
+ Runs last, over files already on disk, sharing one Chromium for the
2535
+ whole batch. A dry run only records what *would* be printed — it must
2536
+ never launch a browser.
2537
+ """
2538
+ from .pdf import PdfJob, render_pdfs
2539
+
2540
+ jobs: list[PdfJob] = []
2541
+ if self._pdf_enabled:
2542
+ for html_path, title in self._pdf_pages:
2543
+ jobs.append(PdfJob(html=html_path, pdf=html_path.with_suffix(".pdf"), title=title))
2544
+ if self._handbook_enabled:
2545
+ default_locale = self.build_config.default_locale
2546
+ site_title = pick_locale(self.build_config.site.title, default_locale, default_locale) or ""
2547
+ for locale in self.build_config.locales:
2548
+ page = output_dir / HANDBOOK_SLUG / f"{locale}.html"
2549
+ if self._rel(page) in results:
2550
+ jobs.append(PdfJob(html=page, pdf=page.with_suffix(".pdf"), title=site_title))
2551
+
2552
+ for job in jobs:
2553
+ self._record_artifact(job.pdf, results, dry_run)
2554
+ if dry_run or not jobs:
2555
+ return
2556
+
2557
+ failures = [r for r in render_pdfs(jobs, self._pdf_config()) if not r.ok]
2558
+ if failures:
2559
+ detail = "\n".join(f" {r.job.pdf.name}: {r.error}" for r in failures)
2560
+ raise BuildError(f"{len(failures)} PDF(s) failed to render:\n{detail}")
2561
+
2562
+ def _pdf_config(self):
2563
+ """Paper settings from ``config.yaml``. ``steppy build`` treats a
2564
+ missing config as non-fatal (it only needs it for role display names),
2565
+ so fall back to the defaults rather than failing the export."""
2566
+ from .models import PdfConfig
2567
+
2568
+ return self.steppy_config.pdf if self.steppy_config else PdfConfig()
2569
+
2570
+ def _write_static_css(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2571
+ """Render the shared guides.css through Jinja's loader so user template
2572
+ overrides in templates/static/guides.css take precedence over the bundled
2573
+ copy (same precedence rule as every other template)."""
2574
+ tpl = self.env.get_template("static/guides.css")
2575
+ content = tpl.render()
2576
+ self._write_file(output_dir / "assets" / "guides.css", content, results, dry_run)
2577
+
2578
+ def _write_static_cursor_js(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2579
+ """Render the shared cursor animation driver to assets/cursor.js. Static
2580
+ pages link this once instead of inlining the IIFE in every page.
2581
+
2582
+ The template body is just ``{{ cursor_iife }}`` — the actual animator
2583
+ source lives in ``packages/player/src/cursor/`` and is bundled into
2584
+ ``src/steppy/_player_bundle/steppy-cursor-legacy.js``."""
2585
+ tpl = self.env.get_template("static/cursor.js.j2")
2586
+ content = tpl.render(cursor_iife=get_legacy_cursor_iife())
2587
+ self._write_file(output_dir / "assets" / "cursor.js", content, results, dry_run)
2588
+
2589
+ def _write_nojekyll(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2590
+ """Empty file disabling GitHub Pages' Jekyll preprocessing.
2591
+ Harmless on other static hosts."""
2592
+ self._write_file(output_dir / ".nojekyll", "", results, dry_run)
2593
+
2594
+ def _write_static_headers(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2595
+ """Emit a ``_headers`` sidecar carrying ``X-Robots-Tag`` for the
2596
+ artifacts that cannot carry a robots meta of their own.
2597
+
2598
+ PDFs are the reason. ``handbook/<locale>.pdf`` is Chromium printing a
2599
+ page that is deliberately ``noindex``, and ``<slug>/<locale>.pdf``
2600
+ duplicates its HTML the same way — but ``page.pdf()`` writes no robots
2601
+ metadata and a PDF has no ``<head>`` to put one in. An HTTP header is the
2602
+ only mechanism, and ``robots.txt`` is the wrong one: per this repo's own
2603
+ note in ``report/render.py``, a disallowed URL is still *listable*, it
2604
+ simply never gets fetched and therefore never learns it is noindex.
2605
+
2606
+ Read by Netlify and Cloudflare Pages; **ignored by GitHub Pages and
2607
+ GitLab Pages**, which are the two the shipped CI scaffolds document — so
2608
+ for the default deployment this genuinely does nothing. Emitted anyway,
2609
+ for the reason ``.nojekyll`` is: harmless where unread, and the only
2610
+ thing available where it is read.
2611
+ """
2612
+ tpl = self.env.get_template("static/_headers.j2")
2613
+ tours = {"dir": GUIDE_SITE_TOURS_DIR} if self._tours_publish is not None else None
2614
+ content = tpl.render(public=not self._is_private_seo(), tours=tours)
2615
+ self._write_file(output_dir / "_headers", content, results, dry_run)
2616
+
2617
+ def _warn_root_only_files(self) -> None:
2618
+ """Warn when ``seo.baseUrl`` carries a path.
2619
+
2620
+ ``robots.txt`` and ``_headers`` are honoured only at the **origin root**,
2621
+ but a GitHub Pages *project* site is served under ``/<repo>/`` — so a
2622
+ correct ``baseUrl`` there has a path, and both files land somewhere no
2623
+ crawler and no host looks. Everything else survives: canonical links,
2624
+ hreflang and ``sitemap.xml`` are absolute, and each page still carries
2625
+ its own robots meta. It is specifically the two sidecars that go silent,
2626
+ and silently.
2627
+ """
2628
+ base = self.build_config.seo.base_url
2629
+ if not base:
2630
+ return
2631
+ prefix = urlsplit(base).path.strip("/")
2632
+ if not prefix:
2633
+ return
2634
+ _warn(
2635
+ f"seo.baseUrl '{base}' has a path, so robots.txt and _headers are written under "
2636
+ f"/{prefix}/ — neither is honoured anywhere but the origin root, so both are "
2637
+ "silently inert. Canonical links, hreflang and sitemap.xml are absolute and "
2638
+ "unaffected, and every page still carries its own robots meta."
2639
+ )
2640
+
2641
+ def _write_static_robots(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2642
+ """Emit robots.txt. Private (default) → ``Disallow: /``; public →
2643
+ ``Allow: /`` plus a ``Sitemap:`` pointer."""
2644
+ public = not self._is_private_seo()
2645
+ tpl = self.env.get_template("static/robots.txt.j2")
2646
+ content = tpl.render(
2647
+ public=public,
2648
+ sitemap_url=self._abs_url("sitemap.xml") if public else None,
2649
+ )
2650
+ self._write_file(output_dir / "robots.txt", content, results, dry_run)
2651
+
2652
+ def _sitemap_urls(self) -> list[SitemapUrl]:
2653
+ """The pages the sitemap lists: absolute URLs, deterministic order.
2654
+
2655
+ The set is exactly **the pages that are their own canonical** — index,
2656
+ guides in navbar order, groups, onboarding. Everything excluded is
2657
+ excluded for a reason a reader can check in the page itself rather than
2658
+ against a list here:
2659
+
2660
+ * redirect stubs (``<dir>/index.html``) canonical to their default-locale
2661
+ sibling;
2662
+ * ``interactive.html`` and ``video.html`` canonical to the guide's
2663
+ step-by-step page (see ``_guide_primary_site_rel``) — they present the
2664
+ same steps in a different shell, and listing them had every guide
2665
+ occupying three or four near-identical indexable URLs;
2666
+ * handbook pages carry an unconditional ``noindex``: one document
2667
+ duplicating every guide on the site.
2668
+
2669
+ ``tests/test_build_seo.py`` asserts that correspondence directly, by
2670
+ reading the emitted HTML rather than by re-deriving this list.
2671
+ """
2672
+ cfg = self.build_config
2673
+ locales = cfg.locales
2674
+ lastmod = cfg.seo.lastmod
2675
+ urls: list[SitemapUrl] = []
2676
+
2677
+ def add(dir_path: str, *, is_index: bool = False) -> None:
2678
+ alternates = tuple(
2679
+ Alternate(alt["hreflang"], alt["href"]) for alt in self._hreflang_alternates(dir_path, is_index)
2680
+ )
2681
+ for loc in locales:
2682
+ urls.append(
2683
+ SitemapUrl(
2684
+ loc=self._abs_url(self._page_site_rel(dir_path, loc, is_index)),
2685
+ alternates=alternates,
2686
+ lastmod=lastmod,
2687
+ )
2688
+ )
2689
+
2690
+ add("", is_index=True)
2691
+ for g in self._guides_in_order():
2692
+ add(g.slug)
2693
+ for grp in cfg.groups:
2694
+ add(grp.slug)
2695
+ onb = cfg.onboarding
2696
+ if onb is not None and onb.enabled:
2697
+ add(ONBOARDING_SLUG)
2698
+ return urls
2699
+
2700
+ def _sitemap_override_template(self) -> Optional[str]:
2701
+ """A project's own ``static/sitemap.xml.j2``, if it has one.
2702
+
2703
+ Steppy bundles no sitemap template — the default is
2704
+ ``src/steppy/sitemap.py``. But a project overriding the name still wins,
2705
+ because the override is the only way to reach the parts of the protocol
2706
+ the generator deliberately does not cover (``<image:image>`` over a
2707
+ guide's screenshots, ``<video:video>``, a ``<sitemapindex>`` split), and
2708
+ because silently ignoring a well-formed override is the exact failure
2709
+ ``_templating.py`` exists to prevent.
2710
+
2711
+ The bundled-directory comparison is load-bearing rather than defensive:
2712
+ ``src/steppy/templates/`` is generated and gitignored, so a developer on
2713
+ an editable install with a stale tree would otherwise trip this branch
2714
+ against Steppy's own vendored copy.
2715
+ """
2716
+ try:
2717
+ _source, filename, _uptodate = self.env.loader.get_source(self.env, "static/sitemap.xml.j2")
2718
+ except jinja2.TemplateNotFound:
2719
+ return None
2720
+ if filename is None:
2721
+ return None
2722
+ bundled = (bundled_templates_dir() / "static" / "sitemap.xml.j2").resolve()
2723
+ if Path(filename).resolve() == bundled:
2724
+ return None
2725
+ return filename
2726
+
2727
+ def _write_static_sitemap(self, output_dir: Path, results: dict[str, str], dry_run: bool) -> None:
2728
+ """Emit sitemap.xml over every indexable page (public seo mode only)."""
2729
+ if self._is_private_seo():
2730
+ return
2731
+ urls = self._sitemap_urls()
2732
+ override = self._sitemap_override_template()
2733
+ if override is not None:
2734
+ if self.build_config.seo.lastmod:
2735
+ _warn(
2736
+ f"sitemap.xml is rendered from your template at {override}; seo.lastmod is "
2737
+ "passed to it as `lastmod` and as each url's `.lastmod`, but the template "
2738
+ "must emit the <lastmod> element itself."
2739
+ )
2740
+ content = self.env.get_template("static/sitemap.xml.j2").render(
2741
+ urls=urls,
2742
+ lastmod=self.build_config.seo.lastmod,
2743
+ )
2744
+ else:
2745
+ content = render_sitemap(urls)
2746
+ for message in limit_warnings(len(urls), len(content.encode("utf-8"))):
2747
+ _warn(message)
2748
+ self._write_file(output_dir / "sitemap.xml", content, results, dry_run)
2749
+
2750
+ # ── File writer (shared) ─────────────────────────────────────────────
2751
+
2752
+ def _write_bytes(self, path: Path, data: bytes, results: dict[str, str], dry_run: bool) -> None:
2753
+ """Write ``data`` as is and record the result.
2754
+
2755
+ For files whose exact bytes matter — tour files are pinned by SRI
2756
+ hashes — where ``_write_file``'s text mode would translate newlines on
2757
+ Windows.
2758
+ """
2759
+ rel = self._rel(path)
2760
+ if dry_run:
2761
+ results[rel] = "planned"
2762
+ return
2763
+ path.parent.mkdir(parents=True, exist_ok=True)
2764
+ if path.exists():
2765
+ if path.read_bytes() == data:
2766
+ results[rel] = "unchanged"
2767
+ return
2768
+ results[rel] = "updated"
2769
+ else:
2770
+ results[rel] = "generated"
2771
+ path.write_bytes(data)
2772
+
2773
+ def _write_file(
2774
+ self,
2775
+ path: Path,
2776
+ content: str,
2777
+ results: dict[str, str],
2778
+ dry_run: bool,
2779
+ ) -> None:
2780
+ """Write a file and record the result."""
2781
+ rel = self._rel(path)
2782
+
2783
+ if dry_run:
2784
+ results[rel] = "planned"
2785
+ return
2786
+
2787
+ path.parent.mkdir(parents=True, exist_ok=True)
2788
+
2789
+ if path.exists():
2790
+ existing = path.read_text(encoding="utf-8")
2791
+ if existing == content:
2792
+ results[rel] = "unchanged"
2793
+ return
2794
+ results[rel] = "updated"
2795
+ else:
2796
+ results[rel] = "generated"
2797
+
2798
+ path.write_text(content, encoding="utf-8")