Build Architecture and Content Pipeline โ
Source: extracted from project README and merged with the former docs/content-pipeline.md, 2026-05-25.
Build Architecture โ
How tutorial markdown becomes deployed HTML, and how code becomes deployed apps. Three independent trigger paths share the parser pipeline but write to different targets.
flowchart TB
subgraph sources[Source repositories]
ProdRepos["sap-tutorials/*<br/>(public tutorial repos)"]
ContribRepos["sap-tutorials/*-Contribution<br/>(in-flight authoring)"]
ThisRepo["this repo<br/>(db/, srv/, srv-qa/, app/,<br/>hugo/, hugo-apps/, scripts/)"]
end
subgraph triggers[Build triggers]
DeployCI["deploy.yml<br/>(push to main / manual)"]
RebuildCI["rebuild-content.yml<br/>(schedule / manual / TUTORIAL_SLUG)"]
QaCI["rebuild-content-qa.yml<br/>(repository_dispatch from<br/>any -Contribution repo)"]
Local["local dev<br/>(npm run dev / cds watch)"]
end
subgraph fetch[Fetch + parse]
FetchProd["scripts/fetch-tutorials.ts<br/>--target hugo<br/>cache: .tutorial-cache/"]
FetchQa["scripts/fetch-tutorials.ts<br/>--target hugo --channel qa<br/>cache: .tutorial-cache-qa/"]
Parsers["scripts/parsers/<br/>(v1 ACCORDION, v2 H3,<br/>images, options, rules,<br/>sanitize-html)"]
end
subgraph hugoBuild[Hugo render]
HugoProd["hugo --minify<br/>โ hugo/public/"]
HugoQa["hugo --config hugo.qa.toml<br/>โ hugo/public-qa/"]
end
subgraph apps[App bundles]
AdminShell["app/admin-shell<br/>(UI5 + 11 Fiori Elements)"]
Analytics["app/analytics-explorer<br/>(Vue 3 + Vite + Monaco)"]
Scanner["app/scanner<br/>(UI5)"]
Display["app/display-app<br/>(Vue 3 + Vite)"]
HugoApps["hugo-apps/<br/>(9 Vue 3 islands)"]
end
subgraph mta[MTA assembly]
CdsBuild["cds build --production<br/>โ gen/srv, gen/srv-qa,<br/>gen/db, gen/db-qa"]
ApprouterBuild["approuter build<br/>(copies hugo/public + qa<br/>+ admin-ui + analytics-ui<br/>+ scanner-ui into static/)"]
Mbt["mbt build<br/>โ mta_archives/<br/>tutorials-ims_*.mtar"]
end
subgraph publish[Content publish]
PublishProd["publish-content.ts<br/>delta-aware, gzip,<br/>sha256 hash compare"]
PublishQa["publish-content.ts<br/>--channel qa<br/>(always --force)"]
end
subgraph deployed[Deployed targets]
SrvDeployed["tutorials-srv +<br/>tutorials-approuter"]
SrvQaDeployed["tutorials-srv-qa"]
HanaProd[("tutorials-hana<br/>(ContentFiles +<br/>ContentManifest BLOBs)")]
HanaQa[("tutorials-hana-qa")]
LocalSqlite[("local SQLite<br/>or hybrid HANA<br/>via cds bind")]
end
ProdRepos --> FetchProd
ContribRepos --> FetchProd
ContribRepos --> FetchQa
Local --> FetchProd
DeployCI --> FetchProd
RebuildCI --> FetchProd
QaCI --> FetchQa
FetchProd --> Parsers
FetchQa --> Parsers
Parsers --> HugoProd
Parsers --> HugoQa
Local --> HugoProd
Local -.->|cds watch| LocalSqlite
ThisRepo --> CdsBuild
ThisRepo --> AdminShell
ThisRepo --> Analytics
ThisRepo --> Scanner
ThisRepo --> Display
ThisRepo --> HugoApps
HugoApps --> HugoProd
DeployCI --> CdsBuild
DeployCI --> AdminShell
DeployCI --> Analytics
DeployCI --> Scanner
DeployCI --> Display
CdsBuild --> Mbt
AdminShell --> ApprouterBuild
Analytics --> ApprouterBuild
Scanner --> ApprouterBuild
HugoProd --> ApprouterBuild
HugoQa --> ApprouterBuild
ApprouterBuild --> Mbt
Display --> Mbt
Mbt -->|cf deploy| SrvDeployed
Mbt -->|cf deploy| SrvQaDeployed
Mbt -->|hdb deployer| HanaProd
Mbt -->|hdb deployer| HanaQa
HugoProd --> PublishProd
HugoQa --> PublishQa
RebuildCI --> PublishProd
QaCI --> PublishQa
PublishProd -->|"POST /content/publish<br/>(bearer)"| SrvDeployed
PublishQa -->|"POST /content/publish<br/>(bearer)"| SrvQaDeployed
SrvDeployed -.->|gzip BLOBs| HanaProd
SrvQaDeployed -.->|gzip BLOBs| HanaQa
classDef trigger fill:#fef3e7,stroke:#d97706,color:#92400e
class DeployCI,RebuildCI,QaCI,Local trigger
classDef target fill:#e7f4ee,stroke:#15803d,color:#14532d
class SrvDeployed,SrvQaDeployed,HanaProd,HanaQa,LocalSqlite targetNotes:
- Local dev uses in-memory SQLite by default (
cds watch); usenpm run dev:hybridfor the full stack against real HANA viacds bind. deploy.ymldoes NOT publish content โ it deploys the apps and HDI schemas. The post-deploy step triggersrebuild-content.ymlto populate HANA. This separation lets content rebuilds run independently of code deploys (a single tutorial fix doesn't require redeploying the srv).rebuild-content.ymlruns in one of three scopes โcatalog-only(~1 min, admin Mission/Group/etc. saves),slug-targeted(~2 min, one-tutorial fix),full(~10 min, everything). Manualgh workflow run ... -f slug=<slug>auto-infersslug-targeted. Admin writes auto-classify per entity via srv/lib/_classify-rebuild-mode.js. Full runbook: rebuild-content-workflow.md.- QA channel is end-to-end isolated: separate fetch cache (
.tutorial-cache-qa/), separate Hugo config (hugo.qa.toml), separate srv (tutorials-srv-qa), separate HDI (tutorials-hana-qa), separate API key (CONTENT_API_KEY_QA). It never touches prod tables. - VSCode extension preview is in-process โ Hugo binary bundled into
tutorials-srv-qa's deploy artifact, shells out per request to render markdown into HTML usingpreview-site/layouts. No content is persisted; tmpdir is cleaned per call.
Build Pipeline โ
Two parallel content pipelines feed two HDI containers. Both end at POST /content/publish on a CAP srv app โ there is no static-file fallback for tutorial HTML.
Prod pipeline โ tutorials-hana โ
sap-tutorials GitHub repos (live discovery via discoverAllTutorials)
โ
scripts/fetch-tutorials.ts --target hugo (cached in .tutorial-cache/)
โโ scripts/parsers/* parse frontmatter, steps, images, options
โโ fetchRulesVr() โ .tutorial-cache/*.rules.vr quiz data from *-Contribution repos
โโ writes hugo/content/tutorials/*.md (gitignored)
CAP_BASE_URL/build/catalog (unauth)
โ
hugo/content/missions/*.md, groups/*.md mission + completion-path pages
build:css โ PostCSS Fundamental Styles โ hugo/assets/css/sap-fundamental.css
build:apps โ Vite bundles hugo-apps/ Vue 3 islands โ hugo/static/js/*.js
(navigator, app-space, event-display, nav-dropdown, scanner-vue,
tutorial-feedback, tutorial-rating, cmd-palette, me)
build:highlight โ syntax-highlights .cds samples
build:hugo โ hugo --minify โ hugo/public/ (full site, incl. tutorials/)
โ
scripts/publish-content.ts SHA-256 diff vs GET /content/hashes
โ gzip โ base64 โ POST /content/publish
(CONTENT_API_KEY bearer; --force to bypass delta)
CAP srv (tutorials-srv) /content/publish
โ
ContentFiles + ContentManifest BLOBs in tutorials-hana
โ
GET /tutorials/{slug} โ approuter rewrites โ /content/tutorials/{slug}
โ decompress, ETag, bounded LRU cache (50MB)Tutorials are explicitly removed from
approuter/static/during build (rm -rf approuter/static/tutorials). Hugopublic/tutorials/*exists only as the source forpublish-content.ts.
QA channel pipeline โ tutorials-hana-qa โ
Parallel author-preview track. Sources only *-Contribution repos, gated by XSUAA scope Tutorial.Author, never touches prod tables.
*-Contribution GitHub repos (ONLY_CONTRIBUTION_REPOS=true)
โ
fetch-tutorials:qa โ .tutorial-cache-qa/ (.channel marker prevents cross-contamination)
โ
build:qa โ hugo --config ../hugo.qa.toml โ hugo/public-qa/
(strips Joule FAB, rating, completion buttons, progress UI)
โโ verify-qa-build.ts fails the build if QA-only stripping didn't apply
โ
publish-content:qa (always --force; CONTENT_API_KEY_QA)
โ
tutorials-srv-qa /content/publish
โ
ContentFiles + ContentManifest in tutorials-hana-qa
โ
GET /tutorials-qa/{slug} (XSUAA + Tutorial.Author at approuter)QA srv re-renders tutorials at runtime using srv-qa/lib/parsers.bundle.mjs, produced by prebuild:parsers-bundle (esbuild ESM bundle of scripts/parsers/). This lets the QA srv accept author-pushed markdown without rebuilding Hugo per author.
Standalone app builds โ
Each lives in its own subtree and copies a dist/ (or webapp/) into the AppRouter's static/<route>/ during MTA build:
| Source | Built by | Approuter path |
|---|---|---|
app/admin-shell/ | build:admin | static/admin-ui/ |
app/analytics-explorer/ | build:analytics-explorer | static/analytics-ui/ |
app/display-app/ | build:display | static/display-app/ |
app/scanner/webapp/ | (UI5 โ copied directly) | static/scanner-ui/ |
hugo-apps/scanner-vue (island) | build:apps | hugo/static/js/scanner-vue.js (loaded as <script> from Hugo) |
Build orchestration โ
build:all chains the pieces in order:
prebuild (parsers bundle)
โ fetch-tutorials --regenerate
โ build:css โ build:apps โ build:analytics-explorer
โ copy-joule-vendor โ build:hugo โ build:highlight โ build:displayDuring build:all, the fetch step calls GET /build/homepage-shelves from the CAP backend and bakes the result into hugo/data/homepage_shelves.json. This JSON drives the verb-spine previews, the comprehensive directory footer, and the per-verb sub-page shelf listings โ the same pattern used by /build/catalog for missions and groups.
Admin shell (build:admin) and QA pipeline (fetch-tutorials:qa โ build:qa โ publish-content:qa) are not in build:all โ they're run independently or via qa:full for the QA loop. Tutorials must be fetched at least once before dev or build:hugo (otherwise hugo/content/tutorials/ is empty).
Parsers (scripts/parsers/) โ
The fetch step (scripts/fetch-tutorials.ts) hands raw markdown + repo metadata to composeTutorial() (compose.ts), which orchestrates format detection, content transforms, and Hugo frontmatter emission. The same module set is bundled into srv-qa/lib/parsers.bundle.mjs (via prebuild:parsers-bundle) and re-used at runtime by the QA srv to render author-pushed drafts without re-running Hugo.
Format detection โ
| Parser | Detection | Delimiter |
|---|---|---|
v2.ts (current) | parser: v2 in frontmatter | ### (H3) headings = step titles |
v1.ts (legacy) | Default | [ACCORDION-BEGIN] / [ACCORDION-END] markers |
Both produce the same in-memory Tutorial shape (types.ts) so downstream consumers don't branch on format.
Module map โ
| File | Role |
|---|---|
compose.ts | Orchestrator โ selects v1/v2, runs transforms, returns the rendered tutorial |
v1.ts / v2.ts | Format-specific step splitters |
frontmatter.ts | gray-matter wrapper, typed against TutorialFrontmatter |
frontmatter-utils.ts | Tag humanization (preserves SAP/HANA/CAP/BTP/etc. acronyms), prerequisite list splitting |
render-frontmatter.ts | Emits the YAML frontmatter Hugo consumes (escapes Hugo delimiters, formats tags) |
hugo-delimiters.ts | Escapes {{ / }} in tutorial source so Hugo doesn't interpret them as templates |
images.ts | Rewrites relative image paths to raw.githubusercontent.com CDN URLs |
image-dimensions.ts | Extracts width/height (cached on disk) so Hugo can emit <img> size attrs and avoid layout shift |
options.ts | Converts [OPTION BEGIN] / [OPTION END] blocks into Vue/Hugo shortcodes |
sanitize-html.ts | Strips unsafe HTML embedded in tutorial source |
rules.ts | Parses rules.vr quiz files (fetched from *-Contribution repos) into ValidationQuestion objects |
cap.ts | Fetches mission/group catalog from CAP_BASE_URL/build/catalog for mission/group page generation |
github.ts | discoverAllTutorials() + commit metadata; honors EXCLUDED_REPOS and TUTORIAL_SLUG for single-slug rebuilds |
recommendations.ts | Computes related-tutorial suggestions from the catalog graph |
types.ts | Shared TS types (Tutorial, TutorialFrontmatter, Step, ValidationQuestion, TutorialNavEntry) |
index.ts | Re-exports for the QA-srv runtime bundle |
discovery-baseline.json | Snapshot of discoverAllTutorials() output โ third-tier discovery fallback when GitHub is unreachable |
Shared transforms (in compose order) โ
frontmatter.tsextracts YAML- v1/v2 splits the body into ordered steps
images.ts+image-dimensions.tsrewrite + size image referencesoptions.tsconverts option blockssanitize-html.tsstrips unsafe HTMLhugo-delimiters.tsescapes{{/}}rules.tsinjectsValidationQuestion[]into the matching stepsrender-frontmatter.tsemits the Hugo.mdfile
For OS-conditional content (Windows / macOS / Linux / BAS variants), the parser consults scripts/parsers/os-classifier.ts, a fuzzy-match dictionary that canonicalizes the messy real-world OS labels in OPTION blocks. OS-flavored groups emit a new {{< os-options >}} shortcode (one panel per canonical OS, with combined labels like "Mac and Linux" duplicating their body across multiple panels). The page-level hasOsOptions: true frontmatter flag is auto-injected when any group on the page is classified OS โ the OP layout uses it to conditionally render the global OS picker. Author override via the osOverrides: frontmatter key when the heuristic misclassifies. See the spec at docs/superpowers/specs/2026-06-09-173-os-conditional-content-design.md.
Navigator Catalog (GET /build/navigator) โ
The navigator endpoint exposes tutorial reachability via three independent data paths, allowing front-ends to surface tutorials through missions, groups, or as standalone learnings.
| Data Path | Source | Mapping |
|---|---|---|
| Mission tutorials | NavigatorCatalog SQL view + Mission CompletionPathItems where taskType='TUTORIAL' | Direct tutorial references inside mission completion paths |
| Nested group tutorials | Mission CompletionPathItems where taskType='GROUP' (JS-side expansion) | Handler expands nested Groups, pairs each tutorial with its parent mission + group |
| Standalone groups | Groups.published=true with no Mission link + GroupPathItems (JS-side scan) | Tutorials reachable through published Groups without a mission parent; emitted as (group, tutorial) pairs with missionId=null |
Response shape (top-level fields):
missions[]โ mission summary refs (existing)groups[]โ Group refs including standalone published GroupstutorialMappings[]โ array of{ slug, missionId, missionTitle, missionSlug, groupId, groupTitle, groupSlug, prev, next }tuples (mission fields are null for standalone-Group tutorials;prev/nextare slug strings or null for end-of-path)checkpointMappings[]โ NEW โ array of{ title, missionId, missionTitle, missionSlug, pathId, pathSlug, itemOrder }milestone markers fromCompletionPathItemswheretaskType='CHECKPOINT'(currently consumer-side TODO for rendering)
Handler: srv/lib/navigator-catalog.js โ in-memory cache (5-minute TTL, auto-invalidated on AdminService writes to Missions, Groups, or CompletionPath* entities).
Cache โ
Two parallel cache directories โ one per channel โ back the fetch step. Both are gitignored.
| Path | Channel | Source repos |
|---|---|---|
.tutorial-cache/ | prod | All sap-tutorials repos minus EXCLUDED_REPOS |
.tutorial-cache-qa/ | QA | *-Contribution repos only (ONLY_CONTRIBUTION_REPOS=true) |
.tutorial-cache-qa/ carries a .channel marker file. npm run dev warns if the cache content channel doesn't match the build target โ switching channels without clearing the cache silently mixes prod and draft content.
Cache contents โ
| Artifact | Purpose | Invalidation |
|---|---|---|
<slug>.md | Raw tutorial markdown from GitHub | SHA mismatch via <slug>.sha |
<slug>.sha | SHA-256 of the upstream .md for change detection | Replaced on each fetch |
<slug>.rules.vr | Quiz validation rules (from *-Contribution repos via fetchRulesVr()) | SHA mismatch |
_discovery.json | Output of discoverAllTutorials() โ slug โ repo + path map | Per-fetch refresh; falls back to scripts/parsers/discovery-baseline.json if GitHub unreachable |
cap-catalog.json | CAP_BASE_URL/build/catalog snapshot (missions, completion paths) | 24h TTL (CACHE_TTL_MS in parsers/cap.ts) |
github-meta.json / github-meta.v2.json | Commit author + timestamp metadata per slug | Per-fetch (rate-limited; honor GITHUB_TOKEN) |
image-dimensions.json | Width/height for every referenced image (avoids layout shift) | Manual delete only โ extraction is expensive |
errors.json | Fetch error log (per slug, last attempt) | Overwritten per run |
_prod-tut.html | Captured production HTML used for parser-output comparison | Manual |
quarantine/ | Tutorials that failed validation (scripts/validate-tutorials.ts) | Created on demand |
Invalidation โ
- Whole-cache reset:
rm -rf .tutorial-cache/(or.tutorial-cache-qa/) โ forces a full re-fetch from GitHub. - Single slug: delete
<slug>.mdand<slug>.sha. Therebuild-content.ymlworkflow does this when an author dispatches the workflow with the optionalsluginput โ it busts that one slug, regenerates the rest from cache, and skips theRepoCatalogbaseline upload so the partial run doesn't overwrite it. - Catalog only: delete
cap-catalog.jsonto force a fresh CAP fetch before the 24h TTL expires. - Images: delete
image-dimensions.jsononly when image references change shape (rare).
Detailed Content Pipeline โ
Complete flow of tutorial content from GitHub source to end-user delivery, including exception handling, versioning, and tracking.
Pipeline Overview โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CONTENT PIPELINE โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โ
โ โ FETCH โโโโโถโ PARSE โโโโโถโ BUILD โโโโโถโ PUBLISH โ โ
โ โ (GitHub) โ โ (MDโAST) โ โ (Hugo) โ โ (Delta โ HANA) โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ
โ โผ โผ โ
โ .tutorial-cache/ ContentFiles (BLOB) โ
โ errors.json ContentManifest โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โ SERVE โโโโ LRU Cache (50MB) โ
โ โ (Decompress+ETag)โ โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โฒ โ
โ โ โ
โ AppRouter /tutorials/* โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโPhase 1: Fetch (scripts/fetch-tutorials.ts) โ
Downloads tutorial markdown from the sap-tutorials GitHub organization.
Steps โ
| Step | Action | Concurrency | Output |
|---|---|---|---|
| 1.1 | GraphQL discovery of repos | Sequential (paginated, 100/page) | .tutorial-cache/_discovery.json |
| 1.2 | Batch metadata prefetch | 3 repos ร 20 tutorials/batch | .tutorial-cache/github-meta.v2.json |
| 1.3 | Download markdown | 5 concurrent tutorials | .tutorial-cache/{slug}.md + .sha |
| 1.4 | Parse & transform | Inline (per tutorial) | hugo/content/tutorials/{slug}.md |
| 1.5 | Fetch CAP catalog | Single request | .tutorial-cache/cap-catalog.json |
| 1.6 | Generate navigation | Inline | hugo/content/tutorials/_nav.json |
Cache Strategy (SHA-based) โ
For each tutorial slug:
local_sha = read .tutorial-cache/{slug}.sha
remote_sha = latest commit SHA from GitHub
if local_sha == remote_sha โ use cached .md (status: "cached")
if local_sha != remote_sha โ re-fetch .md (status: "refreshed")
if no local file โ fetch new (status: "fetched")Cache stored in .tutorial-cache/ (gitignored). Delete directory to force full re-fetch.
Exception Handling โ
| Failure | Scope | Behavior | Recovery |
|---|---|---|---|
| Markdown 404 | Single tutorial | Error thrown, caught | Logged to errors.json; pipeline continues |
| GitHub rate limit | Batch | Batch metadata fails | Fallback metadata applied ({lastCommitSha: '', ...}) |
| GraphQL errors | Discovery | Warnings logged | Continues with discovered repos |
| rules.vr fetch fail | Single tutorial | Returns null silently | Tutorial proceeds without quiz data |
| CAP catalog fail | All missions | Warning logged | Proceeds without mission/group assignments |
| Network timeout | Per request | Standard fetch rejection | Caught per-tutorial; logged |
Error Tracking โ
Failed tutorials are written to .tutorial-cache/errors.json:
[
{
"slug": "tutorial-slug",
"repo": "sap-tutorials/repo-name",
"error": "HTTP 404: Not Found",
"timestamp": "2026-05-05T10:30:00.000Z"
}
]Phase 2: Parse (scripts/parsers/) โ
Transforms raw markdown into Hugo-compatible content pages.
Parser Selection โ
Determined by frontmatter field parser: v2:
- V2 (current): H3 headings (
###) delimit steps - V1 (legacy):
[ACCORDION-BEGIN]/[ACCORDION-END]markers
Transformations Applied โ
| Parser | File | Transformation |
|---|---|---|
| Frontmatter | parsers/frontmatter.ts | Extract YAML metadata (title, level, tags, time) |
| Steps | parsers/steps.ts | Split into numbered steps with titles |
| Images | parsers/images.ts | Resolve relative paths โ raw.githubusercontent.com CDN URLs |
| Options | parsers/options.ts | [OPTION BEGIN]/[OPTION END] โ Hugo shortcodes |
| Rules | parsers/rules.ts | Parse .rules.vr quiz validation files |
| CAP | parsers/cap.ts | Inject mission/group metadata from build catalog |
| HTML | Inline | Escape dangerous HTML; preserve allowed tags |
Safety: HTML Escaping โ
- HTML outside code fences is escaped (prevents XSS in rendered tutorials)
- Allowed tags preserved:
TutorialStep,OptionTabs,template - Component tag balancing: missing closing tags auto-added
Phase 3: Build (Hugo) โ
Standard Hugo static site generation.
npm run build:hugo # โ hugo/public/tutorials/*/index.htmlOutput: One index.html per tutorial slug in hugo/public/tutorials/.
Phase 4: Publish (scripts/publish-content.ts) โ
Delta-aware upload of changed tutorial HTML to SAP HANA Cloud.
Delta Detection Algorithm โ
1. Scan hugo/public/tutorials/ for index.html files
2. Compute SHA-256 hash of each local file
3. GET /content/hashes โ { slug: remoteHash }
4. Compare:
- slug in local but not remote โ NEW (publish)
- local hash != remote hash โ MODIFIED (publish)
- local hash == remote hash โ UNCHANGED (skip)
5. If /content/hashes unreachable โ publish ALL (fail-open)Payload Construction โ
For each changed slug:
- Read HTML file
- Gzip compress
- Base64 encode
- Include
__nav__special entry (navigation metadata)
POST /content/publish
Authorization: Bearer <CONTENT_API_KEY>
Content-Type: application/json
{
"trigger": "ci@<commit-sha>",
"hugoVersion": "0.139.0",
"files": {
"tutorial-slug-1": "<base64-gzipped-html>",
"tutorial-slug-2": "<base64-gzipped-html>",
"__nav__": "<base64-gzipped-json>"
}
}CLI Flags โ
| Flag | Effect |
|---|---|
--dry-run | Show what would change without uploading |
--force | Skip delta detection, republish all files |
--verbose | Extra logging of hash comparisons |
Exception Handling โ
| Failure | Behavior |
|---|---|
/content/hashes returns 503 | Treat all files as changed (publish all) |
| Network error on POST | Script exits with non-zero code |
| 401 Unauthorized | Missing/wrong CONTENT_API_KEY |
| 409 Conflict | Another publish in progress (retry later) |
Slug canonicalization โ
Tutorial slugs are case-sensitive identifiers in the database (Tutorials.slug, ContentFiles.slug, etc.) and the canonical form is lowercase. This is enforced at:
- Read path:
serveHandlerin srv/lib/content-store.js 301-redirects any inbound mixed-case slug to its lowercase form before lookup. - Write path:
upsertTutorialMetadatain srv/lib/content-publish-session.js (and the legacy duplicate in srv/lib/content-store.js) lowercases every publish-payload key beforeSELECT/INSERT/UPDATE. The case-insensitive lookup uses raw SQLLOWER("SLUG") = ?via the srv/lib/_tutorials-table.js helper so it matches legacy mixed-case rows that were seeded before the canonical rule was adopted.
Source markdown filenames in the sap-tutorials GitHub org are not policed for case (some ship with uppercase, e.g. abap-environment-sbpa-workflow-extend-RAP-App). Both surfaces must therefore canonicalize independently.
If you ever see a tutorial display "0 steps" on the group/mission catalog page while the tutorial itself renders correctly, suspect a case mismatch between Tutorials.slug (catalog FK target) and the slug the publisher wrote metadata under. The one-shot repair is scripts/repair-mixed-case-tutorial-duplicates.cjs (dry-run by default; pass --apply to mutate).
The same case-insensitive pattern is applied to serveHandler's soft-delete status check around the SELECT.from(Tutorials).where({ slug }) lookup (the lookup that detects whether a Tutorials row has been soft-deleted via status='INACTIVE'). Without the case-insensitive lookup, an admin soft-delete via AdminService would silently fail to 404 the URL when the canonical slug shipped mixed-case โ the exact-match where({ slug }) would miss the row. A defensive multi-row preference picks the ACTIVE row when both an INACTIVE legacy row and an ACTIVE legacy row coexist for the same lowercased slug.
The repair script scripts/repair-mixed-case-tutorial-duplicates.cjs hard-deletes orphan rows that have zero FK references and INACTIVE-flags only when references survive (Steps, GroupPathItems, CompletionPathItems, NgdsResults, TaskRecords). This avoids leaving INACTIVE landmines that exact-match where({ slug }) lookups might find as the soft-delete row instead of the canonical ACTIVE row.
Phase 5: Content Store (srv/lib/content-store.js) โ
Server-side persistence, versioning, and serving layer.
Database Schema โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ContentManifest โ โ ContentFiles โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ PK version: Integer โ โ PK slug: String(255) โ
โ status: Enum โโโโโถโ PK version: Integer โ
โ trigger: String(500) โ โ content: LargeBinary (gzip) โ
โ fileCount: Integer โ โ contentHash: String(64) โ
โ totalSizeBytes: Int64 โ โ sizeBytes: Integer โ
โ changedSlugs: LargeString โ โ compressedBytes: Integer โ
โ hugoVersion: String(20) โ โ mimeType: String(100) โ
โ publishDurationMs: Integer โ โ created_at: Timestamp โ
โ created_at: Timestamp โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ updated_at: Timestamp โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ContentManifest.status:
PUBLISHING โ in-progress write (transient)
ACTIVE โ currently served to users
SUPERSEDED โ replaced by newer version
ROLLED_BACK โ explicitly revertedPublish Handler (POST /content/publish) โ
โโ Acquire distributed lock (content-publish, 120s TTL) โโโโโโโโโโโโโโโโโโ
โ โ
โ 1. Create manifest (status: PUBLISHING, version: max+1) โ
โ 2. For each file in payload: โ
โ - Decode base64 โ gzipped buffer โ
โ - Decompress โ compute SHA-256 โ
โ - Record: slug, version, content, hash, sizes โ
โ 3. Batch INSERT ContentFiles (groups of 50) โ
โ 4. Mark previous ACTIVE manifest โ SUPERSEDED โ
โ 5. Update current manifest โ ACTIVE + stats โ
โ 6. Invalidate LRU cache โ
โ 7. Log to PipelineLog โ
โ โ
โโ Release lock โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Response 201:
{
"version": 42,
"filesWritten": 5,
"totalSizeBytes": 1234567,
"durationMs": 3200
}Concurrency Control โ
- Distributed lock via
JobLockstable (expiry-based claiming) - Lock key:
content-publish - TTL: 120 seconds (auto-expires if process crashes)
- Conflict response:
409 Conflictwith retry guidance
Serve Handler (GET /content/tutorials/:slug) โ
Request: GET /content/tutorials/abap-dev-create-table
If-None-Match: "abc123..."
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. Resolve active version from ContentManifest โ
โ 2. Check LRU cache (key: slug@version) โ
โ โโ HIT + ETag match โ 304 Not Modified โ
โ โโ HIT โ 200 (X-Content-Source: cache) โ
โ โโ MISS โ continue to DB โ
โ 3. Query ContentFiles (slug + active version) โ
โ โโ HANA: raw SQL (avoids LOB locator expiry bug) โ
โ โโ SQLite: CDS QL (unit tests) โ
โ 4. Decompress gzip โ HTML โ
โ 5. Store in LRU cache โ
โ 6. Return 200 (X-Content-Source: db) โ
โ โ
โ Headers: โ
โ ETag: <contentHash> โ
โ Cache-Control: public, max-age=300 โ
โ Content-Type: text/html; charset=utf-8 โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโLRU Cache Details โ
| Parameter | Value |
|---|---|
| Max size | 50 MB |
| Eviction | Least-recently-used |
| Invalidation | Full flush on publish or rollback |
| Key format | {slug}@{version} |
HANA LOB Workaround โ
HANA BLOB columns return Readable streams with locators that expire before consumption when selected alongside non-BLOB columns in CDS QL. The content store uses raw SQL (cds.run(sql)) for BLOB retrieval on HANA, bypassing the CDS QL layer. SQLite (used in unit tests) uses standard CDS QL since it doesn't have this limitation.
Phase 5.5: Embedding Hook (post-publish) โ
After the manifest goes ACTIVE, per-step embeddings are generated for RAG (Retrieval-Augmented Generation) in the Joule chat.
Pipeline โ
Hugo build โ publish-content โ /content/publish โ manifest ACTIVE
โ setImmediate
embedSlugs(changed)Flow โ
Immediate embed (non-blocking) โ After
POST /content/publishcompletes and the manifest is markedACTIVE,srv/lib/content-store.jsschedulesembedSlugs(changedSlugs)viasetImmediate. The publish HTTP response returns immediately (201) without waiting for embeddings to complete.Hourly reconciliation โ A cron job at minute
:17of every hour (srv/jobs/embedding-reconciliation.js, orchestrated insrv/jobs/scheduler.js) runsrunReconciliationJob. It:- Re-embeds any step whose
contentHashno longer matches the embedding row's storedcontentHash(drift detection). - Embeds any rows in the active manifest that have no embedding yet.
- Uses distributed locking via
runWithLock(key:embedding-reconciliation, 30-minute timeout) for multi-instance safety.
- Re-embeds any step whose
Daily orphan cleanup โ At 03:30 UTC,
srv/jobs/embedding-reconciliation.jsprunes embeddings for tutorials no longer in the activeContentManifest. This keeps the table bounded after content rollbacks or deletions.
All embeddings use the model specified in ChatSettings.embeddingModel (default: text-embedding-3-small via the tutorials-aicore AI Core destination).
Phase 6: Rollback (POST /content/rollback) โ
Reverts to a previous content version without re-publishing.
POST /content/rollback
Authorization: Bearer <CONTENT_API_KEY>
Body: { "targetVersion": 41 } (optional โ defaults to most recent SUPERSEDED)
Steps:
1. Find target version (must be SUPERSEDED status)
2. Current ACTIVE โ ROLLED_BACK
3. Target โ ACTIVE
4. Flush LRU cache
5. Return new active version infoRollback is instantaneous since all version data persists in ContentFiles.
Phase 7: Garbage Collection (srv/jobs/cleanup.js) โ
Scheduled daily at 03:00 UTC by the job scheduler.
Content Version Pruning โ
| Parameter | Default | Purpose |
|---|---|---|
keepCount | 3 | Minimum superseded versions retained for rollback |
olderThanDays | 7 | Only prune versions older than this |
Candidates = ContentManifest WHERE
status IN ('SUPERSEDED', 'ROLLED_BACK')
AND created_at < (now - 7 days)
Candidates sorted by version DESC โ skip first 3 (keepCount)
For remaining: DELETE ContentFiles + DELETE ContentManifestSafety: Never touches ACTIVE or PUBLISHING manifests.
Other Cleanup Tasks โ
| Task | Retention | Schedule |
|---|---|---|
| Content version pruning | 3 versions / 7 days | Daily 03:00 |
| PipelineLog entries | 30 days | Daily 03:00 |
| StepFailures records | 90 days | Daily 03:00 |
| Unused tags | Immediate | Daily 03:00 |
Tracking & Observability โ
ContentManifest (Version History) โ
Each publish creates a manifest row tracking:
- Version number (monotonically increasing)
- Status lifecycle:
PUBLISHING โ ACTIVE โ SUPERSEDED - Trigger source (e.g.,
ci@abc123,manual) - File count and total size
- List of all changed slugs (JSON array in
changedSlugs) - Hugo version used
- Server-side publish duration
PipelineLog โ
Records all pipeline events (publishes, rollbacks) with timestamps, initiator, and outcome. Retained for 30 days.
Response Headers (Serve) โ
| Header | Purpose |
|---|---|
X-Content-Source | cache or db โ indicates whether LRU cache was hit |
X-Content-Version | Active manifest version number |
ETag | SHA-256 hash of content (enables 304 responses) |
Cache-Control | public, max-age=300 (5-minute browser cache) |
Error Surfaces โ
| Endpoint | Error | HTTP Code | Meaning |
|---|---|---|---|
/content/publish | Lock held | 409 | Another publish in progress |
/content/publish | Bad token | 401 | Missing/invalid CONTENT_API_KEY |
/content/tutorials/:slug | No active version | 503 | No content published yet |
/content/tutorials/:slug | Slug not found | 404 | Tutorial not in active manifest |
/content/rollback | No target | 404 | No SUPERSEDED version available |
/content/hashes | No active version | 503 | No content published yet |
End-to-End Flow (CI/CD) โ
โโ CI Pipeline โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ 1. npm install โ
โ 2. npm run fetch-tutorials โ GitHub โ .tutorial-cache/ โ
โ 3. npm run build:all โ Hugo โ hugo/public/ โ
โ 4. npm run publish-content โ Delta โ HANA (ContentFiles) โ
โ โโ CONTENT_API_KEY required โ
โ โโ CAP_BASE_URL points to deployed srv โ
โ 5. npm run test:smoke โ Verify /tutorials/* responds โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโEnvironment Variables โ
| Variable | Required By | Purpose |
|---|---|---|
GITHUB_TOKEN | fetch | Avoid GitHub API rate limits |
CONTENT_API_KEY | publish, rollback | Bearer token for write operations |
CAP_BASE_URL | fetch (catalog), publish | Target CAP server URL |
SMOKE_BASE_URL | smoke tests | AppRouter URL for integration tests |
Performance Metrics โ
Based on recent runs (May 2026) against the full tutorial corpus of 1,378 tutorials across 1,387 repos in the sap-tutorials GitHub organization.
Dataset Profile โ
| Metric | Value |
|---|---|
| Total tutorials discovered | 1,387 |
| Successfully processed | 1,378 |
| Parse errors (malformed frontmatter) | 8 |
| Raw markdown cache size | 14.2 MB |
| Avg markdown file size | 10.5 KB |
| Built HTML files | 2,509 (includes step sub-pages) |
| Total HTML output | 60.6 MB |
| Avg HTML file size | 24.7 KB |
| Largest HTML file | 298 KB |
| Median HTML file | 19.6 KB |
| Gzip compression ratio | ~78% |
| Estimated HANA storage (compressed) | ~22.6 MB |
Phase 1: Fetch Timing โ
Cached Run (regenerate from local .tutorial-cache/) โ
| Phase | Duration | Notes |
|---|---|---|
| Discovery (GraphQL) | 0 ms | Skipped in --regenerate mode |
| Metadata prefetch | 0 ms | Skipped in --regenerate mode |
| Tutorial processing | 3.1 s | Parse + Hugo page generation |
| CAP missions/groups | 123 ms | Catalog fetch (0 missions if CAP not running) |
| Total | 3.2 s |
Per-Tutorial Stats (cached) โ
| Metric | Value |
|---|---|
| Average | 7 ms/tutorial |
| Slowest | 18 ms |
| Fastest | 3 ms |
| Throughput | 426.6 tutorials/sec |
Cold Run (fresh fetch from GitHub) โ
Estimated from concurrency settings and network characteristics:
| Phase | Estimated Duration | Notes |
|---|---|---|
| Discovery (GraphQL) | 3โ5 s | Paginated, ~14 pages ร 100 repos |
| Metadata prefetch | 15โ30 s | 3 concurrent repos ร 20 tutorials/batch |
| Tutorial download | 60โ90 s | 5 concurrent, ~1,378 fetches from raw.githubusercontent.com |
| Tutorial processing | 3โ5 s | CPU-bound parsing (same as cached) |
| CAP missions/groups | 0.5โ2 s | Single HTTP request to catalog endpoint |
| Total (cold) | ~90โ130 s | Dominated by GitHub API/network time |
GitHub rate limit: 5,000 requests/hour with GITHUB_TOKEN; unauthenticated: 60/hour (will fail for full corpus).
Phase 3: Hugo Build โ
| Metric | Value |
|---|---|
| Input pages | ~2,500+ (tutorials + missions + groups + static) |
| Output size | 70 MB (full hugo/public/) |
| Typical build time | 5โ10 s |
| Build command | hugo --minify |
Phase 4: Publish Timing โ
Delta Publish (typical CI โ 1โ10 changed files) โ
| Step | Duration | Notes |
|---|---|---|
| Local hash computation | < 500 ms | SHA-256 of 2,509 files |
Remote hash fetch (GET /content/hashes) | 200โ500 ms | Network to BTP + HANA query |
| Delta calculation | < 10 ms | In-memory comparison |
| Gzip + base64 encoding | < 100 ms | For changed files only |
| Network upload | 200โ1,000 ms | Payload typically < 1 MB |
| Server-side persist | 500โ2,000 ms | Decompress, hash, batch INSERT, manifest update |
| Total (delta) | ~2โ4 s |
Full Publish (all 2,509 files, --force) โ
| Step | Duration | Notes |
|---|---|---|
| Gzip + base64 encoding | 2โ3 s | All 2,509 files |
| Payload size | ~25 MB | Compressed + base64 overhead |
| Network upload | 5โ15 s | Depends on bandwidth to BTP region |
| Server-side persist | 10โ30 s | 50 files/batch ร ~50 batches, plus SHA-256 per file |
| Total (full) | ~20โ50 s |
Server-side publishDurationMs (recorded in ContentManifest) excludes network transfer โ measures only DB writes and hash computation.
Phase 5: Content Serving โ
| Scenario | Response Time | Notes |
|---|---|---|
| LRU cache hit + ETag match | < 1 ms | Returns 304 immediately |
| LRU cache hit (no ETag) | 1โ2 ms | Returns decompressed buffer |
| Cache miss (HANA query) | 20โ80 ms | Raw SQL BLOB fetch + gunzip |
| Cold start (first request) | 50โ150 ms | No cache populated yet |
Cache Warm-Up Behavior โ
After a CAP srv restart, the LRU cache is empty. First ~50 unique tutorial requests populate the cache. At steady state with the 50 MB limit:
| Metric | Value |
|---|---|
| Cache capacity | ~2,000 tutorials (at 24.7 KB avg) |
| Coverage | ~80% of corpus fits in cache |
| Eviction | LRU โ rarely-accessed tutorials evicted first |
| Hit rate (steady state) | 90โ95% (typical usage patterns favor popular tutorials) |
Garbage Collection โ
| Operation | Duration | Frequency |
|---|---|---|
| Content version pruning | 1โ5 s | Daily 03:00 |
| PipelineLog cleanup | < 1 s | Daily 03:00 |
| StepFailures cleanup | < 1 s | Daily 03:00 |
| Unused tags cleanup | < 1 s | Daily 03:00 |
End-to-End Pipeline (CI) โ
| Stage | Cached | Cold |
|---|---|---|
npm install | 10โ20 s | 30โ60 s |
npm run fetch-tutorials | 3 s | 90โ130 s |
npm run build:all | 15โ25 s | 15โ25 s |
npm run publish-content | 2โ4 s | (first deploy: 20โ50 s) |
npm run test:smoke | 5โ10 s | 5โ10 s |
| Total CI (cached) | ~40โ60 s | |
| Total CI (cold) | ~3โ5 min |
Bottlenecks & Scaling Notes โ
| Concern | Current State | Mitigation |
|---|---|---|
| GitHub API rate limit | 1,378 fetches fit in 5,000/hr budget | SHA-based cache prevents re-fetch |
| Payload size (full publish) | ~25 MB JSON | Delta detection reduces to < 1 MB typical |
| HANA BLOB insert | 50 files/batch to avoid tx size limits | Parallel batches not used (sequential) |
| LRU cache cold start | ~50 requests to warm popular content | Pre-warm could be added but not needed |
| Hugo build time | Linear with page count | Already fast (< 10 s for 2,500 pages) |
Request Routing (Production) โ
Browser โ AppRouter (xs-app.json)
/tutorials/(.*) โ rewrite to /content/tutorials/$1 โ CAP srv
โ
โผ
content-store.js
โ
โโโโโโโโโโโดโโโโโโโโโโ
โ LRU Cache Hit? โ
โโโโโโโโโโโฌโโโโโโโโโโ
yes / no
/ \
200 HANA query
(raw SQL)
โ
decompress
โ
200 + cacheTutorial HTML is served exclusively from HANA BLOBs. There is no static file fallback โ if no content has been published, /tutorials/* returns 404.
Resilience: AppRouter Restage / Filesystem Loss โ
Cloud Foundry containers are ephemeral โ a restage, restart, or crash recovery destroys the local filesystem. This section documents the impact on content serving.
What the AppRouter filesystem contains โ
The AppRouter's approuter/static/ directory holds:
- Hugo-built static assets (CSS, JS, images, landing pages)
- NOT tutorials โ explicitly removed during build (
rm -rf approuter/static/tutorials)
What happens on restage โ
โโ AppRouter restaged โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ Lost: โ
โ โข Static assets (CSS, JS, images) โ
โ โข Landing pages, mission pages, group pages โ
โ โ
โ NOT lost (never on filesystem): โ
โ โข Tutorial HTML content (lives in HANA) โ
โ โข Content manifests and version history (HANA) โ
โ โข Navigation metadata (HANA) โ
โ โ
โ Temporarily lost (rebuilt on first request): โ
โ โข CAP srv in-memory LRU cache (50MB) โ cold start, repopulates โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโImpact by component โ
| Component | Storage | Restage Impact | Recovery |
|---|---|---|---|
| Tutorial HTML | HANA BLOBs | None โ never on AppRouter filesystem | Immediate |
| Content versions | HANA (ContentManifest) | None | Immediate |
| LRU cache (CAP srv) | In-memory (srv process) | Lost if srv also restaged | Auto-rebuilds on requests |
| Static assets (CSS/JS) | AppRouter filesystem | Lost โ must redeploy | MTA deploy restores from build artifact |
| Hugo landing pages | AppRouter filesystem | Lost โ must redeploy | MTA deploy restores from build artifact |
Why tutorials survive โ
The architectural decision to store tutorials in HANA rather than as static files was made specifically for this reason:
- Decoupled lifecycle โ Content publishes independently of app deploys. A new tutorial can go live without redeploying the AppRouter.
- Restage-proof โ CF container recreation doesn't affect content availability. The AppRouter is a stateless proxy for
/tutorials/*. - Rollback without redeploy โ
POST /content/rollbackreverts content instantly without touching CF at all.
The request path after restage โ
Browser: GET /tutorials/abap-dev-create-table
AppRouter (freshly restaged, empty filesystem):
1. xs-app.json route: /tutorials/(.*) โ destination "srv-api", path /content/tutorials/$1
2. AppRouter does NOT look for /tutorials/ on its own filesystem
3. Proxies to CAP srv
CAP srv:
4. content-store.js resolves active ContentManifest version
5. LRU cache miss (cold start) โ query HANA
6. Decompress BLOB โ return HTML
7. Populate LRU cache for subsequent requests
Result: 200 OK โ user sees tutorial content as normalRecovery scenarios โ
| Scenario | Tutorial Content | Static Assets | Action Required |
|---|---|---|---|
| AppRouter restage only | Unaffected | Lost | Redeploy MTA (or just approuter module) |
| CAP srv restage only | Unaffected (HANA) | Unaffected | None โ LRU cache rebuilds automatically |
| Both restaged | Unaffected (HANA) | Lost | Redeploy MTA |
| HANA Cloud restart | Temporarily unavailable | Unaffected | Wait for HANA recovery; content intact |
| Full MTA redeploy | Unaffected (HANA) | Restored from build | None |
Edge case: First deploy (no content in HANA) โ
If the AppRouter is deployed before any content has been published to HANA, /tutorials/* returns 404. This is the expected "empty state." Run npm run publish-content against the deployed CAP srv to populate content.
Branching paths (issue #172) โ
Mission curators can declare alt-groups on CompletionPathItems / GroupPathItems. At build time, scripts/parsers/cap.ts and srv/lib/build-catalog.js emit an optional altGroups array on mission frontmatter alongside groups. At runtime, the auth-aware endpoint GET /build/mission/:slug:
- groups items by
(altGroupKey, itemOrder)within each path - for each alt-group, calls
srv/lib/branch/engine.js#pickBranchwith the user's frozenuserState - caches the response per
(slug, userId, fingerprint)for 5 min (honours?nocache=1) - writes one
BranchDecisionsrow per recommendation (telemetry; surface=missionAltGroup, source=pageLoad)
The whole runtime is gated by ChatSettings.branchingEnabled โ when false, the endpoint returns the catalog without the recommendation field. PR 1 (srv/lib/branch/{condition,engine,ranker,user-state}.js, BranchDecisions, branchingEnabled) provides the engine; PR 2 wires the endpoint, the AdminService validator, and the side-nav rendering. PR 3 ships the hydration island + tutorial-level branches.
See the design doc at docs/superpowers/specs/2026-06-09-172-branching-paths-design.md ยง5.2.1, ยง5.6.
Step-level branches and skip-runs (PR 3) โ
Authors mark alternative step-runs with [BRANCH_BEGIN ...]โฆ[BRANCH_END] and skippable steps with skipIf: step frontmatter. Build pipeline:
scripts/parsers/branches.tsruns BEFOREscripts/parsers/v2.ts(compose.tsorchestrates). It rewrites the markdown to a linear stream and stashes branchGroups on parent step entries.scripts/publish-content.ts#extractAllBranchSpecswalks parsed YAML frontmatter and POSTsbranchSpecsalongsidebodyTextsto/content/publish.- CAP persists into
BranchSpecs(sidecar; one row per slug; mirrorsTutorialBodyText). - At runtime,
GET /api/branches/decide?slug=XreadsBranchSpecs, buildsuserState, callspickBranchper branchPoint andevaluateSkipper skipPoint, returns recommendations + skip decisions. Cached per(slug, userId, fingerprint)for 5 min; honours?nocache=1. - The
tutorial-branchesVue island mounts ontutorial-branch-mount/tutorial-skip-mountmarkers + the mission-side-navdata-altgroup-needs-hydration="true"wrapper, and hydrates with the API response.
Gated by ChatSettings.branchingEnabled. When false: the endpoint returns 404 and the island degrades to "render all branches statically, no recommendation."