diff --git a/.github/workflows/rebuild-content.yml b/.github/workflows/rebuild-content.yml index f7650afc6..ed502f4f5 100644 --- a/.github/workflows/rebuild-content.yml +++ b/.github/workflows/rebuild-content.yml @@ -83,11 +83,6 @@ on: required: false type: boolean default: false - legacy-concept-render: - description: '#1327 escape hatch. Default (false): CAP renders /concepts/ list + detail pages server-side; the Hugo concept fetcher is skipped and publish-content calls the render-concepts phase. Set true to restore the legacy Hugo concept pipeline (fetch-concepts emits .md, publish-content walks hugo/public/concepts).' - required: false - type: boolean - default: false env: NODE_VERSION: '22' @@ -387,24 +382,6 @@ jobs: env: CAP_BASE_URL: ${{ steps.srv.outputs.srv_url }} - # [Phase 3-A] Fetch published-concept payload from CAP and write - # one hugo/content/concepts/.md per published concept. Runs on - # `full` and `catalog-only`; skipped on `slug-targeted` (a single-tutorial - # rebuild does not need concept landing-page regeneration). The Hugo - # build picks up the new .md files in the subsequent build step. - # All other Phase 3-A wiring (classifier, kg.after hook, Hugo layout, - # publish-content concept support, AppRouter route, CAP serve handler) - # shipped in #685 — only this workflow invocation was missing. - - name: Fetch published concepts - if: ${{ steps.mode.outputs.effective_mode != 'slug-targeted' }} - run: npm run fetch-concepts - env: - CAP_BASE_URL: ${{ steps.srv.outputs.srv_url }} - # #1327 — fetch-concepts.ts self-guards: it early-exits unless - # LEGACY_CONCEPT_RENDER=true (default new pipeline renders concepts - # in CAP). Passing the input keeps the escape hatch one flag away. - LEGACY_CONCEPT_RENDER: ${{ inputs.legacy-concept-render == true && 'true' || 'false' }} - # [#759] Homepage data feeds — homepage-shelves (60 link cards), # verb-definitions (6 verb tile back-faces), shelf-definitions # (4 shelf-header explainers). All three are read by Hugo at build @@ -564,11 +541,6 @@ jobs: # other mode → whole-catalog delta, unchanged. TUTORIAL_SLUG is the # already-resolved slug (inputs.slug || dispatch_slug). PUBLISH_SLUG: ${{ steps.mode.outputs.effective_mode == 'slug-targeted' && (inputs.slug || steps.mode.outputs.dispatch_slug) || '' }} - # #1327 — drives publish-content's concept handling. Default (false): - # skip the hugo/public/concepts walk and call the render-concepts - # phase (CAP renders concept detail BLOBs). true: legacy Hugo walk, - # no render-concepts call. - LEGACY_CONCEPT_RENDER: ${{ inputs.legacy-concept-render == true && 'true' || 'false' }} # #1373 — synthetic watchdog. A green publish step is not proof rows # landed (the #1372 render-concepts 404 aborted AFTER an earlier diff --git a/docs/developers/architecture/knowledge-graph.md b/docs/developers/architecture/knowledge-graph.md index 8bd5b2f16..00855f98f 100644 --- a/docs/developers/architecture/knowledge-graph.md +++ b/docs/developers/architecture/knowledge-graph.md @@ -22,8 +22,11 @@ Each sub-phase adds one corpus with: and `renderMeta`. - A union extension in `hugo-apps/src/related-graph/types.ts` (`NodeType` + `OtherResource.type`). -- A Hugo template section appended to `hugo/layouts/concepts/single.html`. -- A frontmatter emission block in `scripts/fetch-concepts.ts`. +- A `
` block in the concept detail template + `srv/lib/templates/concept-detail.ejs`, rendered server-side by CAP + (`srv/lib/concept-detail-render.js`) into the `concept-` BLOB. (The + legacy Hugo concept path — `hugo/layouts/concepts/single.html` + + `scripts/fetch-concepts.ts` — was retired in #1327.) ### Phase 4.1 — Learning journeys (#447) diff --git a/docs/developers/operations/testing-endpoints.md b/docs/developers/operations/testing-endpoints.md index 23922a827..637c6958a 100644 --- a/docs/developers/operations/testing-endpoints.md +++ b/docs/developers/operations/testing-endpoints.md @@ -171,7 +171,7 @@ When `EXPOSE_CAP_UI=true` is set on the CAP srv app, these are accessible throug | `/build/slug-mapping` | GET | Slug→ID mapping for all missions/groups | None | | `/build/repo-catalog` | GET | Slug-keyed `DiscoveredTutorial` map (third-tier discovery fallback) | None | | `/build/repo-catalog` | POST | Write the discovered-tutorial baseline (CI-as-canonical-writer) | Bearer (`CONTENT_API_KEY`) | -| `/build/concepts` | GET | Published Knowledge-Graph concepts (slug, name, description, teaches/requires/requiredBy/relatedTo). Consumed at build time by `scripts/fetch-concepts.ts`. | None | +| `/build/concepts` | GET | Published Knowledge-Graph concepts (slug, name, description, teaches/requires/requiredBy/relatedTo). Shares `buildConceptsPayload` with the CAP concept-render pipeline (`GET /content/concepts-index` list page + the `POST /content/publish/render-concepts` detail phase, #1327). | None | | `/graph/publishConcept` | POST | Admin action — sets `publishedAt` + `publishedBy` on a Concept | XSUAA + `KnowledgeGraph.Admin` | | `/graph/unpublishConcept` | POST | Admin action — clears `publishedAt` + `publishedBy` on a Concept | XSUAA + `KnowledgeGraph.Admin` | | `/graph/explore-data` | GET | Bulk graph JSON for the `/explore/` page (nodes + edges + generatedAt; 5-min LRU cache) | None | diff --git a/docs/superpowers/plans/2026-07-08-concepts-scale.md b/docs/superpowers/plans/2026-07-08-concepts-scale.md index d0ca3d622..00657f780 100644 --- a/docs/superpowers/plans/2026-07-08-concepts-scale.md +++ b/docs/superpowers/plans/2026-07-08-concepts-scale.md @@ -607,7 +607,7 @@ Fetch payload once. Read shell fragments once (throw → whole phase fails: "she - [ ] **Step 1** Snapshot parity (**required PR check before Task 5 lands**): 10 hand-picked slugs (varied shapes) in the fixture. Fetch legacy Hugo output from DEV; render same slugs via new pipeline; diff allowing an explicit expected-diff list (render-source marker, ` - - -{{ end }} diff --git a/hugo/layouts/concepts/single.html b/hugo/layouts/concepts/single.html deleted file mode 100644 index 64487c269..000000000 --- a/hugo/layouts/concepts/single.html +++ /dev/null @@ -1,720 +0,0 @@ -{{ define "main" }} -{{/* The fetcher (scripts/fetch-concepts.ts) normalises all four related-entity - arrays to {slug, title} pairs, regardless of whether the source field on - the API payload was `title` (Tutorials.teaches) or `name` (Concept→Concept - edges). So we read `.title` consistently in all four sections. - - [#813] Page name comes from .Title (frontmatter `title:`), which also - drives Hugo's site-wide , og:title, twitter:title meta. The - `default .Params.name` fallback supports pages built before the #813 - fix landed (those have `name:` only) — safe to drop once the next - rebuild has rolled all concepts forward. */}} -{{ $name := .Title | default .Params.name | default .Params.slug }} -<article class="concept-page"> - <header class="concept-page__header"> - <nav class="concept-page__breadcrumb" aria-label="breadcrumb"> - <a href="/">Home</a> › <a href="/concepts/">Concepts</a> › <span>{{ $name }}</span> - </nav> - <h1 class="concept-page__title">{{ $name }}</h1> - {{ with .Params.description }}<p class="concept-page__description">{{ . }}</p>{{ end }} - </header> - - {{ with .Params.teaches }} - <section class="concept-page__section"> - <h2>Tutorials that teach this</h2> - <ul class="concept-card-grid"> - {{ range . }} - <li> - <a class="concept-card" href="/tutorials/{{ .slug }}/"> - <span class="concept-card__type concept-card__type--tutorial">Tutorial</span> - <span class="concept-card__title">{{ .title }}</span> - {{ if or .experienceTag .stepCount }} - <span class="concept-card__meta"> - {{ with .experienceTag }}<span class="kg-difficulty">{{ title . }}</span>{{ end }} - {{ if and .experienceTag .stepCount }} · {{ end }} - {{ with .stepCount }}{{ . }} step{{ if ne . 1 }}s{{ end }}{{ end }} - </span> - {{ end }} - </a> - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 4.1 (#447 §2.6): "Learning journeys covering this" — section #2 - on the concept landing page, between Tutorials and Prerequisites. - - External links use target="_blank" + rel="noopener". Telemetry is wired - up by the page-bottom delegated script (not inline onclick — see CSP - posture in approuter/xs-app.json; we keep the no-inline-handler pattern - used elsewhere on the site so this works even if the policy tightens). - - Hide-when-empty: the surrounding `{{ with }}` guard ensures the H2 and - UL are NEVER emitted for concepts that have no covering journey. - */}} - {{ with .Params.learningJourneys }} - <section - class="concept-page__section" - data-kg-section="learning-journeys" - data-concept-slug="{{ $.Params.slug }}" - > - <h2>Learning journeys covering this</h2> - <ul class="concept-card-grid"> - {{ range . }} - {{ $url := .url }} - {{ $isSafe := or (hasPrefix $url "https://") (hasPrefix $url "http://") }} - <li> - {{ if $isSafe }} - <a class="concept-card" href="{{ $url }}" target="_blank" rel="noopener" data-journey-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Learning Journey <span class="kg-link-out" aria-hidden="true">↗</span></span> - <span class="concept-card__title">{{ .title }}</span> - {{ if or .level .durationHours }} - <span class="concept-card__meta">{{ with .level }}{{ title . }}{{ end }}{{ if and .level .durationHours }} · {{ end }}{{ with .durationHours }}{{ . }}h{{ end }}</span> - {{ end }} - </a> - {{ else }} - <span class="concept-card" data-journey-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Learning Journey</span> - <span class="concept-card__title">{{ .title }}</span> - </span> - {{ end }} - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 4.7 (#748 §4.8.1): "Docs explaining this concept" — section #3 - on the concept landing page, INSERTED between Learning journeys (#2) - and Blog posts (which shifts from #3 to #4). - - This is the FIRST mid-list section insert in the Phase 4 sequence. - Phase 4.1-4.6 each appended to the tail; Phase 4.7 shifts existing - sections #3-#8 down one slot. No other section's HTML changes — only - their file position. - - Row format per §3 Q10: - `<badge> Title ↗ · <anchor label> · <snippet>` - - - `<badge>` is a small tinted pill (`.kg-help-source--{source}`) - reading `SAP Help` / `CAP` / `UI5`. - - `↗` (link-out) is inline, mirroring Phase 4.5/4.6. - - `<anchor label>` is the human-readable H2/H3 title - (title-cased from the slug at payload build time; may be null). - - `<snippet>` is the first ~120 chars of the docs page description, - precomputed at link-INSERT time and served from the - `HelpDocConceptLinks.snippet` column (LOB-locator-safe read). - - URL construction: base URL + optional `#<anchor>` fragment. - - Hide-when-empty: `{{ with .Params.helpDocs }}` — Hugo treats an empty - slice as falsy, so no additional guard needed. - */}} - {{ with .Params.helpDocs }} - <section - class="concept-page__section" - data-kg-section="help-docs" - data-concept-slug="{{ $.Params.slug }}" - > - <h2>Docs explaining this concept</h2> - <ul class="concept-card-grid kg-help-doc-list"> - {{ range . }} - {{ $url := .url }} - {{ $isSafe := or (hasPrefix $url "https://") (hasPrefix $url "http://") }} - {{ $href := $url }} - {{ with .anchor }}{{ $href = printf "%s#%s" $url . }}{{ end }} - <li> - {{ if $isSafe }} - <a class="concept-card" href="{{ $href }}" target="_blank" rel="noopener" data-help-doc-slug="{{ .slug }}"> - <span class="concept-card__badges"><span class="kg-help-source kg-help-source--{{ .source }}">{{ .sourceLabel }}</span> <span class="kg-link-out" aria-hidden="true">↗</span></span> - <span class="concept-card__title">{{ .title }}</span> - {{ with .snippet }}<span class="concept-card__body">{{ . }}</span>{{ end }} - {{ with .anchorLabel }}<span class="concept-card__meta kg-help-anchor">{{ . }}</span>{{ end }} - </a> - {{ else }} - <span class="concept-card" data-help-doc-slug="{{ .slug }}"> - <span class="concept-card__badges"><span class="kg-help-source kg-help-source--{{ .source }}">{{ .sourceLabel }}</span></span> - <span class="concept-card__title">{{ .title }}</span> - </span> - {{ end }} - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 4.2 (#447 §9): "Blog posts discussing this" — section #4 on the - concept landing page (was #3 before Phase 4.7 inserted the help-docs - section above). - - Mirrors the Phase 4.1 hardening: external links open in a new tab - (target=_blank rel=noopener); a URL scheme guard (`$isSafe`) falls back - to a plain <span> for anything that isn't http(s). - - Hide-when-empty: the surrounding `{{ with }}` guard ensures the H2 and - UL are NEVER emitted for concepts that have no blog posts. - - Row format: `title · by Author · Date`. The Hugo `dateFormat` filter - parses the ISO timestamp from BlogPosts.postedAt to a stable - "Jan 2, 2006" rendering — locale-independent and consistent with the - Vue sidebar's `formatDate()` (`en-US`, short month). - */}} - {{ with .Params.blogPosts }} - <section - class="concept-page__section" - data-kg-section="blog-posts" - data-concept-slug="{{ $.Params.slug }}" - > - <h2>Blog posts discussing this</h2> - <ul class="concept-card-grid"> - {{ range . }} - {{ $url := .url }} - {{ $isSafe := or (hasPrefix $url "https://") (hasPrefix $url "http://") }} - <li> - {{ if $isSafe }} - <a class="concept-card" href="{{ $url }}" target="_blank" rel="noopener" data-blog-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Blog <span class="kg-link-out" aria-hidden="true">↗</span></span> - <span class="concept-card__title">{{ .title }}</span> - <span class="concept-card__meta">by {{ .authorName }} · {{ dateFormat "Jan 2, 2006" .postedAt }}</span> - </a> - {{ else }} - <span class="concept-card" data-blog-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Blog</span> - <span class="concept-card__title">{{ .title }}</span> - </span> - {{ end }} - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 4.3 (#447 §8): "Discovery missions teaching this" — section #5 - on the concept landing page, between Blog Posts and Prerequisites. - - Mirrors the Phase 4.1/4.2 hardening: external links open in a new tab - (target=_blank rel=noopener); a URL scheme guard (`$isSafe`) falls back - to a plain <span> for anything that isn't http(s). `categoryLabel` is - the user-facing English label resolved by scripts/fetch-concepts.ts - at frontmatter emission time via srv/lib/discovery-mission-categories.js. - - Hide-when-empty: the surrounding `{{ with }}` guard ensures the H2 and - UL are NEVER emitted for concepts that have no covering mission. - - Row format: `title · effort N · CategoryLabel`. Both meta fields are - conditional — a mission with no effortLevel or no categorySlug on the - wire still renders the title. - */}} - {{ with .Params.discoveryMissions }} - <section - class="concept-page__section" - data-kg-section="discovery-missions" - data-concept-slug="{{ $.Params.slug }}" - > - <h2>Discovery missions teaching this</h2> - <ul class="concept-card-grid"> - {{ range . }} - {{ $url := .url }} - {{ $isSafe := or (hasPrefix $url "https://") (hasPrefix $url "http://") }} - <li> - {{ if $isSafe }} - <a class="concept-card" href="{{ $url }}" target="_blank" rel="noopener" data-mission-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Mission <span class="kg-link-out" aria-hidden="true">↗</span></span> - <span class="concept-card__title">{{ .title }}</span> - {{ if or .effortLevel .categoryLabel }} - <span class="concept-card__meta">{{ if .effortLevel }}effort {{ .effortLevel }}{{ end }}{{ if and .effortLevel .categoryLabel }} · {{ end }}{{ if .categoryLabel }}{{ .categoryLabel }}{{ end }}</span> - {{ end }} - </a> - {{ else }} - <span class="concept-card" data-mission-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Mission</span> - <span class="concept-card__title">{{ .title }}</span> - </span> - {{ end }} - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 4.4 (#447 §9): "Videos teaching this" — section #6 on the - concept landing page, between Discovery Missions and Prerequisites. - - Mirrors the Phase 4.1/4.2/4.3 hardening: external links open in a new - tab (target=_blank rel=noopener); a URL scheme guard (`$isSafe`) falls - back to a plain <span> for anything that isn't http(s). - - Hide-when-empty: the surrounding `{{ with }}` guard ensures the H2 and - UL are NEVER emitted for concepts that have no covering video. - - Row format: `[120×68 thumbnail] title · by ChannelTitle · Date`. - Thumbnails hot-link to i.ytimg.com (allowed by CSP `img-src` — - same precedent as the homepage video band). - */}} - {{ with .Params.videos }} - <section - class="concept-page__section" - data-kg-section="videos" - data-concept-slug="{{ $.Params.slug }}" - > - <h2>Videos teaching this</h2> - <ul class="concept-card-grid"> - {{ range . }} - {{ $url := .url }} - {{ $isSafe := or (hasPrefix $url "https://") (hasPrefix $url "http://") }} - <li> - {{ if $isSafe }} - <a class="concept-card" href="{{ $url }}" target="_blank" rel="noopener" data-video-slug="{{ .slug }}"> - {{ if .thumbnailUrl }}<img src="{{ .thumbnailUrl }}" alt="" class="concept-card__thumb" loading="lazy">{{ end }} - <span class="concept-card__type concept-card__type--external">Video <span class="kg-link-out" aria-hidden="true">↗</span></span> - <span class="concept-card__title">{{ .title }}</span> - {{ if or .channelTitle .publishedAt }} - <span class="concept-card__meta">{{ with .channelTitle }}by {{ . }}{{ end }}{{ if and .channelTitle .publishedAt }} · {{ end }}{{ with .publishedAt }}{{ dateFormat "Jan 2, 2006" . }}{{ end }}</span> - {{ end }} - </a> - {{ else }} - <span class="concept-card" data-video-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Video</span> - <span class="concept-card__title">{{ .title }}</span> - </span> - {{ end }} - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 4.5 (#746 §5): "Official reference documentation" — section #7 - on the concept landing page, between Videos and Prerequisites. - - api.sap.com authority docs. Row format: - `title ↗ · category · apiType` - - Mirrors the Phase 4.1/4.2/4.3/4.4 hardening: external links open in a - new tab (target=_blank rel=noopener); a URL scheme guard (`$isSafe`) - falls back to a plain <span> for anything that isn't http(s). - - No thumbnail rendering — api-docs don't have them. Instead, an inline - `↗` (link-out) icon signals external authority. `category` + `apiType` - are conditional meta fields rendered verbatim from the wire payload. - - Hide-when-empty: the surrounding `{{ with }}` guard ensures the H2 and - UL are NEVER emitted for concepts that have no covering api-doc. - */}} - {{ with .Params.apiDocs }} - <section - class="concept-page__section" - data-kg-section="api-docs" - data-concept-slug="{{ $.Params.slug }}" - > - <h2>Official reference documentation</h2> - <ul class="concept-card-grid"> - {{ range . }} - {{ $url := .url }} - {{ $isSafe := or (hasPrefix $url "https://") (hasPrefix $url "http://") }} - <li> - {{ if $isSafe }} - <a class="concept-card" href="{{ $url }}" target="_blank" rel="noopener" data-api-doc-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">API Doc <span class="kg-link-out" aria-hidden="true">↗</span></span> - <span class="concept-card__title">{{ .title }}</span> - {{ if or .category .apiType }} - <span class="concept-card__meta">{{ if .category }}{{ .category }}{{ end }}{{ if and .category .apiType }} · {{ end }}{{ if .apiType }}<span class="kg-api-type">{{ .apiType }}</span>{{ end }}</span> - {{ end }} - </a> - {{ else }} - <span class="concept-card" data-api-doc-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">API Doc</span> - <span class="concept-card__title">{{ .title }}</span> - </span> - {{ end }} - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 4.6 (#747 §5): "Code samples embodying this" — section #8 - on the concept landing page, between API docs and Prerequisites. - - SAP-samples GitHub repositories that embody this concept. Row format: - `title ↗ · Language · N stars · Updated Mon YYYY` - - Mirrors the Phase 4.5 (#746) shape: external links open in a new tab - (target=_blank rel=noopener); a URL scheme guard (`$isSafe`) falls - back to a plain <span> for anything that isn't http(s). - - No thumbnail rendering — samples are GitHub repos and don't have - thumbnails. The inline `↗` (link-out) icon signals external - authority (reuses the .kg-link-out CSS class from Phase 4.5). - `language`, `stars`, `lastCommitAt` are conditional meta fields - rendered verbatim from the wire payload. `lastCommitAt` is formatted - as "Jan 2006" (month + year only — more compact than Phase 4.4's - full Jan 2, 2006 video-date format). - - Hide-when-empty: the surrounding `{{ with }}` guard ensures the H2 and - UL are NEVER emitted for concepts that have no embodying sample. - */}} - {{ with .Params.samples }} - <section - class="concept-page__section" - data-kg-section="samples" - data-concept-slug="{{ $.Params.slug }}" - > - <h2>Code samples embodying this</h2> - <ul class="concept-card-grid"> - {{ range . }} - {{ $url := .url }} - {{ $isSafe := or (hasPrefix $url "https://") (hasPrefix $url "http://") }} - <li> - {{ if $isSafe }} - <a class="concept-card" href="{{ $url }}" target="_blank" rel="noopener" data-sample-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Sample <span class="kg-link-out" aria-hidden="true">↗</span></span> - <span class="concept-card__title">{{ .title }}</span> - {{ if or .language .stars .lastCommitAt }} - <span class="concept-card__meta">{{ if .language }}<span class="kg-language">{{ .language }}</span>{{ end }}{{ if .stars }} · {{ .stars }} stars{{ end }}{{ if .lastCommitAt }} · Updated {{ dateFormat "Jan 2006" .lastCommitAt }}{{ end }}</span> - {{ end }} - </a> - {{ else }} - <span class="concept-card" data-sample-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Sample</span> - <span class="concept-card__title">{{ .title }}</span> - </span> - {{ end }} - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 4.8 (#765): "Upcoming hands-on events" — section #10 on the concept - landing page, appended after Samples (#9), before Prerequisites. - - Surfaces SAP CodeJams, Devtoberfest, TechEd, and user-group events linked - to this concept via CommunityEventConceptLinks. Row format: - EventTitle ↗ · Location · StartDate [· 🌐] - - The `data-kg-section="community-events"` attribute is used by the telemetry - block below to attach click listeners. The `data-community-event-slug` on - each <a> carries the event slug into the telemetry payload. - - UL is NEVER emitted for concepts with no linked events ({{ with }} guard). - */}} - {{ with .Params.communityEvents }} - <section - class="concept-page__section" - data-kg-section="community-events" - data-concept-slug="{{ $.Params.slug }}" - > - <h2>Upcoming hands-on events</h2> - <ul class="concept-card-grid"> - {{ range . }} - {{ $url := .url }} - {{ $isSafe := or (hasPrefix $url "https://") (hasPrefix $url "http://") }} - <li> - {{ if $isSafe }} - <a class="concept-card" href="{{ $url }}" target="_blank" rel="noopener" data-community-event-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Event <span class="kg-link-out" aria-hidden="true">↗</span></span> - <span class="concept-card__title">{{ .title }}</span> - {{ if or .location .startDate (eq .virtualOrInPerson "virtual") }} - <span class="concept-card__meta">{{ if .location }}<span class="kg-event-location">{{ .location }}</span>{{ end }}{{ if .startDate }} · <span class="kg-event-date">{{ .startDate }}</span>{{ end }}{{ if eq .virtualOrInPerson "virtual" }} · <span class="kg-event-virtual" aria-label="Virtual event">🌐</span>{{ end }}</span> - {{ end }} - </a> - {{ else }} - <span class="concept-card" data-community-event-slug="{{ .slug }}"> - <span class="concept-card__type concept-card__type--external">Event</span> - <span class="concept-card__title">{{ .title }}</span> - </span> - {{ end }} - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{ with .Params.requires }} - <section class="concept-page__section"> - <h2>Prerequisites</h2> - <ul class="concept-card-grid"> - {{ range . }} - <li> - <a class="concept-card" href="/concepts/{{ .slug }}/"> - <span class="concept-card__type concept-card__type--concept">Concept</span> - <span class="concept-card__title">{{ .title }}</span> - {{ with .description }}<span class="concept-card__body">{{ . }}</span>{{ end }} - </a> - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{ with .Params.requiredBy }} - <section class="concept-page__section"> - <h2>Concepts that build on this</h2> - <ul class="concept-card-grid"> - {{ range . }} - <li> - <a class="concept-card" href="/concepts/{{ .slug }}/"> - <span class="concept-card__type concept-card__type--concept">Concept</span> - <span class="concept-card__title">{{ .title }}</span> - {{ with .description }}<span class="concept-card__body">{{ . }}</span>{{ end }} - </a> - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{ with .Params.relatedTo }} - <section class="concept-page__section"> - <h2>Related concepts</h2> - <ul class="concept-card-grid"> - {{ range . }} - <li> - <a class="concept-card" href="/concepts/{{ .slug }}/"> - <span class="concept-card__type concept-card__type--concept">Concept</span> - <span class="concept-card__title">{{ .title }}</span> - {{ with .description }}<span class="concept-card__body">{{ . }}</span>{{ end }} - </a> - </li> - {{ end }} - </ul> - </section> - {{ end }} - - {{/* - Phase 3 (#446) telemetry — fires kg.concept.viewed on page load. - The companion kg.concept.tutorial_clicked event is dispatched from - the sidebar island (hugo-apps/src/related-graph/RelatedGraph.vue) - when a reader follows a concept link FROM a tutorial; this event - measures the inverse — eyeballs landing on a concept page directly. - - Pattern: read the slug from a hidden data-attribute, NOT from - Hugo's `jsonify` filter inline — the minifier has historical - issues with `jsonify` output in inline scripts. - */}} - <div data-concept-slug="{{ .Params.slug }}" id="concept-telemetry" hidden></div> - <script> - (function () { - if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return; - var el = document.getElementById('concept-telemetry'); - if (!el) return; - var conceptSlug = el.dataset.conceptSlug; - try { - window.dispatchEvent(new CustomEvent('kg.concept.viewed', { - detail: { slug: conceptSlug } - })); - } catch (e) { /* never let telemetry break the page */ } - - // Phase 4.1 (#447 §2.6 + Q5): delegated click listener on the - // "Learning journeys covering this" section. CSP-safe — no inline - // onclick="..." attributes (the policy in approuter/xs-app.json - // tolerates 'unsafe-inline' today, but the existing concept-page - // pattern is "server-rendered <script> + addEventListener" so we - // keep that convention to stay correct under any future tightening). - var section = document.querySelector('[data-kg-section="learning-journeys"]'); - if (section) { - var sectionConceptSlug = section.getAttribute('data-concept-slug'); - var links = section.querySelectorAll('a[data-journey-slug]'); - for (var i = 0; i < links.length; i++) { - (function (a) { - a.addEventListener('click', function () { - try { - window.dispatchEvent(new CustomEvent('kg.learning_journey.linked_from_concept_page', { - detail: { - conceptSlug: sectionConceptSlug, - journeySlug: a.getAttribute('data-journey-slug') - } - })); - } catch (e) { /* never let telemetry break the page */ } - }); - })(links[i]); - } - } - - // Phase 4.2 (#447 §9): delegated click listener on the - // "Blog posts discussing this" section. CSP-safe — addEventListener - // only, no inline onclick. Parallels the learning-journeys block - // above; only the event name + detail field name differ. - var blogSection = document.querySelector('[data-kg-section="blog-posts"]'); - if (blogSection) { - var blogSectionConceptSlug = blogSection.getAttribute('data-concept-slug'); - var blogLinks = blogSection.querySelectorAll('a[data-blog-slug]'); - for (var j = 0; j < blogLinks.length; j++) { - (function (a) { - a.addEventListener('click', function () { - try { - window.dispatchEvent(new CustomEvent('kg.blog_post.linked_from_concept_page', { - detail: { - conceptSlug: blogSectionConceptSlug, - blogSlug: a.getAttribute('data-blog-slug') - } - })); - } catch (e) { /* never let telemetry break the page */ } - }); - })(blogLinks[j]); - } - } - - // Phase 4.3 (#447 §8): delegated click listener on the - // "Discovery missions teaching this" section. CSP-safe — - // addEventListener only, no inline onclick. Parallels the - // learning-journeys + blog-posts blocks above; only the event - // name + detail field name differ. - var missionSection = document.querySelector('[data-kg-section="discovery-missions"]'); - if (missionSection) { - var missionSectionConceptSlug = missionSection.getAttribute('data-concept-slug'); - var missionLinks = missionSection.querySelectorAll('a[data-mission-slug]'); - for (var k = 0; k < missionLinks.length; k++) { - (function (a) { - a.addEventListener('click', function () { - try { - window.dispatchEvent(new CustomEvent('kg.discovery_mission.linked_from_concept_page', { - detail: { - conceptSlug: missionSectionConceptSlug, - missionSlug: a.getAttribute('data-mission-slug') - } - })); - } catch (e) { /* never let telemetry break the page */ } - }); - })(missionLinks[k]); - } - } - - // Phase 4.4 (#447 §8): delegated click listener on the - // "Videos teaching this" section. CSP-safe — addEventListener only, - // no inline onclick. Parallels the four prior blocks above; only - // the event name + detail field name differ. - var videoSection = document.querySelector('[data-kg-section="videos"]'); - if (videoSection) { - var videoSectionConceptSlug = videoSection.getAttribute('data-concept-slug'); - var videoLinks = videoSection.querySelectorAll('a[data-video-slug]'); - for (var m = 0; m < videoLinks.length; m++) { - (function (a) { - a.addEventListener('click', function () { - try { - window.dispatchEvent(new CustomEvent('kg.video.linked_from_concept_page', { - detail: { - conceptSlug: videoSectionConceptSlug, - videoSlug: a.getAttribute('data-video-slug') - } - })); - } catch (e) { /* never let telemetry break the page */ } - }); - })(videoLinks[m]); - } - } - - // Phase 4.5 (#746 §6): delegated click listener on the - // "Official reference documentation" section. CSP-safe — - // addEventListener only, no inline onclick. Parallels the five - // prior blocks above; only the event name + detail field name - // differ. - var apiDocSection = document.querySelector('[data-kg-section="api-docs"]'); - if (apiDocSection) { - var apiDocSectionConceptSlug = apiDocSection.getAttribute('data-concept-slug'); - var apiDocLinks = apiDocSection.querySelectorAll('a[data-api-doc-slug]'); - for (var n = 0; n < apiDocLinks.length; n++) { - (function (a) { - a.addEventListener('click', function () { - try { - window.dispatchEvent(new CustomEvent('kg.api-doc.linked_from_concept_page', { - detail: { - conceptSlug: apiDocSectionConceptSlug, - apiDocSlug: a.getAttribute('data-api-doc-slug') - } - })); - } catch (e) { /* never let telemetry break the page */ } - }); - })(apiDocLinks[n]); - } - } - - // Phase 4.6 (#747 §6): delegated click listener on the - // "Code samples embodying this" section. CSP-safe — - // addEventListener only, no inline onclick. Parallels the six - // prior blocks above; only the event name + detail field name - // differ. Uses `var o` to avoid colliding with `var n` from the - // Phase 4.5 api-docs block above. - var sampleSection = document.querySelector('[data-kg-section="samples"]'); - if (sampleSection) { - var sampleSectionConceptSlug = sampleSection.getAttribute('data-concept-slug'); - var sampleLinks = sampleSection.querySelectorAll('a[data-sample-slug]'); - for (var o = 0; o < sampleLinks.length; o++) { - (function (a) { - a.addEventListener('click', function () { - try { - window.dispatchEvent(new CustomEvent('kg.sample.linked_from_concept_page', { - detail: { - conceptSlug: sampleSectionConceptSlug, - sampleSlug: a.getAttribute('data-sample-slug') - } - })); - } catch (e) { /* never let telemetry break the page */ } - }); - })(sampleLinks[o]); - } - } - - // Phase 4.7 (#748 §8): delegated click listener on the "Docs - // explaining this concept" section. CSP-safe — addEventListener - // only, no inline onclick. Parallels the seven prior blocks above; - // only the event name + detail field name differ. Uses `var p` to - // avoid colliding with `var o` from the Phase 4.6 samples block. - var helpDocSection = document.querySelector('[data-kg-section="help-docs"]'); - if (helpDocSection) { - var helpDocSectionConceptSlug = helpDocSection.getAttribute('data-concept-slug'); - var helpDocLinks = helpDocSection.querySelectorAll('a[data-help-doc-slug]'); - for (var p = 0; p < helpDocLinks.length; p++) { - (function (a) { - a.addEventListener('click', function () { - try { - window.dispatchEvent(new CustomEvent('kg.help-doc.linked_from_concept_page', { - detail: { - conceptSlug: helpDocSectionConceptSlug, - helpDocSlug: a.getAttribute('data-help-doc-slug') - } - })); - } catch (e) { /* never let telemetry break the page */ } - }); - })(helpDocLinks[p]); - } - } - - // Phase 4.8 (#765 §8): delegated click listener on the "Upcoming - // hands-on events" section. CSP-safe — addEventListener only, no - // inline onclick. Parallels the eight prior blocks above; only the - // event name + detail field name differ. Uses `var q` to avoid - // colliding with `var p` from the Phase 4.7 help-docs block. - var communityEventSection = document.querySelector('[data-kg-section="community-events"]'); - if (communityEventSection) { - var communityEventConceptSlug = communityEventSection.getAttribute('data-concept-slug'); - var communityEventLinks = communityEventSection.querySelectorAll('a[data-community-event-slug]'); - for (var q = 0; q < communityEventLinks.length; q++) { - (function (a) { - a.addEventListener('click', function () { - try { - window.dispatchEvent(new CustomEvent('kg.community-event.linked_from_concept_page', { - detail: { - conceptSlug: communityEventConceptSlug, - eventSlug: a.getAttribute('data-community-event-slug') - } - })); - } catch (e) { /* never let telemetry break the page */ } - }); - })(communityEventLinks[q]); - } - } - })(); - </script> -</article> -{{ end }} diff --git a/package.json b/package.json index 926d575f1..6827966ff 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "fetch-featured-topics": "tsx scripts/fetch-featured-topics.ts", "fetch-topic-clusters": "tsx scripts/fetch-topic-clusters.ts", "fetch-tutorials:hugo": "tsx scripts/fetch-tutorials.ts --target hugo", - "fetch-concepts": "tsx scripts/fetch-concepts.ts", "fetch-advocates": "tsx scripts/fetch-advocates.ts", "seed-ai-quizzes": "cross-env AI_AUTHOR_BUILD_CAP=10000 npm run fetch-tutorials", "discover-repos": "tsx scripts/fetch-tutorials.ts --discover-only", @@ -75,7 +74,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-concepts && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run build:css && npm run build:apps && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && 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-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run build:css && npm run build:apps && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && 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/__tests__/publish-content-qa.test.ts b/scripts/__tests__/publish-content-qa.test.ts index 25b751ef4..05e6f9395 100644 --- a/scripts/__tests__/publish-content-qa.test.ts +++ b/scripts/__tests__/publish-content-qa.test.ts @@ -51,7 +51,7 @@ describe('publish-content qa channel', () => { // merge-to-main publish. The caller MUST gate the phase to channel==='prod'. it('gates the render-concepts phase to the prod channel only', () => { const gate = SRC.match( - /if \(!legacyConceptRender && !opts\.slug && channel === 'prod'\) \{/, + /if \(!opts\.slug && channel === 'prod'\) \{/, ); expect( gate, diff --git a/scripts/fetch-concepts.ts b/scripts/fetch-concepts.ts deleted file mode 100644 index 24d1cc41a..000000000 --- a/scripts/fetch-concepts.ts +++ /dev/null @@ -1,455 +0,0 @@ -#!/usr/bin/env tsx -// Build-time fetcher for /concepts/<slug>/ Hugo pages. -// -// Calls /build/concepts (CAP) and emits one hugo/content/concepts/<slug>.md -// per publishable concept. Idempotent: deletes the output directory first. -// -// Sibling of scripts/fetch-tutorials.ts. CAP_BASE_URL env var picks the -// target (defaults to http://localhost:4004 for local dev). - -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { categoryLabel } from '../srv/lib/discovery-mission-categories.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const ROOT = path.resolve(__dirname, '..') -const OUT_DIR = path.join(ROOT, 'hugo', 'content', 'concepts') - -const CAP_BASE_URL = process.env.CAP_BASE_URL || 'http://localhost:4004' - -export interface ConceptPayload { - slug: string - name: string - description: string - // #1127: teaches carries tutorial difficulty + step count (both plain - // columns — no NCLOB). requires/requiredBy/relatedTo carry the concept's - // String(500) description for the enriched card body. - teaches: { slug: string; title: string; experienceTag?: string; stepCount?: number }[] - requires: { slug: string; name: string; description?: string }[] - requiredBy: { slug: string; name: string; description?: string }[] - relatedTo: { slug: string; name: string; description?: string }[] - // Phase 4.1 (#447): learning journeys covering this concept. Empty until the - // weekly fetch-learning-journeys cron has populated LearningJourneyConceptLinks. - learningJourneys?: Array<{ - slug: string - title: string - url: string - level?: string - durationHours?: number - }> - // Phase 4.2 (#447 §9): SAP Community blog posts discussing this concept. - // Empty until the daily fetch-blog-posts cron has populated - // BlogPostConceptLinks. Shape mirrors the per-concept array emitted by - // srv/lib/published-concepts-query.js (Task 2's extension). - blogPosts?: Array<{ - slug: string - title: string - url: string - authorName: string - postedAt: string // ISO timestamp - }> - // Phase 4.3 (#447 §8): SAP Discovery Center missions teaching this concept. - // Empty until the weekly fetch-discovery-missions cron has populated - // DiscoveryMissionConceptLinks. Shape mirrors the per-concept array - // emitted by srv/lib/published-concepts-query.js. The backend ships - // `categorySlug` (raw short-code from the MCP); frontmatter() resolves - // it to a user-facing English `categoryLabel` at emission time via the - // shared srv/lib/discovery-mission-categories.js helper. - discoveryMissions?: Array<{ - slug: string - title: string - url: string - effortLevel?: number - categorySlug?: string - }> - // Phase 4.4 (#447 §9): SAP Developers YouTube videos teaching this concept. - // Empty until the daily fetch-videos cron has populated - // VideoConceptLinks. Shape mirrors the per-concept array emitted by - // srv/lib/published-concepts-query.js (Task 2's extension). Pass-through: - // `thumbnailUrl` is already a CDN URL (i.ytimg.com); `channelTitle` + - // `publishedAt` flow through unchanged. CSP `img-src` allows i.ytimg.com. - videos?: Array<{ - slug: string - title: string - url: string - thumbnailUrl?: string - channelTitle?: string - publishedAt?: string // ISO timestamp - }> - // Phase 4.5 (#746 §5): api.sap.com authority documentation referencing this - // concept. Empty until the monthly fetch-api-docs cron has populated - // ApiDocConceptLinks. Shape mirrors the per-concept array emitted by - // srv/lib/published-concepts-query.js. Pass-through: `category` and - // `apiType` flow through verbatim (no helper transformation). The - // `description` LOB column is deliberately NOT in the wire payload - // (LOB-locator safety, see spec §3). - apiDocs?: Array<{ - slug: string - title: string - url: string - category?: string - apiType?: string - }> - // Phase 4.6 (#747 §5): SAP-samples GitHub repositories embodying this - // concept. Empty until the weekly fetch-samples-job has populated - // SampleConceptLinks. Shape mirrors the per-concept array emitted by - // srv/lib/published-concepts-query.js (Task 2's extension). Pass-through: - // `language`, `stars`, `lastCommitAt` flow through verbatim. The - // `description` LOB column is deliberately NOT in the wire payload - // (LOB-locator safety, see spec §3). - samples?: Array<{ - slug: string - title: string - url: string - language?: string - stars?: number - lastCommitAt?: string // ISO timestamp - }> - // Phase 4.7 (#748 §4.7): help-docs from three sources (help.sap.com, - // cap.cloud.sap, ui5.sap.com) explaining this concept. Empty until the - // weekly fetch-help-docs-job has populated HelpDocConceptLinks. Shape - // mirrors the per-concept array emitted by - // srv/lib/published-concepts-query.js (Task 2's extension). Pass-through: - // source, sourceLabel, anchor, anchorLabel, snippet, product all flow - // verbatim into frontmatter. `description` (NCLOB) is deliberately NOT - // in the wire payload — `snippet` is the safe precomputed replacement. - helpDocs?: Array<{ - slug: string - title: string - url: string - source: 'help-sap-com' | 'cap-cloud-sap' | 'ui5-sap-com' - sourceLabel: string - anchor?: string | null - anchorLabel?: string | null - snippet?: string | null - product?: string - confidence?: number - }> - // Phase 4.8 (#765): SAP CodeJams, Devtoberfest, TechEd, and user-group - // events linked to this concept. Empty until the twice-weekly - // fetch-community-events cron has populated CommunityEventConceptLinks. - // Shape mirrors the per-concept array emitted by - // srv/lib/published-concepts-query.js (Task 2's extension). Pass-through: - // all fields flow verbatim into frontmatter. The Hugo template's surrounding - // `{{ with .Params.communityEvents }}` hides the entire section when absent. - communityEvents?: Array<{ - slug: string - title: string - url: string - eventType?: string - location?: string - scope?: string - virtualOrInPerson?: string - startDate?: string - endDate?: string | null - snippet?: string - confidence?: number - }> -} - -interface BuildConceptsResponse { - concepts: ConceptPayload[] - generatedAt: string -} - -export function yamlEscape(s: string): string { - return `"${(s || '') - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n') - .replace(/\t/g, '\\t')}"` -} - -export function frontmatter(c: ConceptPayload): string { - // #1127: cross-link entries now carry optional enrichment fields — - // experienceTag/stepCount for tutorials (teaches), description for - // concepts (requires/requiredBy/relatedTo). Emitted only when present; - // empty arrays still serialize as " []" to keep the YAML valid (a bare - // `relatedTo:[]` with no space fails Hugo's frontmatter parser — see the - // 2026-06-30 rebuild incident). - type Ref = { - slug: string - title?: string - name?: string - experienceTag?: string - stepCount?: number - description?: string - } - const refs = (arr: Ref[]) => { - if (arr.length === 0) return ' []' - return '\n' + arr.map(r => { - const lines = [ - ` - slug: ${yamlEscape(r.slug)}`, - ` title: ${yamlEscape(r.title ?? r.name ?? '')}`, - ] - if (r.experienceTag) lines.push(` experienceTag: ${yamlEscape(r.experienceTag)}`) - if (r.stepCount != null) lines.push(` stepCount: ${r.stepCount}`) - if (r.description) lines.push(` description: ${yamlEscape(r.description)}`) - return lines.join('\n') - }).join('\n') - } - - // Phase 4.1 (#447): emit `learningJourneys` only when non-empty. The Hugo - // concept template at layouts/concepts/single.html guards on `{{ with - // .Params.learningJourneys }}`, which is falsy for both missing and empty - // arrays — but we omit the key entirely for cleaner generated frontmatter. - // Shape matches /build/concepts payload: {slug,title,url,level,durationHours}. - const journeys = (c.learningJourneys && c.learningJourneys.length > 0) - ? (() => { - const lines = ['learningJourneys:'] - for (const j of c.learningJourneys!) { - lines.push(` - slug: ${yamlEscape(j.slug)}`) - lines.push(` title: ${yamlEscape(j.title)}`) - lines.push(` url: ${yamlEscape(j.url)}`) - if (j.level) lines.push(` level: ${yamlEscape(j.level)}`) - if (j.durationHours != null) lines.push(` durationHours: ${j.durationHours}`) - } - return lines.join('\n') - })() - : null - - // Phase 4.2 (#447 §9): emit `blogPosts` only when non-empty. Same omit-when- - // empty discipline as learningJourneys above — the Hugo `{{ with }}` guard - // would already hide the section, but omitting the key keeps generated - // frontmatter tidy. - // Shape matches /build/concepts payload: {slug,title,url,authorName,postedAt}. - // All five fields are required on the wire (Task 2 always populates them - // from BlogPosts.{authorName,postedAt}); no `if` guards inside the loop. - const blogPosts = (c.blogPosts && c.blogPosts.length > 0) - ? (() => { - const lines = ['blogPosts:'] - for (const b of c.blogPosts!) { - lines.push(` - slug: ${yamlEscape(b.slug)}`) - lines.push(` title: ${yamlEscape(b.title)}`) - lines.push(` url: ${yamlEscape(b.url)}`) - lines.push(` authorName: ${yamlEscape(b.authorName)}`) - lines.push(` postedAt: ${yamlEscape(b.postedAt)}`) - } - return lines.join('\n') - })() - : null - - // Phase 4.3 (#447 §8): emit `discoveryMissions` only when non-empty. - // The backend ships a raw `categorySlug` short-code; we resolve it - // to a user-facing English `categoryLabel` at emission time via the - // shared srv/lib/discovery-mission-categories.js helper (known slugs - // map to canonical English; unknown slugs fall back to title-case). - // Per-field guards: `effortLevel` and `categorySlug` are both optional - // on the wire, so the loop only emits those lines when present. - const discoveryMissions = (c.discoveryMissions && c.discoveryMissions.length > 0) - ? (() => { - const lines = ['discoveryMissions:'] - for (const m of c.discoveryMissions!) { - lines.push(` - slug: ${yamlEscape(m.slug)}`) - lines.push(` title: ${yamlEscape(m.title)}`) - lines.push(` url: ${yamlEscape(m.url)}`) - if (m.effortLevel != null) lines.push(` effortLevel: ${m.effortLevel}`) - if (m.categorySlug) { - lines.push(` categoryLabel: ${yamlEscape(categoryLabel(m.categorySlug))}`) - } - } - return lines.join('\n') - })() - : null - - // Phase 4.4 (#447 §9): emit `videos` only when non-empty. Pass-through — - // no helper transformation. `thumbnailUrl` is already a CDN URL from - // i.ytimg.com (allowed by approuter CSP `img-src`); `channelTitle` and - // `publishedAt` (ISO timestamp) flow through unchanged. Per-field guards - // because all three are optional on the wire. - const videos = (c.videos && c.videos.length > 0) - ? (() => { - const lines = ['videos:'] - for (const v of c.videos!) { - lines.push(` - slug: ${yamlEscape(v.slug)}`) - lines.push(` title: ${yamlEscape(v.title)}`) - lines.push(` url: ${yamlEscape(v.url)}`) - if (v.thumbnailUrl) lines.push(` thumbnailUrl: ${yamlEscape(v.thumbnailUrl)}`) - if (v.channelTitle) lines.push(` channelTitle: ${yamlEscape(v.channelTitle)}`) - if (v.publishedAt) lines.push(` publishedAt: ${yamlEscape(v.publishedAt)}`) - } - return lines.join('\n') - })() - : null - - // Phase 4.5 (#746 §5): emit `apiDocs` only when non-empty. Pass-through — - // no helper transformation. `category` and `apiType` are optional on the - // wire; per-field guards skip emission when absent. The Hugo template's - // surrounding `{{ with .Params.apiDocs }}` hides the entire section when - // the key is missing — omitting at the emitter keeps generated - // frontmatter tidy. `description` is deliberately NOT in this shape (LOB - // locator safety — see spec §3). - const apiDocs = (c.apiDocs && c.apiDocs.length > 0) - ? (() => { - const lines = ['apiDocs:'] - for (const a of c.apiDocs!) { - lines.push(` - slug: ${yamlEscape(a.slug)}`) - lines.push(` title: ${yamlEscape(a.title)}`) - lines.push(` url: ${yamlEscape(a.url)}`) - if (a.category) lines.push(` category: ${yamlEscape(a.category)}`) - if (a.apiType) lines.push(` apiType: ${yamlEscape(a.apiType)}`) - } - return lines.join('\n') - })() - : null - - // Phase 4.6 (#747 §5): emit `samples` only when non-empty. Pass-through — - // no helper transformation. `language`, `stars`, and `lastCommitAt` are - // optional on the wire; per-field guards skip emission when absent. The - // Hugo template's surrounding `{{ with .Params.samples }}` hides the - // entire section when the key is missing — omitting at the emitter keeps - // generated frontmatter tidy. `description` is deliberately NOT in this - // shape (LOB locator safety — see spec §3). Note: `stars != null` (not - // truthy) so `stars: 0` still emits the line — defensive against newly - // forked repos. - const samples = (c.samples && c.samples.length > 0) - ? (() => { - const lines = ['samples:'] - for (const s of c.samples!) { - lines.push(` - slug: ${yamlEscape(s.slug)}`) - lines.push(` title: ${yamlEscape(s.title)}`) - lines.push(` url: ${yamlEscape(s.url)}`) - if (s.language) lines.push(` language: ${yamlEscape(s.language)}`) - if (s.stars != null) lines.push(` stars: ${s.stars}`) - if (s.lastCommitAt) lines.push(` lastCommitAt: ${yamlEscape(s.lastCommitAt)}`) - } - return lines.join('\n') - })() - : null - - // Phase 4.7 (#748 §4.7): emit `helpDocs` only when non-empty. Pass-through — - // the /build/concepts payload has already: (1) mapped source → sourceLabel - // via the constant map in published-concepts-query.js, (2) derived - // anchorLabel by title-casing the anchor slug, and (3) precomputed the - // snippet from HelpDocConceptLinks.snippet (LOB-locator-safe read). - // This code just serializes what came off the wire. The Hugo template's - // surrounding `{{ with .Params.helpDocs }}` hides the entire section when - // the array is empty, so omitting the key entirely (rather than emitting - // `helpDocs: []`) keeps generated frontmatter tidy. - const helpDocs = (c.helpDocs && c.helpDocs.length > 0) - ? (() => { - const lines = ['helpDocs:'] - for (const h of c.helpDocs!) { - lines.push(` - slug: ${yamlEscape(h.slug)}`) - lines.push(` title: ${yamlEscape(h.title)}`) - lines.push(` url: ${yamlEscape(h.url)}`) - lines.push(` source: ${yamlEscape(h.source)}`) - lines.push(` sourceLabel: ${yamlEscape(h.sourceLabel)}`) - if (h.anchor) lines.push(` anchor: ${yamlEscape(h.anchor)}`) - if (h.anchorLabel) lines.push(` anchorLabel: ${yamlEscape(h.anchorLabel)}`) - if (h.snippet) lines.push(` snippet: ${yamlEscape(h.snippet)}`) - if (h.product) lines.push(` product: ${yamlEscape(h.product)}`) - } - return lines.join('\n') - })() - : null - - // Phase 4.8 (#765): emit `communityEvents` only when non-empty. Pass-through - // — no helper transformation. `eventType`, `location`, `scope`, - // `virtualOrInPerson`, `startDate`, `endDate` are all optional on the wire; - // per-field guards skip emission when absent. The Hugo template's surrounding - // `{{ with .Params.communityEvents }}` hides the entire section when the key - // is missing — omitting the key (rather than emitting `communityEvents: []`) - // keeps generated frontmatter tidy. `snippet` and `confidence` are - // deliberately not rendered in the Hugo template (they are concept-extraction - // metadata, not user-facing content). They flow through for future tooling. - const communityEvents = (c.communityEvents && c.communityEvents.length > 0) - ? (() => { - const lines = ['communityEvents:'] - for (const ev of c.communityEvents!) { - lines.push(` - slug: ${yamlEscape(ev.slug)}`) - lines.push(` title: ${yamlEscape(ev.title)}`) - lines.push(` url: ${yamlEscape(ev.url)}`) - if (ev.eventType) lines.push(` eventType: ${ev.eventType}`) - if (ev.location) lines.push(` location: ${yamlEscape(ev.location)}`) - if (ev.scope) lines.push(` scope: ${ev.scope}`) - if (ev.virtualOrInPerson) lines.push(` virtualOrInPerson: ${ev.virtualOrInPerson}`) - if (ev.startDate) lines.push(` startDate: ${ev.startDate}`) - if (ev.endDate) lines.push(` endDate: ${ev.endDate}`) - if (ev.snippet) lines.push(` snippet: ${yamlEscape(ev.snippet)}`) - } - return lines.join('\n') - })() - : null - - // NOTE: deliberately no `type:` field — Hugo's type-based lookup is singular - // ("type: concept" → layouts/concept/), but our template lives at - // layouts/concepts/ (matching the section). Section-based lookup is what we - // want; setting `type` here would silently bypass it. - const parts = [ - '---', - `slug: ${yamlEscape(c.slug)}`, - // [#813] Emit `title:` so Hugo's .Title is populated. Hugo's - // site-wide head partial (<title>, og:title, twitter:title) reads - // .Title — without this, every concept page rendered with an empty - // <title>, hurting SEO and breaking social shares. - // The concept-specific layout at layouts/concepts/single.html now - // reads .Title for the H1 too; list.html uses - // `.Title | default .Params.name` so both old (pre-#813) and new - // frontmatter render correctly during the rollout. - `title: ${yamlEscape(c.name)}`, - `description: ${yamlEscape(c.description)}`, - `teaches:${refs(c.teaches)}`, - `requires:${refs(c.requires)}`, - `requiredBy:${refs(c.requiredBy)}`, - `relatedTo:${refs(c.relatedTo)}`, - ] - if (journeys) parts.push(journeys) - if (blogPosts) parts.push(blogPosts) - if (discoveryMissions) parts.push(discoveryMissions) - if (videos) parts.push(videos) - if (apiDocs) parts.push(apiDocs) - if (samples) parts.push(samples) - if (helpDocs) parts.push(helpDocs) - if (communityEvents) parts.push(communityEvents) - parts.push('---', '') - return parts.join('\n') -} - -async function main() { - // #1327 Thread B — under the new pipeline (default) CAP renders concept - // detail pages + the list page server-side, so this Hugo fetcher must NOT - // emit concept .md (they'd become stale droplet copies + double-publish via - // publish-content's concept walk). Only run under the legacy escape hatch. - if (process.env.LEGACY_CONCEPT_RENDER !== 'true') { - console.log('[fetch-concepts] skipped — CAP renders concepts server-side (#1327). Set LEGACY_CONCEPT_RENDER=true to restore the Hugo path.') - return - } - console.log(`[fetch-concepts] GET ${CAP_BASE_URL}/build/concepts`) - const r = await fetch(`${CAP_BASE_URL}/build/concepts`) - if (!r.ok) { - throw new Error(`/build/concepts returned ${r.status}: ${await r.text().catch(() => '')}`) - } - const data = (await r.json()) as BuildConceptsResponse - - await fs.rm(OUT_DIR, { recursive: true, force: true }) - await fs.mkdir(OUT_DIR, { recursive: true }) - - // Counter enhancement (X published of Y total) deferred until /build/concepts - // exposes the total or a tech-user-authed graph probe is available. Out of - // scope for #446. - console.log(`[fetch-concepts] ${data.concepts.length} published concept(s) — writing pages`) - - for (const c of data.concepts) { - const filename = `${c.slug.toLowerCase()}.md` - await fs.writeFile(path.join(OUT_DIR, filename), frontmatter(c), 'utf8') - } - - await fs.writeFile(path.join(OUT_DIR, '_index.md'), - `---\ntitle: Concepts\n---\n`, 'utf8') - - console.log(`[fetch-concepts] wrote ${data.concepts.length} page(s) + _index.md to ${OUT_DIR}`) -} - -// Only run main() when invoked as a CLI — not when imported by tests. -// Mirrors the pattern in scripts/lint-tutorial-markdown.ts. -const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` -if (isMain || import.meta.url.endsWith(process.argv[1]?.replace(/\\/g, '/') ?? '')) { - main().catch((err) => { - console.error(err) - process.exit(1) - }) -} diff --git a/scripts/publish-content.ts b/scripts/publish-content.ts index 91c6ed3b7..e50413078 100644 --- a/scripts/publish-content.ts +++ b/scripts/publish-content.ts @@ -97,39 +97,6 @@ export function discoverTutorials(hugoDir: string): Map<string, string> { return result; } -// Concept landing pages (#446 Track 3-A). Walks hugo/public/concepts/<slug>/ -// and emits a map keyed by `concept-<slug>` so the rest of the publish -// pipeline (hash, payload, session) handles them transparently alongside -// tutorials. The `concept-` prefix lets the serve handler (srv/server.js) -// and ContentFiles share one slug column without a schema change. -export function discoverConcepts(hugoDir: string): Map<string, string> { - const conceptsDir = join(hugoDir, 'concepts'); - const result = new Map<string, string>(); - - let entries: string[]; - try { - entries = readdirSync(conceptsDir).filter(e => !e.startsWith('_')); - } catch { - // concepts/ directory missing entirely (no concepts published yet) — - // that's a normal state for fresh installs and the QA channel. - return result; - } - - for (const entry of entries) { - const indexPath = join(conceptsDir, entry, 'index.html'); - try { - const stat = statSync(indexPath); - if (stat.isFile()) { - result.set(`concept-${entry}`, indexPath); - } - } catch { - // not a concept directory - } - } - - return result; -} - // Concept slug predicate — used to skip Tutorials-only metadata extraction // for concept landing pages (metadata, bodyText, branchSpecs, source markdown // are all keyed off Tutorials.slug; concept-* keys would orphan in those @@ -959,24 +926,13 @@ async function main() { ); } - // #446 Track 3-A — concept landing pages. Merged into the same map so - // hash/payload/session orchestration treats them uniformly; the `concept-` - // prefix on each key is what lets the serve handler route correctly. - // - // #1327 Thread B — under the new pipeline (default) CAP renders concept - // detail pages server-side via the render-concepts phase (below), so we do - // NOT walk hugo/public/concepts here — that would double-publish. The legacy - // Hugo-walk stays available behind LEGACY_CONCEPT_RENDER=true as the escape - // hatch (paired with fetch-concepts.ts, which only emits concept .md under - // the same flag). - const legacyConceptRender = process.env.LEGACY_CONCEPT_RENDER === 'true'; - if (legacyConceptRender) { - const concepts = discoverConcepts(opts.hugoDir); - if (concepts.size > 0) { - for (const [slug, path] of concepts) tutorials.set(slug, path); - log(`Found ${concepts.size} concept landing page(s) (concept-*) in ${opts.hugoDir}/concepts [LEGACY_CONCEPT_RENDER]`); - } - } + // #446 Track 3-A / #1327 Thread B — concept detail pages are rendered + // server-side by CAP via the render-concepts publish phase (below), NOT + // walked from hugo/public/concepts here. The legacy Hugo concept pipeline + // (fetch-concepts.ts + hugo/layouts/concepts + the LEGACY_CONCEPT_RENDER + // escape hatch) was retired in #1327 Task 6 once the CAP path was stable on + // DEV + PROD. The `concept-<slug>` key convention, serve path, and delta + // machinery are unchanged — only the source of the BLOBs moved to CAP. // #1278 — single-tutorial fast path. Filter the discovered map down to the // one requested slug BEFORE hashing (computeLocalHashes reads 1 file instead @@ -1158,8 +1114,7 @@ async function main() { // #1327 Thread B — render concept detail pages server-side into the open // session, AFTER all tutorial batches (so __shell__ is present) and BEFORE - // commit. Skipped under the legacy flag (Hugo-walk already supplied concept - // BLOBs above) and on a single-tutorial slug hotfix (concepts aren't part of + // commit. Skipped on a single-tutorial slug hotfix (concepts aren't part of // that scope; the server carries them forward unchanged at commit). A phase // failure aborts the session — same posture as an append failure. // @@ -1171,7 +1126,7 @@ async function main() { // absent route → 404 → abortSession → exit 1, which is exactly what silently // broke every QA merge-to-main publish. Gating here keeps the caller // consistent with the server's route surface. - if (!legacyConceptRender && !opts.slug && channel === 'prod') { + if (!opts.slug && channel === 'prod') { try { const rc = await withRetry( () => renderConceptsPhase({ baseUrl: opts.baseUrl, apiKey: opts.apiKey, sessionId: begin.sessionId }), diff --git a/srv/lib/build-concepts.js b/srv/lib/build-concepts.js index 8c362089b..e082e9543 100644 --- a/srv/lib/build-concepts.js +++ b/srv/lib/build-concepts.js @@ -1,6 +1,7 @@ // Express middleware backing GET /build/concepts. -// Pattern matches srv/lib/build-catalog.js. Unauthenticated by design; -// consumed by scripts/fetch-concepts.ts at Hugo build time. +// Pattern matches srv/lib/build-catalog.js. Unauthenticated by design. +// Shares buildConceptsPayload with the CAP concept-render pipeline +// (concept-list-page.js + publish-concepts.js, #1327). import cds from '@sap/cds'; import { buildConceptsPayload } from './published-concepts-query.js'; diff --git a/srv/lib/discovery-mission-categories.js b/srv/lib/discovery-mission-categories.js index 8a37d44e4..db19a7458 100644 --- a/srv/lib/discovery-mission-categories.js +++ b/srv/lib/discovery-mission-categories.js @@ -5,8 +5,7 @@ // (e.g. 'onboard', 'intgn'); this table renders them as labels. // // Imported by: -// - scripts/fetch-concepts.ts (concept-page frontmatter resolution) -// - srv/knowledge-graph-service.js (sidebar neighborhood enrichment) +// - srv/lib/kg-other-resources-loader.js (categoryLabel for KG resource enrichment) // // Single source of truth. Add new entries as SAP adds categories; title-case // fallback handles unknown slugs gracefully. diff --git a/srv/lib/published-concepts-query.js b/srv/lib/published-concepts-query.js index ff40214f4..9de865c98 100644 --- a/srv/lib/published-concepts-query.js +++ b/srv/lib/published-concepts-query.js @@ -80,8 +80,8 @@ export async function buildConceptsPayload(db) { // once `ids` grows past a few thousand (5,946 published concepts as of // 2026-07-08). Every `.where({ concept_ID: { in: ids } })` below sends // one bound parameter per element; on HANA the packet blows and the - // whole /build/concepts endpoint 500s, taking `npm run fetch-concepts` - // (and therefore `npm run build:all`) with it. + // whole /build/concepts endpoint 500s, taking the CAP concept-render + // pipeline (concept-list-page.js / publish-concepts.js) with it. // // Same class of defect as #1063 (kg-featured-topics — slug IN over all // ConceptRank). Same repair pattern: fetch link tables unbounded and