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);