diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index acf75f4fc..06ff51d8c 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.18.1 +version: 1.18.2 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: @@ -171,7 +171,7 @@ modules: - cp -r ../../hugo/assets ./hugo/assets - cp -r ../../hugo/data ./hugo/data - cp -r ../../hugo/i18n ./hugo/i18n - - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js ../../srv/lib/runtime-config/alert-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-cache-coherence.js ../../srv/lib/edge-cache-headers.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js ../../srv/lib/page-key-map.js ../../srv/lib/page-fallback.js ../../srv/lib/task-record-submission-id.js ../../srv/lib/image-store.cjs ../../srv/lib/image-ingest.cjs ../../srv/lib/image-source-handler.js ../../srv/lib/img-cdn-fetch.cjs ../../srv/lib/img-cdn-retry.cjs ../../srv/lib/image-warm-utils.js ../../srv/lib/island-manifest.json srv/lib/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" + - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js ../../srv/lib/runtime-config/alert-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-cache-coherence.js ../../srv/lib/edge-cache-headers.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js ../../srv/lib/page-key-map.js ../../srv/lib/page-fallback.js ../../srv/lib/task-record-submission-id.js ../../srv/lib/image-store.cjs ../../srv/lib/image-ingest.cjs ../../srv/lib/image-source-handler.js ../../srv/lib/img-cdn-fetch.cjs ../../srv/lib/img-cdn-retry.cjs ../../srv/lib/image-warm-utils.js ../../srv/lib/attachment-store.cjs ../../srv/lib/attachment-ingest.cjs ../../srv/lib/attachment-mime.cjs ../../srv/lib/attachment-warm-utils.js ../../srv/lib/attachment-source-handler.js ../../srv/lib/attachment-ingest-handler.js ../../srv/lib/island-manifest.json srv/lib/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" - bash -c "node -e \"const p=require('./package.json'); p.dependencies=Object.assign(p.dependencies||{},{cheerio:'^1.2.0','@sap-ai-sdk/foundation-models':'^2.10.0'}); require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2));\"" properties: EXPOSE_CAP_UI: false diff --git a/CLAUDE.md b/CLAUDE.md index 94f8e2dbb..4f3cb024b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,3 +163,5 @@ Subsystem one-liners: - **cds-caching CDS-DB store + metrics (issue #1179 → re-enabled #1182 → metrics disabled #1215 → metrics re-enabled #1222)** — the shared `caching` service (`cds.requires.caching`) uses `store: "cds"` in the `[hybrid]`/`[production]` profiles (base stays `store: "memory"` for local `cds watch` + unit tests). Gives multi-instance CF coherence (a `deleteByTag` bust on one instance invalidates all). **`metrics.enabled` is ON again (#1222)** — #1215 disabled it because on cds-caching ≤2.0.1 it threw `Wrong input for INT type` on every HANA flush (the plugin read back the hourly row via a flattened table-name SELECT → UPPERCASE column keys → `existingHourly.hits` is `undefined` → `undefined + n = NaN` → hdb INT-bind throw), so counters never accumulated (22 hourly rows stuck at 0). **cds-caching 2.0.2 fixes it** ([mikezaschka/cds-caching#27](https://github.com/mikezaschka/cds-caching/issues/27)): readback via the resolved CSN entity (`SELECT.one.from(Metrics)`) + `Number(existingHourly.) || 0` coercion in `StatisticsPersistenceManager.js`. Re-enable is **config-only** (one-way): the plugin auto-persists `METRICSENABLED=1` on connect when config says `enabled:true` (`CachingService.js:133-135`) — no manual SQL to turn it on (the #1215 *disable* needed a `UPDATE ...SET METRICSENABLED=0` because nothing writes it back to 0). After deploy, clear the stale zeroed rows once per env: `DELETE FROM "PLUGIN_CDS_CACHING_METRICS" WHERE "cache"='caching'` (+ `KEYMETRICS`). Guards: `test/unit/caching-metrics-enabled.test.js` (renamed from `-disabled`) + `test/hybrid/caching-cds-store-boot.test.js` now assert metrics ON. **#1179 crash history:** the first srv deploy carrying `store: "cds"` crash-looped on CF (`Duplicate definition of artifact`) and was reverted (PR #1207); #1182 re-enables it with the resolve-guard fix below. **CF resolve-guard fix (#1182), two required halves:** (1) the srv nodejs `build.task` `model` list also includes `cds-caching/db/cache-store` + `cds-caching/db/statistics`, baking the four `plugin.cds_caching.*` entities into `srv/csn.json` (+4 defs, 0 views lost); (2) `srv/lib/strip-precompiled-plugin-roots.js` (called at top of `srv/server.js`, after `cds.plugins`, before model resolve) strips the plugin's runtime `env.roots` push when a precompiled `srv/csn.json` is present — otherwise those 2 extra roots tip CF's `resolve.many(env.roots)` past `length===1`, re-merging every `requires[].model` onto the precompiled csn → crash. Gated on `srv/csn.json` existence: strips in CF production, no-op in hybrid `cds watch` (compiles from source) and dev/unit (`store:memory` pushes nothing). Baking alone or stripping alone is insufficient — both are load-bearing. Reproducible locally from `gen/srv` (not the source tree — that always collapses to the single csn). **Metrics HDI gotcha:** this project's **explicit `build.tasks` list in `.cdsrc.json` suppresses cds's auto-registration of the plugin's build task** — so the four tables (`CacheStore` + `Caches`/`Metrics`/`KeyMetrics`) do NOT emit unless you add them by hand: `{ "for": "cds-caching" }` (emits `CacheStore.hdbtable`) + `{ "for": "hana", "src": "db", "dest": "db", "options": { "model": ["cds-caching/db/statistics"] } }` (the 3 metrics tables — model must be `statistics`-only; adding `db` to a `db`-dest task's model drops all ~247 service `.hdbview`s). QA container (`tutorials-hana-qa`) intentionally gets none — srv-qa doesn't wire caching. Metrics OData management API deliberately NOT registered (write actions). Test harness: unit workers get a stable memory-store config via `cds_requires_caching_*` env vars in `vitest.config.ts` (NOT a setupFiles that imports `@sap/cds` — that installs getter-only `SELECT`/`INSERT` globals and breaks tests assigning `globalThis.SELECT`) to close the fork-pool boot race (#1177). Full reference: [docs/developers/reference/cds-caching-store.md](docs/developers/reference/cds-caching-store.md). - **User-facing UI changes want a committed e2e spec** — a per-PR unit test can't catch a cross-PR seam (that's how #1366's value-help widening shipped dead behind #1353's custom dialog; see #1371). Changing `app/admin/**`, `app/**/webapp/**`, `hugo/layouts/**`, or `hugo-apps/**` triggers an *advisory* PR nudge (`.github/workflows/e2e-coverage-nudge.yml`) to add/update a `test/e2e/` spec. It never blocks merge; the existing post-DEV-deploy `e2e` CI job is where coverage is actually exercised. Pattern: [docs/developers/reference/e2e-coverage-pattern.md](docs/developers/reference/e2e-coverage-pattern.md). + +- **Freshness detector grounding needs the corpus-embedding backfill** — the `checkFreshness`/`freshness-scan` engine cosine-searches `ApiDocs`/`Samples` embeddings. Those columns are populated by `srv/jobs/freshness-corpus-embedding-job.js` (nightly `17 3` + on-demand `runJob`). Until it runs in an env, grounding returns nothing and every API-obsolescence claim degrades to `confidence: Low` (fail-open, by design). LLM calls use the SAP AI SDK directly (`@sap-ai-sdk/orchestration`, forced tool-call), NOT `@cap-js/ai`; unit tests inject `globalThis.__FRESHNESS_TEST_IMPL__`. Bulk scan gated by `FRESHNESS_SCAN_ENABLED` (default OFF). **Tutorial markdown is sourced from `ContentFiles.sourceContent` via `getTutorialSource(slug)` in `srv/lib/content-store.js` — NOT from `Steps.description`** (Steps are never populated with step markdown; reading Steps would yield nothing). Findings carry a **global `codeBlockIndex`** across the whole-tutorial markdown — per-step attribution is deferred because the persisted source is not split per step. diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 741a29f37..88cf38ec7 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -653,7 +653,8 @@ annotate AdminService.Tutorials with @UI: { Value: isolated, Label: 'Isolated', Criticality: { $edmJson: { $If: [ { $Path: 'isolated' }, 1, 0 ] } } - } + }, + { Value: openHighCount, Label: 'Stale flags', Criticality: freshnessCriticality } ], Facets: [ { $Type: 'UI.ReferenceFacet', ID: 'General', Label: 'General', Target: '@UI.FieldGroup#General' }, @@ -688,7 +689,8 @@ annotate AdminService.Tutorials with @UI: { Label : 'Rebuild this tutorial', Action : 'AdminService.rebuildContent', ![@UI.Importance]: #High, - } + }, + { $Type: 'UI.DataFieldForAction', Label: 'Check freshness', Action: 'AdminService.checkFreshness', ![@UI.Importance]: #High } ] }; @@ -941,6 +943,8 @@ annotate AdminService.AuthorAiRequests with @UI: { // SourceMarkdownFacet from PR-2, Feedback) is preserved verbatim, with // the four new ones tacked on between Contributors and Feedback so // the Source Markdown section from PR-2 still sits next to Feedback. +// FreshnessFacet (spec 2026-08-22-tutorial-freshness-detector) is appended +// in the same block further below. annotate AdminService.Tutorials with @UI: { Facets: [ { $Type: 'UI.ReferenceFacet', ID: 'General', Label: 'General', Target: '@UI.FieldGroup#General' }, @@ -956,6 +960,8 @@ annotate AdminService.Tutorials with @UI: { Target: 'codeCheckSpecs/@UI.LineItem' }, { $Type: 'UI.ReferenceFacet', Label: 'AI-Author Requests', ID: 'AiRequestsFacet', Target: 'aiRequests/@UI.LineItem' }, + { $Type: 'UI.ReferenceFacet', ID: 'FreshnessFacet', Label: 'Freshness', + Target: 'freshnessFindings/@UI.LineItem' }, { $Type: 'UI.CollectionFacet', ID: 'Feedback', Label: 'Feedback', Facets: [ { $Type: 'UI.ReferenceFacet', ID: 'FeedbackSummary', Target: 'feedbackSummary/@UI.FieldGroup#FeedbackSummary', @@ -4261,3 +4267,36 @@ annotate AdminService.TopicClustersAdmin with @( Capabilities.UpdateRestrictions.Updatable : false, Capabilities.DeleteRestrictions.Deletable : false ); + +// --- Tutorial Freshness Detector (spec 2026-08-22-tutorial-freshness-detector) --- +// Surfaces per-finding analysis rows on the Tutorials Object Page and wires the +// Set Disposition action. Criticality paths delegate to the virtual +// `confidenceCriticality` field (computed by after('READ','FreshnessFinding') +// in admin-service.js). +annotate AdminService.FreshnessFinding with @UI: { + LineItem: [ + { Value: confidence, Criticality: confidenceCriticality, ![@UI.Importance]: #High }, + { Value: severity }, + { Value: category }, + { Value: stepRef, Label: 'Step' }, + { Value: codeBlockIndex, Label: 'Block' }, + { Value: summary }, + { Value: suggestedFix }, + { Value: groundingSource, Label: 'Source' }, + { Value: disposition, Criticality: confidenceCriticality }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.setDisposition', Label: 'Set disposition' } + ], + PresentationVariant: { + // Sort on the persisted numeric ranks (High=3..Low=1) so High-confidence + // findings sort FIRST. A descending sort on the confidence/severity STRING + // columns is OData-lexical (High < Low < Medium), which sorts High last. + SortOrder: [ + { Property: confidenceRank, Descending: true }, + { Property: severityRank, Descending: true } + ] + } +}; +annotate AdminService.FreshnessFinding with { + suggestedFix @UI.MultiLineText; + evidence @UI.MultiLineText; +}; diff --git a/db/external-content.cds b/db/external-content.cds index a6e4b49f8..8be2e13c8 100644 --- a/db/external-content.cds +++ b/db/external-content.cds @@ -505,3 +505,17 @@ entity RelevanceSeedExemplars : cuid, managed { active : Boolean default true; note : String(500); } + +// ============================================================================ +// Task 2 (spec 2026-08-22): Vector columns for direct code→doc grounding +// cosine search. Raw BLOB path for SQLite unit tests; HANA uses embeddingVec. +// Task 3 backfills both columns; they are nullable until then. +// ============================================================================ +extend entity ApiDocs with { + embedding : LargeBinary; // raw Float32 BLOB (SQLite unit-test path) + embeddingVec : Vector(1536); // HANA REAL_VECTOR (COSINE_SIMILARITY path) +} +extend entity Samples with { + embedding : LargeBinary; + embeddingVec : Vector(1536); +} diff --git a/db/tutorial-assets.cds b/db/tutorial-assets.cds new file mode 100644 index 000000000..d030f5a54 --- /dev/null +++ b/db/tutorial-assets.cds @@ -0,0 +1,16 @@ +using { Attachments } from '@cap-js/attachments'; +using { com.sap.developers.ims.Tutorials } from './schema'; + +namespace com.sap.developers.ims; + +entity TutorialAssets { + key ID : UUID; + sourceUrl : String(1024); // raw.githubusercontent.com URL; one-row-per-sourceUrl maintained by attachment-store put() (delete-then-insert), NOT a DB constraint + tutorial : Association to Tutorials on tutorial.slug = slug; + slug : String(255); // lowercase canonical + channel : String(8); // 'prod' | 'qa' + contentHash : String(64); // sha-256 of stored bytes + mimeType : String(128); + filename : String(255); // for Content-Disposition + content : Composition of many Attachments; +} diff --git a/db/tutorial-freshness.cds b/db/tutorial-freshness.cds new file mode 100644 index 000000000..ca42f7053 --- /dev/null +++ b/db/tutorial-freshness.cds @@ -0,0 +1,50 @@ +// db/tutorial-freshness.cds +// Task 2 (spec 2026-08-22): Persistence layer for the tutorial freshness detector. +// +// FreshnessReport — one current report per tutorial (replaced on re-run). +// FreshnessFinding — individual code-freshness issues extracted by the LLM. +// +// Embedding columns for direct cosine-search grounding are added to ApiDocs +// and Samples via extend blocks in db/external-content.cds. + +namespace com.sap.developers.ims; + +using { com.sap.developers.ims as ims } from './schema'; +using { cuid, managed } from '@sap/cds/common'; + +// One CURRENT report per tutorial (replaced on re-run, not a history table). +entity FreshnessReport : cuid, managed { + tutorial : Association to ims.Tutorials; + runAt : Timestamp; + model : String(100); + cost : String(20); // e.g. '$0.62' + status : String(20) default 'QUEUED'; // QUEUED | RUNNING | DONE | FAILED + error : String(1000); + openHighCount : Integer default 0; // findings: confidence=High AND disposition=OPEN + findings : Composition of many FreshnessFinding on findings.report = $self; +} + +entity FreshnessFinding : cuid { + report : Association to FreshnessReport; + tutorial : Association to ims.Tutorials; // direct nav for FE facet + LR + fingerprint : String(64); // SHA-256(category + location + evidence) + category : String(30); // obsolete-dep | deprecated-api | dated-style | hardcoded-secret | broken-flow + severity : String(10); // High | Medium | Low + confidence : String(10); // High | Medium | Low (primary visual weight) + // Numeric sort ranks (High=3, Medium=2, Low=1, unknown=0) — stamped in + // freshness-persist.js. Fiori PresentationVariant sorts on these so High sorts + // FIRST (OData descending on the String columns is lexical → High sorts last). + confidenceRank : Integer; + severityRank : Integer; + stepRef : Integer; + codeBlockIndex : Integer; + lang : String(40); + evidence : LargeString; + summary : String(500); + suggestedFix : LargeString; + groundingSource : String(500); + disposition : String(12) default 'OPEN'; // OPEN | ACCEPTED | DISMISSED | FIXED + dispositionBy : String(255); + dispositionAt : Timestamp; + dispositionNote : String(1000); +} diff --git a/docs/developers/reference/mcp-server.md b/docs/developers/reference/mcp-server.md index b8185b0e3..17907c1ee 100644 --- a/docs/developers/reference/mcp-server.md +++ b/docs/developers/reference/mcp-server.md @@ -83,7 +83,7 @@ Standard JSON-RPC codes: `-32700` parse error, `-32600` invalid request, `-32601 ## Curated tools -Eight tools are curated in the CDS surfaces (four in `SearchService`, two in `HomepageService`, two in `KnowledgeGraphService`). Each also appears as an OData function at the same URL — the MCP wrapper reuses the CDS handler verbatim. +Ten tools are curated in the CDS surfaces (five in `SearchService`, three in `HomepageService`, two in `KnowledgeGraphService`). Each also appears as an OData function at the same URL — the MCP wrapper reuses the CDS handler verbatim. ### 1. `search_tutorials` @@ -405,6 +405,102 @@ Handler at `srv/knowledge-graph-service.js:1376` also re-uses `neighborhood()` a --- +## Tier 2 curated tools + +Two additional anonymous tools extend the public MCP surface with the community-events catalog and full news-article bodies. Both are `@requires: 'any'`. + +### `search_events` + +**Purpose.** Search the public SAP community events catalog — CodeJams, Devtoberfest, TechEd, and user-group events. The same events shown on the homepage events band, but fully searchable and filterable. Ordered by start date (soonest first). + +**Endpoint.** `/mcp/search` + +| Argument | Type | Required | Notes | +| --- | --- | --- | --- | +| `query` | `String` | no | Case-insensitive substring match on event `title` and `description`. | +| `eventType` | `String` | no | One of `'codejam'`, `'teched'`, `'devtoberfest'`, `'usergroup'`. Unknown values are ignored (no filter). | +| `region` | `String` | no | `'AMERICAS'`, `'EMEA'`, `'APJ'`, `'VIRTUAL'`, or `'ALL'` (default). `'VIRTUAL'` matches `virtualOrInPerson='virtual'`. | +| `upcomingOnly` | `Boolean` | no | Default `true` — only events not yet ended (in-progress multi-day events are kept). `false` includes past events. | +| `limit` | `Integer` | no | Default 20, hard max 50. | + +**Return shape** (handler `srv/lib/mcp-events-search.js`, backed by `CommunityEvents` in `db/external-content.cds`): + +```jsonc +[ + { + "slug": "string", + "title": "string", + "eventType": "string", + "description": "string", + "location": "string", + "region": "AMERICAS | EMEA | APJ | UNKNOWN", + "isVirtual": false, + "startDate": "2026-10-01", + "endDate": "2026-10-02", + "url": "string" + } +] +``` + +Fails open — returns `[]` on any DB error, never a 500. + +**Example.** + +```json +{ + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "search_events", + "arguments": { "query": "CAP", "eventType": "codejam", "region": "EMEA", "limit": 10 } + } +} +``` + +--- + +### `get_news_detail` + +**Purpose.** Fetch the full article body of one SAP Developer News item by URL. Complements `get_recent_news` (which returns only title/link/summary): pass a news item's `link` and this server-fetches the article, strips it to readable text, and returns it with metadata. + +**Endpoint.** `/mcp/homepage` + +| Argument | Type | Required | Notes | +| --- | --- | --- | --- | +| `url` | `String` | yes | The article `link` from `get_recent_news`. **Must** be an SAP news host (`news.sap.com`, `community.sap.com`, `blogs.sap.com`) or subdomain — other hosts are rejected with a JSON-RPC error (SSRF guard). | + +**Return shape** (handler `srv/lib/mcp-news-detail.js`): + +```jsonc +{ + "title": "string", + "url": "string", + "publishedAt": "2026-08-01T09:00:00Z", // from article:published_time meta, may be null + "summary": "string", // og:description / meta description + "content": "string (readable body text, capped at 20k chars)", + "fetchedAt": "2026-08-22T10:00:00.000Z" +} +``` + +Server-fetches with an 8s timeout and a 1-hour read-through cache keyed by URL. A disallowed host returns a `400`-class error; an upstream failure returns `502`. + +**Example.** + +```json +{ + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "get_news_detail", + "arguments": { "url": "https://news.sap.com/2026/08/some-episode/" } + } +} +``` + +--- + ## Auto tools (all services) Every MCP-enabled service also exposes: diff --git a/docs/end-users/experimental-features.md b/docs/end-users/experimental-features.md index 697ed17c2..b55b63088 100644 --- a/docs/end-users/experimental-features.md +++ b/docs/end-users/experimental-features.md @@ -2,18 +2,18 @@ Two opt-in webcam features live under the **Tutorial preferences** gear in the header: -- **Eye-tracking auto-scroll** — the page scrolls down when you look near the bottom of the viewport for about half a second. -- **Hand-gesture step navigation** — show an open palm to the camera, then sweep left or right to go to the previous or next step. +- **Eye-tracking auto-scroll** — after a one-time calibration, tilting your head/gaze down scrolls the page down and tilting up scrolls it back up (held for about half a second). +- **Hand-gesture step navigation** — show an open palm to the camera, then sweep left or right to move to the previous or next **step within the tutorial**. Both features are **off by default** and require an explicit "Start camera" click each browser session. ## How they work -Camera frames are processed entirely on your device by Google's MediaPipe `tasks-vision` library, running in WebAssembly inside your browser. **No video, no images, and no derived data are sent to any server.** The detector outputs (a normalized gaze position, a swipe direction) drive page actions locally. +Camera frames are processed entirely on your device by Google's MediaPipe `tasks-vision` library, running in WebAssembly inside your browser. **No video, no images, and no derived data are sent to any server.** The detector outputs (a head-tilt amount, a swipe direction) drive page actions locally. -Eye-tracking estimates approximately where in the viewport you are looking by comparing the position of your iris to the corners of your eyes. It does not record gaze data. The detector simply asks "is gaze near the bottom of the screen for at least 600 ms?" and triggers a single scroll action when yes. +Eye-tracking measures how far you tilt your head/gaze up or down, relative to the resting position captured during calibration. It does not record gaze data. Tilt down past your calibrated range for about 600 ms and it scrolls the page down; tilt up and it scrolls back up. -Hand-gesture navigation looks for an open palm and tracks its horizontal motion across frames. A fast sweep over a fraction of the camera frame triggers a click on the existing Previous / Next links. +Hand-gesture navigation looks for an open palm and tracks its horizontal motion across frames. A sweep over a fraction of the camera frame moves you to the previous or next step within the current tutorial. ## Privacy and control @@ -23,6 +23,24 @@ Hand-gesture navigation looks for an open palm and tracks its horizontal motion - Your preference (the toggle position) is stored in `localStorage`. The active-camera state is stored in `sessionStorage` and dies with the tab. - Disable a feature by toggling it off in the Tutorial preferences popover. +## Calibrate for best results + +Both camera features work out of the box, but a quick one-time calibration makes +them noticeably more reliable for your camera, seating position, and screen. When +you first start a feature you'll be offered a short calibration; you can also run +it anytime from the **Calibrate** button in Tutorial preferences. + +- **Eye-tracking:** press Begin, then slowly look from the top of the page to the + bottom and back for about five seconds, letting your head follow your gaze. +- **Hand gestures:** press Begin, then hold an open palm up and sweep it left and + right a few times for about five seconds. + +Hand gestures fall back to sensible defaults without calibration, but **eye-tracking +needs calibration** to learn your resting head position — until you calibrate, it +won't scroll. Your calibration is stored only in this browser and is never sent +anywhere. These remain experimental, hands-free conveniences, not assistive +technologies. + ## Not assistive technology These are experimental input demos, not accessibility tools. People who rely on hands-free input every day have purpose-built options that work better and run system-wide: diff --git a/docs/end-users/mcp-quickstart.md b/docs/end-users/mcp-quickstart.md index 308acf802..8e59d9686 100644 --- a/docs/end-users/mcp-quickstart.md +++ b/docs/end-users/mcp-quickstart.md @@ -15,8 +15,8 @@ Three CDS services are mounted under `/mcp/*` using `@cap-js/mcp@1.1.1` over the | Service | Mount | Curated tools | | --- | --- | --- | -| SearchService | `/mcp/search` | `search_tutorials`, `list_missions`, `get_mission`, `get_tutorial` | -| HomepageService | `/mcp/homepage` | `get_recent_news`, `get_recent_videos` | +| SearchService | `/mcp/search` | `search_tutorials`, `list_missions`, `get_mission`, `get_tutorial`, `search_events` | +| HomepageService | `/mcp/homepage` | `get_recent_news`, `get_recent_videos`, `get_news_detail` | | KnowledgeGraphService | `/mcp/graph` | `kg_prerequisites`, `kg_what_to_learn_next` | One-liners: @@ -25,8 +25,10 @@ One-liners: - **`list_missions`** — enumerate curated learning missions. - **`get_mission`** — fetch a mission's ordered tutorial list by slug. - **`get_tutorial`** — fetch a single tutorial's metadata and rendered steps. +- **`search_events`** — search the public SAP community events catalog (CodeJams, Devtoberfest, TechEd, user groups) by text, type, and region. - **`get_recent_news`** — recent SAP Developer News episodes shown on the homepage. - **`get_recent_videos`** — recent SAP Developers YouTube videos shown on the homepage. +- **`get_news_detail`** — full article body for one news item, fetched by its URL (complements `get_recent_news`). - **`kg_prerequisites`** — concepts you should already know before this tutorial. - **`kg_what_to_learn_next`** — recommended follow-on tutorials from the knowledge graph. diff --git a/docs/superpowers/plans/2026-08-21-tutorial-attachment-object-store.md b/docs/superpowers/plans/2026-08-21-tutorial-attachment-object-store.md new file mode 100644 index 000000000..99d12847f --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-tutorial-attachment-object-store.md @@ -0,0 +1,1347 @@ +# Tutorial Attachment Object-Store Pipeline — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Serve tutorial repo attachment files (`.txt/.zip/.pdf/.csv/.json/...`) through the object store like images, so relative attachment links stop 404-ing (the #1931 follow-up). + +**Architecture:** Mirror the image asset pipeline minus resize/WebP. A parser rewrites relative allowlisted links to raw-GitHub URLs; a new Hugo `render-link.html` hook wraps them to `/content/attachment-source?u=` (plus a download sibling). A new `TutorialAssets` CDS entity (own `@cap-js/attachments` composition on the existing shared S3 binding) holds bytes. A push ingest endpoint + backfill script populate the store; an anonymous serve endpoint streams with inline/download disposition (`.html` neutered). No approuter changes — the existing `^/content/(.*)$ → srv-api` route carries the endpoint. + +**Tech Stack:** SAP CAP (Node.js, `@sap/cds`), `@cap-js/attachments` v4 + `@aws-sdk/client-s3`, Hugo/Goldmark render hooks, TypeScript parsers (`scripts/parsers/`), Vitest (`unit` project, in-memory SQLite via `cds.test`), Express handlers. + +**Spec:** `docs/superpowers/specs/2026-08-21-tutorial-attachment-object-store-design.md` + +## Global Constraints + +- **srv-qa cp-list audit:** any new `srv/lib/*` file reachable from `content-store.js`/`content-publish-session.js`/`server.js` at runtime MUST be added to the `srv-qa` `cp` list in `.deploy/mta.yaml` (line ~174) or QA boot crashes at deploy. Guarded by `scripts/check-srv-qa-cp-list.ts` (runs in `postbuild:apps`). +- **CDS model validation:** run `npx cds deploy --to sqlite::memory:` before committing any `db/**/*.cds` change. +- **Schema build:** after adding an entity, run `npm run build:cds` (`cds build --production`); never hand-author `.hdbmigrationtable` ALTERs. +- **No raw SQL:** use `cds.ql`/CQL. (Store copies the image-store CQL pattern.) +- **Never SELECT a HANA BLOB alongside non-BLOB metadata in one CDS QL query** — the store fetches metadata and content in separate steps (image-store pattern already does this). +- **Tutorial slugs are lowercase canonical** — never compare slugs without `.toLowerCase()`; the store keys on `sourceUrl`, not slug, so this is informational. +- **PR over direct merge:** land via `gh pr create` from the feature branch; never direct-merge to `main`. +- **Vitest:** run unit tests with `npx vitest run --project unit ` from repo root. +- **Malware scanning stays mocked** (`status:'Clean'` hardcoded) — image parity, documented gap. Do not wire a real scanner. +- **RAW_BASE_URL** = `https://raw.githubusercontent.com` (exported from `scripts/parsers/types.ts`). +- **Attachment host allowlist** = `raw.githubusercontent.com` only (matches images). + +--- + +## File Structure + +**Parser / build (TypeScript):** +- `scripts/parsers/attachment-links.ts` — allowlist + `resolveAttachmentLinks()` (relative → raw URL, fence-aware). +- `scripts/parsers/compose.ts` — MODIFY: call `resolveAttachmentLinks` after `resolveImageURLs`. +- `hugo/layouts/_default/_markup/render-link.html` — NEW Hugo hook: passthrough + attachment wrap + download sibling. + +**Data model:** +- `db/tutorial-assets.cds` — `TutorialAssets` entity. + +**Runtime (srv, JS/ESM + CJS):** +- `srv/lib/attachment-store.cjs` — head/put/getStream/remove (keyed by sourceUrl; persists filename). +- `srv/lib/attachment-mime.cjs` — `extToMime()`, `dispositionFor()`. +- `srv/lib/attachment-warm-utils.js` — `extractAttachmentUrls()`, `warmAttachments()` (reuses `channelFor` from image-warm-utils). +- `srv/lib/attachment-ingest.cjs` — `ingestAttachment()`. +- `srv/lib/attachment-source-handler.js` — GET serve handler + `warmAttachmentsLive()`. +- `srv/lib/attachment-ingest-handler.js` — POST push handler. +- `srv/server.js` — MODIFY: register the two routes. +- `srv/lib/content-publish-session.js` — MODIFY: fire attachment warm beside image warm. + +**Scripts / deploy:** +- `scripts/backfill-attachments.ts` — enumerate + push. +- `package.json` — MODIFY: add `backfill-attachments` script. +- `.deploy/mta.yaml` — MODIFY: add 4 new `srv/lib/attachment-*` files to srv-qa `cp` list. + +--- + +## Task 1: Attachment-link resolver (parser) + +**Files:** +- Create: `scripts/parsers/attachment-links.ts` +- Test: `test/parsers/attachment-links.test.ts` + +**Interfaces:** +- Produces: `ATTACHMENT_EXTENSIONS: Set` (lowercase, no dot); `isAttachmentPath(path: string): boolean`; `resolveAttachmentLinks(content: string, opts: { repo: string, branch: string, slug: string, rewrite?: boolean }): string`. +- Consumes: `RAW_BASE_URL` from `./types.js`; `createFenceTracker` from `./fence-tracker.js`. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/parsers/attachment-links.test.ts +import { describe, it, expect } from 'vitest' +import { resolveAttachmentLinks, isAttachmentPath } from '../../scripts/parsers/attachment-links.js' + +const opts = { repo: 'abap-core-development', branch: 'main', slug: 'rap100' } +const base = 'https://raw.githubusercontent.com/sap-tutorials/abap-core-development/main/tutorials/rap100' + +describe('resolveAttachmentLinks', () => { + it('rewrites a relative allowlisted link to a raw-GitHub URL', () => { + const out = resolveAttachmentLinks('[doc](EX2_DDLX.txt)', opts) + expect(out).toBe(`[doc](${base}/EX2_DDLX.txt)`) + }) + it('rewrites ./-prefixed links and strips the ./', () => { + expect(resolveAttachmentLinks('[d](./a.csv)', opts)).toBe(`[d](${base}/a.csv)`) + }) + it('leaves images (![]) untouched', () => { + expect(resolveAttachmentLinks('![alt](img.png)', opts)).toBe('![alt](img.png)') + }) + it('leaves absolute, anchor, mailto, root-relative, and ../ links untouched', () => { + for (const s of ['[a](https://x.com/f.txt)', '[a](#sec)', '[a](mailto:x@y.z)', '[a](/other/f.txt)', '[a](../sib/f.txt)']) { + expect(resolveAttachmentLinks(s, opts)).toBe(s) + } + }) + it('leaves non-allowlisted extensions untouched', () => { + expect(resolveAttachmentLinks('[a](page.aspx)', opts)).toBe('[a](page.aspx)') + }) + it('does not touch link-like text inside a fenced code block', () => { + const src = '```md\n[x](y.txt)\n```' + expect(resolveAttachmentLinks(src, opts)).toBe(src) + }) + it('is idempotent (already-raw URLs are left as-is)', () => { + const once = resolveAttachmentLinks('[d](EX2.txt)', opts) + expect(resolveAttachmentLinks(once, opts)).toBe(once) + }) + it('respects rewrite:false', () => { + expect(resolveAttachmentLinks('[d](a.txt)', { ...opts, rewrite: false })).toBe('[d](a.txt)') + }) + it('isAttachmentPath matches allowlist case-insensitively', () => { + expect(isAttachmentPath('X.TXT')).toBe(true) + expect(isAttachmentPath('x.png')).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/parsers/attachment-links.test.ts` +Expected: FAIL — cannot resolve module `attachment-links.js`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// scripts/parsers/attachment-links.ts +import { RAW_BASE_URL } from './types.js' +import { createFenceTracker } from './fence-tracker.js' + +// Repo attachment file extensions served through the object store (lowercase, no dot). +// KEEP IN SYNC with the Hugo render-link hook's disposition and srv/lib/attachment-mime.cjs. +export const ATTACHMENT_EXTENSIONS = new Set([ + 'txt', 'csv', 'json', 'md', 'sql', 'abap', 'properties', + 'yaml', 'yml', 'xml', 'html', 'zip', 'pdf', 'war', 'jar', 'zargo', 'har', +]) + +export function isAttachmentPath(path: string): boolean { + const m = /\.([a-z0-9]+)$/i.exec(path.trim()) + return m ? ATTACHMENT_EXTENSIONS.has(m[1].toLowerCase()) : false +} + +export interface AttachmentResolveOpts { + repo: string + branch: string + slug: string + rewrite?: boolean +} + +// Matches a markdown link `[text](dest)` NOT preceded by `!` (which would be an image). +// Destination captured up to whitespace or `)`; an optional `"title"` is preserved. +const LINK_RE = /(^|[^!])(\[[^\]]*\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g + +export function resolveAttachmentLinks(content: string, opts: AttachmentResolveOpts): string { + const { repo, branch, slug, rewrite = true } = opts + if (!rewrite) return content + const base = `${RAW_BASE_URL}/sap-tutorials/${repo}/${branch}/tutorials/${slug}` + const fence = createFenceTracker() + return content + .split('\n') + .map((line) => { + if (fence(line)) return line // inside a code fence — leave verbatim + return line.replace(LINK_RE, (m, pre, open, dest, tail) => { + if (/^(https?:\/\/|#|mailto:|\/)/i.test(dest)) return m + if (dest.includes('../')) return m + if (!isAttachmentPath(dest)) return m + const clean = dest.replace(/^\.?\//, '') + return `${pre}${open}${base}/${clean}${tail}` + }) + }) + .join('\n') +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/parsers/attachment-links.test.ts` +Expected: PASS (all 9 cases). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/parsers/attachment-links.ts test/parsers/attachment-links.test.ts +git commit -m "feat(attachments): parser resolver for relative attachment links (#1931)" +``` + +--- + +## Task 2: Wire the resolver into compose + +**Files:** +- Modify: `scripts/parsers/compose.ts` (right after the `resolveImageURLs(mergedBody, …)` call, ~line 108-112) +- Test: `test/parsers/attachment-links-compose.test.ts` (or extend an existing compose test) + +**Interfaces:** +- Consumes: `resolveAttachmentLinks` from `./attachment-links.js` (Task 1). + +- [ ] **Step 1: Write the failing test** + +```ts +// test/parsers/attachment-links-compose.test.ts +import { describe, it, expect } from 'vitest' +import { composeTutorial } from '../../scripts/parsers/compose.js' + +describe('compose rewrites attachment links in the body', () => { + it('body [doc](EX2.txt) becomes a raw-GitHub URL', () => { + const md = `---\ntitle: T\n---\n\n## Intro\n\nSee [doc](EX2.txt) below.\n` + const out = composeTutorial(md, { repo: 'abap-core-development', branch: 'main', slug: 'rap100', target: 'hugo' } as any) + expect(out.body ?? JSON.stringify(out)).toContain( + 'https://raw.githubusercontent.com/sap-tutorials/abap-core-development/main/tutorials/rap100/EX2.txt' + ) + }) +}) +``` + +> NOTE: inspect `composeTutorial`'s real signature/return shape in `scripts/parsers/compose.ts` and adjust the call/assertion to match (it returns a composed object; assert against the field that carries the rendered body). If an existing compose test already exercises `resolveImageURLs`, add this case there instead of a new file. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/parsers/attachment-links-compose.test.ts` +Expected: FAIL — body still contains the bare `EX2.txt`. + +- [ ] **Step 3: Add the call in compose.ts** + +Find (~line 108): +```ts + let processedBody = resolveImageURLs(mergedBody, { + repo: opts.repo, branch: opts.branch, slug: opts.slug, + rewriteImages: opts.rewriteImages, + }) +``` +Add immediately after it: +```ts + // [#1931] Rewrite relative attachment links (.txt/.zip/.pdf/...) to raw-GitHub + // URLs so the render-link hook can route them through /content/attachment-source. + // Gated by rewriteImages (same "resolve relative repo paths" switch as images). + processedBody = resolveAttachmentLinks(processedBody, { + repo: opts.repo, branch: opts.branch, slug: opts.slug, + rewrite: opts.rewriteImages, + }) +``` +Add the import at the top with the other parser imports: +```ts +import { resolveAttachmentLinks } from './attachment-links.js' +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/parsers/attachment-links-compose.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/parsers/compose.ts test/parsers/attachment-links-compose.test.ts +git commit -m "feat(attachments): wire attachment-link resolver into compose (#1931)" +``` + +--- + +## Task 3: Hugo render-link hook + +**Files:** +- Create: `hugo/layouts/_default/_markup/render-link.html` +- Test: `test/parsers/render-link-hook.test.ts` (template-content assertions — a full Hugo build is verified in Task 16) + +**Interfaces:** +- Emits browser URL shape `/content/attachment-source?u=` and download sibling `…&dl=1`. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/parsers/render-link-hook.test.ts +import { describe, it, expect } from 'vitest' +import { readFileSync, existsSync } from 'node:fs' + +const p = 'hugo/layouts/_default/_markup/render-link.html' + +describe('render-link hook', () => { + it('exists', () => { expect(existsSync(p)).toBe(true) }) + it('wraps raw.githubusercontent destinations to the attachment endpoint', () => { + const t = readFileSync(p, 'utf8') + expect(t).toContain('raw.githubusercontent.com') + expect(t).toContain('/content/attachment-source?u=') + expect(t).toContain('dl=1') // download sibling + expect(t).toContain('urlquery') // encodes the source URL + }) + it('has a passthrough branch for non-attachment links', () => { + const t = readFileSync(p, 'utf8') + expect(t).toContain('.Destination | safeURL') // default anchor emission + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/parsers/render-link-hook.test.ts` +Expected: FAIL — file does not exist. + +- [ ] **Step 3: Write the hook** + +```go-html-template +{{- /* Render hook for links (issue #1931). + Default behavior for all links is Hugo's standard emission. Attachment + links whose destination is a raw.githubusercontent.com URL with an + allowlisted extension (rewritten by scripts/parsers/attachment-links.ts) + are routed through the CAP attachment store and get a download sibling. + KEEP the extension list in sync with attachment-links.ts / attachment-mime.cjs. */ -}} +{{- $dest := .Destination -}} +{{- $exts := slice "txt" "csv" "json" "md" "sql" "abap" "properties" "yaml" "yml" "xml" "html" "zip" "pdf" "war" "jar" "zargo" "har" -}} +{{- $ext := lower (path.Ext $dest | strings.TrimPrefix ".") -}} +{{- $isAttachment := and (hasPrefix $dest "https://raw.githubusercontent.com/") (in $exts $ext) -}} +{{- if $isAttachment -}} + {{- $enc := $dest | urlquery -}} + {{- $view := printf "/content/attachment-source?u=%s" $enc -}} + {{- $dl := printf "/content/attachment-source?u=%s&dl=1" $enc -}} + {{ .Text | safeHTML }} +{{- else -}} + {{ .Text | safeHTML }} +{{- end -}} +``` + +> NOTE: verify against the running Hugo version that `path.Ext`, `strings.TrimPrefix`, and `in` are available (they are in modern Hugo). Confirm no OTHER partial already emits links in a way this would double-wrap; the option-tabs shortcode reprocesses inner markdown, so keep the emitted `` on a single line (same single-line rule as `render-image.html`, issue #1591) — the template above keeps each `` unbroken. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/parsers/render-link-hook.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add hugo/layouts/_default/_markup/render-link.html test/parsers/render-link-hook.test.ts +git commit -m "feat(attachments): Hugo render-link hook routes attachment links to store (#1931)" +``` + +--- + +## Task 4: TutorialAssets CDS entity + +**Files:** +- Create: `db/tutorial-assets.cds` +- Test: `test/unit/tutorial-assets-model.test.js` (mirror `test/unit/tutorial-images-model.test.js`) + +**Interfaces:** +- Produces entity `com.sap.developers.ims.TutorialAssets` with composition `…TutorialAssets.content`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/tutorial-assets-model.test.js +import { describe, it, expect } from 'vitest' +import cds from '@sap/cds' + +describe('TutorialAssets model', () => { + it('compiles with a filename column and an Attachments composition', async () => { + const m = await cds.load(['db/tutorial-assets.cds'], { root: '.' }) + const e = cds.linked(m).definitions['com.sap.developers.ims.TutorialAssets'] + expect(e).toBeTruthy() + expect(e.elements.sourceUrl.length).toBe(1024) + expect(e.elements.filename).toBeTruthy() + expect(e.elements.content.type).toBe('cds.Composition') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/tutorial-assets-model.test.js` +Expected: FAIL — file `db/tutorial-assets.cds` not found. + +- [ ] **Step 3: Write the entity** + +```cds +using { Attachments } from '@cap-js/attachments'; +using { com.sap.developers.ims.Tutorials } from './schema'; + +namespace com.sap.developers.ims; + +entity TutorialAssets { + key ID : UUID; + sourceUrl : String(1024); // raw.githubusercontent.com URL; one-row-per-sourceUrl maintained by attachment-store put() (delete-then-insert), NOT a DB constraint + tutorial : Association to Tutorials on tutorial.slug = slug; + slug : String(255); // lowercase canonical + channel : String(8); // 'prod' | 'qa' + contentHash : String(64); // sha-256 of stored bytes + mimeType : String(128); + filename : String(255); // for Content-Disposition + content : Composition of many Attachments; +} +``` + +- [ ] **Step 4: Run test + validate model deploys** + +Run: `npx vitest run --project unit test/unit/tutorial-assets-model.test.js` +Expected: PASS. +Then (Global Constraint): `npx cds deploy --to sqlite::memory:` +Expected: exits 0, no compile error. + +- [ ] **Step 5: Commit** + +```bash +git add db/tutorial-assets.cds test/unit/tutorial-assets-model.test.js +git commit -m "feat(attachments): TutorialAssets entity (#1931)" +``` + +--- + +## Task 5: attachment-store.cjs + +**Files:** +- Create: `srv/lib/attachment-store.cjs` +- Test: `test/unit/attachment-store.test.js` (mirror `test/unit/image-store.test.js`) + +**Interfaces:** +- Produces: `head(sourceUrl) → {exists, ID?, contentHash?, mimeType?, filename?}`; `put(sourceUrl, {buffer, mimeType, contentHash, slug, channel, filename}) → void`; `getStream(sourceUrl) → {stream, mimeType, filename} | null`; `remove(sourceUrl) → void`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/attachment-store.test.js +import { describe, it, expect } from 'vitest' +import cds from '@sap/cds' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +cds.test('serve', '--project', '.', '--in-memory') +const store = require('../../srv/lib/attachment-store.cjs') + +describe('attachment-store round-trip', () => { + const url = 'https://raw.githubusercontent.com/o/r/main/EX2.txt' + it('put → head → getStream returns the same bytes, mime, and filename', async () => { + const buffer = Buffer.from('@Search.searchable: true', 'utf8') + await store.put(url, { buffer, mimeType: 'text/plain; charset=utf-8', contentHash: 'h1', slug: 's', channel: 'prod', filename: 'EX2.txt' }) + const h = await store.head(url) + expect(h.exists).toBe(true) + expect(h.contentHash).toBe('h1') + expect(h.filename).toBe('EX2.txt') + const got = await store.getStream(url) + const chunks = [] + for await (const c of got.stream) chunks.push(c) + expect(Buffer.concat(chunks)).toEqual(buffer) + expect(got.mimeType).toMatch(/text\/plain/) + expect(got.filename).toBe('EX2.txt') + }) + it('head returns exists:false for an unknown url', async () => { + expect((await store.head('https://raw.githubusercontent.com/o/r/main/none.txt')).exists).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/attachment-store.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the store (copy of image-store.cjs, entity swapped, filename added)** + +```js +'use strict' +const cds = require('@sap/cds') +const { Readable } = require('node:stream') + +// Metadata on TutorialAssets; original bytes in its Attachments composition. +// Mirror of image-store.cjs; see that file for the withCtx/tenant rationale. +function linkedContent() { + return cds.linked(cds.model).definitions['com.sap.developers.ims.TutorialAssets.content'] +} +function withCtx(fn) { return cds.context ? fn() : cds.tx(fn) } + +async function head(sourceUrl) { + return withCtx(async () => { + const { TutorialAssets } = cds.entities('com.sap.developers.ims') + const row = await SELECT.one.from(TutorialAssets) + .columns('ID', 'contentHash', 'mimeType', 'filename').where({ sourceUrl }) + return row + ? { exists: true, ID: row.ID, contentHash: row.contentHash, mimeType: row.mimeType, filename: row.filename } + : { exists: false } + }) +} + +async function put(sourceUrl, { buffer, mimeType, contentHash, slug, channel, filename }) { + return withCtx(async () => { + const { TutorialAssets } = cds.entities('com.sap.developers.ims') + await remove(sourceUrl) // delete-then-insert avoids NonUpdatableProperties:[content] 409 + const parentID = cds.utils.uuid() + const name = filename || sourceUrl.split('/').pop() + await INSERT.into(TutorialAssets).entries({ ID: parentID, sourceUrl, slug, channel, contentHash, mimeType, filename: name }) + const AttachmentsSrv = await cds.connect.to('attachments') + await AttachmentsSrv.put(linkedContent(), { + ID: cds.utils.uuid(), up__ID: parentID, url: cds.utils.uuid(), + content: Readable.from(buffer), mimeType, filename: name, status: 'Clean', + }) + }) +} + +async function getStream(sourceUrl) { + return withCtx(async () => { + const meta = await head(sourceUrl) + if (!meta.exists) return null + const Content = linkedContent() + const att = await SELECT.one.from(Content).columns('ID').where({ up__ID: meta.ID }) + if (!att) return null + const AttachmentsSrv = await cds.connect.to('attachments') + const stream = await AttachmentsSrv.get(Content, { ID: att.ID }) + return stream ? { stream, mimeType: meta.mimeType, filename: meta.filename } : null + }) +} + +async function remove(sourceUrl) { + return withCtx(async () => { + const { TutorialAssets } = cds.entities('com.sap.developers.ims') + await DELETE.from(TutorialAssets).where({ sourceUrl }) + }) +} + +module.exports = { head, put, getStream, remove } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/unit/attachment-store.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/attachment-store.cjs test/unit/attachment-store.test.js +git commit -m "feat(attachments): attachment-store CRUD on TutorialAssets (#1931)" +``` + +--- + +## Task 6: attachment-mime.cjs (MIME + disposition) + +**Files:** +- Create: `srv/lib/attachment-mime.cjs` +- Test: `test/unit/attachment-mime.test.js` + +**Interfaces:** +- Produces: `extToMime(filenameOrUrl: string) → string`; `dispositionFor(mimeType: string, opts: { download?: boolean, filename?: string }) → { contentType: string, disposition: string }`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/attachment-mime.test.js +import { describe, it, expect } from 'vitest' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +const { extToMime, dispositionFor } = require('../../srv/lib/attachment-mime.cjs') + +describe('extToMime', () => { + it('maps known extensions', () => { + expect(extToMime('a.txt')).toMatch(/text\/plain/) + expect(extToMime('a.json')).toBe('application/json') + expect(extToMime('a.csv')).toBe('text/csv') + expect(extToMime('a.zip')).toBe('application/zip') + expect(extToMime('a.pdf')).toBe('application/pdf') + }) + it('falls back to octet-stream for unknown', () => { + expect(extToMime('a.bin')).toBe('application/octet-stream') + }) +}) + +describe('dispositionFor', () => { + it('text types serve inline', () => { + expect(dispositionFor('text/plain; charset=utf-8', { filename: 'a.txt' }).disposition).toMatch(/^inline/) + }) + it('binaries force attachment with filename', () => { + const d = dispositionFor('application/zip', { filename: 'a.zip' }) + expect(d.disposition).toBe('attachment; filename="a.zip"') + }) + it('text/html is neutered to text/plain inline', () => { + const d = dispositionFor('text/html', { filename: 'a.html' }) + expect(d.contentType).toMatch(/text\/plain/) + expect(d.disposition).toMatch(/^inline/) + }) + it('download:true forces attachment for any type', () => { + const d = dispositionFor('text/plain; charset=utf-8', { download: true, filename: 'a.txt' }) + expect(d.disposition).toBe('attachment; filename="a.txt"') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/attachment-mime.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the module** + +```js +'use strict' +// Extension→MIME + Content-Disposition policy for tutorial attachments (#1931). +// KEEP the extension set in sync with scripts/parsers/attachment-links.ts and the render-link hook. + +const EXT_MIME = { + txt: 'text/plain; charset=utf-8', csv: 'text/csv', json: 'application/json', + md: 'text/markdown; charset=utf-8', sql: 'text/plain; charset=utf-8', + abap: 'text/plain; charset=utf-8', properties: 'text/plain; charset=utf-8', + yaml: 'text/plain; charset=utf-8', yml: 'text/plain; charset=utf-8', + xml: 'text/plain; charset=utf-8', html: 'text/html', + zip: 'application/zip', pdf: 'application/pdf', + war: 'application/java-archive', jar: 'application/java-archive', + zargo: 'application/octet-stream', har: 'application/json', +} + +function extToMime(filenameOrUrl) { + const m = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec(String(filenameOrUrl)) + const ext = m ? m[1].toLowerCase() : '' + return EXT_MIME[ext] || 'application/octet-stream' +} + +// Inline-viewable MIME classes (rest download). +const INLINE = new Set(['text/plain', 'text/csv', 'text/markdown', 'application/json', 'application/xml']) + +function baseType(mime) { return String(mime).split(';')[0].trim().toLowerCase() } + +function dispositionFor(mimeType, { download = false, filename = 'file' } = {}) { + const safeName = String(filename).replace(/"/g, '') + // text/html is neutered: serve as text/plain, inline, never executed. + if (baseType(mimeType) === 'text/html' && !download) { + return { contentType: 'text/plain; charset=utf-8', disposition: `inline; filename="${safeName}"` } + } + if (download) return { contentType: mimeType, disposition: `attachment; filename="${safeName}"` } + const inline = INLINE.has(baseType(mimeType)) + return { contentType: mimeType, disposition: `${inline ? 'inline' : 'attachment'}; filename="${safeName}"` } +} + +module.exports = { extToMime, dispositionFor } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/unit/attachment-mime.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/attachment-mime.cjs test/unit/attachment-mime.test.js +git commit -m "feat(attachments): ext→MIME + Content-Disposition policy (#1931)" +``` + +--- + +## Task 7: attachment-warm-utils.js + +**Files:** +- Create: `srv/lib/attachment-warm-utils.js` +- Test: `test/unit/attachment-warm-utils.test.js` + +**Interfaces:** +- Consumes: `channelFor` from `./image-warm-utils.js` (re-export). +- Produces: `extractAttachmentUrls(html: string) → string[]`; `warmAttachments(urls, { slug, ingestFn }) → Promise`; re-export `channelFor`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/attachment-warm-utils.test.js +import { describe, it, expect, vi } from 'vitest' +import { extractAttachmentUrls, warmAttachments } from '../../srv/lib/attachment-warm-utils.js' + +describe('extractAttachmentUrls', () => { + it('extracts and decodes u= from attachment-source hrefs (view + dl)', () => { + const raw = 'https://raw.githubusercontent.com/o/r/main/tutorials/s/EX2.txt' + const enc = encodeURIComponent(raw) + const html = `d` + expect(extractAttachmentUrls(html)).toEqual([raw]) // deduped + }) + it('returns [] when there are no attachment links', () => { + expect(extractAttachmentUrls('

no links

')).toEqual([]) + }) +}) + +describe('warmAttachments', () => { + it('calls ingestFn per url and never throws on failure', async () => { + const ingestFn = vi.fn().mockRejectedValue(new Error('boom')) + await expect(warmAttachments(['a', 'b'], { slug: 's', ingestFn })).resolves.toBeUndefined() + expect(ingestFn).toHaveBeenCalledTimes(2) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/attachment-warm-utils.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the module** + +```js +// srv/lib/attachment-warm-utils.js +// Pure warm-orchestration utilities for tutorial attachments (mirror of image-warm-utils.js). +export { channelFor } from './image-warm-utils.js' +import { channelFor } from './image-warm-utils.js' + +/** + * Extract + decode unique source URLs from `/content/attachment-source?u=` hrefs. + * Matches both `?u=` and `&u=`; captures up to the next & / quote / whitespace / >. + * @param {string} html + * @returns {string[]} + */ +export function extractAttachmentUrls(html) { + const results = new Set() + const re = /\/content\/attachment-source[^"'\s>]*[?&]u=([^&"'\s>]+)/g + let m + while ((m = re.exec(html)) !== null) { + try { results.add(decodeURIComponent(m[1])) } catch { /* skip malformed */ } + } + return [...results] +} + +/** + * Warm the attachment store for `urls`. Per-URL try/catch; always resolves. + * @param {string[]} urls + * @param {{ slug: string, ingestFn: (url: string, opts: {slug: string, channel: string}) => Promise<{action: string, status?: number}> }} opts + * @returns {Promise} + */ +export async function warmAttachments(urls, { slug, ingestFn }) { + for (const url of urls) { + const channel = channelFor(url) + try { + const result = await ingestFn(url, { slug, channel }) + if (result?.action === 'failed') { + console.warn(`[attachment-warm] slug=${slug} u=${url}: ingest failed (status=${result.status})`) + } + } catch (err) { + console.warn(`[attachment-warm] slug=${slug} u=${url}: ingest threw: ${err?.message}`) + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/unit/attachment-warm-utils.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/attachment-warm-utils.js test/unit/attachment-warm-utils.test.js +git commit -m "feat(attachments): warm-utils (extract + orchestrate) (#1931)" +``` + +--- + +## Task 8: attachment-ingest.cjs + +**Files:** +- Create: `srv/lib/attachment-ingest.cjs` +- Test: `test/unit/attachment-ingest.test.js` (mirror `test/unit/image-ingest.test.js`) + +**Interfaces:** +- Consumes: `fetchImageResponse` from `./img-cdn-fetch.cjs` (reused — generic HTTP fetch with anon-first/token-on-404), `extToMime` from `./attachment-mime.cjs`, store from Task 5. +- Produces: `ingestAttachment(sourceUrl, { slug, channel, deps }) → { action: 'stored'|'unchanged'|'failed', status?, contentHash?, mimeType? }` where `deps = { fetchImageResponse, safeFetch, resolveSecret, store, hash? }`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/attachment-ingest.test.js +import { describe, it, expect, vi } from 'vitest' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +const { ingestAttachment } = require('../../srv/lib/attachment-ingest.cjs') + +function res(body, { ok = true, status = 200, ct = 'text/plain' } = {}) { + return { ok, status, headers: new Map([['content-type', ct], ['content-length', String(body.length)]]), + arrayBuffer: async () => Buffer.from(body) } +} + +describe('ingestAttachment', () => { + const url = 'https://raw.githubusercontent.com/o/r/main/EX2.txt' + it('stores on a fresh URL', async () => { + const store = { head: vi.fn().mockResolvedValue({ exists: false }), put: vi.fn().mockResolvedValue() } + const deps = { fetchImageResponse: vi.fn().mockResolvedValue(res('hello')), safeFetch: {}, resolveSecret: {}, store } + const out = await ingestAttachment(url, { slug: 's', channel: 'prod', deps }) + expect(out.action).toBe('stored') + expect(store.put).toHaveBeenCalledOnce() + }) + it('is unchanged when hash matches', async () => { + const buf = Buffer.from('hello') + const crypto = require('node:crypto') + const h = crypto.createHash('sha256').update(buf).digest('hex') + const store = { head: vi.fn().mockResolvedValue({ exists: true, contentHash: h }), put: vi.fn() } + const deps = { fetchImageResponse: vi.fn().mockResolvedValue(res('hello')), store } + const out = await ingestAttachment(url, { slug: 's', channel: 'prod', deps }) + expect(out.action).toBe('unchanged') + expect(store.put).not.toHaveBeenCalled() + }) + it('fails on a non-ok fetch', async () => { + const deps = { fetchImageResponse: vi.fn().mockResolvedValue(res('', { ok: false, status: 404 })), store: {} } + const out = await ingestAttachment(url, { slug: 's', channel: 'prod', deps }) + expect(out).toEqual({ action: 'failed', status: 404 }) + }) + it('uses extToMime when the response content-type is generic', async () => { + const store = { head: vi.fn().mockResolvedValue({ exists: false }), put: vi.fn().mockResolvedValue() } + const deps = { fetchImageResponse: vi.fn().mockResolvedValue(res('{}', { ct: 'application/octet-stream' })), store } + await ingestAttachment('https://raw.githubusercontent.com/o/r/main/a.json', { slug: 's', channel: 'prod', deps }) + expect(store.put.mock.calls[0][1].mimeType).toBe('application/json') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/attachment-ingest.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the module (copy of image-ingest.cjs, MIME from extToMime)** + +```js +'use strict' +const crypto = require('node:crypto') +const { extToMime } = require('./attachment-mime.cjs') +const ATTACHMENT_HOSTS = new Set(['raw.githubusercontent.com']) +const MAX_BYTES = Number(process.env.ATTACHMENT_MAX_BYTES) || 25 * 1024 * 1024 + +async function ingestAttachment(sourceUrl, { slug, channel, deps }) { + const { fetchImageResponse, safeFetch, resolveSecret, store, + hash = (buf) => crypto.createHash('sha256').update(buf).digest('hex') } = deps + let host + try { host = new URL(sourceUrl).hostname } catch { return { action: 'failed', status: 400 } } + + const res = await fetchImageResponse(sourceUrl, { + safeFetch, resolveSecret, host, allowedHosts: ATTACHMENT_HOSTS, timeoutMs: 12000, maxRetries: 2, + }) + if (!res.ok) return { action: 'failed', status: res.status } + + const contentLength = Number(res.headers.get('content-length')) + if (!Number.isNaN(contentLength) && contentLength > MAX_BYTES) return { action: 'failed', status: 413 } + + const buffer = Buffer.from(await res.arrayBuffer()) + if (buffer.length > MAX_BYTES) return { action: 'failed', status: 413 } + + const contentHash = hash(buffer) + const existing = await store.head(sourceUrl) + if (existing.exists && existing.contentHash === contentHash) return { action: 'unchanged', contentHash } + + // GitHub serves most text attachments as text/plain; trust a specific content-type, + // otherwise derive from the extension so .json/.csv/.pdf get correct types. + const ct = res.headers.get('content-type') || '' + const mimeType = (ct && ct !== 'application/octet-stream') ? ct : extToMime(sourceUrl) + const filename = sourceUrl.split('/').pop() + await store.put(sourceUrl, { buffer, mimeType, contentHash, slug, channel, filename }) + return { action: 'stored', contentHash, mimeType } +} + +module.exports = { ingestAttachment } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/unit/attachment-ingest.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/attachment-ingest.cjs test/unit/attachment-ingest.test.js +git commit -m "feat(attachments): ingestAttachment with ext-aware MIME (#1931)" +``` + +--- + +## Task 9: attachment-source-handler.js (serve + warm-live) + +**Files:** +- Create: `srv/lib/attachment-source-handler.js` +- Test: `test/unit/attachment-source-endpoint.test.js` (mirror `test/unit/image-source-endpoint.test.js`) + +**Interfaces:** +- Consumes: store (Task 5), `ingestAttachment` (Task 8), `dispositionFor` (Task 6), `channelFor`/`warmAttachments` (Task 7), `fetchImageResponse` (`./img-cdn-fetch.cjs`), `safeFetch`, `resolveSecret`. +- Produces: `attachmentSourceHandler(req, res)`; `warmAttachmentsLive(urls, { slug })`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/attachment-source-endpoint.test.js +import { describe, it, expect } from 'vitest' +import cds from '@sap/cds' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +const project = cds.test('serve', '--project', '.', '--in-memory') +const store = require('../../srv/lib/attachment-store.cjs') +const base = '/content/attachment-source' + +describe('GET /content/attachment-source', () => { + it('streams a stored .txt inline', async () => { + const url = 'https://raw.githubusercontent.com/o/r/main/EX2.txt' + await store.put(url, { buffer: Buffer.from('code'), mimeType: 'text/plain; charset=utf-8', contentHash: 'h', slug: 's', channel: 'prod', filename: 'EX2.txt' }) + const res = await project.get(`${base}?u=${encodeURIComponent(url)}`, { responseType: 'arraybuffer' }) + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/text\/plain/) + expect(res.headers['content-disposition']).toMatch(/^inline/) + expect(res.headers['x-content-type-options']).toBe('nosniff') + }) + it('dl=1 forces attachment disposition', async () => { + const url = 'https://raw.githubusercontent.com/o/r/main/D2.txt' + await store.put(url, { buffer: Buffer.from('x'), mimeType: 'text/plain; charset=utf-8', contentHash: 'h2', slug: 's', channel: 'prod', filename: 'D2.txt' }) + const res = await project.get(`${base}?u=${encodeURIComponent(url)}&dl=1`, { responseType: 'arraybuffer' }) + expect(res.headers['content-disposition']).toMatch(/^attachment/) + }) + it('400 on missing u', async () => { + await expect(project.get(base)).rejects.toMatchObject({ response: { status: 400 } }) + }) + it('404 on a miss that cannot self-heal (github-blocked in test)', async () => { + const url = 'https://raw.githubusercontent.com/o/r/main/missing.txt' + await expect(project.get(`${base}?u=${encodeURIComponent(url)}`)).rejects.toMatchObject({ response: { status: 404 } }) + }) +}) +``` + +> NOTE: the 404 self-heal case relies on the srv being unable to fetch GitHub in the test env (network-blocked / anon-404), mirroring `img-store-github-blocked.test.js`. If the test env has network, stub `fetchImageResponse` or assert on `img-store-github-blocked.test.js`'s approach. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/attachment-source-endpoint.test.js` +Expected: FAIL — handler/route not present. + +- [ ] **Step 3: Write the handler** (Task 11 registers the route; this test will stay red until Task 11 — that is expected. Implement the handler now; re-run after Task 11.) + +```js +// srv/lib/attachment-source-handler.js +// Express handler for GET /content/attachment-source?u=&dl=. Anonymous. +// Streams the stored attachment; self-heals on miss (single-flight). Mirror of image-source-handler.js. +import cds from '@sap/cds' +import { createRequire } from 'node:module' +import { safeFetch } from './safe-fetch.js' +import { resolveSecret } from './secret-resolver.js' +import { channelFor, warmAttachments } from './attachment-warm-utils.js' + +const require = createRequire(import.meta.url) +const attachmentStore = require('./attachment-store.cjs') +const { ingestAttachment } = require('./attachment-ingest.cjs') +const { dispositionFor } = require('./attachment-mime.cjs') +const { fetchImageResponse } = require('./img-cdn-fetch.cjs') + +const LOG = cds.log('attachment-source') +const _inflight = new Map() + +export function warmAttachmentsLive(urls, { slug }) { + const ingestFn = (url, { slug: s, channel }) => + ingestAttachment(url, { slug: s, channel, deps: { fetchImageResponse, safeFetch, resolveSecret, store: attachmentStore } }) + return warmAttachments(urls, { slug, ingestFn }) +} + +export async function attachmentSourceHandler(req, res) { + const u = req.query.u + if (!u) return res.status(400).json({ error: 'Missing u parameter' }) + const download = req.query.dl === '1' || req.query.dl === 'true' + + let got = await attachmentStore.getStream(u) + if (!got) { + let p = _inflight.get(u) + if (!p) { + const channel = channelFor(u) + p = ingestAttachment(u, { slug: '', channel, + deps: { fetchImageResponse, safeFetch, resolveSecret, store: attachmentStore } }) + .finally(() => _inflight.delete(u)) + _inflight.set(u, p) + } + let result = { action: 'failed' } + try { result = await p } catch (err) { LOG.warn('[attachment-source] self-heal threw:', err.message) } + if (result.action === 'failed') return res.status(404).json({ error: 'Attachment unavailable' }) + got = await attachmentStore.getStream(u) + if (!got) return res.status(404).json({ error: 'Attachment unavailable' }) + } + + const filename = got.filename || String(u).split('/').pop() || 'file' + const { contentType, disposition } = dispositionFor(got.mimeType || 'application/octet-stream', { download, filename }) + res.setHeader('Content-Type', contentType) + res.setHeader('Content-Disposition', disposition) + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Cache-Control', 'public, max-age=3600') + res.setHeader('X-Content-Source', 'attachment-store') + + got.stream.on('error', (err) => { + LOG.warn('[attachment-source] stream error:', err.message) + if (!res.headersSent) res.status(500).json({ error: 'Stream error' }) + }) + got.stream.pipe(res) +} +``` + +- [ ] **Step 4: Commit (test remains red until Task 11 registers the route)** + +```bash +git add srv/lib/attachment-source-handler.js test/unit/attachment-source-endpoint.test.js +git commit -m "feat(attachments): serve handler with inline/download disposition (#1931)" +``` + +--- + +## Task 10: attachment-ingest-handler.js (POST push) + +**Files:** +- Create: `srv/lib/attachment-ingest-handler.js` +- Test: `test/unit/attachment-ingest-endpoint.test.js` (mirror `test/unit/image-ingest-endpoint.test.js`) + +**Interfaces:** +- Consumes: store (Task 5), `channelFor` (Task 7), `extToMime` (Task 6). +- Produces: `attachmentIngestHandler(req, res)` — POST `/content/attachment?u=&slug=&channel=&force=`, body = raw bytes. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/attachment-ingest-endpoint.test.js +import { describe, it, expect } from 'vitest' +import cds from '@sap/cds' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +const project = cds.test('serve', '--project', '.', '--in-memory') +const store = require('../../srv/lib/attachment-store.cjs') +const base = '/content/attachment' +const KEY = process.env.CONTENT_API_KEY || 'test-key' // set in vitest env for this suite + +describe('POST /content/attachment', () => { + it('stores pushed bytes then serves them', async () => { + const url = 'https://raw.githubusercontent.com/o/r/main/push.txt' + const res = await project.post(`${base}?u=${encodeURIComponent(url)}&slug=s`, Buffer.from('pushed'), { + headers: { authorization: `Bearer ${KEY}`, 'content-type': 'text/plain' }, + }) + expect(res.status).toBe(200) + expect(res.data.action).toBe('stored') + const got = await store.getStream(url) + const chunks = []; for await (const c of got.stream) chunks.push(c) + expect(Buffer.concat(chunks).toString()).toBe('pushed') + }) + it('401 without the api key', async () => { + const url = 'https://raw.githubusercontent.com/o/r/main/noauth.txt' + await expect(project.post(`${base}?u=${encodeURIComponent(url)}`, Buffer.from('x'), + { headers: { 'content-type': 'text/plain' } })).rejects.toMatchObject({ response: { status: 401 } }) + }) +}) +``` + +> NOTE: `CONTENT_API_KEY` must be present for `contentAuthMiddleware`. Mirror how `image-ingest-endpoint.test.js` sets it (env var in the suite or `vitest.config.ts`). Copy that exact setup. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/attachment-ingest-endpoint.test.js` +Expected: FAIL — route not registered. + +- [ ] **Step 3: Write the handler** (copy of image-ingest-handler.js, ext-aware MIME) + +```js +// srv/lib/attachment-ingest-handler.js +// POST /content/attachment?u=&slug=&channel=&force= — persist client-supplied attachment bytes. +// Bytes-in (srv CF egress is GitHub-anon-404'd, same as images). Auth: CONTENT_API_KEY. +import cds from '@sap/cds' +import crypto from 'node:crypto' +import { createRequire } from 'node:module' +import { channelFor } from './attachment-warm-utils.js' + +const require = createRequire(import.meta.url) +const attachmentStore = require('./attachment-store.cjs') +const { extToMime } = require('./attachment-mime.cjs') +const LOG = cds.log('attachment-ingest') +const MAX_BYTES = Number(process.env.ATTACHMENT_MAX_BYTES) || 25 * 1024 * 1024 + +export async function attachmentIngestHandler(req, res) { + const u = req.query.u + if (!u) return res.status(400).json({ error: 'Missing u parameter' }) + const buffer = req.body + if (!Buffer.isBuffer(buffer) || buffer.length === 0) return res.status(400).json({ error: 'Empty body' }) + if (buffer.length > MAX_BYTES) return res.status(400).json({ error: 'Attachment too large' }) + + const slug = typeof req.query.slug === 'string' ? req.query.slug : '' + const channel = typeof req.query.channel === 'string' && req.query.channel ? req.query.channel : channelFor(u) + const reqCt = req.get('content-type') || '' + const mimeType = (reqCt && reqCt !== 'application/octet-stream') ? reqCt : extToMime(u) + const filename = String(u).split('/').pop() + const contentHash = crypto.createHash('sha256').update(buffer).digest('hex') + const force = req.query.force === '1' || req.query.force === 'true' + + try { + if (!force) { + const existing = await attachmentStore.head(u) + if (existing.exists && existing.contentHash === contentHash) return res.status(200).json({ action: 'unchanged', contentHash }) + } + await attachmentStore.put(u, { buffer, mimeType, contentHash, slug, channel, filename }) + return res.status(200).json({ action: 'stored', contentHash }) + } catch (err) { + LOG.error('[attachment-ingest] store put failed for', u, '-', err.message) + return res.status(500).json({ error: 'store write failed' }) + } +} +``` + +- [ ] **Step 4: Commit (test red until Task 11)** + +```bash +git add srv/lib/attachment-ingest-handler.js test/unit/attachment-ingest-endpoint.test.js +git commit -m "feat(attachments): POST push ingest handler (#1931)" +``` + +--- + +## Task 11: Register routes in server.js + +**Files:** +- Modify: `srv/server.js` (imports near line 31-32; route registration near lines 523-527) + +**Interfaces:** +- Consumes: `attachmentSourceHandler` (Task 9), `attachmentIngestHandler` (Task 10), existing `contentAuthMiddleware`. + +- [ ] **Step 1: Add imports** (next to the image handler imports) + +```js +import { attachmentSourceHandler } from './lib/attachment-source-handler.js'; +import { attachmentIngestHandler } from './lib/attachment-ingest-handler.js'; +``` + +- [ ] **Step 2: Register the routes** (next to the `/content/image` routes, ~lines 523-527) + +```js + app.get('/content/attachment-source', attachmentSourceHandler); + app.post('/content/attachment', contentAuthMiddleware, express.raw({ type: '*/*', limit: '25mb' }), attachmentIngestHandler); +``` + +- [ ] **Step 3: Run the serve + ingest endpoint tests (now they can pass)** + +Run: `npx vitest run --project unit test/unit/attachment-source-endpoint.test.js test/unit/attachment-ingest-endpoint.test.js` +Expected: PASS (all cases from Tasks 9 & 10). + +- [ ] **Step 4: Commit** + +```bash +git add srv/server.js +git commit -m "feat(attachments): register /content/attachment-source + /content/attachment routes (#1931)" +``` + +--- + +## Task 12: Warm attachments on publish + +**Files:** +- Modify: `srv/lib/content-publish-session.js` (inside the `setImmediate` warm block, ~lines 248-261) +- Test: `test/unit/attachment-warm-utils.test.js` already covers extraction; add an assertion here that the block imports the attachment warm modules (guards against a copy/paste regression). + +- [ ] **Step 1: Add attachment warm beside image warm** + +Inside the existing `if (slugHtmlMap.size > 0) { setImmediate(async () => { … }) }` block, after the image-warm loop, add: +```js + const { extractAttachmentUrls } = await import('./attachment-warm-utils.js'); + const { warmAttachmentsLive } = await import('./attachment-source-handler.js'); + for (const [slug, html] of slugHtmlMap) { + const aUrls = extractAttachmentUrls(html); + if (aUrls.length > 0) await warmAttachmentsLive(aUrls, { slug }); + } +``` +> These run in the same `try` as the image warm (failures already swallowed and logged; never fail publish). In DEV the srv can't fetch GitHub so these warm calls no-op-fail — population is via Task 13 backfill. In PROD they auto-populate. + +- [ ] **Step 2: Add a guard test** + +```js +// append to test/unit/attachment-warm-utils.test.js +import { readFileSync } from 'node:fs' +it('content-publish-session wires attachment warm', () => { + const src = readFileSync('srv/lib/content-publish-session.js', 'utf8') + expect(src).toContain('extractAttachmentUrls') + expect(src).toContain('warmAttachmentsLive') +}) +``` + +- [ ] **Step 3: Run** + +Run: `npx vitest run --project unit test/unit/attachment-warm-utils.test.js` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add srv/lib/content-publish-session.js test/unit/attachment-warm-utils.test.js +git commit -m "feat(attachments): warm attachment store on publish (#1931)" +``` + +--- + +## Task 13: backfill-attachments.ts + npm script + +**Files:** +- Create: `scripts/backfill-attachments.ts` (mirror `scripts/backfill-images.ts`) +- Modify: `package.json` — add `"backfill-attachments": "tsx scripts/backfill-attachments.ts"` +- Test: `test/scripts/backfill-attachments.test.ts` (unit-test `collectAttachmentUrls` over a fixture HTML dir) + +**Interfaces:** +- Produces: `collectAttachmentUrls(publicDir: string) → Map` (sourceUrl → slug); CLI with `--limit --concurrency --dry-run --force`. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/scripts/backfill-attachments.test.ts +import { describe, it, expect } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { collectAttachmentUrls } from '../../scripts/backfill-attachments.js' + +describe('collectAttachmentUrls', () => { + it('collects attachment source URLs from built tutorial HTML', () => { + const root = mkdtempSync(join(tmpdir(), 'bf-')) + const dir = join(root, 'tutorials', 'rap100'); mkdirSync(dir, { recursive: true }) + const raw = 'https://raw.githubusercontent.com/sap-tutorials/abap-core-development/main/tutorials/rap100/EX2.txt' + writeFileSync(join(dir, 'index.html'), `d`) + const map = collectAttachmentUrls(root) + expect(map.get(raw)).toBe('rap100') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/scripts/backfill-attachments.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the script** + +Copy `scripts/backfill-images.ts` verbatim, then change: +- Import `extractAttachmentUrls` from `./parsers/... ` → actually from `../srv/lib/attachment-warm-utils.js`? `backfill-images.ts` imports `extractImgCdnUrls` — replicate that import path but for attachments (`extractAttachmentUrls` from `srv/lib/attachment-warm-utils.js`). Match how backfill-images imports it. +- Rename `collectImageUrls` → `collectAttachmentUrls` (uses `extractAttachmentUrls`). +- Rename `fetchImage`→`fetchAttachment`, `pushImage`→`pushAttachment`. +- Change the push endpoint from `/content/image` to `/content/attachment`. +- Keep the anon-first → Bearer-token-on-404 fetch, the concurrency pool, and the `--limit/--concurrency/--dry-run/--force` flags unchanged. +- `export function collectAttachmentUrls(publicDir: string): Map` so the test can import it. + +> Follow `scripts/backfill-images.ts` structure exactly; the only semantic differences are the extraction function, the endpoint path, and the identifiers. Do not re-derive the pool/flag logic. + +- [ ] **Step 4: Add the npm script + run test** + +Add to `package.json` `scripts`: +```json +"backfill-attachments": "tsx scripts/backfill-attachments.ts", +``` +Run: `npx vitest run --project unit test/scripts/backfill-attachments.test.ts` +Expected: PASS. +Then dry-run smoke (requires a prior `npm run build:all` producing `hugo/public`): +`CAP_BASE_URL=http://localhost:4004 CONTENT_API_KEY=x npx tsx scripts/backfill-attachments.ts --dry-run --limit 3` +Expected: lists candidate attachment URLs, pushes nothing. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/backfill-attachments.ts package.json test/scripts/backfill-attachments.test.ts +git commit -m "feat(attachments): backfill-attachments push script (#1931)" +``` + +--- + +## Task 14: Add new srv files to srv-qa cp list + +**Files:** +- Modify: `.deploy/mta.yaml` (srv-qa builder `cp` command, ~line 174) + +**Interfaces:** none (deploy packaging). + +- [ ] **Step 1: Add the four runtime files to the cp list** + +In the long `cp ... srv/lib/` command, append these to the source file list (before ` srv/lib/`): +``` +../../srv/lib/attachment-store.cjs ../../srv/lib/attachment-ingest.cjs ../../srv/lib/attachment-mime.cjs ../../srv/lib/attachment-warm-utils.js ../../srv/lib/attachment-source-handler.js ../../srv/lib/attachment-ingest-handler.js +``` +> Rationale (Global Constraint): `server.js` registers the serve/ingest routes and `content-publish-session.js` (already copied) dynamically imports `attachment-warm-utils.js` + `attachment-source-handler.js`, which pull the store/ingest/mime modules. All six must be present in srv-qa or QA boot crashes. + +- [ ] **Step 2: Run the srv-qa cp-list guard** + +Run: `npx tsx scripts/check-srv-qa-cp-list.ts` +Expected: PASS (no missing transitive deps reported). + +- [ ] **Step 3: Commit** + +```bash +git add .deploy/mta.yaml +git commit -m "chore(attachments): add attachment-* srv files to srv-qa cp list (#1931)" +``` + +--- + +## Task 15: Build CDS artifacts + full test/lint sweep + +**Files:** none created; validates the schema build and full suite. + +- [ ] **Step 1: Build production CDS artifacts** + +Run: `npm run build:cds` +Expected: exits 0; emits `TutorialAssets` hdbtable + `@cap-js/attachments` composition table + a fresh `.hdbmigrationtable` under the gen output. Verify no "Duplicate definition"/resolve errors. Do NOT hand-edit any migration table. + +- [ ] **Step 2: Run the full unit suite** + +Run: `npm test` +Expected: PASS, including all new attachment tests. + +- [ ] **Step 3: Run the postbuild guards that touch srv-qa/routes** + +Run: `npx tsx scripts/check-srv-qa-cp-list.ts && npx tsx scripts/check-srv-qa-route-drift.ts && npx tsx scripts/check-public-endpoints.ts` +Expected: PASS. `check-public-endpoints.ts` must accept the new anonymous `GET /content/attachment-source` (if it enforces an allowlist of public endpoints, add `/content/attachment-source` there — same posture as `/content/image-source`). + +- [ ] **Step 4: Commit any guard/config updates** + +```bash +git add -A +git commit -m "chore(attachments): cds build artifacts + public-endpoint allowlist (#1931)" +``` + +--- + +## Task 16: End-to-end verification (local hybrid or DEV) + +**Files:** none — live verification. + +- [ ] **Step 1: Build + run against a backend with the RAP100 tutorial present** + +Fetch + build so the RAP100 page bakes the new link shape: +```bash +npm run fetch-tutorials +npm run build:all +``` +Grep the built page for the wrapped link: +```bash +grep -o '/content/attachment-source?u=[^"]*EX2_DDLX[^"]*' hugo/public/tutorials/abap-environment-rap100-enhance-data-model/index.html +``` +Expected: a `/content/attachment-source?u=...EX2_DDLX...` href (and a sibling `&dl=1`), NOT the bare `EX2_DDLX_ZRAP100_C_TRAVELTP.txt`. + +- [ ] **Step 2: Populate the store + verify serving (hybrid)** + +With a `cds bind`'d hybrid srv (real HANA + S3) or a DEV deploy: +```bash +CAP_BASE_URL= CONTENT_API_KEY= npm run backfill-attachments -- --limit 50 +curl -sI "/content/attachment-source?u=$(python3 -c "import urllib.parse;print(urllib.parse.quote('https://raw.githubusercontent.com/sap-tutorials/abap-core-development/main/tutorials/abap-environment-rap100-enhance-data-model/EX2_DDLX_ZRAP100_C_TRAVELTP.txt'))")" +``` +Expected: `200`, `Content-Type: text/plain; charset=utf-8`, `Content-Disposition: inline; filename="EX2_DDLX_ZRAP100_C_TRAVELTP.txt"`, `X-Content-Type-Options: nosniff`. Append `&dl=1` → `Content-Disposition: attachment; …`. + +- [ ] **Step 3: Open the PR** + +```bash +git push -u origin worktree-attachment-object-store +gh pr create --repo sap-tutorials/tutorials-ims --base main \ + --title "Attachment object-store pipeline: serve tutorial repo attachments like images (#1931)" \ + --body "Implements docs/superpowers/specs/2026-08-21-tutorial-attachment-object-store-design.md. Fixes dead relative attachment links (#1931 follow-up) by ingesting .txt/.zip/.pdf/.csv/.json into the object store and serving via /content/attachment-source (inline text, download binaries, .html neutered, ?dl=1 to force download)." +``` + +> Deploy note (from CLAUDE.md/memory): a full deploy must run `npm run build:all` before `mbt build`; content publish into HANA is a built-in final deploy step; the QA content rebuild must also run. Confirm deploy scope with the maintainer. The new `TutorialAssets` HDI artifacts deploy with the srv module. + +--- + +## Self-Review + +**Spec coverage:** §1 detect+bake → Tasks 1-3; §2 store → Tasks 4-5; §3 ingest → Tasks 8,10,11; §4 warm → Tasks 7,12; §5 serve → Tasks 6,9,11; §6 backfill → Task 13; §7 deploy wiring → Tasks 14-15; testing → per-task + Task 15-16; out-of-scope (real scan, subsystem B, prerequisites links) not implemented, as specified. ✅ + +**Placeholder scan:** No TBD/TODO in code steps. Two "NOTE" callouts (compose signature in Task 2, CONTENT_API_KEY setup in Task 10, github-blocked assumption in Task 9, backfill copy in Task 13) point the executor to the exact template file/test to mirror — not placeholders for logic. ✅ + +**Type consistency:** `resolveAttachmentLinks(content, {repo,branch,slug,rewrite})` (Tasks 1,2); store `put(url,{buffer,mimeType,contentHash,slug,channel,filename})` / `getStream→{stream,mimeType,filename}` (Tasks 5,8,9,10); `ingestAttachment(url,{slug,channel,deps})→{action,status?,contentHash?,mimeType?}` (Tasks 8,9); `extractAttachmentUrls(html)→string[]`, `warmAttachments(urls,{slug,ingestFn})`, `warmAttachmentsLive(urls,{slug})` (Tasks 7,9,12); `dispositionFor(mime,{download,filename})→{contentType,disposition}`, `extToMime(name)→string` (Tasks 6,8,9,10); routes `/content/attachment-source` + `/content/attachment` (Tasks 9,10,11,12,13). Consistent across tasks. ✅ diff --git a/docs/superpowers/plans/2026-08-21-tutorial-display-preferences.md b/docs/superpowers/plans/2026-08-21-tutorial-display-preferences.md new file mode 100644 index 000000000..3fa3ec736 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-tutorial-display-preferences.md @@ -0,0 +1,951 @@ +# Tutorial Display Preferences Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let tutorial-detail readers reclaim vertical space via a "Display" section in the existing Tutorial-preferences popover (header locked/compact/auto-hide, footer auto-hide, hide breadcrumbs/feedback), auto-applying compact defaults on short viewports (incl. high-scale laptops). + +**Architecture:** Extend the existing `tutorial-prefs` Vue island. Preferences persist as individual `tut.pref.*` localStorage keys via `prefs-store.ts`. A pre-paint snippet in `head.html` and a new `display-chrome.ts` compute **effective** header/footer modes (explicit pref, else viewport-height-derived) and set `data-tut-*` attributes on ``; a scoped CSS block in `ui5-overrides.css` keys off those attributes. Auto-hide scroll/hover behavior and the `matchMedia('(max-height:900px)')` threshold live in JS (single source of truth — no CSS media query). + +**Tech Stack:** Hugo templates, Vue 3 + UI5 Web Components (`@ui5/webcomponents`), TypeScript, Vitest (unit: `happy-dom`; e2e: Playwright via `test/e2e/_browser.js`). + +**Spec:** `docs/superpowers/specs/2026-08-21-tutorial-display-preferences-design.md` + +## Global Constraints + +- **Scope: tutorial detail page only** (`data-page-kind="tutorial"`, i.e. Hugo `.Type == "tutorials"`). No other page type may change behavior. The Display popover section gates on `onTutorialPage`; the auto-hide handlers install only when `onTutorial`. +- **Persistence: localStorage only** (per-device). No CAP/server. Keys use the existing `tut.pref.*` convention. +- **Island gzip budget:** `tutorial-prefs.js` must stay **≤ 8192 bytes gzipped** (`MAX_TUTORIAL_PREFS_GZIP`, `hugo-apps/vite.config.ts`). The Vite build **errors** past it. Keep `display-chrome.ts` lean; if it trips the budget, `await import()` it lazily from `main.ts` like the camera modules. +- **No-flash:** explicit header/footer prefs must be applied by the `head.html` pre-paint snippet (before first paint), mirroring the existing `reader`/`embed` pre-paint. Guard with `{{ if not site.Params.previewMode }}`. +- **Threshold:** `SHORT_VIEWPORT_MAX_HEIGHT = 900` (CSS px) defined in `constants.ts`; the inline pre-paint snippet hardcodes `900` with a comment referencing the constant (it cannot import — documented duplication, same as the embed allowlist). +- **Reduced motion:** auto-hide transitions must be suppressed under `prefers-reduced-motion: reduce` (mirror the reader-mode block in `ui5-overrides.css`). +- **`prefers-reduced-motion` + reader coexistence:** where reader mode already hides `.breadcrumbs`/`.feedback-share`/`footer` with `!important`, that wins — do not fight it. +- **Test commands** (run from repo root): + - Island unit tests: `npx vitest run --project unit hugo-apps/src/tutorial-prefs/.test.ts` + - Hugo template unit tests: `npx vitest run --project unit test/unit/hugo/.test.ts` + - E2E (self-skips without base URL): `npx vitest run test/e2e/.test.js` +- **Commit cadence:** commit after each task's tests pass. Branch: `worktree-tutorial-display-prefs-1966`. + +--- + +## File Structure + +- `hugo-apps/src/tutorial-prefs/constants.ts` *(modify)* — keys, threshold, types. +- `hugo-apps/src/tutorial-prefs/prefs-store.ts` *(modify)* — typed get/set for display prefs. +- `hugo-apps/src/tutorial-prefs/display-chrome.ts` *(create)* — effective-mode computation, attribute apply, auto-hide install. +- `hugo-apps/src/tutorial-prefs/display-chrome.test.ts` *(create)* — unit tests for the pure + apply logic. +- `hugo-apps/src/tutorial-prefs/prefs-store.test.ts` *(modify)* — add display-pref round-trip tests. +- `hugo-apps/src/tutorial-prefs/TutorialPrefsPopover.vue` *(modify)* — Display section + props/emits. +- `hugo-apps/src/tutorial-prefs/main.ts` *(modify)* — reactive state, wiring, init call. +- `hugo/layouts/partials/head.html` *(modify)* — pre-paint block. +- `hugo/assets/css/ui5-overrides.css` *(modify)* — tutorial-scoped display cascade. +- `test/unit/hugo/tutorial-display-prefs-prepaint.test.ts` *(create)* — asserts pre-paint + CSS presence. +- `test/e2e/tutorial-display-prefs.test.js` *(create)* — real-browser toggle + short-viewport auto path. + +--- + +### Task 1: Constants + types + +**Files:** +- Modify: `hugo-apps/src/tutorial-prefs/constants.ts` + +**Interfaces:** +- Produces: `KEY_PREF_HEADER`, `KEY_PREF_FOOTER`, `KEY_PREF_BREADCRUMBS`, `KEY_PREF_FEEDBACK` (string consts); `SHORT_VIEWPORT_MAX_HEIGHT = 900`; types `HeaderMode = 'locked' | 'thinbar' | 'autohide'`, `FooterMode = 'shown' | 'autohide'`, `OnOff = 'on' | 'off'`. + +- [ ] **Step 1: Add constants and types** + +Append to `hugo-apps/src/tutorial-prefs/constants.ts`: + +```ts +// Display-chrome preferences (#1966). Individual keys mirror the tut.pref.* convention. +export const KEY_PREF_HEADER = 'tut.pref.header'; +export const KEY_PREF_FOOTER = 'tut.pref.footer'; +export const KEY_PREF_BREADCRUMBS = 'tut.pref.breadcrumbs'; +export const KEY_PREF_FEEDBACK = 'tut.pref.feedback'; + +// Below this CSS-px viewport height, header→thinbar + footer→autohide by default +// (unless the user set an explicit pref). CSS px shrink under OS scaling / browser +// zoom, so high-DPI laptops cross this automatically. Mirrored (with a comment) in +// the head.html pre-paint snippet, which cannot import this module. +export const SHORT_VIEWPORT_MAX_HEIGHT = 900; + +export type HeaderMode = 'locked' | 'thinbar' | 'autohide'; +export type FooterMode = 'shown' | 'autohide'; +export type OnOff = 'on' | 'off'; +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd hugo-apps && npx tsc --noEmit -p tsconfig.json` (or `npm run -s typecheck` if present) +Expected: no new errors referencing `constants.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add hugo-apps/src/tutorial-prefs/constants.ts +git commit -m "feat(display-prefs): add constants + types for tutorial display prefs (#1966)" +``` + +--- + +### Task 2: prefs-store display getters/setters + +**Files:** +- Modify: `hugo-apps/src/tutorial-prefs/prefs-store.ts` +- Test: `hugo-apps/src/tutorial-prefs/prefs-store.test.ts` + +**Interfaces:** +- Consumes: keys + types from Task 1. +- Produces: + - `getHeaderPref(): HeaderMode | null` (null = unset/explicit-absent; invalid stored value → null) + - `setHeaderPref(v: HeaderMode): void` + - `getFooterPref(): FooterMode | null` + - `setFooterPref(v: FooterMode): void` + - `getBreadcrumbsPref(): OnOff` (default `'on'`) + - `setBreadcrumbsPref(v: OnOff): void` + - `getFeedbackPref(): OnOff` (default `'on'`) + - `setFeedbackPref(v: OnOff): void` + +- [ ] **Step 1: Write the failing tests** + +Append to `hugo-apps/src/tutorial-prefs/prefs-store.test.ts` (inside the existing top-level `describe`, or a new `describe`): + +```ts +import { + getHeaderPref, setHeaderPref, getFooterPref, setFooterPref, + getBreadcrumbsPref, setBreadcrumbsPref, getFeedbackPref, setFeedbackPref +} from './prefs-store'; + +describe('prefs-store — display prefs (#1966)', () => { + beforeEach(() => { localStorage.clear(); }); + + it('header/footer default to null (unset) and round-trip', () => { + expect(getHeaderPref()).toBeNull(); + expect(getFooterPref()).toBeNull(); + setHeaderPref('thinbar'); + setFooterPref('autohide'); + expect(getHeaderPref()).toBe('thinbar'); + expect(getFooterPref()).toBe('autohide'); + }); + + it('header ignores invalid stored values', () => { + localStorage.setItem('tut.pref.header', 'bogus'); + expect(getHeaderPref()).toBeNull(); + }); + + it('breadcrumbs/feedback default to "on" and round-trip', () => { + expect(getBreadcrumbsPref()).toBe('on'); + expect(getFeedbackPref()).toBe('on'); + setBreadcrumbsPref('off'); + setFeedbackPref('off'); + expect(getBreadcrumbsPref()).toBe('off'); + expect(getFeedbackPref()).toBe('off'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run --project unit hugo-apps/src/tutorial-prefs/prefs-store.test.ts` +Expected: FAIL — `getHeaderPref` etc. not exported. + +- [ ] **Step 3: Implement** + +Append to `hugo-apps/src/tutorial-prefs/prefs-store.ts` (import the new consts/types at top): + +```ts +import { + KEY_PREF_HEADER, KEY_PREF_FOOTER, KEY_PREF_BREADCRUMBS, KEY_PREF_FEEDBACK, + type HeaderMode, type FooterMode, type OnOff +} from './constants'; + +const HEADER_MODES: HeaderMode[] = ['locked', 'thinbar', 'autohide']; +const FOOTER_MODES: FooterMode[] = ['shown', 'autohide']; + +export function getHeaderPref(): HeaderMode | null { + const v = safeLocal()?.getItem(KEY_PREF_HEADER); + return (v && (HEADER_MODES as string[]).includes(v)) ? (v as HeaderMode) : null; +} +export function setHeaderPref(v: HeaderMode): void { safeSet(safeLocal(), KEY_PREF_HEADER, v); } + +export function getFooterPref(): FooterMode | null { + const v = safeLocal()?.getItem(KEY_PREF_FOOTER); + return (v && (FOOTER_MODES as string[]).includes(v)) ? (v as FooterMode) : null; +} +export function setFooterPref(v: FooterMode): void { safeSet(safeLocal(), KEY_PREF_FOOTER, v); } + +export function getBreadcrumbsPref(): OnOff { return safeLocal()?.getItem(KEY_PREF_BREADCRUMBS) === 'off' ? 'off' : 'on'; } +export function setBreadcrumbsPref(v: OnOff): void { safeSet(safeLocal(), KEY_PREF_BREADCRUMBS, v); } + +export function getFeedbackPref(): OnOff { return safeLocal()?.getItem(KEY_PREF_FEEDBACK) === 'off' ? 'off' : 'on'; } +export function setFeedbackPref(v: OnOff): void { safeSet(safeLocal(), KEY_PREF_FEEDBACK, v); } +``` + +> Note: `safeLocal` / `safeSet` are existing module-private helpers — reuse them (do not redeclare). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run --project unit hugo-apps/src/tutorial-prefs/prefs-store.test.ts` +Expected: PASS (all, including pre-existing). + +- [ ] **Step 5: Commit** + +```bash +git add hugo-apps/src/tutorial-prefs/prefs-store.ts hugo-apps/src/tutorial-prefs/prefs-store.test.ts +git commit -m "feat(display-prefs): store getters/setters for display prefs (#1966)" +``` + +--- + +### Task 3: display-chrome — effective modes + attribute apply + +**Files:** +- Create: `hugo-apps/src/tutorial-prefs/display-chrome.ts` +- Test: `hugo-apps/src/tutorial-prefs/display-chrome.test.ts` + +**Interfaces:** +- Consumes: prefs-store getters (Task 2), `HeaderMode`/`FooterMode`/`OnOff` + `SHORT_VIEWPORT_MAX_HEIGHT` (Task 1). +- Produces: + - `interface DisplayPrefs { header: HeaderMode | null; footer: FooterMode | null; breadcrumbs: OnOff; feedback: OnOff; }` + - `interface Effective { header: HeaderMode; footer: FooterMode; breadcrumbs: OnOff; feedback: OnOff; }` + - `computeEffective(prefs: DisplayPrefs, shortViewport: boolean): Effective` — pure. header: pref ?? (short ? 'thinbar' : 'locked'); footer: pref ?? (short ? 'autohide' : 'shown'); breadcrumbs/feedback pass through. + - `readPrefs(): DisplayPrefs` — reads all four via prefs-store. + - `isShortViewport(): boolean` — `matchMedia('(max-height: 900px)').matches` (guarded). + - `applyDisplayChrome(doc?: Document): void` — computes effective from `readPrefs()` + `isShortViewport()` and sets `data-tut-header`, `data-tut-footer`, `data-tut-breadcrumbs`, `data-tut-feedback` on ``. + +- [ ] **Step 1: Write the failing tests** + +Create `hugo-apps/src/tutorial-prefs/display-chrome.test.ts`: + +```ts +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { computeEffective, applyDisplayChrome, type DisplayPrefs } from './display-chrome'; + +const NONE: DisplayPrefs = { header: null, footer: null, breadcrumbs: 'on', feedback: 'on' }; + +describe('computeEffective (#1966)', () => { + it('tall viewport, no prefs → locked/shown', () => { + expect(computeEffective(NONE, false)).toEqual({ header: 'locked', footer: 'shown', breadcrumbs: 'on', feedback: 'on' }); + }); + it('short viewport, no prefs → thinbar/autohide', () => { + expect(computeEffective(NONE, true)).toEqual({ header: 'thinbar', footer: 'autohide', breadcrumbs: 'on', feedback: 'on' }); + }); + it('explicit prefs override the short-viewport default', () => { + const e = computeEffective({ header: 'locked', footer: 'shown', breadcrumbs: 'off', feedback: 'off' }, true); + expect(e).toEqual({ header: 'locked', footer: 'shown', breadcrumbs: 'off', feedback: 'off' }); + }); +}); + +describe('applyDisplayChrome (#1966)', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute('data-tut-header'); + document.documentElement.removeAttribute('data-tut-footer'); + }); + it('writes effective attributes from stored prefs', () => { + localStorage.setItem('tut.pref.header', 'autohide'); + localStorage.setItem('tut.pref.breadcrumbs', 'off'); + applyDisplayChrome(document); + const html = document.documentElement; + expect(html.getAttribute('data-tut-header')).toBe('autohide'); + expect(html.getAttribute('data-tut-breadcrumbs')).toBe('off'); + // footer unset + happy-dom viewport not short → 'shown' + expect(html.getAttribute('data-tut-footer')).toBe('shown'); + expect(html.getAttribute('data-tut-feedback')).toBe('on'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run --project unit hugo-apps/src/tutorial-prefs/display-chrome.test.ts` +Expected: FAIL — module `./display-chrome` not found. + +- [ ] **Step 3: Implement `display-chrome.ts` (compute + apply only; auto-hide added in Task 4)** + +Create `hugo-apps/src/tutorial-prefs/display-chrome.ts`: + +```ts +import { + getHeaderPref, getFooterPref, getBreadcrumbsPref, getFeedbackPref +} from './prefs-store'; +import { + SHORT_VIEWPORT_MAX_HEIGHT, type HeaderMode, type FooterMode, type OnOff +} from './constants'; + +export interface DisplayPrefs { + header: HeaderMode | null; + footer: FooterMode | null; + breadcrumbs: OnOff; + feedback: OnOff; +} +export interface Effective { + header: HeaderMode; + footer: FooterMode; + breadcrumbs: OnOff; + feedback: OnOff; +} + +export function computeEffective(prefs: DisplayPrefs, shortViewport: boolean): Effective { + return { + header: prefs.header ?? (shortViewport ? 'thinbar' : 'locked'), + footer: prefs.footer ?? (shortViewport ? 'autohide' : 'shown'), + breadcrumbs: prefs.breadcrumbs, + feedback: prefs.feedback + }; +} + +export function readPrefs(): DisplayPrefs { + return { + header: getHeaderPref(), + footer: getFooterPref(), + breadcrumbs: getBreadcrumbsPref(), + feedback: getFeedbackPref() + }; +} + +export function isShortViewport(): boolean { + try { + return typeof matchMedia === 'function' + && matchMedia(`(max-height: ${SHORT_VIEWPORT_MAX_HEIGHT}px)`).matches; + } catch { return false; } +} + +export function applyDisplayChrome(doc: Document = document): void { + const eff = computeEffective(readPrefs(), isShortViewport()); + const html = doc.documentElement; + html.setAttribute('data-tut-header', eff.header); + html.setAttribute('data-tut-footer', eff.footer); + html.setAttribute('data-tut-breadcrumbs', eff.breadcrumbs); + html.setAttribute('data-tut-feedback', eff.feedback); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run --project unit hugo-apps/src/tutorial-prefs/display-chrome.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add hugo-apps/src/tutorial-prefs/display-chrome.ts hugo-apps/src/tutorial-prefs/display-chrome.test.ts +git commit -m "feat(display-prefs): compute + apply effective display-chrome attributes (#1966)" +``` + +--- + +### Task 4: display-chrome — auto-hide handlers + live re-apply + +**Files:** +- Modify: `hugo-apps/src/tutorial-prefs/display-chrome.ts` +- Test: `hugo-apps/src/tutorial-prefs/display-chrome.test.ts` + +**Interfaces:** +- Consumes: `applyDisplayChrome`, `readPrefs`, `isShortViewport`, `computeEffective` (Task 3). +- Produces: + - `installAutoHide(doc?: Document): () => void` — attaches: (a) a `scroll` listener that toggles `data-tut-header-hidden` on `` when the effective header is `autohide` (hidden on scroll-down past a small threshold, shown on scroll-up or near top); (b) a `matchMedia('(max-height:900px)')` `change` listener that calls `applyDisplayChrome()`. Returns a teardown fn that removes all listeners. Idempotent-safe to call once from `init()`. + - Footer reveal is CSS `:hover` on a bottom hotspot (Task 6 CSS) — no JS needed beyond the attribute already set by `applyDisplayChrome`. `installAutoHide` does not manage footer hover. + +- [ ] **Step 1: Write the failing test** + +Append to `hugo-apps/src/tutorial-prefs/display-chrome.test.ts`: + +```ts +import { installAutoHide } from './display-chrome'; + +describe('installAutoHide (#1966)', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute('data-tut-header-hidden'); + }); + + it('returns a teardown function and does not throw', () => { + localStorage.setItem('tut.pref.header', 'autohide'); + applyDisplayChrome(document); + const teardown = installAutoHide(document); + expect(typeof teardown).toBe('function'); + // Simulate a downward scroll; header-hidden should be set for autohide mode. + Object.defineProperty(window, 'scrollY', { value: 400, configurable: true }); + window.dispatchEvent(new Event('scroll')); + expect(document.documentElement.getAttribute('data-tut-header-hidden')).toBe(''); + // Scroll back to top → shown. + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + window.dispatchEvent(new Event('scroll')); + expect(document.documentElement.hasAttribute('data-tut-header-hidden')).toBe(false); + teardown(); + }); + + it('does not hide the header when effective header is not autohide', () => { + // no pref, tall viewport → locked + applyDisplayChrome(document); + const teardown = installAutoHide(document); + Object.defineProperty(window, 'scrollY', { value: 400, configurable: true }); + window.dispatchEvent(new Event('scroll')); + expect(document.documentElement.hasAttribute('data-tut-header-hidden')).toBe(false); + teardown(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run --project unit hugo-apps/src/tutorial-prefs/display-chrome.test.ts` +Expected: FAIL — `installAutoHide` not exported. + +- [ ] **Step 3: Implement** + +Append to `hugo-apps/src/tutorial-prefs/display-chrome.ts`: + +```ts +export function installAutoHide(doc: Document = document): () => void { + const html = doc.documentElement; + let lastY = typeof window !== 'undefined' ? window.scrollY : 0; + const HIDE_AFTER = 80; // px scrolled before hiding + + const onScroll = () => { + if (html.getAttribute('data-tut-header') !== 'autohide') { + html.removeAttribute('data-tut-header-hidden'); + return; + } + const y = window.scrollY; + if (y <= HIDE_AFTER) { + html.removeAttribute('data-tut-header-hidden'); // near top → always show + } else if (y > lastY) { + html.setAttribute('data-tut-header-hidden', ''); // scrolling down → hide + } else if (y < lastY) { + html.removeAttribute('data-tut-header-hidden'); // scrolling up → show + } + lastY = y; + }; + + const mql = (typeof matchMedia === 'function') + ? matchMedia(`(max-height: ${SHORT_VIEWPORT_MAX_HEIGHT}px)`) : null; + const onMedia = () => applyDisplayChrome(doc); + + window.addEventListener('scroll', onScroll, { passive: true }); + mql?.addEventListener?.('change', onMedia); + + return () => { + window.removeEventListener('scroll', onScroll); + mql?.removeEventListener?.('change', onMedia); + }; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run --project unit hugo-apps/src/tutorial-prefs/display-chrome.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add hugo-apps/src/tutorial-prefs/display-chrome.ts hugo-apps/src/tutorial-prefs/display-chrome.test.ts +git commit -m "feat(display-prefs): auto-hide header scroll handler + live threshold re-apply (#1966)" +``` + +--- + +### Task 5: Popover "Display" section + main.ts wiring + +**Files:** +- Modify: `hugo-apps/src/tutorial-prefs/TutorialPrefsPopover.vue` +- Modify: `hugo-apps/src/tutorial-prefs/main.ts` + +**Interfaces:** +- Consumes: `readPrefs`, `applyDisplayChrome`, `installAutoHide` (Tasks 3-4); `setHeaderPref`, `setFooterPref`, `setBreadcrumbsPref`, `setFeedbackPref` (Task 2); `HeaderMode`/`FooterMode`/`OnOff` (Task 1); `PAGE_KIND_TUTORIAL` (existing). +- Produces (popover props): `headerMode: HeaderMode`, `footerAutohide: boolean`, `breadcrumbsOn: boolean`, `feedbackOn: boolean`. Emits: `('set-header', HeaderMode)`, `('toggle-footer')`, `('toggle-breadcrumbs')`, `('toggle-feedback')`. + +- [ ] **Step 1: Add the Display section to `TutorialPrefsPopover.vue`** + +In the template, inside `