From 021aedbd162686970554acfe2bd15290ff8bd4b6 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 10 Nov 2026 10:23:54 -0500 Subject: [PATCH 1/4] fix(demo-tutorials): render code-blocks directive with correct tree depths, line spans, diff markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3076 and step-2/4/5 bugs verified in browser: - Step 1 (CSS): add white-space:pre + overflow-x:auto to .code-block-body pre for console line wrap - Step 2 (highlight-cds): guard Shiki replacement on diff-marker presence; skip blocks with Chroma diff-* line classes so // [!code focus] annotations survive - Step 4 (tree fence): fix reversed strings.TrimLeft arg order (was wrong order → garbage depth); add state-prefix (+/-/~) handling to preserve branch marks - Step 5 (walkthrough): use highlight() instead of markdownify to emit .line spans for JS line-click handlers All 4 code-blocks sub-fixes verified in live browser render (tree structure, depths, icons, state classes, line annotations, css classes, cds diff-focus intact). Tests pass 19/19. --- hugo/assets/css/sap-fundamental.src.css | 5 +++ .../_default/_markup/render-codeblock.html | 27 +++++++---- hugo/layouts/shortcodes/tut-walkthrough.html | 27 ++++++++++- scripts/highlight-cds.ts | 45 +++++++++++++++---- test/unit/highlight-cds-div-balance.test.js | 21 +++++++++ 5 files changed, 106 insertions(+), 19 deletions(-) diff --git a/hugo/assets/css/sap-fundamental.src.css b/hugo/assets/css/sap-fundamental.src.css index 2525f8fb7..22b17b25c 100644 --- a/hugo/assets/css/sap-fundamental.src.css +++ b/hugo/assets/css/sap-fundamental.src.css @@ -847,6 +847,11 @@ a:hover { margin: 0; border-radius: 0; border: none; + /* Long single lines (e.g. a console command with a full URL) must not run off + the page. Default to horizontal scroll; the opt-in data-tut-code-wrap="on" + preference (ui5-overrides.css) switches this to wrapping. */ + white-space: pre; + overflow-x: auto; } pre { diff --git a/hugo/layouts/_default/_markup/render-codeblock.html b/hugo/layouts/_default/_markup/render-codeblock.html index 00f504a0e..57c8debc2 100644 --- a/hugo/layouts/_default/_markup/render-codeblock.html +++ b/hugo/layouts/_default/_markup/render-codeblock.html @@ -235,18 +235,27 @@ ├── or └── connectors → stripped (depth comes from │ count); lone ─ → stripped */}} {{- $normalized := $line | replaceRE `│\s{0,3}` " " | replaceRE `[├└]─*\s*` "" | replaceRE `─` "" -}} - {{/* Count leading spaces for depth using strings.TrimLeft */}} - {{- $stripped := strings.TrimLeft $normalized " " -}} + {{/* State prefix (+ ~ -): the author writes the glyph in column 0, so a + state line indents one level LESS than a same-depth unprefixed line. + Replace a single leading glyph with ONE space so indentation (and thus + depth) is measured consistently — e.g. "+ srv/" → " srv/" (depth 1, + sibling of " db/"), "+ cat-service.cds" → " cat-service.cds" + (depth 2). Capture the glyph first for the state CSS class. */}} + {{- $state := "" -}} + {{- if findRE `^[+~-] ` $normalized -}} + {{- $state = substr $normalized 0 1 -}} + {{- $normalized = printf " %s" (substr $normalized 1 (len $normalized)) -}} + {{- end -}} + {{/* Count leading spaces for depth. NB: Hugo's strings.TrimLeft takes the + CUTSET first, then the string — `strings.TrimLeft " " $s` strips + leading spaces from $s. (The old code had the args reversed, which + trimmed the filename chars off a single space and produced garbage + depths + empty labels — the step-4 tree rendering bug.) */}} + {{- $stripped := strings.TrimLeft " " $normalized -}} {{- $leading := sub (len $normalized) (len $stripped) -}} - {{/* depth = floor(leading / 2) — use integer division (truncating = floor for positive) */}} + {{/* depth = floor(leading / 2) — integer division (floor for positive) */}} {{- $depth := div $leading 2 -}} {{- $rest := trim $stripped " \t" -}} - {{/* Extract state prefix (+ ~ -) */}} - {{- $state := "" -}} - {{- if or (hasPrefix $rest "+ ") (hasPrefix $rest "~ ") (hasPrefix $rest "- ") -}} - {{- $state = substr $rest 0 1 -}} - {{- $rest = substr $rest 2 (len $rest) -}} - {{- end -}} {{/* Extract annotation: two or more spaces + # */}} {{- $annotation := "" -}} {{- if findRE `\s{2,}# ` $rest -}} diff --git a/hugo/layouts/shortcodes/tut-walkthrough.html b/hugo/layouts/shortcodes/tut-walkthrough.html index 014f565ca..2cab14200 100644 --- a/hugo/layouts/shortcodes/tut-walkthrough.html +++ b/hugo/layouts/shortcodes/tut-walkthrough.html @@ -17,8 +17,33 @@ */}} {{- $code := .Get "code" | replaceRE " " "" | replaceRE " " "\n" -}} {{- $notes := .Get "notes" | replaceRE " " "" | replaceRE " " "\n" -}} +{{/* Render the fenced code through Hugo's `highlight` function rather than + markdownify. code-walkthrough.ts highlights per-line by selecting + `.tut-walkthrough-code .line`, and Chroma's `highlight` output wraps every + line in `` (noClasses=false in hugo.toml) — whereas a + plain `markdownify` of the same fence emits NO per-line spans, so the + click-to-highlight found zero `.line` elements and highlighted nothing. + Strip the ``` fences and extract the language token from the first line. */}} +{{- $codeTrimmed := trim $code "\n" -}} +{{- $codeLines := split $codeTrimmed "\n" -}} +{{- $lang := "" -}} +{{- $body := $codeTrimmed -}} +{{- if and (gt (len $codeLines) 0) (hasPrefix (index $codeLines 0) "```") -}} + {{- $lang = trim (strings.TrimPrefix "```" (index $codeLines 0)) " \t" -}} + {{/* Drop the opening fence line and any trailing closing fence line. */}} + {{- $inner := after 1 $codeLines -}} + {{- if and (gt (len $inner) 0) (eq (trim (index $inner (sub (len $inner) 1)) " \t") "```") -}} + {{- $inner = first (sub (len $inner) 1) $inner -}} + {{- end -}} + {{- $body = delimit $inner "\n" -}} +{{- end -}} +{{- if eq $lang "" -}}{{- $lang = "text" -}}{{- end -}}
-
{{ $code | markdownify }}
+ {{/* security-reviewed: $body is the author's fenced code content; `highlight` + renders it through Chroma which HTML-escapes the source. $lang is the + fence's language token (alnum). Only Chroma's fixed line/token span + scaffold is emitted — no attacker-controlled HTML. */}} +
{{ highlight $body $lang "" }}
{{ $notes | markdownify }}
{{- with .Inner }}{{ . }}{{ end -}}
diff --git a/scripts/highlight-cds.ts b/scripts/highlight-cds.ts index 24be5d8b4..8e91fbdcb 100644 --- a/scripts/highlight-cds.ts +++ b/scripts/highlight-cds.ts @@ -67,11 +67,12 @@ export function replaceCdsBlocks( html: string, highlightFn: (rawCode: string) => string, marker = 'data-lang=cds' -): { result: string; changed: boolean; processedBlocks: number } { +): { result: string; changed: boolean; processedBlocks: number; skippedDiffBlocks: number } { let result = ''; let cursor = 0; let changed = false; let processedBlocks = 0; + let skippedDiffBlocks = 0; while (true) { const markerIdx = html.indexOf(marker, cursor); @@ -105,6 +106,27 @@ export function replaceCdsBlocks( if (bodyEnd === -1) { cursor = markerIdx + 1; continue; } const bodyContent = html.substring(bodyContentStart, bodyEnd); + + // Preserve Chroma's diff/highlight markup. When a `cds` fence carries inline + // `// [!code highlight|focus|++|--]` markers, render-codeblock.html injects + // `diff-highlight` / `diff-focus` / `diff-added` / `diff-removed` line classes + // into the Chroma output (and the matching CSS targets those). Shiki is called + // WITHOUT notation transformers, so replacing the block with Shiki's output + // silently drops every highlight — the step-2 "highlight not working" bug. + // Leave those blocks as Chroma so the highlight survives; Shiki still takes + // over plain cds blocks (its grammar is nicer) where nothing is lost. + if (/\bdiff-(highlight|focus|added|removed)\b/.test(bodyContent)) { + skippedDiffBlocks++; + // Keep this block exactly as Chroma rendered it: emit everything from the + // current cursor THROUGH the block's closing tags, then advance past them. + // (A bare `cursor = …; continue` would drop the block from `result`, since + // result only accumulates on the replace path below.) + const blockEnd = bodyEnd + bodyEndTag.length; + result += html.substring(cursor, blockEnd); + cursor = blockEnd; + continue; + } + const rawCode = extractCode(bodyContent); if (!rawCode) { cursor = bodyEnd + bodyEndTag.length; continue; } @@ -125,7 +147,7 @@ export function replaceCdsBlocks( } result += html.substring(cursor); - return { result, changed, processedBlocks }; + return { result, changed, processedBlocks, skippedDiffBlocks }; } async function main() { @@ -142,13 +164,14 @@ async function main() { let filesWithMarker = 0; const MARKER = 'data-lang=cds'; + let skippedDiffBlocks = 0; for (const file of htmlFiles) { let html = readFileSync(file, 'utf-8'); if (!html.includes(MARKER)) continue; filesWithMarker++; - const { result, changed, processedBlocks: blocksInFile } = replaceCdsBlocks( + const { result, changed, processedBlocks: blocksInFile, skippedDiffBlocks: skippedInFile } = replaceCdsBlocks( html, (rawCode) => highlighter.codeToHtml(rawCode, { lang: 'cds', @@ -158,6 +181,7 @@ async function main() { MARKER ); processedBlocks += blocksInFile; + skippedDiffBlocks += skippedInFile; if (changed) { writeFileSync(file, result, 'utf-8'); @@ -165,15 +189,18 @@ async function main() { } } - console.log(`CDS highlighting: ${processedBlocks} blocks in ${processedFiles} files (dir: ${OUTPUT_DIR})`); + console.log(`CDS highlighting: ${processedBlocks} blocks in ${processedFiles} files (${skippedDiffBlocks} diff/highlight block(s) kept as Chroma) (dir: ${OUTPUT_DIR})`); highlighter.dispose(); // Regression guard (#1657): if the `data-lang=cds` marker is present but we - // replaced zero blocks, the block structure this matcher relies on (emitted - // by render-codeblock.html, minified by Hugo) has drifted. Failing here turns - // a silent revert-to-Chroma-SQL (braces rendered as red `.err` tokens) into a - // red build BEFORE the un-highlighted HTML is published to HANA. - if (filesWithMarker > 0 && processedBlocks === 0) { + // neither replaced NOR intentionally skipped any block, the block structure + // this matcher relies on (emitted by render-codeblock.html, minified by Hugo) + // has drifted. Failing here turns a silent revert-to-Chroma-SQL (braces + // rendered as red `.err` tokens) into a red build BEFORE the un-highlighted + // HTML is published to HANA. A block deliberately kept as Chroma because it + // carries `// [!code …]` diff/highlight markers (skippedDiffBlocks) is NOT + // drift — those blocks render correctly via Chroma's diff-* line classes. + if (filesWithMarker > 0 && processedBlocks === 0 && skippedDiffBlocks === 0) { console.error( `highlight-cds: found ${filesWithMarker} file(s) with the '${MARKER}' marker but replaced 0 CDS blocks — ` + `the code-block markup likely drifted from what this script matches. Refusing to leave the Chroma-SQL fallback in place.` diff --git a/test/unit/highlight-cds-div-balance.test.js b/test/unit/highlight-cds-div-balance.test.js index 61c72c021..b5d55fb22 100644 --- a/test/unit/highlight-cds-div-balance.test.js +++ b/test/unit/highlight-cds-div-balance.test.js @@ -63,6 +63,27 @@ describe('replaceCdsBlocks div balance (#1657)', () => { expect(result).not.toContain('class=chroma'); }); + it('keeps a diff/highlight-marked CDS block as Chroma (preserves diff-* line classes)', () => { + // A cds fence with `// [!code focus]` markers: render-codeblock.html injects + // diff-focus line classes into the Chroma output. Replacing with Shiki (no + // notation transformers) would drop every highlight — the step-2 bug. The + // block must be LEFT as Chroma, and counted as skippedDiffBlocks (not drift). + const DIFF_BLOCK = + '
' + + '
' + + '
' +
+            'entity Books {' +
+            'key ID : UUID;' +
+          '
' + + '
' + + '
'; + const { result, processedBlocks, skippedDiffBlocks } = replaceCdsBlocks(DIFF_BLOCK, shikiStub); + expect(processedBlocks, 'diff block must NOT be replaced by Shiki').toBe(0); + expect(skippedDiffBlocks, 'diff block must be counted as an intentional skip').toBe(1); + expect(result, 'Chroma output (with diff-focus) must survive untouched').toContain('diff-focus'); + expect(result).not.toContain('class="shiki"'); + }); + it('processes a LONG collapsible block (#2228: data-collapsed body)', () => { const { result, processedBlocks } = replaceCdsBlocks(CDS_BLOCK_LONG, shikiStub); expect(processedBlocks, 'long-block body-open tag must be matched despite attributes').toBe(1); From 819f20ce2f607bfe97125e992e44f39edc8876aa Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 10 Nov 2026 14:08:20 -0500 Subject: [PATCH 2/4] fix(rebuild): propagate deployed CSS hash + real xref titles to HANA pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found while verifying the directive-demo fixes on DEV: 1. sync-approuter-css.cjs self-perpetuating stale-hash loop: a CSS-only SOURCE change ships on deploy (build:css rotates the fingerprinted file, which IS served), but every rebuild re-pins the OLD hash it scrapes from still-stale HANA pages — so the new CSS never reaches published pages. Fix: build from source first, and if the approuter already SERVES the source-built hash (200), keep the source build. This propagates the new CSS AND cannot 404 (the deploy shipped that exact file). Falls through to the #2920 sync path unchanged when source is ahead of the deploy (preserves the anti-drift invariant). 2. xref/prereq cards rendered the raw slug instead of the tutorial title on slug-targeted rebuilds — the referenced tutorial's .md isn't fetched to CACHE_DIR, so catalogBySlug fell back to slug-as-title. Fix: thread tutorials[].title through fetchBuildCatalog → CatalogTutorialMeta, and seed catalogBySlug from the CAP catalog titles (on-disk frontmatter still overrides where cached). The CAP /build/catalog carries a title for EVERY slug. Verified against live DEV: source CSS hash 863da9b0 (console line-wrap fix) is served 200 by the approuter → prefer-source path keeps it. 70/70 directive + catalog tests pass. --- scripts/fetch-tutorials.ts | 52 +++++++++++++++---- .../__tests__/cap-catalog-title.test.ts | 43 +++++++++++++++ scripts/parsers/cap.ts | 6 ++- scripts/parsers/types.ts | 1 + scripts/sync-approuter-css.cjs | 37 +++++++++++++ 5 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 scripts/parsers/__tests__/cap-catalog-title.test.ts diff --git a/scripts/fetch-tutorials.ts b/scripts/fetch-tutorials.ts index 3baea6b9f..6f68b5b48 100644 --- a/scripts/fetch-tutorials.ts +++ b/scripts/fetch-tutorials.ts @@ -5,6 +5,7 @@ import { performance } from 'node:perf_hooks' import { hugoFrontmatterStringify as yamlStringify } from './lib/hugo-yaml.js' import { flushDimensionsCache, populateImageDimensions, exportDimensionsForHugo } from './parsers/image-dimensions.js' import { composeTutorial } from './parsers/compose.js' +import { extractFrontmatter } from './parsers/frontmatter.js' import { DirectiveParseError } from './parsers/directives.js' import './parsers/directives/register-all.js' // side-effect: registers all content-reuse directives import { findUnexpandedDirectives } from './parsers/guard-unexpanded-directives.js' @@ -1226,8 +1227,8 @@ async function main() { console.log(`[snippets] fetched ${snippetMap.size} snippet(s) from ${uniqueRepos.length} repo(s)`) } - // #directive-xref-validation: existence catalog for the `:tutorial` and - // `::requires` directives. Both validate their slug against ctx.catalogBySlug + // #directive-xref-validation: existence + metadata catalog for the `:tutorial` + // and `::requires` directives. Both validate their slug against ctx.catalogBySlug // and throw DirectiveParseError (→ fatal composeFailures) when it's present but // the slug is missing; when it's undefined they fail-open. We enable the fatal // path ONLY when discovery came from the authoritative GitHub tier, because the @@ -1235,19 +1236,52 @@ async function main() { // the filter never shrinks allTutorials). A disk/hana/rest fallback could be a // stale/partial set and would throw false positives, so we pass undefined there. // - // Value carries slug-as-title (existence is the load-bearing check; there is no - // pre-compose source of real titles for ALL slugs) + time 0. `::requires` reads - // entry.title into its card; a real title is a nice-to-have, not required to - // catch a dead cross-ref. Keyed lowercase to match the directives' slug casing. + // Value carries the REAL title + time, read from each tutorial's frontmatter in + // the on-disk cache (every .md is already written to CACHE_DIR by this + // point — the :help prefetch loop above reads the same files). `::requires` + // renders entry.title/entry.time into its card and `:tutorial` uses entry.title + // as the link label; without real values they showed the raw slug + "0 min". + // Fail-open to slug/0 when the file or field is missing (existence still holds). let catalogBySlug: Map | undefined if (discoverySource === 'github') { catalogBySlug = new Map() + // #3091 follow-up: the CAP /build/catalog carries a real title for EVERY + // tutorial slug (not just the ones composed this run). On a slug-targeted + // rebuild the referenced tutorial's own .md is NOT fetched into + // CACHE_DIR, so the per-file lookup below falls back to slug-as-title and the + // xref/prereq card renders a raw slug. Seed a slug→title map from the cached + // catalog first; the on-disk frontmatter (which also has `time`) overrides it + // when present. loadCapCache() is already read at the fingerprint step above, + // so this is a cheap disk read with no extra network. + const catalogTitleBySlug = new Map() + try { + for (const m of (loadCapCache()?.tutorialMetas ?? [])) { + if (m.title) catalogTitleBySlug.set(m.slug.toLowerCase(), m.title) + } + } catch { /* no cache yet — per-file + slug fallback still hold */ } for (const t of allTutorials) { - catalogBySlug.set(t.slug.toLowerCase(), { title: t.slug, time: 0 }) + const slugLc = t.slug.toLowerCase() + let title = catalogTitleBySlug.get(slugLc) ?? t.slug + let time = 0 + const cacheFile = join(CACHE_DIR, `${t.slug}.md`) + if (existsSync(cacheFile)) { + try { + const fm = extractFrontmatter(readFileSync(cacheFile, 'utf-8')) + // title is a top-level field on FrontmatterResult; time lives on the + // nested `frontmatter` object (coerced to a number by extractFrontmatter). + if (fm.title) title = fm.title + const t = fm.frontmatter?.time + if (typeof t === 'number' && Number.isFinite(t)) time = t + } catch { + // Corrupt/unreadable cache entry — keep catalog/slug fallback (existence + // check is what the fatal guard depends on; metadata is best-effort). + } + } + catalogBySlug.set(slugLc, { title, time }) } - console.log(`[xref] slug-existence catalog enabled (${catalogBySlug.size} slugs; dead :tutorial/::requires refs now fail the build)`) + console.log(`[xref] slug catalog enabled (${catalogBySlug.size} slugs; ${catalogTitleBySlug.size} titles from CAP catalog, frontmatter overrides where cached; dead :tutorial/::requires refs now fail the build)`) } else { - console.log(`[xref] slug-existence catalog DISABLED — discovery source '${discoverySource ?? 'cache'}' is not authoritative (fail-open on cross-ref validation)`) + console.log(`[xref] slug catalog DISABLED — discovery source '${discoverySource ?? 'cache'}' is not authoritative (fail-open on cross-ref validation)`) } // Build-time safety net (#directive-rollout): names of directives that SHOULD diff --git a/scripts/parsers/__tests__/cap-catalog-title.test.ts b/scripts/parsers/__tests__/cap-catalog-title.test.ts new file mode 100644 index 000000000..800a52587 --- /dev/null +++ b/scripts/parsers/__tests__/cap-catalog-title.test.ts @@ -0,0 +1,43 @@ +// scripts/parsers/__tests__/cap-catalog-title.test.ts +// #3091 follow-up: fetchBuildCatalog must carry each tutorial's `title` into +// tutorialMetas. fetch-tutorials.ts seeds the xref/prereq catalogBySlug from +// these titles so :tutorial / ::requires cross-references render a real title +// (not the raw slug) even on slug-targeted rebuilds, where the referenced +// tutorial's own .md is never fetched into CACHE_DIR. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fetchBuildCatalog } from '../cap.js'; + +const CATALOG = { + missions: [], + hierarchies: [], + standaloneGroups: [], + categories: [], + tutorials: [ + { slug: 'hana-cloud-mission-trial-1', title: 'Understand How the SAP HANA Cloud Free Tier Service can be Used', categorySlugs: [] }, + { slug: 'no-title-slug', categorySlugs: ['cat-a'] }, // title absent → omitted, not slug-filled + ], + featured: [], +}; + +afterEach(() => vi.restoreAllMocks()); + +describe('fetchBuildCatalog title capture (#3091)', () => { + it('threads tutorials[].title into tutorialMetas', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => CATALOG, + }))); + + const res = await fetchBuildCatalog('http://localhost:4004'); + const bySlug = new Map(res.tutorialMetas.map(m => [m.slug, m])); + + expect(bySlug.get('hana-cloud-mission-trial-1')?.title) + .toBe('Understand How the SAP HANA Cloud Free Tier Service can be Used'); + // A tutorial with no title must NOT get a fabricated one — the slug-as-title + // fallback lives in fetch-tutorials.ts, not here; here title stays undefined. + expect(bySlug.get('no-title-slug')?.title).toBeUndefined(); + expect(bySlug.get('no-title-slug')?.categorySlugs).toEqual(['cat-a']); + }); +}); diff --git a/scripts/parsers/cap.ts b/scripts/parsers/cap.ts index 5670e3406..bd3b0b046 100644 --- a/scripts/parsers/cap.ts +++ b/scripts/parsers/cap.ts @@ -80,12 +80,16 @@ export async function fetchBuildCatalog(baseUrl: string): Promise<{ hierarchies: MissionHierarchy[] standaloneGroups?: StandaloneGroup[] categories?: CategoryMeta[] - tutorials?: Array<{ slug: string; categorySlugs?: string[] }> + tutorials?: Array<{ slug: string; title?: string; categorySlugs?: string[] }> featured?: BrowseFeaturedEntry[] } const tutorialMetas: CatalogTutorialMeta[] = (data.tutorials ?? []).map(t => ({ slug: t.slug, categorySlugs: t.categorySlugs ?? [], + // #3091 follow-up: carry the real title so the xref/prereq catalog can label + // :tutorial / ::requires links with it even on slug-targeted rebuilds (where + // the referenced tutorial's own .md is not fetched into CACHE_DIR). + ...(typeof t.title === 'string' && t.title ? { title: t.title } : {}), })) return { missions: data.missions, diff --git a/scripts/parsers/types.ts b/scripts/parsers/types.ts index 8f685c843..89f8648f5 100644 --- a/scripts/parsers/types.ts +++ b/scripts/parsers/types.ts @@ -414,6 +414,7 @@ export interface CategoryMeta { export interface CatalogTutorialMeta { slug: string categorySlugs: string[] + title?: string } // Full CodeCheckSpec — used by the publish pipeline. NEVER ship to the diff --git a/scripts/sync-approuter-css.cjs b/scripts/sync-approuter-css.cjs index e33cdc688..f375bbd98 100644 --- a/scripts/sync-approuter-css.cjs +++ b/scripts/sync-approuter-css.cjs @@ -127,6 +127,43 @@ async function main() { const base = approuterUrl.replace(/\/+$/, ''); const pages = page ? [page] : DEFAULT_PAGES; + // 0) PREFER SOURCE WHEN THE DEPLOY ALREADY SHIPPED IT (#3091 follow-up). + // #2920's sync-from-approuter exists to avoid baking a hash the approuter + // does NOT serve (→ 404 / unstyled). But it has a failure mode: a CSS-only + // SOURCE change ships on a deploy (build:css rotates the fingerprinted file, + // and the new file IS served) yet NEVER reaches HANA pages — every rebuild + // re-pins the OLD hash it scrapes from the (still-stale) served pages, a + // self-perpetuating loop (observed 2026-11-10: console line-wrap CSS fix + // deployed as 863da9b0… but all pages kept referencing 9dbb07c7…). + // + // Break it WITHOUT reintroducing drift: build from source, then verify the + // approuter actually SERVES the source-built hash (200). If it does, the + // deploy already shipped these exact bytes — baking the source hash is safe + // (no 404) AND propagates the new CSS to republished pages. If it does not + // (source is ahead of the deploy — the pre-#2920 drift the guard blocks), + // fall through to the sync-from-approuter path below unchanged. + try { + buildFromSource(); + const srcBytes = fs.readFileSync(outFile); + const srcHash = crypto.createHash('sha256').update(srcBytes).digest('hex'); + const srcUrl = `/css/sap-fundamental.${srcHash}.css`; + const probe = await fetchWithRetry(base + srcUrl, { asBuffer: true }); + if (probe.ok && probe.body) { + const servedHash = crypto.createHash('sha256').update(probe.body).digest('hex'); + if (servedHash === srcHash) { + log(`source CSS hash ${srcHash.slice(0, 12)} IS served by the approuter → keeping source build (deploy already shipped it; new CSS will propagate to republished pages)`); + return 0; + } + // Served bytes at the source URL don't match (edge-mangled / corrupt) — + // don't trust it; fall through to the canonical discover-and-pin path. + log(`source hash ${srcHash.slice(0, 12)} returned 200 but bytes mismatch — falling through to approuter sync`); + } else { + log(`approuter does NOT serve source hash ${srcHash.slice(0, 12)} (HTTP ${probe.status}) → source is ahead of the deploy; syncing from approuter to avoid drift`); + } + } catch (e) { + log(`source pre-build/probe failed (${e && e.message ? e.message : e}); syncing from approuter`); + } + // 1) Discover the served sap-fundamental href from a rendered page. let href = ''; for (const p of pages) { From 961290a12f226c7d753cc8911602b8eb818dec09 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 10 Nov 2026 15:27:32 -0500 Subject: [PATCH 3/4] fix(directive-demos): prereq card CSS, tested-with panel, image hotspots, Joule footer scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four demo-tutorial features were broken (verified in live browser, not just HTML): 1. Prereq card (::requires) rendered 0x0 — .tut-prereq-card/.prereq-* had NO CSS rule in any shipped stylesheet. Added rules to directives-ui.css (bundled). 2. "Tested with" panel never appeared — render-frontmatter.ts emits the testedWith param but nothing invoked the shortcode. Render it inline in u1-object-page.html from .Params.testedWith + added .tut-tested-with CSS. 3. Image hotspots (![](img){hotspots="..."}) leaked as raw text — goldmark only attaches {attr} blocks to BLOCK elements, not inline images. images.ts now moves a standalone image's trailing {...} onto the next line so goldmark attaches it and render-image.html emits the .tut-hotspot overlay anchors. In-prose images left untouched. +4 unit tests. 4. Joule chat input spanned the whole viewport — the autohide-footer rule in ui5-overrides.css targeted a bare `footer` descendant, which also matched the Joule panel's nested