diff --git a/.editorconfig b/.editorconfig index c6c8b362..fb16b128 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,9 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true + +# These files carry an intentional trailing space after a YAML block-scalar +# indicator ("mapping: | ") as a regression fixture for DOC-2433 - trimming +# it silently weakens the test (see fixture-yaml-trailing-space) +[{tests/bloblang-interactive/mini-playground-test.html,preview-src/bloblang-syntax-test.adoc}] +trim_trailing_whitespace = false diff --git a/preview-src/bloblang-syntax-test.adoc b/preview-src/bloblang-syntax-test.adoc index 57e53b02..5019c0ee 100644 --- a/preview-src/bloblang-syntax-test.adoc +++ b/preview-src/bloblang-syntax-test.adoc @@ -367,4 +367,96 @@ spec: env: - name: DATABASE_URL value: "postgres://localhost:5432/mydb" ----- \ No newline at end of file +---- + +== Regression: Block Scalar Indicator Must Stay on Its Own Line + +Reported against docs.redpanda.com as +https://redpandadata.atlassian.net/browse/DOC-2433[DOC-2433]: this exact +example (the Connect quickstart's producer pipeline) rendered as +`mapping: |let jokes = [` on one line instead of `|` followed by a line +break. Root cause was `extractBloblangFromBlock()` in +`src/js/17-bloblang-yaml.js` trimming away the newline + indentation +between the block-scalar indicator and the first line of Bloblang before +`processMultilineMapping()` discarded the token's original content +entirely. + +**Check visually**: `mapping: |` must end its own line, with `let jokes` +starting the next line, indented — not glued onto the same line as `|`. +If this collapses again, the leading-whitespace preservation in +`processMultilineMapping()` has regressed. + +[source,yaml] +---- +input: + generate: + interval: 5s + count: 0 + mapping: | + let jokes = [ + "Why don't scientists trust atoms? Because they make up everything!", + "I'm reading a book about anti-gravity. It's impossible to put down!" + ] + root = jokes.index(random_int(seed: timestamp_unix_nano(), max: jokes.length() - 1)) +output: + redpanda: + topic: dad-jokes +---- + +== Regression: Literal Block Scalars Are Not Rewritten (DOC-2441) + +https://redpandadata.atlassian.net/browse/DOC-2441[DOC-2441]: YAML performs +no quote or escape processing on literal block scalars, so the highlighter +must not either. + +**Check visually and with the copy button**: `join("\n")` and `join("\t")` +must render (and copy) as two characters each - a backslash followed by a +letter - never as a real line break or tab inside the string literal. + +[source,yaml] +---- +pipeline: + processors: + - mapping: | + root.lines = this.values.join("\n") + root.tabbed = this.cols.join("\t") +---- + +**Check visually**: the first line's opening quote on `"prefix-"` and the +last line's closing quote on `"-suffix"` must both render. They belong to +two different string literals, and must not be stripped as if they enclosed +the whole block. + +[source,yaml] +---- +pipeline: + processors: + - mapping: | + "prefix-" + this.id + "-suffix" +---- + +**Check visually**: a trailing space after the `|` indicator must not glue +the first Bloblang line onto the `mapping:` line (the space after `|` below +is intentional). + +[source,yaml] +---- +pipeline: + processors: + - mapping: | + let doubled = this.value * 2 + root.result = $doubled +---- + +**Check visually and with the copy button**: quoted flow-scalar mappings must +keep their quotes in the rendered output. The double-quoted example's escapes +are processed once: `\"` renders as `"` and `\\n` renders as the two +characters `\n` inside the string literal. + +[source,yaml] +---- +pipeline: + processors: + - mapping: "root.joined = this.parts.join(\"\\n\")" + - mapping: 'root.id = this.user_id.string()' +---- diff --git a/src/js/17-bloblang-yaml.js b/src/js/17-bloblang-yaml.js index 538b21d3..69eac7de 100644 --- a/src/js/17-bloblang-yaml.js +++ b/src/js/17-bloblang-yaml.js @@ -121,25 +121,57 @@ } /** - * Extract Bloblang code from a YAML literal block string - * Handles | and > block scalars + * Extract Bloblang code from a YAML scalar token's text. + * + * Returns { leading, code }: + * - leading: the whitespace trim() strips from the front of the token. + * For block scalars this is the newline + indentation that separates + * the | or > indicator from the first content line (the token can also + * begin with spaces or tabs, because the [ \t]* after the indicator is + * part of the scalar token in Prism's YAML grammar). It must be + * restored when the token is re-rendered, or "mapping: |" collapses + * onto the same line as the first Bloblang statement (DOC-2433). + * - code: the Bloblang source to tokenize. + * + * Block scalars (mapping: | ...) are literal source text: YAML performs + * no quote or escape processing on them, so neither does this function - + * rewriting \n inside them, or stripping a coincidental leading/trailing + * quote pair, corrupts the rendered and copied code (DOC-2441). + * + * Quoted flow scalars (mapping: "root = ...") are unwrapped and + * unescaped according to their quote style: double quotes process + * backslash escapes, single quotes only the '' escape. The stripped + * quote character is returned so the caller can re-render it - the + * quotes are part of the YAML source the reader sees and copies. */ - function extractBloblangFromBlock(tokenText) { - // Remove leading/trailing quotes if present + function extractBloblangFromBlock(tokenText, isBlockScalar) { + var leading = tokenText.slice(0, tokenText.length - tokenText.trimStart().length) var text = tokenText.trim() - if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { - text = text.slice(1, -1) + var quote = null + + if (!isBlockScalar) { + var q = text.charAt(0) + if ((q === '"' || q === "'") && text.length >= 2 && text.endsWith(q)) { + quote = q + text = text.slice(1, -1) + if (q === '"') { + // Single pass so an escaped backslash can't feed a later rule + // (the old sequential replace chain turned \\n into \). + // Escapes this display code doesn't translate stay verbatim + // rather than losing their backslash (\r must not become r). + text = text.replace(/\\(.)/g, function (match, ch) { + if (ch === 'n') return '\n' + if (ch === 't') return '\t' + if (ch === '"' || ch === "'" || ch === '\\' || ch === '/') return ch + return match + }) + } else { + text = text.replace(/''/g, "'") + } + } } - // Handle escape sequences - text = text - .replace(/\\n/g, '\n') - .replace(/\\t/g, '\t') - .replace(/\\"/g, '"') - .replace(/\\'/g, "'") - .replace(/\\\\/g, '\\') - - return text + return { leading: leading, code: text, quote: quote } } /** @@ -303,10 +335,20 @@ * Also handles continuation content after blank lines */ function processMultilineMapping(token) { - var bloblangCode = extractBloblangFromBlock(token.textContent) - - // Check for continuation content after this token - var continuationNodes = collectLiteralBlockContinuation(token) + var rawText = token.textContent + // Prism's YAML grammar gives block scalars the "scalar" class ("string" + // is only an alias on them); a token with "string" alone is a quoted + // flow scalar, whose quotes/escapes YAML actually processes. + var isBlockScalar = token.classList.contains('scalar') + var extracted = extractBloblangFromBlock(rawText, isBlockScalar) + var bloblangCode = extracted.code + var leadingWhitespace = extracted.leading + + // Check for continuation content after this token. Only block scalars + // can have it (Prism splits them at blank lines); a quoted flow scalar + // is single-line by grammar, and collecting "continuation" for one + // swallows sibling keys that sit at or above the fallback base indent. + var continuationNodes = isBlockScalar ? collectLiteralBlockContinuation(token) : [] var continuationText = extractTextFromNodes(continuationNodes) // Combine the token content with continuation @@ -323,7 +365,15 @@ var wrapper = document.createElement('span') wrapper.className = 'bloblang-embedded' - wrapper.innerHTML = highlighted + // Re-render the quotes stripped from a quoted flow scalar (same as + // processSingleLineCheck) so the reader still sees them and the copy + // button keeps them. + var quoteHtml = extracted.quote ? '' + extracted.quote + '' : '' + // leadingWhitespace is whitespace-only (what trim() stripped), so it is + // HTML-inert and safe to concatenate unescaped. Asymmetry note: trailing + // whitespace on the scalar's last content line is still dropped by the + // trim - invisible in the render, observable only via the copy button. + wrapper.innerHTML = leadingWhitespace + quoteHtml + highlighted + quoteHtml token.innerHTML = '' token.appendChild(wrapper) diff --git a/tests/bloblang-interactive/mini-playground-test.html b/tests/bloblang-interactive/mini-playground-test.html index 29dd5890..f35529de 100644 --- a/tests/bloblang-interactive/mini-playground-test.html +++ b/tests/bloblang-interactive/mini-playground-test.html @@ -170,6 +170,77 @@

Test Summary

- name: app image: myapp:latest + + +
+
+
pipeline:
+  processors:
+    - mapping: | 
+        let doubled = this.value * 2
+        root.result = $doubled
+
+ + +
+
+
pipeline:
+  processors:
+    - mapping: |
+        root.lines = this.values.join("\n")
+        root.tabbed = this.cols.join("\t")
+
+ + +
+
+
pipeline:
+  processors:
+    - mapping: |
+        "prefix-" + this.id + "-suffix"
+
+ + +
+
+
pipeline:
+  processors:
+    - mapping: "root = this.name.uppercase()"
+
+ + +
+
+
pipeline:
+  processors:
+    - mapping: "root.joined = this.parts.join(\"\\n\")"
+    - mapping: "root.clean = this.text.replace_all(\"\r\", \"\")"
+
+ + +
+
+
pipeline:
+  processors:
+    - mapping: 'root.id = this.user_id.string()'
+
+ + +
+
+
pipeline:
+  processors:
+    - mapping: |-
+        root.value = this.count
+