steppy-dev 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/build.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")
|