diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index 0002a805876..2097febc8a3 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -56,9 +56,20 @@ function renderMarkdownTemplate(markdown) { core.info(`[renderMarkdownTemplate] Starting template rendering`); core.info(`[renderMarkdownTemplate] Input length: ${markdown.length} characters`); + // Preserve fenced code blocks to avoid processing {{#if}} markers inside them + const _codeBlocks = []; + const _FENCE_PH = "\x00FENCE\x00"; + const _stripped = markdown.replace(/`{3,}[^\n]*\n[\s\S]*?\n`{3,}[ \t]*/g, m => { + _codeBlocks.push(m); + return `${_FENCE_PH}${_codeBlocks.length - 1}${_FENCE_PH}`; + }); + if (_codeBlocks.length > 0) { + core.info(`[renderMarkdownTemplate] Preserved ${_codeBlocks.length} fenced code block(s) from template processing`); + } + // Count conditionals before processing - const blockConditionals = (markdown.match(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g) || []).length; - const inlineConditionals = (markdown.match(/{{#if\s+([^}]*)}}([\s\S]*?){{\/if}}/g) || []).length - blockConditionals; + const blockConditionals = (_stripped.match(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g) || []).length; + const inlineConditionals = (_stripped.match(/{{#if\s+([^}]*)}}([\s\S]*?){{\/if}}/g) || []).length - blockConditionals; core.info(`[renderMarkdownTemplate] Found ${blockConditionals} block conditional(s) and ${inlineConditionals} inline conditional(s)`); @@ -68,7 +79,7 @@ function renderMarkdownTemplate(markdown) { // First pass: Handle blocks where tags are on their own lines // Captures: (leading newline)(opening tag line)(condition)(body)(closing tag line)(trailing newline) - let result = markdown.replace(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body, closeLine, trailNL) => { + let result = _stripped.replace(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body, closeLine, trailNL) => { blockCount++; const condTrimmed = cond.trim(); const truthyResult = isTruthy(cond); @@ -126,6 +137,18 @@ function renderMarkdownTemplate(markdown) { core.info(`[renderMarkdownTemplate] Cleaned up ${excessiveLines} excessive blank line sequence(s)`); core.info(`[renderMarkdownTemplate] Length change from cleanup: ${beforeCleanup} -> ${result.length} characters`); } + // Restore fenced code blocks + if (_codeBlocks.length > 0) { + result = result.replace(/\x00FENCE\x00(\d+)\x00FENCE\x00/g, (_, i) => _codeBlocks[+i]); + } + + // Runtime assertion: number of fence markers must be the same before and after processing + const _inputFenceCount = (markdown.match(/`{3,}/g) || []).length; + const _outputFenceCount = (result.match(/`{3,}/g) || []).length; + if (_inputFenceCount !== _outputFenceCount) { + core.warning(`[renderMarkdownTemplate] Fence count mismatch: input had ${_inputFenceCount} fence marker(s), output has ${_outputFenceCount}`); + } + core.info(`[renderMarkdownTemplate] Final output length: ${result.length} characters`); return result; diff --git a/actions/setup/js/interpolate_prompt.test.cjs b/actions/setup/js/interpolate_prompt.test.cjs index ef08d8fd2c3..fa9cb02ea1f 100644 --- a/actions/setup/js/interpolate_prompt.test.cjs +++ b/actions/setup/js/interpolate_prompt.test.cjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "url"; const { ERR_CONFIG } = require("./error_codes.cjs"); const __filename = fileURLToPath(import.meta.url), __dirname = path.dirname(__filename), - core = { info: vi.fn(), setFailed: vi.fn() }; + core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() }; global.core = core; const interpolatePromptScript = fs.readFileSync(path.join(__dirname, "interpolate_prompt.cjs"), "utf8"), { isTruthy } = require("./is_truthy.cjs"), diff --git a/actions/setup/js/interpolate_prompt_additional.test.cjs b/actions/setup/js/interpolate_prompt_additional.test.cjs index 8e39517db28..069efecadb8 100644 --- a/actions/setup/js/interpolate_prompt_additional.test.cjs +++ b/actions/setup/js/interpolate_prompt_additional.test.cjs @@ -4,7 +4,7 @@ import path from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url), __dirname = path.dirname(__filename), - core = { info: vi.fn(), setFailed: vi.fn() }; + core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() }; global.core = core; const { isTruthy } = require("./is_truthy.cjs"), interpolatePromptScript = fs.readFileSync(path.join(__dirname, "interpolate_prompt.cjs"), "utf8"), @@ -200,5 +200,37 @@ describe("renderMarkdownTemplate - Additional Edge Cases", () => { const output = renderMarkdownTemplate("{{#if false}}\nContent\n{{/if}}Line after"); expect(output).toBe("Line after"); })); + }), + describe("fenced code blocks", () => { + (it("should preserve {{#if false}} markers inside a fenced code block (regression)", () => { + const input = "```js\n{{#if false}}\nHidden\n{{/if}}\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe(input); + }), + it("should preserve {{#if true}} markers inside a fenced code block", () => { + const input = "```js\n{{#if true}}\nVisible\n{{/if}}\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe(input); + }), + it("should process conditionals outside fenced blocks while preserving inside", () => { + const input = "{{#if false}}\nRemove this\n{{/if}}\n```js\n{{#if false}}\nKeep this\n{{/if}}\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe("```js\n{{#if false}}\nKeep this\n{{/if}}\n```"); + }), + it("should preserve fence count (no fence markers lost or gained)", () => { + const input = "```js\n{{#if false}}\nHidden\n{{/if}}\n```"; + const output = renderMarkdownTemplate(input); + expect((output.match(/`{3,}/g) || []).length).toBe((input.match(/`{3,}/g) || []).length); + }), + it("should preserve multiple fenced code blocks unchanged", () => { + const input = "```js\ncode 1\n```\n\n```py\ncode 2\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe(input); + }), + it("should handle fenced blocks adjacent to conditionals", () => { + const input = "{{#if true}}\nKeep\n{{/if}}\n```python\nprint('hello')\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe("Keep\n```python\nprint('hello')\n```"); + })); })); }); diff --git a/actions/setup/js/render_template.cjs b/actions/setup/js/render_template.cjs index d9f65a06fc2..8f3813ea584 100644 --- a/actions/setup/js/render_template.cjs +++ b/actions/setup/js/render_template.cjs @@ -24,9 +24,20 @@ function renderMarkdownTemplate(markdown) { core.info(`[renderMarkdownTemplate] Starting template rendering`); core.info(`[renderMarkdownTemplate] Input length: ${markdown.length} characters`); + // Preserve fenced code blocks to avoid processing {{#if}} markers inside them + const _codeBlocks = []; + const _FENCE_PH = "\x00FENCE\x00"; + const _stripped = markdown.replace(/`{3,}[^\n]*\n[\s\S]*?\n`{3,}[ \t]*/g, m => { + _codeBlocks.push(m); + return `${_FENCE_PH}${_codeBlocks.length - 1}${_FENCE_PH}`; + }); + if (_codeBlocks.length > 0) { + core.info(`[renderMarkdownTemplate] Preserved ${_codeBlocks.length} fenced code block(s) from template processing`); + } + // Count conditionals before processing - const blockConditionals = (markdown.match(/(\n?)([ \t]*{{#if\s+(.*?)\s*}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g) || []).length; - const inlineConditionals = (markdown.match(/{{#if\s+(.*?)\s*}}([\s\S]*?){{\/if}}/g) || []).length - blockConditionals; + const blockConditionals = (_stripped.match(/(\n?)([ \t]*{{#if\s+(.*?)\s*}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g) || []).length; + const inlineConditionals = (_stripped.match(/{{#if\s+(.*?)\s*}}([\s\S]*?){{\/if}}/g) || []).length - blockConditionals; core.info(`[renderMarkdownTemplate] Found ${blockConditionals} block conditional(s) and ${inlineConditionals} inline conditional(s)`); @@ -37,7 +48,7 @@ function renderMarkdownTemplate(markdown) { // First pass: Handle blocks where tags are on their own lines // Captures: (leading newline)(opening tag line)(condition)(body)(closing tag line)(trailing newline) // Uses .*? (non-greedy) with \s* to handle expressions with or without trailing spaces - let result = markdown.replace(/(\n?)([ \t]*{{#if\s+(.*?)\s*}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body) => { + let result = _stripped.replace(/(\n?)([ \t]*{{#if\s+(.*?)\s*}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body) => { blockCount++; const truthyResult = isTruthy(cond); @@ -82,6 +93,18 @@ function renderMarkdownTemplate(markdown) { // Clean up excessive blank lines (more than one blank line = 2 newlines) result = result.replace(/\n{3,}/g, "\n\n"); + // Restore fenced code blocks + if (_codeBlocks.length > 0) { + result = result.replace(/\x00FENCE\x00(\d+)\x00FENCE\x00/g, (_, i) => _codeBlocks[+i]); + } + + // Runtime assertion: number of fence markers must be the same before and after processing + const _inputFenceCount = (markdown.match(/`{3,}/g) || []).length; + const _outputFenceCount = (result.match(/`{3,}/g) || []).length; + if (_inputFenceCount !== _outputFenceCount) { + core.warning(`[renderMarkdownTemplate] Fence count mismatch: input had ${_inputFenceCount} fence marker(s), output has ${_outputFenceCount}`); + } + core.info(`[renderMarkdownTemplate] Final output length: ${result.length} characters`); return result; diff --git a/actions/setup/js/render_template.test.cjs b/actions/setup/js/render_template.test.cjs index 08140c40386..fbd9ae1fe57 100644 --- a/actions/setup/js/render_template.test.cjs +++ b/actions/setup/js/render_template.test.cjs @@ -86,4 +86,36 @@ describe("renderMarkdownTemplate", () => { const output = renderMarkdownTemplate(" {{#if true}}\n Content\n {{/if}}\nNext line"); expect(output).toBe(" Content\nNext line"); })); + describe("fenced code blocks", () => { + it("should preserve {{#if false}} markers inside a fenced code block (regression)", () => { + const input = "```js\n{{#if false}}\nHidden\n{{/if}}\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe(input); + }); + it("should preserve {{#if true}} markers inside a fenced code block", () => { + const input = "```js\n{{#if true}}\nVisible\n{{/if}}\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe(input); + }); + it("should process conditionals outside fenced blocks while preserving inside", () => { + const input = "{{#if false}}\nRemove this\n{{/if}}\n```js\n{{#if false}}\nKeep this\n{{/if}}\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe("```js\n{{#if false}}\nKeep this\n{{/if}}\n```"); + }); + it("should preserve fence count (no fence markers lost or gained)", () => { + const input = "```js\n{{#if false}}\nHidden\n{{/if}}\n```"; + const output = renderMarkdownTemplate(input); + expect((output.match(/`{3,}/g) || []).length).toBe((input.match(/`{3,}/g) || []).length); + }); + it("should preserve multiple fenced code blocks unchanged", () => { + const input = "```js\ncode 1\n```\n\n```py\ncode 2\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe(input); + }); + it("should handle fenced blocks with language tag and conditional outside", () => { + const input = "{{#if true}}\nKeep\n{{/if}}\n```python\nprint('hello')\n```"; + const output = renderMarkdownTemplate(input); + expect(output).toBe("Keep\n```python\nprint('hello')\n```"); + }); + }); });