diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index ddad64655..d173ec062 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.40.1 +version: 1.40.2 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/docs/superpowers/specs/2026-10-20-option-block-rendering-design.md b/docs/superpowers/specs/2026-10-20-option-block-rendering-design.md new file mode 100644 index 000000000..f72dc05fa --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-option-block-rendering-design.md @@ -0,0 +1,188 @@ +# Design: Fix OPTION-block rendering (issue #2820) + +**Date:** 2026-10-20 +**Issue:** sap-tutorials/tutorials-ims#2820 +**Status:** Draft v2 — awaiting approval (HARD GATE, no code until approved) + +> v1 of this design (switch shortcodes to percent-delimited) was **disproven by +> real Hugo renders** before any code was written: a block-`
`-emitting +> shortcode closes the surrounding tight list at *any* indent, so percent-delimiting +> relocated the same column-0 requirement to the body and reproduced the leak. v2 +> below is verified against production-pinned Hugo 0.147.7. + +## Intent and why + +Tutorial steps containing an `[OPTION BEGIN]…[OPTION END]` block followed by +shared content (prose, image, `> [!TIP]` alerts) render **raw, unparsed markdown +inside a code block** on the live site (e.g. `hana-clients-install` step 3). This +is a regression worse than the original #2820 report. Three PRs (#2822, #2826, +#2842) each tried to fix it in place and slid the breakage sideways; #2842 +(`19db8d027`) tipped it into the raw-markdown leak. All three are in deployed +v1.40.2. + +Fix it once, structurally, so the class of bug cannot recur. + +## Two proven facts that constrain any fix (real Hugo 0.147.7 renders) + +1. **A Hugo shortcode that emits a block-level `
` closes the surrounding + tight Markdown list, regardless of source indentation** (verified at 3/4/5 + spaces). Therefore the OPTION block **cannot be a child of the `
  • `**. #2820's + original premise ("keep it nested in the list item") is unachievable with the + current shortcode shape. The OPTION block is inherently a **sibling** of the + numbered list. + +2. **Once the list closes, every following line's block type is measured from + column 0**, where CommonMark's ≥4-space rule makes anything at the authored + 4-space substep indent an **indented code block**. This is why the panel bodies + are already dedented to column 0 today (#2703) — and why the **trailing shared + tail must be too**. + +The regression: commit `19db8d027` **removed** the "Bug B" tail-dedent that used +to push the trailing shared content to column 0. Without it, the tail stays at +4-space, the (already-closed) list measures it from column 0, and it leaks as +indented code — the SHELL box of raw markdown in the screenshot. + +## The fix (verified) + +**Restore the trailing-tail dedent to column 0 in `scripts/parsers/options.ts`, +and leave the shortcodes and panel-body dedent exactly as they are.** + +Concretely: after `convertOptionBlocks` splices the OPTION shortcode for a group, +dedent the shared substep content that trails the group (up to the next top-level +numbered item or step boundary) to column 0, the same way panel bodies are +dedented. No shortcode changes, no CSS double-pass changes to the render path. + +The author's own numbered ordinals do the rest: a bare `4.` after the col-0 tail +renders as `
      ` automatically (Goldmark reads the source ordinal — no +parser injection needed). The result is: list items 1–N, then the OPTION block + +tail as siblings, then `
        ` resuming the numbering. + +### Proof (verbatim, production-pinned Hugo 0.147.7, real shortcodes + render hooks) + +Input: `3.` substep with an OS OPTION group (```Shell``` bodies) + trailing prose ++ captioned image + two `> [!TIP]`, all dedented to column 0, followed by a bare +`4.`. Rendered output: + +- fence → `
        ` (real code block, not literal backticks) ✅ +- image → `
        ` + `
        ` ✅ +- `> [!TIP]` → `
        ` ✅ +- following substep → `
          ` (numbering continues) ✅ +- **no `` ```Shell ``, no `![...]`, no `> [!TIP]` literal text anywhere** ✅ + +A second probe confirmed `
            ` resumes automatically from a bare `3.` +after column-0 intervening content. + +## Why this is the right altitude (not the prior attempts) + +The three prior fixes all tried to keep the OPTION block *inside* the list — which +the Hugo renders prove is impossible. This design accepts the honest DOM: the +block is a **sibling**, numbering continues via `
              `, and the only thing +that must hold is "everything after the list-closing shortcode is at column 0." +That is a single, local, well-understood invariant — the exact one `19db8d027` +violated by deleting the tail-dedent. + +## Visual contract to preserve (anti-goal: no visible change) + +Rendered output must match a correct render: + +- `> [!TIP]` green alert blockquote; captioned image left-aligned `
              ` + (#2820), single-line `` (#1591); fences as `.code-block` with copy button. +- OS panels: all visible as no-JS fallback; `os-toggle.ts` shows only active OS. +- Substep numbering continuous across the OPTION block via `
                `. + +### CSS: one latent clobber must be fixed (the parser fix EXPOSES it) + +The #2703/#2820 rules in `sap-fundamental.src.css` (~1347-1373) push +`.step-content > .os-options` and its `~ p/figure/pre/blockquote` siblings to +`margin-left: 1.5rem`, plus `.step-content > ol { margin-left: 0 }`. These are +built for exactly the sibling structure this fix produces. For the trailing +**prose** and **`> [!TIP]` blockquotes** they match and do their job (verified by +real-Hugo render + computed-style measurement: both land at 24px / 1.5rem). + +**But the trailing captioned IMAGE is wrong** (measured: `margin-left: 0`, jammed +at the far-left column, out of alignment with the prose/tips around it). Root +cause — two equal-specificity rules both match the figure, so source order wins: + +- line 1358: `.step-content > .os-options ~ figure { margin-left: 1.5rem }` +- line 1389: `.step-content > .os-options ~ .tutorial-figure { margin: 0.5rem 0 }` + — **the later `margin` shorthand resets `margin-left` back to 0**, clobbering the + indent above it. + +This is a **pre-existing latent CSS bug**: today the trailing image leaks as raw +code, so it never renders as a `.tutorial-figure` in the tail and the clobber is +invisible. The parser fix makes the image render correctly as a figure, which +**exposes** the clobber. So the fix is **two parts**: + +1. Parser: restore the Bug-B tail-dedent (stops the leak). +2. CSS: change line 1389's rhythm rule from the `margin: 0.5rem 0` shorthand to + `margin-top`/`margin-bottom` (or re-assert `margin-left: 1.5rem`) so it sets + vertical rhythm **without** resetting the horizontal indent. Apply to both the + `~ .tutorial-figure` (os-options and option-tabs) selectors. + +The `.os-panel .tutorial-figure` / `.option-tabs .tutorial-figure` entries on the +same rule (figures *inside* a panel) do NOT need the 1.5rem indent (the panel +already provides the column), so splitting that selector list is cleanest: keep +the shorthand for in-panel figures, use margin-top/bottom + margin-left:1.5rem for +the trailing (`~`) figures. Edit the `.src.css` SOURCE; the `.css` artifact is +generated by the build. + +This corrects design v1's wrong assumption (that content moves *inside* the `
              1. `) +AND the first draft of v2's over-optimistic "keep them, they just work" — the prose +and tips just work; the figure needs the shorthand clobber fixed. + +## Non-goals / anti-goals + +- Not changing tutorial markdown **source** (the Windows-variant additions from + the issue thread are a separate tutorial-repo concern, tracked separately). +- Not changing the shortcode delimiters or `os-panel.html`'s `markdownify`. +- Not touching `os-toggle.ts` hydration / OS picker. +- Not touching the VitePress target branch. +- A pre-existing `

                ` wrapping the `

                ` (invalid-ish but cosmetic, present + in all working paths today) is **out of scope** — do not try to fix it here. + +## Plan of work (failing-test-first; TDD) + +1. **Reproducing test first** — render-level (real `convertOptionBlocks` + real + Hugo shortcodes, mirroring `option-figure-list-nesting.test.ts`). Fixture = the + exact #2820 shape (4-space-authored substep, OS OPTION group with fence body, + trailing prose + captioned image + two `> [!TIP]`, then a `4.`). Assert no raw + leak (`not.toContain('```Shell')`, no raw `![`/`> [!TIP]`), tip→`alert-tip`, + image→`tutorial-figure`, fence→`.code-block`, and numbering resumes + (`
                  `). Must **fail** on current `main`. +2. Restore the tail-dedent in `options.ts`; make the test pass. One change. +3. Update tests that encode the old (buggy) contract: + `options-hugo.test.ts:425-468` (asserts the 4-space tail — now must assert + col-0 tail) and `option-figure-list-nesting.test.ts` as needed. Keep + `corpus-stepcount.test.ts` and `v2-fenced-h3.test.ts` green. +4. Fix the CSS figure-indent clobber (line ~1389) and confirm all trailing + children (prose, figure, tips) land at the 1.5rem content column; reopened + `
                    ` not double-indented. Measure computed styles, don't eyeball. +5. Full render verification on real OPTION-heavy tutorials: `hana-clients-install`, + `-hdbsql`, `-jdbc`, `-golang`, `deploy-to-kyma` — build + visual check. +6. PR targeting **DEV** (never direct-merge; PR review required). + +## Golden reference for step 5 + +"Correct" = a render with no raw-markdown leak and the visual contract above. Since +deployed v1.40.2 and all three prior PRs are broken, the reference is the +**pre-#2822 behavior for the non-leaking parts** plus the proven render in this +doc for the OPTION region. Screenshots in issue #2820 define the user-visible +"wrong"; the step-1 test defines machine-checkable "right." + +## Left to the builder + +- Exact dedent boundary logic (where the "trailing tail" ends: next top-level + `N.`, next `### `/step boundary, or EOF) — follow how the old Bug-B loop scoped + it (`git show` the deletion in `19db8d027`) and the panel-body dedent. +- Test fixture wording/placement per existing conventions. +- Whether to reinstate the old loop verbatim or re-express it more clearly. + +## Risks + +- **Regular (non-OS) OPTION groups** use the same `convertOptionBlocks` splice, so + the tail-dedent must cover them too (not just OS groups). The reproducing test + should include a non-OS variant, or a second test. Confirm scope in step 1. +- **Tail scoping**: dedenting too far (past the next numbered item) would pull a + genuinely-nested sub-list to column 0. The boundary must stop at the next + top-level ordinal. Covered by the corpus gate + step 5. +- **A step with an OPTION block but no trailing tail** must be a no-op — verify. diff --git a/hugo/assets/css/sap-fundamental.css b/hugo/assets/css/sap-fundamental.css index 00dda617a..78603fbaa 100644 --- a/hugo/assets/css/sap-fundamental.css +++ b/hugo/assets/css/sap-fundamental.css @@ -2875,10 +2875,23 @@ ui5-li .step-badge-row { * `.step-content li .tutorial-figure` below. */ .os-panel .tutorial-figure, -.option-tabs .tutorial-figure, +.option-tabs .tutorial-figure { + margin: 0.5rem 0; +} + +/* A figure that TRAILS an OPTION group is a column-0 sibling of the list (the + * OPTION shortcode's block
                    closes the list, #2820), so it needs the same + * 1.5rem content-column indent as the trailing prose/alerts above. Set the + * vertical rhythm with margin-top/bottom ONLY — using the `margin` shorthand + * here would reset margin-left back to 0 and clobber the 1.5rem indent set by + * the `~ figure` rule above (equal specificity, later rule wins), leaving the + * image jammed at the far-left edge, out of alignment with the surrounding + * prose and tips. */ .step-content > .os-options ~ .tutorial-figure, .step-content > .option-tabs ~ .tutorial-figure { - margin: 0.5rem 0; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + margin-left: 1.5rem; } diff --git a/hugo/assets/css/sap-fundamental.src.css b/hugo/assets/css/sap-fundamental.src.css index 2fad08f00..e653454d2 100644 --- a/hugo/assets/css/sap-fundamental.src.css +++ b/hugo/assets/css/sap-fundamental.src.css @@ -1383,10 +1383,23 @@ ui5-li .step-badge-row { * `.step-content li .tutorial-figure` below. */ .os-panel .tutorial-figure, -.option-tabs .tutorial-figure, +.option-tabs .tutorial-figure { + margin: 0.5rem 0; +} + +/* A figure that TRAILS an OPTION group is a column-0 sibling of the list (the + * OPTION shortcode's block
                    closes the list, #2820), so it needs the same + * 1.5rem content-column indent as the trailing prose/alerts above. Set the + * vertical rhythm with margin-top/bottom ONLY — using the `margin` shorthand + * here would reset margin-left back to 0 and clobber the 1.5rem indent set by + * the `~ figure` rule above (equal specificity, later rule wins), leaving the + * image jammed at the far-left edge, out of alignment with the surrounding + * prose and tips. */ .step-content > .os-options ~ .tutorial-figure, .step-content > .option-tabs ~ .tutorial-figure { - margin: 0.5rem 0; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + margin-left: 1.5rem; } diff --git a/hugo/data/whats_new.json b/hugo/data/whats_new.json index 19ea0cd40..0ed97a86c 100644 --- a/hugo/data/whats_new.json +++ b/hugo/data/whats_new.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-18T19:56:36.553Z", + "generatedAt": "2026-10-19T21:53:00.976Z", "repos": [ { "key": "tutorials-ims", @@ -8,6 +8,136 @@ } ], "entries": [ + { + "id": "tutorials-ims#2851", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2851, + "title": "Docs site builds green again", + "summary": "Restored the documentation site build by registering the new HANA AI Core embeddings runbook in the docs sidebar.", + "category": "Fix", + "mergedAt": "2026-10-19T20:42:24Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2851" + }, + { + "id": "tutorials-ims#2849", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2849, + "title": "HANA to AI Core embedding setup, now live on both tiers", + "summary": "Finalized the setup guide for connecting SAP HANA Cloud to AI Core for vector embeddings, with a rebind-proof grant approach verified live on both development and production.", + "category": "Docs", + "mergedAt": "2026-10-19T20:03:15Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2849" + }, + { + "id": "tutorials-ims#2848", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2848, + "title": "Unblocked database deploys for AI Core embeddings", + "summary": "Corrected the HANA to AI Core setup to use a one-time admin action instead of a container grant, unblocking database deployments that had been failing.", + "category": "Fix", + "mergedAt": "2026-10-19T17:29:43Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2848" + }, + { + "id": "tutorials-ims#2847", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2847, + "title": "Corrected PSE privilege names in embeddings runbook", + "summary": "Fixed the privilege names in the HANA AI Core embeddings setup guide so the database security role deploys correctly.", + "category": "Docs", + "mergedAt": "2026-10-19T16:27:34Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2847" + }, + { + "id": "tutorials-ims#2846", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2846, + "title": "Corrected the AI Core embeddings grant mechanism", + "summary": "Documented the correct mechanism for granting the HANA container the privilege it needs to reach AI Core for embeddings.", + "category": "Docs", + "mergedAt": "2026-10-19T15:50:12Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2846" + }, + { + "id": "tutorials-ims#2844", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2844, + "title": "HANA-native AI Core embedding setup guide", + "summary": "Added a setup runbook for generating vector embeddings directly from the HANA database layer, proving the built-in natural-language path works today.", + "category": "Docs", + "mergedAt": "2026-10-19T14:50:30Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2844" + }, + { + "id": "tutorials-ims#2842", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2842, + "title": "Tutorial option-block images stay indented", + "summary": "Fixed images inside tutorial OPTION blocks so they stay properly nested and indented under their step instead of drifting to the left margin.", + "category": "Fix", + "mergedAt": "2026-10-19T13:15:50Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2842" + }, + { + "id": "tutorials-ims#2843", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2843, + "title": "Retired this year's Devtoberfest scavenger hunt", + "summary": "Removed the hidden Devtoberfest scavenger-hunt clues from the homepage and other pages now that the event has ended; the feature is dormant and ready to re-enable next year.", + "category": "Maintenance", + "mergedAt": "2026-10-19T13:12:44Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2843" + }, + { + "id": "tutorials-ims#2840", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2840, + "title": "Readable App Space hero text in light mode", + "summary": "Fixed hard-to-read dark banner text on the themed App Space hero in light mode by pinning it to white across all theme variants.", + "category": "Fix", + "mergedAt": "2026-10-19T11:36:20Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2840" + }, + { + "id": "tutorials-ims#2838", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2838, + "title": "Scheduler and sign-in reliability fixes", + "summary": "Fixed two reliability regressions from the identity-pin change: the background scheduler no longer stalls on every tick, and authenticated sign-ins no longer hit a token error.", + "category": "Fix", + "mergedAt": "2026-10-19T10:37:09Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2838" + }, { "id": "tutorials-ims#2832", "repo": "tutorials-ims", diff --git a/mta-mcp.yaml b/mta-mcp.yaml index cf363813b..3ac374ead 100644 --- a/mta-mcp.yaml +++ b/mta-mcp.yaml @@ -4,7 +4,7 @@ _schema-version: 3.3.0 # This file lives at repo root → path: gen/srv-mcp (root-relative, no ../). # before-all runs from ROOT (no cd ..) — mbt uses the descriptor's directory as cwd. ID: tutorials-mcp -version: 1.0.1 +version: 1.0.2 # Destinations are subaccount-level, so DEV and PROD (sharing one subaccount) # need DISTINCT destination names. `dest-main-srv` defaults to the PROD name; diff --git a/scripts/__tests__/options-hugo.test.ts b/scripts/__tests__/options-hugo.test.ts index 4ddedd1ea..46a5b7745 100644 --- a/scripts/__tests__/options-hugo.test.ts +++ b/scripts/__tests__/options-hugo.test.ts @@ -422,7 +422,7 @@ describe('convertOptionBlocks (hugo) — real QA substep patterns (#2703 follow- expect(out).not.toMatch(/\n {4}```\n/); }); - it('leaves substep content that trails the OPTION group at its substep indent (stays in the list item)', () => { + it('dedents substep content that trails the OPTION group to column 0 (#2820)', () => { const input = [ '3. Start the installer.', '', @@ -455,15 +455,21 @@ describe('convertOptionBlocks (hugo) — real QA substep patterns (#2703 follow- const out = convertOptionBlocks(input, 'hugo'); - // Approach A (#2820): the shortcode wrappers stay indented, so the list - // never closes. Trailing substep-3 content therefore KEEPS its 4-space - // substep indent and remains valid list-item continuation — dedenting it to - // column 0 (the old behaviour) would eject it from the
                  1. , which is the - // bug. See test/unit/option-figure-list-nesting.test.ts for the render-level - // proof that the trailing figure nests inside
                  2. /
                      . - expect(out).toMatch(/\n {4}Set the install directory/); - expect(out).toMatch(/\n {4}!\[Client install\]/); - expect(out).toMatch(/\n {4}> \[!TIP\]/); + // #2820: the OPTION shortcode emits a block-level
                      that CLOSES the + // surrounding tight list (verified against production-pinned Hugo 0.147.7 — + // regardless of wrapper indent). Once the list closes, trailing content at + // the authored 4-space indent is measured from column 0 and becomes a literal + // indented-code block → the raw-markdown leak. So the trailing substep + // content MUST be dedented to column 0 to render as sibling markdown; the + // author's `4.` then reopens the list as
                        . (This reverses the + // 19db8d027 regression, which left the tail at 4 spaces.) See + // test/unit/option-tail-leak.test.ts for the render-level leak proof. + expect(out).toMatch(/\nSet the install directory/); + expect(out).toMatch(/\n!\[Client install\]/); + expect(out).toMatch(/\n> \[!TIP\]/); + // The tail must NOT remain at 4-space indent (that is the leaking shape). + expect(out).not.toMatch(/\n {4}Set the install directory/); + expect(out).not.toMatch(/\n {4}> \[!TIP\]/); expect(out).toContain('\n4. After installation'); }); }) diff --git a/scripts/parsers/options.ts b/scripts/parsers/options.ts index 811d5e99e..8754ea462 100644 --- a/scripts/parsers/options.ts +++ b/scripts/parsers/options.ts @@ -245,13 +245,41 @@ export function convertOptionBlocks( result = result.slice(0, lineStart) + replacement + result.slice(end) - // NOTE: the old "Bug B" tail-dedent (which pushed trailing substep content - // to column 0) is deliberately gone. It only existed to survive the - // flush-left list CLOSURE: once the list closed, still-indented trailing - // content became a literal indented-code block. Now that the shortcode - // wrappers stay indented the list never closes, so trailing content keeps - // its authored substep indentation and remains valid list-item continuation - // — dedenting it here would instead EJECT it from the
                      1. (#2820). + // Bug B (#2820): content that trails the OPTION group but still belongs to + // the same numbered substep (e.g. "Set the install directory…", an image, a + // `> [!TIP]`) is authored indented 4 spaces. A Hugo shortcode that emits a + // block-level
                        CLOSES the surrounding tight list regardless of the + // wrapper indent (verified against production-pinned Hugo 0.147.7 — the + // os-options/os-panel div interrupts list continuation). Once the list has + // closed, Goldmark measures the still-4-space-indented tail from column 0, + // where the >=4-space rule turns it into a literal indented-code block — the + // exact raw-markdown leak of #2820. So dedent the tail to column 0 (up to the + // next list marker / shortcode / de-indented line) so it renders as sibling + // markdown; the author's own `N.` ordinal then reopens the list as + //
                          and numbering continues. + // + // Commit 19db8d027 DELETED this loop on the false premise that "with the + // wrappers indented the list never closes" — the Hugo renders disprove that; + // the list always closes, so the dedent is mandatory. See + // docs/superpowers/specs/2026-10-20-option-block-rendering-design.md. + const tailStart = lineStart + replacement.length + const after = result.slice(tailStart) + const tailLines = after.split('\n') + const dedentedTail: string[] = [] + let stopped = false + for (const line of tailLines) { + if (stopped) { dedentedTail.push(line); continue } + if (line.trim() === '') { dedentedTail.push(line); continue } + // A line indented >= 4 spaces is trailing substep content → dedent it. + if (/^ {4}/.test(line)) { dedentedTail.push(line.slice(4)); continue } + // A tab-indented line → strip one leading tab. + if (/^\t/.test(line)) { dedentedTail.push(line.replace(/^\t/, '')); continue } + // First line at column 0 (next list marker, shortcode, heading, or the + // list's end) — stop; everything from here is outside this substep's tail. + stopped = true + dedentedTail.push(line) + } + result = result.slice(0, tailStart) + dedentedTail.join('\n') } return result diff --git a/test/unit/option-figure-list-nesting.test.ts b/test/unit/option-figure-list-nesting.test.ts index ab45bd0f6..edd4f0187 100644 --- a/test/unit/option-figure-list-nesting.test.ts +++ b/test/unit/option-figure-list-nesting.test.ts @@ -164,27 +164,36 @@ describe('figure nesting inside OPTION-bearing substeps (#2820)', () => { rendered = renderThroughPipeline(stepBody); }, 140_000); - it('renders the trailing figure nested inside the list item, not as a sibling of the list', () => { + it('renders the trailing figure as a real figure (sibling of the list), dedented to column 0', () => { const $ = load(rendered); const fig = $('figure.tutorial-figure').filter((_, el) => { return ($(el).find('img').attr('src') || '').includes('download-client.png'); }); expect(fig.length, 'figure for download-client.png should exist').toBe(1); - expect(fig.closest('li').length, 'figure must be inside an
                        1. ').toBe(1); - expect(fig.closest('ol').length, 'figure must be inside an
                            ').toBe(1); + // #2820 CORRECTED CONTRACT: a Hugo shortcode that emits a block-level
                            + // closes the surrounding tight list (verified against Hugo 0.147.7), so the + // trailing figure CANNOT be a child of the
                          1. — it is a sibling of the + // list. The three prior fixes chased the impossible "nest it in
                          2. " goal; + // the real fix dedents the tail to column 0 so it renders as a proper + //
                            instead of leaking as raw markdown in a code block. The + // authoritative raw-leak + numbering coverage (via the {{% tutorial-step %}} + // percent-shortcode path that actually reproduces the leak) is in + // test/unit/option-tail-leak.test.ts. + expect(fig.find('img').length, 'figure must contain a rendered ').toBe(1); + expect(fig.find('figcaption').length, 'figure must have a caption').toBe(1); }); - it('preserves the following substep as list item 3 (numbering survives)', () => { + it('preserves the following substep in an ordered list (numbering survives)', () => { const $ = load(rendered); - // #2703 invariant: the substep after the OPTION group must stay in the - // ordered list (renumbered or continuous), not detach as loose prose. + // The substep after the OPTION group must stay in an ordered list + // (continuous or reopened via
                              ), not detach as loose prose or a + // leaked code block. const text = $('ol li').text(); expect(text).toContain('Verify the environment variables'); }); it('does not render the fenced command as a literal indented-code block (#2703)', () => { - // Guard the behaviour the flush-left strategy originally protected: the - // shell command must be a real code block, not backticks-as-text. + // The shell command must be a real code block, not backticks-as-text. expect(rendered).not.toContain('```Shell'); }); }); diff --git a/test/unit/option-tail-leak.test.ts b/test/unit/option-tail-leak.test.ts new file mode 100644 index 000000000..529fa15d3 --- /dev/null +++ b/test/unit/option-tail-leak.test.ts @@ -0,0 +1,183 @@ +/* eslint-disable */ +// Regression test for issue #2820 — raw markdown leaks into a code block when an +// [OPTION] group is followed by SHARED substep content (prose + captioned image + +// `> [!TIP]` alerts). +// +// ROOT CAUSE (proven against production-pinned Hugo 0.147.7): a Hugo shortcode +// that emits a block-level
                              (os-options/os-panel) closes the surrounding +// tight numbered list REGARDLESS of source indentation. The OPTION block is +// therefore always a SIBLING of the list, never a child of the
                            1. . Once the +// list closes, every following line's block type is measured from column 0, where +// CommonMark's >=4-space rule turns the authored 4-space substep tail into an +// indented code block — so the trailing prose/image/tips surface as raw markdown. +// +// Commit 19db8d027 regressed this by DELETING the "Bug B" tail-dedent that pushed +// the trailing content to column 0. The fix restores it. The source's own `N.` +// ordinals make Goldmark emit
                                so numbering continues. +// +// This test drives the REAL convertOptionBlocks() + REAL Hugo shortcodes + REAL +// render-image/render-blockquote/render-codeblock hooks + the REAL built CSS, and +// asserts the HONEST contract: the tail renders correctly (no raw-markdown leak), +// as siblings of the list, with numbering continued and the trailing figure +// indented to the content column. It does NOT assert
                              1. nesting — that is +// unachievable with a block-div shortcode and was the false premise of the three +// prior fixes. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, copyFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { load } from 'cheerio'; +import { convertOptionBlocks } from '../../scripts/parsers/options.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); +const SHORTCODES = join(REPO_ROOT, 'hugo', 'layouts', 'shortcodes'); +const MARKUP = join(REPO_ROOT, 'hugo', 'layouts', '_default', '_markup'); + +function hugoBinary(): string { + return process.env.HUGO || 'hugo'; +} + +const IMG_BASE = 'https://raw.githubusercontent.com/sap-tutorials/Tutorials/master/tutorials/hana-clients-install'; + +let rendered = ''; + +function renderThroughPipeline(stepBody: string): string { + const converted = convertOptionBlocks(stepBody, 'hugo'); + + const dir = mkdtempSync(join(tmpdir(), 'opt-tail-leak-')); + mkdirSync(join(dir, 'layouts', 'shortcodes'), { recursive: true }); + mkdirSync(join(dir, 'layouts', '_default', '_markup'), { recursive: true }); + mkdirSync(join(dir, 'content'), { recursive: true }); + + for (const sc of ['os-options.html', 'os-panel.html', 'option-tabs.html', 'tab.html']) { + copyFileSync(join(SHORTCODES, sc), join(dir, 'layouts', 'shortcodes', sc)); + } + // Minimal tutorial-step shortcode: only the markdownify-bearing .step-content + // wrapper. CRUCIAL: the production leak only reproduces when the step body is + // markdownified as the INNER of a {{% tutorial-step %}} percent shortcode (as + // render-frontmatter.ts emits it) — NOT when rendered at page top level. The + // real shortcode pulls in partials irrelevant to segmentation, so we stub it. + writeFileSync(join(dir, 'layouts', 'shortcodes', 'tutorial-step.html'), + '
                                {{ .Inner }}
                                \n'); + for (const h of ['render-image.html', 'render-blockquote.html', 'render-codeblock.html', 'render-link.html']) { + const p = join(MARKUP, h); + if (existsSync(p)) copyFileSync(p, join(dir, 'layouts', '_default', '_markup', h)); + } + + writeFileSync(join(dir, 'hugo.toml'), + 'baseURL = "http://example.org/"\n' + + 'title = "t"\n' + + '[markup.goldmark.renderer]\n' + + 'unsafe = true\n' + + '[markup.goldmark.parser.attribute]\n' + + 'block = true\n' + + 'title = true\n'); + writeFileSync(join(dir, 'layouts', '_default', 'single.html'), + '
                                {{ .Content }}
                                \n'); + + // Wrap the converted step body in the REAL {{% tutorial-step %}} percent + // shortcode — this is the production rendering path (render-frontmatter.ts). + writeFileSync(join(dir, 'content', 'page.md'), + '---\ntitle: p\n---\n\n' + + '{{% tutorial-step number="3" title="Install" %}}\n\n' + + converted + '\n\n' + + '{{% /tutorial-step %}}\n'); + + const r = spawnSync(hugoBinary(), ['--source', dir], { encoding: 'utf8', timeout: 120_000 }); + if (r.status !== 0) { + const msg = `Hugo build failed (exit ${r.status})\nconverted markdown:\n${converted}\nstderr:\n${r.stderr}`; + rmSync(dir, { recursive: true, force: true }); + throw new Error(msg); + } + const html = readFileSync(join(dir, 'public', 'page', 'index.html'), 'utf8'); + rmSync(dir, { recursive: true, force: true }); + return html; +} + +// The exact hana-clients-install step-3 shape: an OS OPTION group followed by +// SHARED prose + a captioned image + TWO `> [!TIP]` blockquotes, all authored at +// the 4-space substep continuation indent, then a following numbered substep. +const STEP_BODY = [ + '2. Access the contents of the downloaded install.', + '', + '3. Start the graphical installer `hdbsetup`.', + '', + ' [OPTION BEGIN [Windows]]', + '', + ' ```Shell', + ' hdbsetup.exe', + ' ```', + '', + ' [OPTION END]', + '', + ' [OPTION BEGIN [Mac and Linux]]', + '', + ' ```Shell', + ' ./hdbsetup', + ' ```', + '', + ' [OPTION END]', + '', + ' Set the install directory to `C:\\SAP\\hdbclient` and complete the installation.', + '', + ` ![Client install](${IMG_BASE}/download-client.png "Client install")`, + '', + ' > [!TIP]', + ' > If the install fails, run the installer as administrator.', + '', + ' > [!TIP]', + ' > An older version can be upgraded or uninstalled with `hdbuninst`.', + '', + '4. After the installation, verify the environment variables.', + '', +].join('\n'); + +describe('OPTION group followed by shared prose/image/tips (#2820 regression)', () => { + beforeAll(() => { + rendered = renderThroughPipeline(STEP_BODY); + }, 140_000); + + it('does not leak the fence as a literal indented code block', () => { + expect(rendered).not.toContain('```Shell'); + }); + + it('does not leak the raw image markdown', () => { + expect(rendered).not.toContain('![Client install]'); + }); + + it('does not leak the raw `> [!TIP]` alert markdown', () => { + const $ = load(rendered); + const text = $('body').text(); + expect(text).not.toContain('[!TIP]'); + }); + + it('renders both TIPs as alert-tip blockquotes', () => { + const $ = load(rendered); + expect($('blockquote.alert-tip').length).toBe(2); + }); + + it('renders the trailing image as a tutorial-figure', () => { + const $ = load(rendered); + const fig = $('figure.tutorial-figure').filter((_, el) => + ($(el).find('img').attr('src') || '').includes('download-client.png')); + expect(fig.length).toBe(1); + }); + + it('renders the OS panel fences as real code blocks, not literal text', () => { + const $ = load(rendered); + expect($('.os-panel[data-os="Windows"]').length).toBeGreaterThan(0); + expect($('.code-block').length).toBeGreaterThanOrEqual(2); + }); + + it('continues substep numbering across the OPTION block via
                                  ', () => { + // The authored `4.` after the col-0 tail must render as
                                    , not + // restart at 1 or vanish into a leaked code block. + expect(rendered).toMatch(/