Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions hugo/assets/css/sap-fundamental.src.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 18 additions & 9 deletions hugo/layouts/_default/_markup/render-codeblock.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 -}}
Expand Down
27 changes: 26 additions & 1 deletion hugo/layouts/shortcodes/tut-walkthrough.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<span class="line">` (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 -}}
<div class="tut-walkthrough" data-walkthrough>
<div class="tut-walkthrough-code">{{ $code | markdownify }}</div>
{{/* 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. */}}
<div class="tut-walkthrough-code">{{ highlight $body $lang "" }}</div>
<div class="tut-walkthrough-notes">{{ $notes | markdownify }}</div>
{{- with .Inner }}{{ . }}{{ end -}}
</div>
45 changes: 36 additions & 9 deletions scripts/highlight-cds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
Expand All @@ -125,7 +147,7 @@ export function replaceCdsBlocks(
}

result += html.substring(cursor);
return { result, changed, processedBlocks };
return { result, changed, processedBlocks, skippedDiffBlocks };
}

async function main() {
Expand All @@ -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',
Expand All @@ -158,22 +181,26 @@ async function main() {
MARKER
);
processedBlocks += blocksInFile;
skippedDiffBlocks += skippedInFile;

if (changed) {
writeFileSync(file, result, 'utf-8');
processedFiles++;
}
}

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.`
Expand Down
21 changes: 21 additions & 0 deletions test/unit/highlight-cds-div-balance.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
'<div class=code-block data-lang=cds>' +
'<div class=code-block-body data-diff=true>' +
'<div class=highlight><pre tabindex=0 class=chroma><code>' +
'<span class=line>entity Books {</span>' +
'<span class="line diff-focus">key ID : UUID;</span>' +
'</code></pre></div>' +
'</div>' +
'</div>';
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);
Expand Down
Loading