diff --git a/approuter/xs-app.json b/approuter/xs-app.json index 0ed2cb08a..c1128a70a 100644 --- a/approuter/xs-app.json +++ b/approuter/xs-app.json @@ -371,6 +371,7 @@ "destination": "srv-api", "authenticationType": "xsuaa" }, + { "source": "^/search/?(\\?.*)?$", "target": "/tutorial-navigator/$1", "status": 301, "authenticationType": "none" }, { "source": "^/search/(.*)$", "target": "/search/$1", @@ -396,7 +397,7 @@ "authenticationType": "none" }, { - "source": "^/build/(breadcrumb-context|catalog|co-completions|concepts|homepage-shelves|kg-stats|mission|my-progress|navigator|repo-catalog|slug-mapping|tag-labels|topics-gallery)(/.*)?(\\?.*)?$", + "source": "^/build/(breadcrumb-context|catalog|co-completions|concepts|homepage-shelves|kg-stats|mission|my-progress|navigator|repo-catalog|slug-mapping|tag-labels|topics-gallery|topics-tree|topics)(/.*)?(\\?.*)?$", "target": "/build/$1$2$3", "destination": "srv-api", "authenticationType": "none" @@ -582,7 +583,8 @@ "cacheControl": "public, max-age=3600" }, { "source": "^/browse/?(\\?.*)?$", "target": "/content/pages/browse/$1", "destination": "srv-api", "authenticationType": "none" }, - { "source": "^/topics/?(\\?.*)?$", "target": "/content/pages/topics/$1", "destination": "srv-api", "authenticationType": "none" }, + { "source": "^/topics/?(\\?.*)?$", "target": "/content/topics-index$1", "destination": "srv-api", "authenticationType": "none" }, + { "source": "^/topics/(.*)$", "target": "/content/topics/$1", "destination": "srv-api", "authenticationType": "none" }, { "source": "^/tutorial-navigator/?(\\?.*)?$", "target": "/content/pages/tutorial-navigator/$1", "destination": "srv-api", "authenticationType": "none" }, { "source": "^/developer-advocates/?(\\?.*)?$", "target": "/content/pages/developer-advocates/$1", "destination": "srv-api", "authenticationType": "none" }, { "source": "^/developer-advocates/([^/?]+)/?(\\?.*)?$", "target": "/content/developer-advocates/$1", "destination": "srv-api", "authenticationType": "none" }, diff --git a/docs/superpowers/plans/2026-09-01-tag-tree-topics.md b/docs/superpowers/plans/2026-09-01-tag-tree-topics.md new file mode 100644 index 000000000..6856c3ec4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-tag-tree-topics.md @@ -0,0 +1,1371 @@ +# Tag-Tree Topics 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:** Rebuild `/topics/` as a first-class CAP-served subsystem with the same technical quality as `/concepts/` — stable SAP-tag-hierarchy slugs, a server-rendered tree index + progressive island, deterministically-populated concepts, working search, and dynamic per-slug HANA-blob serving. + +**Architecture:** A topic = a live SAP tag (a tag applied to ≥1 tutorial). Two JSON build feeds (`/build/topics-tree`, `/build/topics/:slug`) assemble data directly from `Tags`/`TutorialTags`/`TutorialConceptLinks`. A CAP index renderer (`topic-list-page.js` → `/content/topics-index`) and detail renderer (`topic-detail-render.js`, published as `topic-` HANA blobs) mirror the concepts subsystem exactly, wrapped in the shared chrome shell. The approuter gains `^/topics/(.*)$` + `^/topics/?$` dynamic routes and a `/search/` → `/tutorial-navigator/` redirect. Legacy `topics_gallery`/Hugo topics section is retired. + +**Tech Stack:** SAP CAP (Node.js, `@sap/cds`), Express handlers, Hugo, Vue 3 island (`hugo-apps/`, Vite), HANA content blobs (gzip), AppRouter (`xs-app.json`), Vitest (`cds.test`). + +**Spec:** `docs/superpowers/specs/2026-09-01-tag-tree-topics-design.md` + +## Global Constraints + +- **PRs target `DEV`; `main` is protected — no direct-to-main path.** Branch off `origin/DEV`. This worktree is already rebased onto `origin/DEV`. +- **Never write raw SQL** — use `cds.ql`/CQL. Exception: reading HANA BLOBs alongside metadata (use raw `db.run()` per the BLOB-locator rule); topic payload queries touch no BLOBs, so CQL applies throughout the query layer. +- **Never SELECT a HANA BLOB alongside metadata in one CDS QL query.** The publish/serve blob paths reuse the existing concepts blob helpers (`composeShell`, session helpers) which already obey this. +- **Resolve entities via `cds.entities(NS)`** with `NS = 'com.sap.developers.ims'`, not bare `SELECT.from('X')` (CI Node 22 vs local Node 24 drift). +- **HANA stores columns UPPERCASE**; junction FK columns are `tutorial_ID`, `tag_ID`, `concept_ID`. Query via entity refs + CQL (CAP maps casing), never hand-cased raw SQL here. +- **`srv/lib/*` reachable from `content-store.js` must be in `.deploy/mta.yaml`'s `srv-qa` `cp` list**, with transitive `./` imports re-walked (Task 12). +- **Never `publish-content` from a workstation** — topic blobs publish via `gh workflow run rebuild-content.yml`. Local verification uses `/build/topics/:slug` JSON + hybrid tests. +- **Fail-open everywhere:** empty `TutorialConceptLinks`/`Tags` → empty payload + `error` field, never a 500. Mirrors `build-topics-gallery.js` posture. +- **Tutorial slugs are lowercase canonical** — `.toLowerCase()` tutorial slugs before emitting hrefs. +- **Test bootstrap:** `cds.test('serve', …, '--in-memory')` — NOT `cds.deploy(cds.model)` (broken in unit tests). +- **Pre-commit for any `db/**/*.cds` change:** `npx cds deploy --to sqlite::memory:`. (This plan adds NO new CDS entities — all sources already exist.) +- **Address the user as Tom.** + +--- + +## File Structure + +**New files:** +- `srv/lib/topic-slug.js` — pure slug flatten / collision-qualify / legacy-normalize utilities. +- `srv/lib/topics-query.js` — data assemblers: `buildTopicsTreePayload(db)`, `buildTopicDetailPayload(db, slug)`, `resolveTopicBySlug`. +- `srv/lib/build-topics.js` — Express JSON feed handlers `buildTopicsTreeHandler`, `buildTopicDetailHandler` (mirrors `build-concepts.js`). +- `srv/lib/topic-list-page.js` — CAP index renderer + `topicsIndexHandler` (mirrors `concept-list-page.js`). +- `srv/lib/topic-detail-render.js` — `renderTopicDetail(topic)` → `{body, contentHash}` (mirrors `concept-detail-render.js`). +- `srv/lib/publish-topics.js` — `renderTopicsIntoSession(...)` blob publisher (mirrors `publish-concepts.js`). +- `hugo-apps/src/topics-tree/main.ts` + `App.vue` — progressive-enhancement island. +- `test/unit/topic-slug.test.js`, `test/unit/topics-query.test.js`, `test/unit/topic-list-page.test.js` — unit tests. +- `test/hybrid/topics-publish-serve.test.js` — hybrid round-trip. +- `test/e2e/topics.spec.ts` — post-deploy e2e. + +**Modified files:** +- `srv/server.js` — register 4 routes. +- `srv/lib/page-key-map.js` — add `TOPIC_KEY_PREFIX` + `discoverTopicPages`. +- `scripts/publish-content.ts` — call `discoverTopicPages` in the non-slug publish path. +- `approuter/xs-app.json` — topics detail/index routes, `topics-tree` build allow-list, `/search/` redirect. +- `hugo/layouts/topics/list.html` — repoint search form (interim, before section retirement). +- `.deploy/mta.yaml` — `srv-qa` `cp` list. +- `hugo-apps/vite.config.ts` — register `topics-tree` island entry. +- **Deletions (Task 11):** `hugo/content/topics/btp-basics.md`, `hugo/content/topics/cap-fundamentals.md`, `hugo/layouts/topics/list.html`, `hugo/layouts/topics/single.html`, `hugo/data/topics_gallery.json`, `scripts/fetch-topics-gallery.ts`, and orphaned tests referencing them. + +--- + +## Task 1: Pure slug utilities (`srv/lib/topic-slug.js`) + +**Files:** +- Create: `srv/lib/topic-slug.js` +- Test: `test/unit/topic-slug.test.js` + +**Interfaces:** +- Produces: + - `flattenTopicSlug(value: string): string` — `'sap-hana-cloud--data-lake'` → `'sap-hana-cloud-data-lake'`. + - `buildTopicSlugMap(liveTags: Array<{titlePath, label, tutorialCount, conceptCount}>): { bySlug: Map, byTag: Map }` — deterministic collision-qualify. + - `normalizeLegacyTopicSlug(slug: string): string` — strips a single trailing `-` disambiguator. + - `parseTitlePath(titlePath: string): { facet: string, value: string, segments: string[] }`. + - `Tag` shape: `{ titlePath, facet, value, segments, slug, label, tutorialCount, conceptCount }`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/topic-slug.test.js +import { describe, it, expect } from 'vitest'; +import { + flattenTopicSlug, parseTitlePath, buildTopicSlugMap, normalizeLegacyTopicSlug, +} from '../../srv/lib/topic-slug.js'; + +describe('flattenTopicSlug', () => { + it('collapses -- and lowercases', () => { + expect(flattenTopicSlug('sap-hana-cloud--data-lake')).toBe('sap-hana-cloud-data-lake'); + expect(flattenTopicSlug('SAP-HANA-Cloud')).toBe('sap-hana-cloud'); + }); +}); + +describe('parseTitlePath', () => { + it('splits facet, value and -- segments', () => { + expect(parseTitlePath('software-product-function>sap-hana-cloud--data-lake')).toEqual({ + facet: 'software-product-function', + value: 'sap-hana-cloud--data-lake', + segments: ['sap-hana-cloud', 'data-lake'], + }); + }); +}); + +describe('buildTopicSlugMap', () => { + it('qualifies collisions with facet, first-by-titlePath wins bare', () => { + const { bySlug } = buildTopicSlugMap([ + { titlePath: 'software-product>foo-bar', label: 'A' }, + { titlePath: 'topic>foo--bar', label: 'B' }, // also flattens to foo-bar + ]); + expect(bySlug.has('foo-bar')).toBe(true); // software-product wins (sorts first) + expect(bySlug.get('foo-bar').label).toBe('A'); + expect(bySlug.has('topic-foo-bar')).toBe(true); // loser facet-qualified + expect(bySlug.get('topic-foo-bar').label).toBe('B'); + }); +}); + +describe('normalizeLegacyTopicSlug', () => { + it('strips a trailing numeric disambiguator', () => { + expect(normalizeLegacyTopicSlug('sap-hana-smart-data-streaming-development-2')) + .toBe('sap-hana-smart-data-streaming-development'); + expect(normalizeLegacyTopicSlug('sap-hana-cloud')).toBe('sap-hana-cloud'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/topic-slug.test.js --project unit` +Expected: FAIL — module not found / functions undefined. + +- [ ] **Step 3: Write minimal implementation** + +```js +// srv/lib/topic-slug.js +export function flattenTopicSlug(value) { + return String(value).replace(/--/g, '-').toLowerCase(); +} + +export function parseTitlePath(titlePath) { + const idx = String(titlePath).indexOf('>'); + const facet = idx === -1 ? '' : titlePath.slice(0, idx); + const value = idx === -1 ? titlePath : titlePath.slice(idx + 1); + return { facet, value, segments: value.split('--') }; +} + +// Deterministic: sort by titlePath, first occupant of a slug keeps it bare; +// later collisions are facet-qualified `-`. +export function buildTopicSlugMap(liveTags) { + const bySlug = new Map(); + const byTag = new Map(); + const sorted = [...liveTags].sort((a, b) => a.titlePath.localeCompare(b.titlePath)); + for (const raw of sorted) { + const { facet, value, segments } = parseTitlePath(raw.titlePath); + const base = flattenTopicSlug(value); + let slug = base; + if (bySlug.has(slug)) slug = `${facet}-${base}`; + // If even the qualified slug collides, suffix an index (defensive; asserted-rare). + let n = 2; + while (bySlug.has(slug)) slug = `${facet}-${base}-${n++}`; + const tag = { + titlePath: raw.titlePath, facet, value, segments, slug, + label: raw.label || segments[segments.length - 1], + tutorialCount: raw.tutorialCount ?? 0, + conceptCount: raw.conceptCount ?? 0, + }; + bySlug.set(slug, tag); + byTag.set(raw.titlePath, slug); + } + return { bySlug, byTag }; +} + +export function normalizeLegacyTopicSlug(slug) { + return String(slug).replace(/-\d+$/, ''); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/topic-slug.test.js --project unit` +Expected: PASS (4 suites). + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/topic-slug.js test/unit/topic-slug.test.js +git commit -m "feat(topics): pure slug flatten + collision-qualify + legacy-normalize" +``` + +--- + +## Task 2: Data assemblers (`srv/lib/topics-query.js`) + +**Files:** +- Create: `srv/lib/topics-query.js` +- Test: `test/unit/topics-query.test.js` + +**Interfaces:** +- Consumes: `flattenTopicSlug`, `parseTitlePath`, `buildTopicSlugMap`, `normalizeLegacyTopicSlug` (Task 1). +- Produces: + - `loadLiveTags(db): Promise` — tags applied to ≥1 tutorial, with `tutorialCount` + `conceptCount`. + - `buildTopicsTreePayload(db): Promise<{ tree, buildAt, error }>` where `tree = [{ facet, label, children: TreeNode[] }]`, `TreeNode = { segment, label, slug?, tutorialCount?, conceptCount?, children: TreeNode[] }`. + - `resolveTopicBySlug(db, slug): Promise<{ tag: Tag|null, redirectTo: string|null }>` — legacy `-N` strip + facet-qualify resolution. + - `buildTopicDetailPayload(db, slug): Promise<{ slug, label, facet, tutorials, concepts, relatedTags, buildAt, error, notFound?, redirectTo? }>`. + - `tutorials[]` = `{ slug, title, level, time, href, isNew }`; `concepts[]` = `{ slug, name, rank }`; `relatedTags[]` = `{ slug, label }`. + +**Reference to mirror:** tag→tutorial join pattern `srv/lib/kg-projection.js:1027-1038`; unbounded-fetch-filter-in-Node HANA pattern `srv/lib/published-concepts-query.js:78-92`; teaches-link join `published-concepts-query.js:95-104`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/topics-query.test.js +import { describe, it, beforeAll, expect } from 'vitest'; +import cds from '@sap/cds'; +import { + loadLiveTags, buildTopicsTreePayload, resolveTopicBySlug, buildTopicDetailPayload, +} from '../../srv/lib/topics-query.js'; + +const NS = 'com.sap.developers.ims'; + +describe('topics-query', () => { + let db; + beforeAll(async () => { + await cds.test('serve', '--in-memory', '--project', process.cwd()); + db = await cds.connect.to('db'); + const { Tutorials, Tags, TutorialTags, TutorialConceptLinks, Concepts } = cds.entities(NS); + await db.run(INSERT.into(Tags).entries([ + { ID: 't1', titlePath: 'software-product>sap-hana-cloud', label: 'SAP HANA Cloud', name: 'sap-hana-cloud' }, + { ID: 't2', titlePath: 'software-product-function>sap-hana-cloud--data-lake', label: 'Data Lake', name: 'sap-hana-cloud--data-lake' }, + ])); + await db.run(INSERT.into(Tutorials).entries([ + { ID: 'tut1', slug: 'hana-intro', title: 'HANA Intro', experienceTag: 'Beginner' }, + ])); + await db.run(INSERT.into(TutorialTags).entries([ + { tutorial_ID: 'tut1', tag_ID: 't1' }, + ])); + await db.run(INSERT.into(Concepts).entries([ + { ID: 'c1', slug: 'in-memory-database', name: 'In-Memory Database', status: 'ACTIVE', publishedAt: new Date().toISOString() }, + ])); + await db.run(INSERT.into(TutorialConceptLinks).entries([ + { ID: 'l1', tutorial_ID: 'tut1', concept_ID: 'c1', predicate: 'teaches' }, + ])); + }); + + it('loadLiveTags returns only tags with ≥1 tutorial, with counts', async () => { + const live = await loadLiveTags(db); + const slugs = live.map(t => t.slug).sort(); + expect(slugs).toContain('sap-hana-cloud'); + expect(slugs).not.toContain('sap-hana-cloud-data-lake'); // t2 has no tutorial + const hana = live.find(t => t.slug === 'sap-hana-cloud'); + expect(hana.tutorialCount).toBe(1); + expect(hana.conceptCount).toBe(1); + }); + + it('buildTopicsTreePayload groups by facet', async () => { + const { tree, error } = await buildTopicsTreePayload(db); + expect(error).toBeFalsy(); + const facet = tree.find(f => f.facet === 'software-product'); + expect(facet.children.some(n => n.slug === 'sap-hana-cloud')).toBe(true); + }); + + it('buildTopicDetailPayload returns tutorials + concepts', async () => { + const p = await buildTopicDetailPayload(db, 'sap-hana-cloud'); + expect(p.notFound).toBeFalsy(); + expect(p.tutorials.map(t => t.slug)).toContain('hana-intro'); + expect(p.concepts.map(c => c.slug)).toContain('in-memory-database'); + }); + + it('resolveTopicBySlug strips legacy -N and redirects', async () => { + const r = await resolveTopicBySlug(db, 'sap-hana-cloud-2'); + expect(r.tag?.slug).toBe('sap-hana-cloud'); + expect(r.redirectTo).toBe('/topics/sap-hana-cloud/'); + }); + + it('unknown slug is notFound with redirect to /topics/', async () => { + const p = await buildTopicDetailPayload(db, 'does-not-exist'); + expect(p.notFound).toBe(true); + expect(p.redirectTo).toBe('/topics/'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/topics-query.test.js --project unit` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```js +// srv/lib/topics-query.js +import cds from '@sap/cds'; +import { buildTopicSlugMap, parseTitlePath, normalizeLegacyTopicSlug } from './topic-slug.js'; + +const NS = 'com.sap.developers.ims'; +const MAX_TUTORIALS = 60; +const MAX_CONCEPTS = 24; + +function ent() { + const { Tags, TutorialTags, Tutorials, TutorialConceptLinks, Concepts, ConceptRank } = cds.entities(NS); + return { Tags, TutorialTags, Tutorials, TutorialConceptLinks, Concepts, ConceptRank }; +} + +function humanizeFacet(facet) { + return String(facet).split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); +} + +// Live-tag set (applied to ≥1 tutorial) with per-tag tutorial + concept counts. +export async function loadLiveTags(db) { + const { Tags, TutorialTags, TutorialConceptLinks } = ent(); + const tags = await db.run(SELECT.from(Tags).columns('ID', 'titlePath', 'label', 'name')); + const tagById = new Map(tags.map(t => [t.ID, t])); + const links = await db.run(SELECT.from(TutorialTags).columns('tutorial_ID', 'tag_ID')); + + const tutorialIdsByTag = new Map(); // tag_ID -> Set(tutorial_ID) + for (const l of links) { + if (!tagById.has(l.tag_ID)) continue; + (tutorialIdsByTag.get(l.tag_ID) ?? tutorialIdsByTag.set(l.tag_ID, new Set()).get(l.tag_ID)).add(l.tutorial_ID); + } + + // Bulk teaches-links → tutorial_ID -> Set(concept_ID); unbounded fetch, filter in Node. + const teaches = await db.run( + SELECT.from(TutorialConceptLinks).columns('tutorial_ID', 'concept_ID').where({ predicate: 'teaches' }), + ); + const conceptsByTutorial = new Map(); + for (const t of teaches) { + if (!t.concept_ID) continue; + (conceptsByTutorial.get(t.tutorial_ID) ?? conceptsByTutorial.set(t.tutorial_ID, new Set()).get(t.tutorial_ID)).add(t.concept_ID); + } + + const liveRaw = []; + for (const [tagId, tutSet] of tutorialIdsByTag) { + const tag = tagById.get(tagId); + if (!tag?.titlePath) continue; + const conceptSet = new Set(); + for (const tutId of tutSet) for (const c of (conceptsByTutorial.get(tutId) ?? [])) conceptSet.add(c); + liveRaw.push({ titlePath: tag.titlePath, label: tag.label, tutorialCount: tutSet.size, conceptCount: conceptSet.size }); + } + const { bySlug } = buildTopicSlugMap(liveRaw); + return [...bySlug.values()]; +} + +export async function buildTopicsTreePayload(db) { + try { + const live = await loadLiveTags(db); + const facets = new Map(); // facet -> node + for (const tag of live) { + if (!facets.has(tag.facet)) facets.set(tag.facet, { facet: tag.facet, label: humanizeFacet(tag.facet), children: [] }); + const facetNode = facets.get(tag.facet); + let level = facetNode.children; + for (let i = 0; i < tag.segments.length; i++) { + const seg = tag.segments[i]; + let node = level.find(n => n.segment === seg); + if (!node) { node = { segment: seg, label: seg, children: [] }; level.push(node); } + if (i === tag.segments.length - 1) { + node.slug = tag.slug; + node.label = tag.label || seg; + node.tutorialCount = tag.tutorialCount; + node.conceptCount = tag.conceptCount; + } + level = node.children; + } + } + const sortRec = (nodes) => { + nodes.sort((a, b) => a.label.localeCompare(b.label)); + for (const n of nodes) sortRec(n.children); + }; + const tree = [...facets.values()].sort((a, b) => a.label.localeCompare(b.label)); + for (const f of tree) sortRec(f.children); + return { tree, buildAt: new Date().toISOString(), error: null }; + } catch (err) { + return { tree: [], buildAt: new Date().toISOString(), error: err.message }; + } +} + +export async function resolveTopicBySlug(db, slug) { + const live = await loadLiveTags(db); + const bySlug = new Map(live.map(t => [t.slug, t])); + if (bySlug.has(slug)) return { tag: bySlug.get(slug), redirectTo: null }; + const base = normalizeLegacyTopicSlug(slug); + if (base !== slug && bySlug.has(base)) return { tag: bySlug.get(base), redirectTo: `/topics/${base}/` }; + return { tag: null, redirectTo: '/topics/' }; +} + +export async function buildTopicDetailPayload(db, slug) { + try { + const { tag, redirectTo } = await resolveTopicBySlug(db, slug); + if (!tag) return { slug, notFound: true, redirectTo, tutorials: [], concepts: [], relatedTags: [], buildAt: new Date().toISOString(), error: null }; + if (redirectTo) return { slug: tag.slug, notFound: false, redirectTo, tutorials: [], concepts: [], relatedTags: [], buildAt: new Date().toISOString(), error: null }; + + const { Tags, TutorialTags, Tutorials, TutorialConceptLinks, Concepts, ConceptRank } = ent(); + + // tutorials carrying this tag + const tagRow = await db.run(SELECT.one.from(Tags).columns('ID').where({ titlePath: tag.titlePath })); + const ttRows = tagRow ? await db.run(SELECT.from(TutorialTags).columns('tutorial_ID').where({ tag_ID: tagRow.ID })) : []; + const tutIds = new Set(ttRows.map(r => r.tutorial_ID)); + const allTuts = await db.run(SELECT.from(Tutorials).columns('ID', 'slug', 'title', 'experienceTag', 'timeToComplete', 'isNew')); + const tutorials = allTuts + .filter(t => tutIds.has(t.ID)) + .map(t => ({ + slug: String(t.slug || '').toLowerCase(), + title: t.title, + level: t.experienceTag || null, + time: t.timeToComplete || null, + href: `/tutorials/${String(t.slug || '').toLowerCase()}/`, + isNew: !!t.isNew, + })) + .sort((a, b) => a.title.localeCompare(b.title)) + .slice(0, MAX_TUTORIALS); + + // concepts taught by those tutorials (unbounded fetch + Node filter) + const teaches = await db.run(SELECT.from(TutorialConceptLinks).columns('tutorial_ID', 'concept_ID').where({ predicate: 'teaches' })); + const conceptIds = new Set(teaches.filter(l => tutIds.has(l.tutorial_ID) && l.concept_ID).map(l => l.concept_ID)); + const allConcepts = await db.run(SELECT.from(Concepts).columns('ID', 'slug', 'name').where({ status: 'ACTIVE' })); + const rankRows = await db.run(SELECT.from(ConceptRank).columns('slug', 'score')).catch(() => []); + const rankBySlug = new Map(rankRows.map(r => [r.slug, r.score])); + const concepts = allConcepts + .filter(c => conceptIds.has(c.ID)) + .map(c => ({ slug: c.slug, name: c.name, rank: rankBySlug.get(c.slug) ?? 0 })) + .sort((a, b) => b.rank - a.rank || a.name.localeCompare(b.name)) + .slice(0, MAX_CONCEPTS); + + // related tags = same-facet siblings sharing the parent segment + const parent = tag.segments.slice(0, -1); + const relatedTags = live + .filter(t => t.slug !== tag.slug && t.facet === tag.facet) + .filter(t => parent.length === 0 || parent.every((seg, i) => t.segments[i] === seg)) + .map(t => ({ slug: t.slug, label: t.label })) + .sort((a, b) => a.label.localeCompare(b.label)) + .slice(0, 24); + + return { + slug: tag.slug, label: tag.label, facet: tag.facet, + tutorials, concepts, relatedTags, + buildAt: new Date().toISOString(), error: null, + }; + } catch (err) { + return { slug, tutorials: [], concepts: [], relatedTags: [], buildAt: new Date().toISOString(), error: err.message }; + } +} +``` + +> **NOTE for implementer:** verify the real column names on `Tutorials` before running — the recon confirmed `slug`, `title`, `experienceTag`, `stepCount`, `isNew`, `primaryTag`. Confirm the "time to complete" column name via `cds.entities(NS).Tutorials` (candidates: `timeToComplete`, `time`, `estimatedTime`); adjust the `.columns(...)` and mapping to the actual name. If absent, drop `time` from the projection. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/topics-query.test.js --project unit` +Expected: PASS (5 cases). Fix column-name mismatches surfaced here. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/topics-query.js test/unit/topics-query.test.js +git commit -m "feat(topics): tree + detail data assemblers (tag-sourced, concept-enriched, fail-open)" +``` + +--- + +## Task 3: JSON build feed handlers (`srv/lib/build-topics.js`) + +**Files:** +- Create: `srv/lib/build-topics.js` + +**Interfaces:** +- Consumes: `buildTopicsTreePayload`, `buildTopicDetailPayload` (Task 2). +- Produces: `buildTopicsTreeHandler(req, res)`, `buildTopicDetailHandler(req, res)` — Express handlers. + +**Reference to mirror:** `srv/lib/build-concepts.js:11-22`. + +- [ ] **Step 1: Write the implementation** + +```js +// srv/lib/build-topics.js +import cds from '@sap/cds'; +import { buildTopicsTreePayload, buildTopicDetailPayload } from './topics-query.js'; + +export async function buildTopicsTreeHandler(req, res) { + const db = await cds.connect.to('db'); + const payload = await buildTopicsTreePayload(db); + res.set('Cache-Control', 'public, max-age=60'); + res.json(payload); +} + +export async function buildTopicDetailHandler(req, res) { + const db = await cds.connect.to('db'); + const slug = String(req.params.slug || '').toLowerCase(); + const payload = await buildTopicDetailPayload(db, slug); + res.set('Cache-Control', 'public, max-age=60'); + res.status(payload.notFound ? 404 : 200).json(payload); +} +``` + +- [ ] **Step 2: Commit** (registration + live verification happen in Task 8) + +```bash +git add srv/lib/build-topics.js +git commit -m "feat(topics): /build/topics-tree + /build/topics/:slug JSON feed handlers" +``` + +--- + +## Task 4: CAP index renderer (`srv/lib/topic-list-page.js`) + +**Files:** +- Create: `srv/lib/topic-list-page.js` +- Test: `test/unit/topic-list-page.test.js` + +**Interfaces:** +- Consumes: `buildTopicsTreePayload` (Task 2); `chrome-shell.js` exports `createShellLoader`, `composeShell`, `ShellMarkerError`; `edge-cache-headers.js` `setContentCacheHeaders`; `island-manifest.json`. +- Produces: + - `buildTopicListModel(db, deps = {}): Promise<{ tree, version }>`. + - `renderTopicListBody(model): string` — BODY fragment: inline ``; + +function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} +function jsonForScript(obj) { + return JSON.stringify(obj).replace(/${node.tutorialCount ?? 0} tutorials · ${node.conceptCount ?? 0} concepts` + : ''; + const label = node.slug + ? `${esc(node.label)}${count}` + : `${esc(node.label)}`; + if (node.children && node.children.length) { + return `
  • ${label}
      ${node.children.map(renderNode).join('')}
  • `; + } + return `
  • ${label}
  • `; +} + +export function renderTopicListBody(model) { + const facets = (model.tree || []).map(f => + `
  • ${esc(f.label)}
      ${f.children.map(renderNode).join('')}
  • `, + ).join(''); + const data = jsonForScript({ tree: model.tree || [] }); + return `${TOPICS_STYLE} + + +`; +} + +export async function buildTopicListModel(db, _deps = {}) { + const payload = await buildTopicsTreePayload(db); + return { tree: payload.tree || [], version: null }; +} + +export function createTopicListPage({ namespace = DEFAULT_NAMESPACE } = {}) { + const NS = namespace; + const { ContentManifest } = cds.entities(NS); + async function getActiveVersion(db) { + const row = await db.run(SELECT.one.from(ContentManifest).columns('version').where({ status: 'ACTIVE' })).catch(() => null); + return row?.version ?? null; + } + const shellLoader = createShellLoader({ + namespace: NS, hanaTableName: HANA_TABLE, hanaCurrentTableName: HANA_CURRENT_TABLE, + getActiveVersion: () => cds.connect.to('db').then(getActiveVersion), + }); + let cache = null; // { version, gz, etag } + + async function topicsIndexHandler(req, res) { + try { + const db = await cds.connect.to('db'); + const version = await getActiveVersion(db); + if (cache && cache.version === version) { + if (req.headers['if-none-match'] === cache.etag) { res.status(304).end(); return; } + setContentCacheHeaders(res, { slug: 'topics' }); + res.set('Content-Encoding', 'gzip').set('ETag', cache.etag).set('X-Content-Source', 'db-current').type('html').send(cache.gz); + return; + } + const model = await buildTopicListModel(db); + model.version = version; + const body = renderTopicListBody(model); + const meta = { kind: 'topics-index', slug: 'topics', title: 'Explore topics', description: 'Browse SAP developer topics by product hierarchy.' }; + const shell = await shellLoader.get(); + if (!shell) throw new ShellMarkerError('shell unavailable'); + const html = composeShell(shell, body, meta); + const gz = gzipSync(Buffer.from(html, 'utf8')); + const etag = `"${createHash('sha256').update(gz).digest('hex').slice(0, 32)}"`; + cache = { version, gz, etag }; + if (req.headers['if-none-match'] === etag) { res.status(304).end(); return; } + setContentCacheHeaders(res, { slug: 'topics' }); + res.set('Content-Encoding', 'gzip').set('ETag', etag).set('X-Content-Source', 'db-current').type('html').send(gz); + } catch (err) { + if (cache) { + res.set('Content-Encoding', 'gzip').set('X-Content-Source', 'db-stale').type('html').send(cache.gz); + return; + } + res.status(503).type('text/plain').send('topics index unavailable'); + } + } + return { topicsIndexHandler, _invalidate() { cache = null; shellLoader.invalidate?.(); } }; +} + +export const { topicsIndexHandler } = createTopicListPage(); +export default topicsIndexHandler; +``` + +> **NOTE for implementer:** the cache/etag/shell-loader boilerplate above is transcribed from `concept-list-page.js:239-334`. **Diff your version against that source** and adopt any details you missed (exact `setContentCacheHeaders` args, `getActiveVersion` source table, `ContentManifest` query shape). Do NOT invent behavior the concepts handler doesn't have. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/topic-list-page.test.js --project unit` +Expected: PASS (2 cases). + +- [ ] **Step 5: Add chrome-shell cases + verify concepts untouched** + +Edit `srv/lib/chrome-shell.js`: in `canonicalUrlFor` (kind switch ~`:75-89`) add `case 'topics-index': return '/topics/';` and `case 'topic': return \`/topics/${meta.slug}/\`;`. Add the identical two cases to `buildBreadcrumbJsonLd` (~`:99-137`). + +- [ ] **Step 6: Commit** + +```bash +git add srv/lib/topic-list-page.js srv/lib/chrome-shell.js test/unit/topic-list-page.test.js +git commit -m "feat(topics): CAP-rendered tree index (topic-list-page) + chrome-shell topic canonical/breadcrumb" +``` + +--- + +## Task 5: CAP detail renderer (`srv/lib/topic-detail-render.js`) + +**Files:** +- Create: `srv/lib/topic-detail-render.js` +- Test: extend `test/unit/topics-query.test.js` (add a render block) or new `test/unit/topic-detail-render.test.js`. + +**Interfaces:** +- Consumes: topic detail payload shape from Task 2. +- Produces: `renderTopicDetail(topic): { body: string, contentHash: string }` — `body` supplies its own `
    ` (the shell has only the marker). `topic` = `{ slug, label, facet, tutorials, concepts, relatedTags }`. + +**Reference to mirror:** `srv/lib/concept-detail-render.js:51` (`renderConceptDetail` → `{body, contentHash}`, `body = \`
    ${…}
    \``, `contentHash = sha256(body)`, throws if key fields missing). + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/topic-detail-render.test.js +import { describe, it, expect } from 'vitest'; +import { renderTopicDetail } from '../../srv/lib/topic-detail-render.js'; + +describe('renderTopicDetail', () => { + const topic = { + slug: 'sap-hana-cloud', label: 'SAP HANA Cloud', facet: 'software-product', + tutorials: [{ slug: 'hana-intro', title: 'HANA Intro', level: 'Beginner', time: 15, href: '/tutorials/hana-intro/', isNew: true }], + concepts: [{ slug: 'in-memory-database', name: 'In-Memory Database', rank: 0.9 }], + relatedTags: [{ slug: 'sap-hana-cloud-data-lake', label: 'Data Lake' }], + }; + it('renders a
    body with breadcrumb, tutorials, concepts, related tags', () => { + const { body, contentHash } = renderTopicDetail(topic); + expect(body.startsWith('
    ')).toBe(true); + expect(body.endsWith('
    ')).toBe(true); + expect(body).toContain('SAP HANA Cloud'); + expect(body).toContain('href="/tutorials/hana-intro/"'); + expect(body).toContain('href="/concepts/in-memory-database/"'); + expect(body).toContain('href="/topics/sap-hana-cloud-data-lake/"'); + expect(contentHash).toMatch(/^[a-f0-9]{64}$/); + }); + it('throws when slug or label missing', () => { + expect(() => renderTopicDetail({ slug: '', label: 'x' })).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/topic-detail-render.test.js --project unit` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```js +// srv/lib/topic-detail-render.js +import { createHash } from 'node:crypto'; + +function esc(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export function renderTopicDetail(topic) { + if (!topic?.slug || !topic?.label) throw new Error('renderTopicDetail: slug and label required'); + const tutorials = (topic.tutorials || []).map(t => ` +
  • + ${esc(t.title)} + ${t.isNew ? 'NEW' : ''} + ${t.level ? `${esc(t.level)}` : ''} +
  • `).join(''); + const concepts = (topic.concepts || []).map(c => ` +
  • ${esc(c.name)}
  • `).join(''); + const related = (topic.relatedTags || []).map(r => ` + `).join(''); + + const conceptsSection = concepts + ? `
    +

    Concepts in this topic

    +
      ${concepts}
    +
    ` + : ''; + const relatedSection = related + ? `` + : ''; + + const body = `
    +
    + +
    +

    ${esc(topic.label)}

    +

    ${esc(topic.facet)}

    +
    +
    +

    Tutorials

    +
      ${tutorials || '
    • No tutorials yet.
    • '}
    +
    + ${conceptsSection} + ${relatedSection} +
    +
    `; + const contentHash = createHash('sha256').update(body).digest('hex'); + return { body, contentHash }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/topic-detail-render.test.js --project unit` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/topic-detail-render.js test/unit/topic-detail-render.test.js +git commit -m "feat(topics): topic detail body renderer" +``` + +--- + +## Task 6: Blob publisher (`srv/lib/publish-topics.js`) + +**Files:** +- Create: `srv/lib/publish-topics.js` + +**Interfaces:** +- Consumes: `loadLiveTags`, `buildTopicDetailPayload` (Task 2); `renderTopicDetail` (Task 5); `composeShell` (chrome-shell). +- Produces: `renderTopicsIntoSession({ db, sessionId, helpers, priorHashes = {}, shell, deps = {} }): Promise<{ topicsSeen, topicsChanged, topicsSkipped, topicsErrored, durationMs }>`. Key = `topic-${slug}`. Meta = `{ kind: 'topic', slug, title: label, description }`. + +**Reference to mirror:** `srv/lib/publish-concepts.js:78-171` — per-item loop, `composeShell` wrap, `sha256(fullDoc)`, delta-skip on `priorHashes[key] === contentHash`, `gzipSync(fullDoc).toString('base64')`, `MAX_ERROR_RATE = 0.05` abort, `BATCH_SIZE = 20` append via `helpers.appendToSession`, `loadPriorTopicHashes` filtering `slug.startsWith('topic-')`. + +- [ ] **Step 1: Write the implementation** + +```js +// srv/lib/publish-topics.js +import { gzipSync } from 'node:zlib'; +import { createHash } from 'node:crypto'; +import { loadLiveTags, buildTopicDetailPayload } from './topics-query.js'; +import { renderTopicDetail } from './topic-detail-render.js'; +import { composeShell } from './chrome-shell.js'; + +const BATCH_SIZE = 20; +const MAX_ERROR_RATE = 0.05; +const META_DESC_MAX = 160; + +function topicMetaDescription(topic) { + const n = topic.tutorials?.length ?? 0; + const c = topic.concepts?.length ?? 0; + return `${topic.label}: ${n} tutorials and ${c} concepts on developers.sap.com.`.slice(0, META_DESC_MAX); +} + +export async function renderTopicsIntoSession({ db, sessionId, helpers, priorHashes = {}, shell, deps = {} }) { + const start = Date.now(); + const live = deps.loadLiveTags ? await deps.loadLiveTags(db) : await loadLiveTags(db); + let seen = 0, changed = 0, skipped = 0, errored = 0; + let batch = []; + const flush = async () => { + if (!batch.length) return; + await helpers.appendToSession({ sessionId, files: Object.fromEntries(batch) }); + batch = []; + }; + for (const tag of live) { + seen++; + try { + const topic = await buildTopicDetailPayload(db, tag.slug); + if (topic.notFound || topic.error) { errored++; continue; } + const { body } = renderTopicDetail(topic); + const meta = { kind: 'topic', slug: topic.slug, title: topic.label, description: topicMetaDescription(topic) }; + const fullDoc = composeShell(shell, body, meta); + const key = `topic-${topic.slug}`; + const contentHash = createHash('sha256').update(fullDoc).digest('hex'); + if (priorHashes[key] === contentHash) { skipped++; continue; } + batch.push([key, gzipSync(Buffer.from(fullDoc, 'utf8')).toString('base64')]); + changed++; + if (batch.length >= BATCH_SIZE) await flush(); + } catch { + errored++; + if (seen > 20 && errored / seen > MAX_ERROR_RATE) throw new Error('renderTopicsIntoSession: error rate exceeded'); + } + } + await flush(); + return { topicsSeen: seen, topicsChanged: changed, topicsSkipped: skipped, topicsErrored: errored, durationMs: Date.now() - start }; +} +``` + +> **NOTE for implementer:** wiring `renderTopicsIntoSession` into the actual publish orchestration (the POST `/content/publish/render-*` chain and `loadPriorConceptHashes` equivalent) mirrors `publish-concepts.js:159-194`. Locate where `renderConceptsIntoSession` is invoked during a publish run and register a sibling `renderTopicsIntoSession` call in the same place, loading prior hashes filtered by `slug.startsWith('topic-')`. Confirm the `shell`/`helpers`/`sessionId` objects passed to concepts are reused verbatim. + +- [ ] **Step 2: Commit** + +```bash +git add srv/lib/publish-topics.js +git commit -m "feat(topics): per-slug HANA blob publisher (renderTopicsIntoSession)" +``` + +--- + +## Task 7: Dynamic-slug discovery in publish (`page-key-map.js` + `publish-content.ts`) + +**Files:** +- Modify: `srv/lib/page-key-map.js` (add `TOPIC_KEY_PREFIX`, `isTopicKey`, `discoverTopicPages`) +- Modify: `scripts/publish-content.ts:~1058-1071` (call site) +- Test: extend `test/unit` page-key-map coverage if present; else add `test/unit/page-key-map-topics.test.js`. + +**Interfaces:** +- Produces: `TOPIC_KEY_PREFIX = 'topic-'`; `isTopicKey(key): boolean`; `discoverTopicPages(hugoDir): Map`. + +> **DESIGN NOTE:** topic detail blobs are produced server-side by `renderTopicsIntoSession` (Task 6), NOT from Hugo files on disk — there is no `hugo/.../topic-/index.html`. So `discoverTopicPages` is only relevant if topics are published as file-backed blobs. Since topics are **server-rendered at publish time**, the canonical publish path is the `renderTopicsIntoSession` server call (Task 6), invoked in the publish orchestration alongside `renderConceptsIntoSession`. **Confirm which mechanism the concepts subsystem actually uses at publish time** (recon shows concepts use the *server-side* `renderConceptsIntoSession`, not `discoverPageFiles`). If concepts are server-rendered, SKIP the `discoverTopicPages` file-walker entirely and rely solely on Task 6's server call — this task then reduces to registering that call. + +- [ ] **Step 1: Determine the actual publish mechanism** + +Read `scripts/publish-content.ts` around the concepts publish step and `srv/lib/publish-concepts.js:159-194`. Decide: +- **(A) Server-rendered** (expected): concepts blobs are generated by a POST to `/content/publish/render-concepts`. → Register a sibling topics render call; **do not** add `discoverTopicPages`. Skip Steps 2-4 below. +- **(B) File-backed**: concepts index/detail come from disk via `discoverPageFiles`. → Add `discoverTopicPages` (Steps 2-4). + +- [ ] **Step 2 (only if B): Add discovery helper** + +```js +// srv/lib/page-key-map.js — near AUTHOR/ADVOCATE prefixes +export const TOPIC_KEY_PREFIX = 'topic-'; +export const isTopicKey = (key) => key.startsWith(TOPIC_KEY_PREFIX); + +export function discoverTopicPages(hugoDir) { + const out = new Map(); + const base = path.join(hugoDir, 'topics'); + let entries = []; + try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (!e.isDirectory()) continue; + const slug = e.name; + if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) continue; + const idx = path.join(base, slug, 'index.html'); + if (fs.existsSync(idx)) out.set(`${TOPIC_KEY_PREFIX}${slug}`, idx); + } + return out; +} +``` + +- [ ] **Step 3 (only if B): Wire the call site** in `scripts/publish-content.ts` next to the author/advocate discovery block: + +```ts +const topics = discoverTopicPages(opts.hugoDir); +for (const [key, absPath] of topics) tutorials.set(key, absPath); +``` + +- [ ] **Step 4 (only if B): Test + commit.** + +- [ ] **Step 5 (path A): Register the server render call** + +Add the `renderTopicsIntoSession` invocation to the publish orchestration alongside the concepts call (mirror `publish-concepts.js`'s `createRenderConcepts`/handler registration; expose `POST /content/publish/render-topics` in `srv/server.js` if concepts has an analogous route). Load prior hashes filtering `slug.startsWith('topic-')`. + +- [ ] **Step 6: Commit** + +```bash +git add srv/lib/page-key-map.js scripts/publish-content.ts srv/server.js +git commit -m "feat(topics): register topic-blob publish step in content publish path" +``` + +--- + +## Task 8: Register CAP routes (`srv/server.js`) + +**Files:** +- Modify: `srv/server.js` + +**Interfaces:** +- Consumes: `buildTopicsTreeHandler`, `buildTopicDetailHandler` (Task 3); `topicsIndexHandler`, `createTopicListPage` (Task 4); `buildTopicDetailPayload`, `resolveTopicBySlug` (Task 2); `renderTopicDetail` (Task 5); shell loader + serve helpers already used by `/content/concepts/:slug`. + +**Reference to mirror:** imports `server.js:15-37`; `/build/*` registrations `:296-306`; `/content/concepts/:slug` wrapper `:503-528`; `/content/concepts-index` `:533`. + +- [ ] **Step 1: Add imports** (top of `srv/server.js`, with sibling imports) + +```js +import { buildTopicsTreeHandler, buildTopicDetailHandler } from './lib/build-topics.js'; +import { topicsIndexHandler } from './lib/topic-list-page.js'; +import { resolveTopicBySlug, buildTopicDetailPayload } from './lib/topics-query.js'; +import { renderTopicDetail } from './lib/topic-detail-render.js'; +``` + +- [ ] **Step 2: Register build feeds** (next to `:298-300`) + +```js +app.get('/build/topics-tree', buildTopicsTreeHandler); +app.get('/build/topics/:slug', buildTopicDetailHandler); +``` + +- [ ] **Step 3: Register content index** (next to `:533`) + +```js +app.get('/content/topics-index', topicsIndexHandler); +``` + +- [ ] **Step 4: Register content detail** — serves the published `topic-` blob, with legacy/retired redirect fallthrough. Mirror the `/content/concepts/:slug` wrapper (`:503-528`) that rewrites `req.params.slug = \`concept-${lower}\`` then delegates to `serveHandler`. For topics, resolve legacy/retired slugs to a 301 before the blob lookup: + +```js +app.get('/content/topics/:slug', async (req, res, next) => { + let raw = String(req.params.slug || '').replace(/\.html$/, ''); + const lower = raw.toLowerCase(); + if (raw !== lower) { res.redirect(301, `/topics/${lower}/`); return; } + // legacy -N / retired resolution + try { + const db = await cds.connect.to('db'); + const { tag, redirectTo } = await resolveTopicBySlug(db, lower); + if (!tag && redirectTo) { res.redirect(301, redirectTo); return; } + if (tag && redirectTo) { res.redirect(301, redirectTo); return; } + } catch { /* fail-open to blob lookup */ } + // delegate to the shared blob serve handler with topic- key prefix (same as concepts) + req.params.slug = `topic-${lower}`; + return serveHandler(req, res, next); +}); +``` + +> **NOTE for implementer:** `serveHandler` is the same content-store serve function `/content/concepts/:slug` delegates to (`server.js:526-527`). Confirm its exact name/signature at that line and match it. If a served `topic-` blob is missing (never published), `serveHandler` will 404 — acceptable; the resolve step above catches *known-live-but-unpublished* only insofar as it doesn't redirect them. For a fully fail-open detail path, when `serveHandler` would 404 AND the slug resolves to a live tag, fall back to on-the-fly render: +> ```js +> // optional fallback inside a wrapper around serveHandler's 404 +> const payload = await buildTopicDetailPayload(db, lower); +> if (!payload.notFound) { /* renderTopicDetail + composeShell + send */ } +> ``` +> Decide based on whether concepts has an equivalent live-render fallback; prefer parity. + +- [ ] **Step 5: Verify build feeds live** + +```bash +cds watch & # or npm run dev:hybrid for real HANA +sleep 8 +curl -s http://localhost:4004/build/topics-tree | jq '.tree | length, .error' +curl -s http://localhost:4004/build/topics/sap-hana-cloud | jq '{tutorials: (.tutorials|length), concepts: (.concepts|length), error}' +``` +Expected: tree length ≥ 1, `error: null`; detail returns tutorials/concepts arrays (may be empty in a bare in-memory DB — verify against seeded/hybrid data). + +- [ ] **Step 6: Commit** + +```bash +git add srv/server.js +git commit -m "feat(topics): register /build/topics-tree, /build/topics/:slug, /content/topics-index, /content/topics/:slug" +``` + +--- + +## Task 9: Approuter routes + search redirect (`approuter/xs-app.json`) + +**Files:** +- Modify: `approuter/xs-app.json` + +**Reference:** concepts index route `:546-550`, detail route `:552-556`, `/build/*` allow-list `:399`, current `/topics/` route `:585`, `/search/` OData route `:375-376`, catch-all `:605`. + +- [ ] **Step 1: Add `topics-tree` to the `/build/*` allow-list alternation** (`:399`) — insert `|topics-tree` into the group (keep `topics-gallery` until Task 11 retires it, or replace it): + +```json +{ "source": "^/build/(breadcrumb-context|catalog|co-completions|concepts|homepage-shelves|kg-stats|mission|my-progress|navigator|repo-catalog|slug-mapping|tag-labels|topics-gallery|topics-tree|topics)(/.*)?(\\?.*)?$", "target": "/build/$1$2$3", "destination": "srv-api", "authenticationType": "none" } +``` + +> `topics` (bare) covers `/build/topics/:slug`; `topics-tree` covers the index feed. Ensure `topics` does not shadow `topics-tree` — regex alternation is ordered but both are matched as whole path segments by the `(/.*)?` boundary, so list `topics-tree` before `topics`. + +- [ ] **Step 2: Replace the `page-topics` index route** (`:585`) with the dynamic index route, and add the detail route immediately after. Both MUST precede the catch-all `^(.*)$` (`:605`): + +```json +{ "source": "^/topics/?(\\?.*)?$", "target": "/content/topics-index$1", "destination": "srv-api", "authenticationType": "none" }, +{ "source": "^/topics/(.*)$", "target": "/content/topics/$1", "destination": "srv-api", "authenticationType": "none" } +``` + +- [ ] **Step 3: Add the `/search/` → `/tutorial-navigator/` redirect, scoped to bare `/search/` + query only** so it does NOT shadow the OData `SearchService` routes (`^/search/(.*)$` at `:375-376`). Place this redirect BEFORE the OData `/search/` route: + +```json +{ "source": "^/search/?(\\?.*)?$", "target": "/tutorial-navigator/$1", "status": 301, "authenticationType": "none" } +``` + +> Verify `/search/SearchableItems` etc. still route to `srv-api` (the `^/search/(.*)$` route must remain and must be reached for non-empty paths). Test both after deploy. + +- [ ] **Step 4: Validate JSON** + +Run: `jq . approuter/xs-app.json > /dev/null && echo OK` +Expected: `OK` (no parse error). + +- [ ] **Step 5: Commit** + +```bash +git add approuter/xs-app.json +git commit -m "feat(topics): approuter dynamic /topics routes + /search 301 to navigator" +``` + +--- + +## Task 10: Progressive-enhancement island (`hugo-apps/src/topics-tree/`) + +**Files:** +- Create: `hugo-apps/src/topics-tree/main.ts`, `hugo-apps/src/topics-tree/App.vue` +- Modify: `hugo-apps/vite.config.ts:~316` (add entry) + +**Interfaces:** +- Consumes: server-embedded ` - - diff --git a/hugo-apps/src/topics-map/ClusterMap.vue b/hugo-apps/src/topics-map/ClusterMap.vue deleted file mode 100644 index 6fd8fde27..000000000 --- a/hugo-apps/src/topics-map/ClusterMap.vue +++ /dev/null @@ -1,278 +0,0 @@ - - - - - diff --git a/hugo-apps/src/topics-map/main.ts b/hugo-apps/src/topics-map/main.ts deleted file mode 100644 index d4e09aef5..000000000 --- a/hugo-apps/src/topics-map/main.ts +++ /dev/null @@ -1,18 +0,0 @@ -// hugo-apps/src/topics-map/main.ts -// -// Mount-on-discovery for the Topics Cluster Map island. -// Vite emits this file as hugo/static/js/topics-map.js (configured in -// hugo-apps/vite.config.ts). The topics layouts emit mount points: -// -//
    (list.html — full gallery map) -//
    (single.html — mini-map) -// -// Optional data-focus-cluster attribute causes the island to auto-expand -// the named cluster on mount (used by the cluster-detail mini-map). - -import { createApp } from 'vue'; -import App from './App.vue'; - -document.querySelectorAll('[data-vue-island="topics-map"]').forEach((el) => { - createApp(App, { focusCluster: el.getAttribute('data-focus-cluster') || '' }).mount(el); -}); diff --git a/hugo-apps/src/topics-tree/main.ts b/hugo-apps/src/topics-tree/main.ts new file mode 100644 index 000000000..059ce5f50 --- /dev/null +++ b/hugo-apps/src/topics-tree/main.ts @@ -0,0 +1,70 @@ +// hugo-apps/src/topics-tree/main.ts +// +// Progressive enhancement over the server-rendered
    /
      tree that +// renderTopicListBody() (srv/lib/topic-list-page.js) emits under +//
      . +// Adds: type-ahead filter and hash deep-link (#topic=). +// Inert until JS loads; the server markup is fully functional without it. + +function boot(): void { + const root = document.getElementById('topics-tree-root'); + const input = document.getElementById('topics-filter-input') as HTMLInputElement | null; + if (!root || !input) return; + + const allDetails = Array.from(root.querySelectorAll('details')); + // Capture the initial open state so we can restore on query clear. + const initialOpen = new Map(allDetails.map(d => [d, d.open])); + + function applyFilter(q: string): void { + if (!q) { + // Restore all li visibility and original details open states. + root.querySelectorAll('li').forEach(li => { li.style.display = ''; }); + allDetails.forEach(d => { d.open = initialOpen.get(d) ?? false; }); + return; + } + + // Hide every li and close every details; then show matching leaves + // and walk up to reveal their ancestors. + root.querySelectorAll('li').forEach(li => { li.style.display = 'none'; }); + allDetails.forEach(d => { d.open = false; }); + + root.querySelectorAll('li').forEach(li => { + // Only process leaf nodes (those without a direct nested
      ). + if (li.querySelector(':scope > details')) return; + const text = (li.textContent ?? '').toLowerCase(); + if (!text.includes(q)) return; + // Match: reveal this leaf and walk up to show all ancestor li + open ancestor details. + li.style.display = ''; + let el: Element | null = li.parentElement; + while (el && el !== root) { + if (el.tagName === 'LI') (el as HTMLElement).style.display = ''; + if (el.tagName === 'DETAILS') (el as HTMLDetailsElement).open = true; + el = el.parentElement; + } + }); + } + + input.addEventListener('input', () => { + applyFilter(input.value.trim().toLowerCase()); + }); + + // Deep-link: #topic= opens the matching node's ancestor details + scrolls into view. + const m = location.hash.match(/topic=([a-zA-Z0-9-]+)/); + if (m) { + const link = root.querySelector(`a[href="/topics/${m[1]}/"]`); + if (link) { + let d: HTMLDetailsElement | null = link.closest('details'); + while (d) { + d.open = true; + d = d.parentElement?.closest('details') ?? null; + } + link.scrollIntoView({ block: 'center' }); + } + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); +} else { + boot(); +} diff --git a/hugo-apps/vite.config.ts b/hugo-apps/vite.config.ts index 22b08a76d..4d95ce18f 100644 --- a/hugo-apps/vite.config.ts +++ b/hugo-apps/vite.config.ts @@ -12,7 +12,6 @@ const MAX_ADVOCATES_GZIP = 30 * 1024; const MAX_PUZZLE_GZIP = 30 * 1024; const MAX_ADVOCATE_PROFILE_GZIP = 25 * 1024; const MAX_RELATED_GRAPH_GZIP = 12 * 1024; -const MAX_TOPICS_MAP_GZIP = 150 * 1024; const MAX_ALERTS_GZIP = 12 * 1024; const MAX_HOMEPAGE_EXPLAINERS_GZIP = 12 * 1024; const MAX_HOMEPAGE_PERSONALIZER_GZIP = 12 * 1024; @@ -216,24 +215,6 @@ function relatedGraphBudget() { }; } -function topicsMapBudget() { - return { - name: 'topics-map-budget', - generateBundle(_opts: unknown, bundle: Record) { - const chunk = Object.values(bundle).find((c: any) => c.type === 'chunk' && c.name === 'topics-map'); - if (!chunk) return; - const gz = gzipSync(chunk.code).length; - if (gz > MAX_TOPICS_MAP_GZIP) { - // @ts-ignore — Rollup plugin context - this.error(`topics-map.js is ${gz} bytes gzipped (> ${MAX_TOPICS_MAP_GZIP}). Move code to a lazy chunk.`); - } else { - // @ts-ignore - this.warn(`topics-map.js: ${gz} bytes gzipped (budget ${MAX_TOPICS_MAP_GZIP}).`); - } - } - }; -} - function petoberfestBudget() { return { name: 'petoberfest-budget', @@ -253,7 +234,7 @@ function petoberfestBudget() { } export default defineConfig({ - plugins: [vue(), cssInjectedByJsPlugin({ relativeCSSInjection: true }), tutorialPrefsBudget(), codeCheckBudget(), validationBudget(), tutorialBranchesBudget(), advocatesBudget(), puzzleBudget(), relatedGraphBudget(), alertsBudget(), homepageExplainersBudget(), advocateProfileBudget(), homepagePersonalizerBudget(), petoberfestBudget(), topicsMapBudget()], + plugins: [vue(), cssInjectedByJsPlugin({ relativeCSSInjection: true }), tutorialPrefsBudget(), codeCheckBudget(), validationBudget(), tutorialBranchesBudget(), advocatesBudget(), puzzleBudget(), relatedGraphBudget(), alertsBudget(), homepageExplainersBudget(), advocateProfileBudget(), homepagePersonalizerBudget(), petoberfestBudget()], // Approuter serves these bundles at /js/. Without `base`, Vite emits // dynamic-import paths as `./chunks/x.js` which the browser resolves // against the *document URL* (e.g. `/` → `/chunks/x.js` → 404). Setting @@ -314,7 +295,7 @@ export default defineConfig({ 'homepage-bands': resolve(__dirname, 'src/homepage-bands/index.ts'), 'kg-stats-counter': resolve(__dirname, 'src/kg-stats-counter/main.ts'), 'concepts-filter': resolve(__dirname, 'src/concepts-filter/main.ts'), - 'topics-map': resolve(__dirname, 'src/topics-map/main.ts'), + 'topics-tree': resolve(__dirname, 'src/topics-tree/main.ts'), 'homepage-explainers': resolve(__dirname, 'src/homepage-explainers/index.ts'), 'homepage-personalizer': resolve(__dirname, 'src/homepage-personalizer/index.ts'), 'featured-topics-carousel': resolve(__dirname, 'src/featured-topics-carousel/main.ts'), diff --git a/hugo/assets/css/topics.css b/hugo/assets/css/topics.css index 88fedb2d8..27693b25c 100644 --- a/hugo/assets/css/topics.css +++ b/hugo/assets/css/topics.css @@ -339,3 +339,112 @@ margin-top: 2rem; min-height: 0; } + +/* ======================================================== + Topic detail page (srv/lib/topic-detail-render.js) + .topic-* classes (singular, no 's') — distinct from the + retired .topics-detail/* cluster-detail classes above. + ======================================================== */ +.topic-detail { + max-width: 800px; + margin: 0 auto; + padding: 0 1rem 3rem; +} + +/* Breadcrumb */ +.topic-breadcrumb { + padding: 1.25rem 0 0; +} +.topic-breadcrumb__list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + list-style: none; + padding: 0; + margin: 0; + font-size: 0.85rem; + color: var(--sapContent_LabelColor, #6a6d70); +} +.topic-breadcrumb__list li:not(:last-child)::after { + content: '/'; + margin-left: 0.5rem; + color: var(--sapContent_LabelColor, #9ea0a2); +} +.topic-breadcrumb__list a { + color: var(--sapLinkColor, #0064d9); + text-decoration: none; +} +.topic-breadcrumb__list a:hover { text-decoration: underline; } + +/* Header */ +.topic-detail__header { + padding: 1.5rem 0 1rem; +} +.topic-detail__title { + font-size: 1.75rem; + font-weight: 700; + color: var(--sapTextColor, #32363a); + margin: 0 0 0.5rem; +} +.topic-detail__facet { + font-size: 0.875rem; + color: var(--sapContent_LabelColor, #6a6d70); + margin: 0; + text-transform: capitalize; +} + +/* Sections (tutorials, concepts, related) */ +.topic-tutorials, +.topic-concepts, +.topic-related { + margin: 1.75rem 0; + padding-top: 1.25rem; + border-top: 1px solid var(--sapContent_ForegroundBorderColor, #e5e5e5); +} +.topic-tutorials h2, +.topic-concepts h2, +.topic-related h2 { + font-size: 1.15rem; + font-weight: 600; + color: var(--sapTextColor, #32363a); + margin: 0 0 0.75rem; +} +.topic-tutorials__list, +.topic-concepts__list, +.topic-related__list { + list-style: none; + padding: 0; + margin: 0; +} +.topic-tutorials__item, +.topic-concepts__item, +.topic-related__item { + padding: 0.3rem 0; +} +.topic-tutorials__link, +.topic-concepts__item a, +.topic-related__item a { + color: var(--sapLinkColor, #0064d9); + text-decoration: none; +} +.topic-tutorials__link:hover, +.topic-concepts__item a:hover, +.topic-related__item a:hover { text-decoration: underline; } +.topic-tutorials__new { + font-size: 0.7rem; + font-weight: 600; + color: var(--sapPositiveTextColor, #107e3e); + background: var(--sapSuccessBackground, #f1fdf6); + padding: 0.1rem 0.4rem; + border-radius: 3px; + margin-left: 0.35rem; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.topic-tutorials__level { + font-size: 0.78rem; + color: var(--sapContent_LabelColor, #6a6d70); + margin-left: 0.35rem; +} diff --git a/hugo/content/topics/_index.md b/hugo/content/topics/_index.md deleted file mode 100644 index 022c81fad..000000000 --- a/hugo/content/topics/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Topics" -type: topics -layout: list -description: "Explore SAP developer topics. Browse clusters of related concepts and follow a suggested learning path." ---- diff --git a/hugo/content/topics/btp-basics.md b/hugo/content/topics/btp-basics.md deleted file mode 100644 index 2474fcad3..000000000 --- a/hugo/content/topics/btp-basics.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "BTP Basics" -type: topics -layout: single -cluster: "btp-basics" ---- diff --git a/hugo/content/topics/cap-fundamentals.md b/hugo/content/topics/cap-fundamentals.md deleted file mode 100644 index fbab98f68..000000000 --- a/hugo/content/topics/cap-fundamentals.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "CAP Fundamentals" -type: topics -layout: single -cluster: "cap-fundamentals" ---- diff --git a/hugo/data/topics_gallery.json b/hugo/data/topics_gallery.json deleted file mode 100644 index 0a9745758..000000000 --- a/hugo/data/topics_gallery.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "gallery": [ - { - "slug": "cap-fundamentals", - "label": "CAP Fundamentals", - "rationale": "Core concepts for building cloud-native apps with SAP CAP — service definitions, data models, event handlers.", - "memberCount": 12, - "tutorialCount": 8, - "topConcepts": [ - { "slug": "core-data-services-cds", "name": "CDS Data Model" }, - { "slug": "cap-application-development", "name": "CAP Service" }, - { "slug": "odata-v4-service", "name": "OData V4" }, - { "slug": "cap-for-nodejs", "name": "Node.js Runtime" } - ] - }, - { - "slug": "btp-basics", - "label": "BTP Basics", - "rationale": "Getting started with SAP Business Technology Platform — subaccounts, runtimes, and service bindings.", - "memberCount": 9, - "tutorialCount": 6, - "topConcepts": [ - { "slug": "btp-account-model", "name": "Subaccount" }, - { "slug": "cloud-foundry-account-model", "name": "Cloud Foundry" }, - { "slug": "cf-service-bindings", "name": "Service Binding" } - ] - } - ], - "clusters": { - "cap-fundamentals": { - "slug": "cap-fundamentals", - "label": "CAP Fundamentals", - "rationale": "Core concepts for building cloud-native apps with SAP CAP — service definitions, data models, event handlers.", - "memberCount": 12, - "tutorialCount": 8, - "orderMode": "path", - "concepts": [ - { "slug": "core-data-services-cds", "name": "CDS Data Model" }, - { "slug": "cap-application-development", "name": "CAP Service" }, - { "slug": "odata-v4-service", "name": "OData V4" }, - { "slug": "cap-for-nodejs", "name": "Node.js Runtime" }, - { "slug": "cds-annotations", "name": "CDS Annotations" } - ], - "peers": [ - { "slug": "btp-basics", "label": "BTP Basics", "weight": 0.72 } - ] - }, - "btp-basics": { - "slug": "btp-basics", - "label": "BTP Basics", - "rationale": "Getting started with SAP Business Technology Platform — subaccounts, runtimes, and service bindings.", - "memberCount": 9, - "tutorialCount": 6, - "orderMode": "ranked", - "concepts": [ - { "slug": "btp-account-model", "name": "Subaccount" }, - { "slug": "cloud-foundry-account-model", "name": "Cloud Foundry" }, - { "slug": "cf-service-bindings", "name": "Service Binding" }, - { "slug": "xsuaa-authentication", "name": "XSUAA" } - ], - "peers": [ - { "slug": "cap-fundamentals", "label": "CAP Fundamentals", "weight": 0.72 } - ] - } - }, - "buildAt": "2026-08-09T15:00:00.000Z", - "error": null -} diff --git a/hugo/layouts/topics/list.html b/hugo/layouts/topics/list.html deleted file mode 100644 index 02cec0cc0..000000000 --- a/hugo/layouts/topics/list.html +++ /dev/null @@ -1,79 +0,0 @@ -{{ define "main" }} -{{- /* - topics/list.html — Topics gallery page (/topics/). - Renders a card grid from site.Data.topics_gallery.gallery. - Progressive enhancement: map island mounts into #topics-map when - /js/topics-map.js is present (built in Task 9). Safe if absent. - - Data shape (hugo/data/topics_gallery.json): - { gallery: [{slug, label, rationale, memberCount, tutorialCount, - topConcepts:[{slug,name}]}], - clusters: {slug: {...}}, buildAt, error } -*/ -}} -{{- $g := site.Data.topics_gallery -}} -{{- $css := resources.Get "css/topics.css" | fingerprint -}} -{{ if $css }}{{ end }} - -
      - -
      -
      -

      Explore topics

      -

      Browse curated clusters of related SAP developer concepts and follow a suggested learning path through each topic.

      - - -
      -
      - - {{ if and $g (gt (len $g.gallery) 0) }} - - {{ else }} -
      -

      Topic clusters are being prepared. Check back shortly, or search tutorials directly.

      -
      - {{ end }} - - {{- /* Map island mount point. Populated by /js/topics-map.js (built by hugo-apps, - always present after npm run build:apps). Mount is inert until JS loads, - so no empty-state guard is needed here — same posture as concepts-filter.js. */ -}} - - - -
      -{{ end }} diff --git a/hugo/layouts/topics/single.html b/hugo/layouts/topics/single.html deleted file mode 100644 index cec21785e..000000000 --- a/hugo/layouts/topics/single.html +++ /dev/null @@ -1,108 +0,0 @@ -{{ define "main" }} -{{- /* - topics/single.html — Cluster detail page (/topics//). - Reads cluster data from site.Data.topics_gallery.clusters keyed by - .Params.cluster (set in the per-cluster content stub written by - scripts/fetch-topics-gallery.ts). - - Data shape for a cluster entry: - { slug, label, rationale, memberCount, tutorialCount, - orderMode: 'path'|'ranked', - concepts: [{slug, name}], - peers: [{slug, label, weight}] } -*/ -}} -{{- $c := index site.Data.topics_gallery.clusters .Params.cluster -}} -{{- $css := resources.Get "css/topics.css" | fingerprint -}} -{{ if $css }}{{ end }} - -{{ if $c }} - -
      - - - -
      -

      {{ $c.label }}

      - {{- with $c.rationale }} -

      {{ . }}

      - {{- end }} -
      - {{ $c.memberCount }} concepts · {{ $c.tutorialCount }} tutorials -
      -
      - - {{- /* Suggested order / concept list — switches on orderMode */ -}} - {{ with $c.concepts }} -
      - {{ if eq $c.orderMode "path" }} -

      A suggested order through this topic

      -
        - {{- range . -}} -
      1. - {{ .name }} -
      2. - {{- end -}} -
      - {{ else }} -

      Concepts in this topic

      - - {{ end }} -
      - {{ end }} - - {{- /* Peer clusters section */ -}} - {{ with $c.peers }} -
      -

      Topics that connect to this one

      - -
      - {{ end }} - - {{- /* Optional mini-map island: progressively enhanced. - data-focus-cluster is read by the topics-map island to - highlight the current cluster in the graph. */ -}} - - - -
      - -{{ else }} -
      - -

      Topic not found

      -

      The cluster data for this topic was not available at build time. Browse all topics.

      -
      -{{ end }} - -{{ end }} diff --git a/package.json b/package.json index 22221db2d..835766644 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "fetch-shelf-definitions": "tsx scripts/fetch-shelf-definitions.ts", "fetch-featured-topics": "tsx scripts/fetch-featured-topics.ts", "fetch-topic-clusters": "tsx scripts/fetch-topic-clusters.ts", - "fetch-topics-gallery": "tsx scripts/fetch-topics-gallery.ts", "fetch-tutorials:hugo": "tsx scripts/fetch-tutorials.ts --target hugo", "fetch-advocates": "tsx scripts/fetch-advocates.ts", "seed-ai-quizzes": "cross-env AI_AUTHOR_BUILD_CAP=10000 npm run fetch-tutorials", @@ -88,7 +87,7 @@ "build:display": "cd app/display-app && npm install && npm run build", "copy-joule-vendor": "node scripts/copy-joule-vendor.mjs", "check-deploy-cap-target": "node scripts/check-deploy-cap-target.cjs", - "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-tags && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run fetch-topics-gallery && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl", + "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-tags && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl", "build:deploy": "npm run check-deploy-cap-target && npm run build:all", "deploy": "node scripts/deploy-mta.cjs", "build:admin": "npm --prefix app/admin-shell run build", diff --git a/scripts/fetch-topics-gallery.ts b/scripts/fetch-topics-gallery.ts deleted file mode 100644 index cb413442a..000000000 --- a/scripts/fetch-topics-gallery.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'; -import { join, dirname } from 'node:path'; - -const OUT = join('hugo', 'data', 'topics_gallery.json'); -const CONTENT_DIR = join('hugo', 'content', 'topics'); -const INDEX_STUB = '_index.md'; - -export async function writeTopicsGallery(capBase = process.env.CAP_BASE_URL || 'http://localhost:4004') { - let payload: any; - try { - const res = await fetch(`${capBase}/build/topics-gallery`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - payload = await res.json(); - } catch (err) { - console.warn(`[fetch-topics-gallery] fail-open: ${(err as Error).message}`); - payload = { gallery: [], clusters: {}, buildAt: new Date().toISOString(), error: 'fetch_failed' }; - } - mkdirSync(dirname(OUT), { recursive: true }); - writeFileSync(OUT, JSON.stringify(payload, null, 2)); - console.log(`[fetch-topics-gallery] wrote ${payload.gallery?.length ?? 0} cards -> ${OUT}`); - - // Write per-cluster content stubs (Hugo needs a .md per cluster to bake detail pages) - mkdirSync(CONTENT_DIR, { recursive: true }); - - // Remove stale stubs (all *.md except _index.md) - try { - const existing = readdirSync(CONTENT_DIR); - for (const f of existing) { - if (f.endsWith('.md') && f !== INDEX_STUB) { - unlinkSync(join(CONTENT_DIR, f)); - } - } - } catch { - // CONTENT_DIR may not exist yet on very first run — mkdirSync above ensures it - } - - // Write one stub per cluster - const clusters = payload.clusters ?? {}; - for (const slug of Object.keys(clusters)) { - const cluster = clusters[slug]; - const label = (cluster.label ?? slug).replace(/"/g, '\\"'); - const stub = [ - '---', - `title: "${label}"`, - `type: topics`, - `layout: single`, - `cluster: "${slug}"`, - '---', - '', - ].join('\n'); - writeFileSync(join(CONTENT_DIR, `${slug}.md`), stub); - } - console.log(`[fetch-topics-gallery] wrote ${Object.keys(clusters).length} cluster stubs -> ${CONTENT_DIR}/`); -} - -// CLI entry (tsx scripts/fetch-topics-gallery.ts) -if (import.meta.url === `file://${process.argv[1]}`) { - writeTopicsGallery().catch(e => { console.error(e); process.exit(1); }); -} diff --git a/scripts/lib/publish-client.ts b/scripts/lib/publish-client.ts index 121e60855..a1e5e053e 100644 --- a/scripts/lib/publish-client.ts +++ b/scripts/lib/publish-client.ts @@ -100,6 +100,26 @@ export async function renderConceptsPhase(i: RenderConceptsInput): Promise` BLOBs to the session (delta skips unchanged). + * Mirrors renderConceptsPhase exactly: must run AFTER the __shell__ sidecar is + * present and BEFORE commit. + */ +export async function renderTopicsPhase(i: RenderTopicsInput): Promise { + return postJson(`${i.baseUrl}/content/publish/render-topics`, i.apiKey, { sessionId: i.sessionId }); +} + export async function abortSession({ baseUrl, apiKey, sessionId, reason }: { baseUrl: string; apiKey: string; sessionId: string; reason?: string; }): Promise<{ aborted: boolean }> { diff --git a/scripts/publish-content.ts b/scripts/publish-content.ts index 9e4e520f0..e1bd2535e 100644 --- a/scripts/publish-content.ts +++ b/scripts/publish-content.ts @@ -5,7 +5,7 @@ import { gzipSync } from 'node:zlib'; import { userInfo, hostname } from 'node:os'; import { parse as parseYaml } from 'yaml'; import { parseChannel, type Channel } from './fetch-tutorials.js'; -import { beginSession, appendBatch, commitSession, abortSession, fetchRemoteHashes, fetchRemoteSourceHashes, renderConceptsPhase } from './lib/publish-client.js'; +import { beginSession, appendBatch, commitSession, abortSession, fetchRemoteHashes, fetchRemoteSourceHashes, renderConceptsPhase, renderTopicsPhase } from './lib/publish-client.js'; import { withRetry, formatErrorChain } from './lib/publish-retry.js'; import { chunk, runConcurrent } from './lib/publish-batcher.js'; import { collectCodeCheckSpecs, publishCodeCheckSpecs } from './lib/publish-codecheck.js'; @@ -1245,6 +1245,25 @@ async function main() { await abortSession({ baseUrl: opts.baseUrl, apiKey: opts.apiKey, sessionId: begin.sessionId, reason: 'render-concepts failed' }); process.exit(1); } + // tag-tree-topics Task 7 — render topic detail pages alongside concepts. + // Same guard (prod-only, full-publish): POST /content/publish/render-topics + // is not registered on srv-qa (topics are a prod-only content surface). + try { + const rt = await withRetry( + () => renderTopicsPhase({ baseUrl: opts.baseUrl, apiKey: opts.apiKey, sessionId: begin.sessionId }), + { + attempts: 3, backoffMs: [1000, 3000, 9000], + onAttemptFail: (attempt, err, willRetry) => { + console.error(`[publish-content] render-topics failed (attempt ${attempt}/3): ${formatErrorChain(err)}${willRetry ? ' — retrying' : ''}`); + }, + } + ); + log(`render-topics: ${rt.topicsChanged} changed, ${rt.topicsSkipped} skipped, ${rt.topicsErrored} errored of ${rt.topicsSeen} (${rt.durationMs} ms)`); + } catch (err) { + console.error(`[publish-content] render-topics failed permanently: ${formatErrorChain(err)}`); + await abortSession({ baseUrl: opts.baseUrl, apiKey: opts.apiKey, sessionId: begin.sessionId, reason: 'render-topics failed' }); + process.exit(1); + } } let commit; diff --git a/srv/lib/build-topics.js b/srv/lib/build-topics.js new file mode 100644 index 000000000..331779244 --- /dev/null +++ b/srv/lib/build-topics.js @@ -0,0 +1,30 @@ +// srv/lib/build-topics.js +import cds from '@sap/cds'; +import { buildTopicsTreePayload, buildTopicDetailPayload } from './topics-query.js'; + +const log = cds.log('build-topics'); + +export async function buildTopicsTreeHandler(req, res) { + try { + const db = await cds.connect.to('db'); + const payload = await buildTopicsTreePayload(db); + res.set('Cache-Control', 'public, max-age=60'); + res.json(payload); + } catch (err) { + log.error('failed to build /build/topics-tree payload', err); + res.status(500).json({ error: 'Build topics query failed' }); + } +} + +export async function buildTopicDetailHandler(req, res) { + try { + const db = await cds.connect.to('db'); + const slug = String(req.params.slug || '').toLowerCase(); + const payload = await buildTopicDetailPayload(db, slug); + res.set('Cache-Control', 'public, max-age=60'); + res.status(payload.notFound ? 404 : 200).json(payload); + } catch (err) { + log.error('failed to build /build/topics/:slug payload', err); + res.status(500).json({ error: 'Build topic detail query failed' }); + } +} diff --git a/srv/lib/chrome-shell.js b/srv/lib/chrome-shell.js index fb1f51aa6..020f7d380 100644 --- a/srv/lib/chrome-shell.js +++ b/srv/lib/chrome-shell.js @@ -84,6 +84,9 @@ export function canonicalUrlFor(meta) { case 'puzzle': return `${CANONICAL_ORIGIN}/puzzles/${slug}/`; // #1914 follow-up: the /puzzles/ section index (also CAP-served). case 'puzzles-index': return `${CANONICAL_ORIGIN}/puzzles/`; + // tag-tree-topics: topics section index and individual topic pages. + case 'topics-index': return `${CANONICAL_ORIGIN}/topics/`; + case 'topic': return `${CANONICAL_ORIGIN}/topics/${slug}/`; default: return null; } } @@ -123,6 +126,14 @@ export function buildBreadcrumbJsonLd(meta, canonicalUrl) { case 'puzzles-index': crumbs.push({ name: 'Puzzles', item: `${CANONICAL_ORIGIN}/puzzles/` }); break; + // tag-tree-topics: topics section index and individual topic pages. + case 'topics-index': + crumbs.push({ name: 'Topics', item: `${CANONICAL_ORIGIN}/topics/` }); + break; + case 'topic': + crumbs.push({ name: 'Topics', item: `${CANONICAL_ORIGIN}/topics/` }); + crumbs.push({ name: leaf, item: canonicalUrl }); + break; default: return null; } diff --git a/srv/lib/page-key-map.js b/srv/lib/page-key-map.js index 4d6025cce..c3e822463 100644 --- a/srv/lib/page-key-map.js +++ b/srv/lib/page-key-map.js @@ -22,7 +22,6 @@ export function extForMime(mimeType) { export const IN_SCOPE_PAGES = [ { route: '/', key: 'page-index', file: 'index.html', mimeType: 'text/html' }, { route: '/browse/', key: 'page-browse', file: 'browse/index.html', mimeType: 'text/html' }, - { route: '/topics/', key: 'page-topics', file: 'topics/index.html', mimeType: 'text/html' }, { route: '/tutorial-navigator/', key: 'page-tutorial-navigator', file: 'tutorial-navigator/index.html', mimeType: 'text/html' }, { route: '/developer-advocates/', key: 'page-developer-advocates', file: 'developer-advocates/index.html', mimeType: 'text/html' }, { route: '/devtoberfest/', key: 'page-devtoberfest', file: 'devtoberfest/index.html', mimeType: 'text/html' }, diff --git a/srv/lib/publish-topics.js b/srv/lib/publish-topics.js new file mode 100644 index 000000000..d4bf95db7 --- /dev/null +++ b/srv/lib/publish-topics.js @@ -0,0 +1,174 @@ +// srv/lib/publish-topics.js +// +// Task 6 of the tag-tree-topics plan. A session-scoped publish phase that +// renders every live topic tag and appends `topic-` BLOBs to an open +// publish session. Each BLOB is a FULL HTML document: the topic BODY +// (renderTopicDetail) composed into the __shell__ chrome via composeShell, +// mirroring the same pattern used by publish-concepts.js for concept pages. +// +// Dark launch: no publish-content.ts caller yet (Task 7 wires it). + +import cds from '@sap/cds'; +import { gzipSync } from 'node:zlib'; +import { createHash } from 'node:crypto'; +import { loadLiveTags, buildTopicDetailPayload, loadTopicCorpus } from './topics-query.js'; +import { renderTopicDetail } from './topic-detail-render.js'; +import { composeShell, createShellLoader } from './chrome-shell.js'; +import { createSessionHelpers } from './content-publish-session.js'; +import * as metrics from './metrics.js'; + +const DEFAULT_NAMESPACE = 'com.sap.developers.ims'; +const BATCH_SIZE = 20; +const MAX_ERROR_RATE = 0.05; // >5% of topics erroring aborts the phase + +const META_DESC_MAX = 160; // Google truncates around 155–160 chars. + +export function topicMetaDescription(topic) { + const n = topic.tutorials?.length ?? 0; + const c = topic.concepts?.length ?? 0; + return `${topic.label}: ${n} tutorial${n === 1 ? '' : 's'} and ${c} concept${c === 1 ? '' : 's'} on developers.sap.com.`.slice(0, META_DESC_MAX); +} + +/** + * Render every live topic into an open publish session. + * + * @param {object} args + * @param {object} args.db CDS db service (passed to loadLiveTags / buildTopicDetailPayload). + * @param {string} args.sessionId Open publish session id. + * @param {object} args.helpers { appendToSession } (from createSessionHelpers). + * @param {object} args.priorHashes Map of `topic-` → prior stored + * contentHash (full-doc sha256) for delta skip. + * @param {{before:string,after:string}} args.shell Parsed __shell__ halves. + * @param {object} [args.deps] { loadLiveTags, buildTopicDetailPayload, loadTopicCorpus } — defaults to real. + * @returns {Promise<{topicsSeen,topicsChanged,topicsSkipped,topicsErrored,durationMs}>} + */ +export async function renderTopicsIntoSession({ db, sessionId, helpers, priorHashes = {}, shell, deps = {} }) { + const started = Date.now(); + if (!shell || typeof shell.before !== 'string' || typeof shell.after !== 'string') { + throw new Error('render-topics: shell unavailable — __shell__ sidecar not yet published'); + } + const _loadLiveTags = deps.loadLiveTags || loadLiveTags; + const _buildTopicDetailPayload = deps.buildTopicDetailPayload || buildTopicDetailPayload; + const _loadTopicCorpus = deps.loadTopicCorpus || loadTopicCorpus; + + // Load the full corpus ONCE before the loop — avoids O(topics × full-corpus) + // redundant DB reads. The serve path (srv/server.js) calls buildTopicDetailPayload + // with only 2 args, so the corpus-free path remains intact for single-slug lookups. + const corpus = await _loadTopicCorpus(db); + const live = corpus.live; + const topicsSeen = live.length; + + // Render + delta-filter into a flat file map first, so we can enforce the + // error threshold before writing anything. + const changedFiles = {}; // key → base64(gzip(full doc)) + let topicsSkipped = 0; + let topicsErrored = 0; + + for (const tag of live) { + const key = `topic-${tag.slug}`; + try { + const topic = await _buildTopicDetailPayload(db, tag.slug, corpus); + if (topic.notFound || topic.error) { + topicsErrored++; + metrics.counter('topic_render_error'); + console.error(`[render-topics] topic "${tag.slug}" payload returned error/notFound`); + continue; + } + const { body } = renderTopicDetail(topic); + const meta = { + kind: 'topic', + slug: topic.slug, + title: topic.label, + description: topicMetaDescription(topic), + }; + const fullDoc = composeShell(shell, body, meta); + const contentHash = createHash('sha256').update(fullDoc, 'utf-8').digest('hex'); + if (priorHashes[key] === contentHash) { + topicsSkipped++; + continue; + } + changedFiles[key] = gzipSync(Buffer.from(fullDoc, 'utf-8')).toString('base64'); + } catch (err) { + topicsErrored++; + metrics.counter('topic_render_error'); + console.error(`[render-topics] topic "${tag.slug}" render failed — carrying forward prior BLOB: ${err.message}`); + } + } + + // Corrupt-run guard: if a large fraction of topics errored, abort so the + // caller can roll the session back rather than commit a degraded corpus. + if (topicsSeen > 0 && topicsErrored / topicsSeen > MAX_ERROR_RATE) { + throw new Error(`render-topics: error rate too high (${topicsErrored}/${topicsSeen}) — aborting phase`); + } + + // Append in batches of BATCH_SIZE. + const keys = Object.keys(changedFiles); + for (let i = 0; i < keys.length; i += BATCH_SIZE) { + const slice = keys.slice(i, i + BATCH_SIZE); + const files = {}; + for (const k of slice) files[k] = changedFiles[k]; + await helpers.appendToSession({ sessionId, files }); + } + + const durationMs = Date.now() - started; + const topicsChanged = keys.length; + metrics.observe('topic_render_ms', durationMs); + metrics.counter('topics_rendered_total', topicsChanged); + metrics.counter('topics_skipped_total', topicsSkipped); + return { topicsSeen, topicsChanged, topicsSkipped, topicsErrored, durationMs }; +} + +/** + * Express handler factory for POST /content/publish/render-topics. Bound to + * a namespace; owns getActiveVersion + shellLoader + prior-hash lookup. + */ +export function createRenderTopics({ namespace = DEFAULT_NAMESPACE } = {}) { + const hanaTableName = () => `${namespace.replace(/\./g, '_').toUpperCase()}_CONTENTFILES`; + + async function getActiveVersion() { + const { ContentManifest } = cds.entities(namespace); + const [row] = await SELECT.from(ContentManifest).where({ status: 'ACTIVE' }).columns('version'); + return row?.version ?? null; + } + + const shellLoader = createShellLoader({ namespace, hanaTableName, getActiveVersion }); + const helpers = createSessionHelpers({ namespace }); + + // Prior stored hashes for topic-* slugs at the current ACTIVE version, for + // delta skip. Same source as hashesHandler. + async function loadPriorTopicHashes() { + const activeVersion = await getActiveVersion(); + if (activeVersion === null) return {}; + const { ContentFiles } = cds.entities(namespace); + const rows = await SELECT.from(ContentFiles) + .where({ version: activeVersion }) + .columns('slug', 'contentHash'); + const out = {}; + for (const r of rows) { + if (typeof r.slug === 'string' && r.slug.startsWith('topic-')) out[r.slug] = r.contentHash; + } + return out; + } + + async function renderTopicsHandler(req, res) { + const sessionId = req.body?.sessionId; + if (!sessionId) return res.status(400).json({ error: 'sessionId is required' }); + try { + const db = await cds.connect.to('db'); + const shell = await shellLoader.get(); // { before, after, version } | null + const priorHashes = await loadPriorTopicHashes(); + const counts = await renderTopicsIntoSession({ db, sessionId, helpers, priorHashes, shell }); + return res.status(200).json(counts); + } catch (err) { + console.error('[render-topics] phase failed:', err?.message); + metrics.counter('topic_render_batch_failure'); + // Leave the session for the caller to abort — do not commit here. + return res.status(500).json({ error: err?.message || 'render-topics failed' }); + } + } + + return { renderTopicsHandler }; +} + +const _default = createRenderTopics(); +export const renderTopicsHandler = _default.renderTopicsHandler; diff --git a/srv/lib/topic-detail-render.js b/srv/lib/topic-detail-render.js new file mode 100644 index 000000000..59216a3ef --- /dev/null +++ b/srv/lib/topic-detail-render.js @@ -0,0 +1,56 @@ +import { createHash } from 'node:crypto'; + +function esc(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +export function renderTopicDetail(topic) { + if (!topic?.slug || !topic?.label) throw new Error('renderTopicDetail: slug and label required'); + const tutorials = (topic.tutorials || []).map(t => ` +
    • + ${esc(t.title)} + ${t.isNew ? 'NEW' : ''} + ${t.level ? `${esc(t.level)}` : ''} +
    • `).join(''); + const concepts = (topic.concepts || []).map(c => ` +
    • ${esc(c.name)}
    • `).join(''); + const related = (topic.relatedTags || []).map(r => ` + `).join(''); + + const conceptsSection = concepts + ? `
      +

      Concepts in this topic

      +
        ${concepts}
      +
      ` + : ''; + const relatedSection = related + ? `` + : ''; + + const body = `
      +
      + +
      +

      ${esc(topic.label)}

      +

      ${esc(topic.facet)}

      +
      +
      +

      Tutorials

      +
        ${tutorials || '
      • No tutorials yet.
      • '}
      +
      + ${conceptsSection} + ${relatedSection} +
      +
      `; + const contentHash = createHash('sha256').update(body, 'utf-8').digest('hex'); + return { body, contentHash }; +} diff --git a/srv/lib/topic-list-page.js b/srv/lib/topic-list-page.js new file mode 100644 index 000000000..7b40ae55e --- /dev/null +++ b/srv/lib/topic-list-page.js @@ -0,0 +1,201 @@ +// srv/lib/topic-list-page.js +// +// CAP-rendered Topics index page: GET /content/topics-index → /topics/. +// Mirrors the pattern of concept-list-page.js (Task 2 of concepts-scale plan). +// Renders an SSR shell with a nested
      /