diff --git a/.ci/scripts/check-render-artifacts.sh b/.ci/scripts/check-render-artifacts.sh new file mode 100755 index 0000000000..79ab798a0b --- /dev/null +++ b/.ci/scripts/check-render-artifacts.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# check-render-artifacts.sh +# +# Scan built Hugo HTML for forbidden render artifacts — patterns that +# should be impossible on a correctly built page. Their presence signals +# a rendering bug (whitespace leak in a render hook, broken shortcode +# template, etc.) that would otherwise ship to production as visibly +# broken content. +# +# The canonical example is influxdata/docs-v2#7079, where a whitespace +# leak in the `placeholders`/`callout` branch of render-codeblock.html +# caused Goldmark to HTML-escape highlighted code blocks into literal +# `
<div class="highlight"…` fragments on every
+# page that used either attribute. Every pattern in this script is a
+# fingerprint of that class of bug.
+#
+# Usage:
+# .ci/scripts/check-render-artifacts.sh [target]
+#
+# Arguments:
+# target Directory or file to scan. Defaults to `public`.
+#
+# Exit codes:
+# 0 No forbidden patterns found.
+# 1 At least one forbidden pattern found. The script prints every
+# offending file and the pattern that matched.
+# 2 Target directory/file does not exist.
+#
+# Run this immediately after `npx hugo --quiet` in CI so that any
+# regression fails the build before downstream jobs (Cypress, Vale,
+# link-check) waste time.
+#
+# Known gaps / possible future additions:
+#
+# - `{{<` / `{{%` : Indicates an unrendered Hugo shortcode. Currently
+# excluded because legitimate docs contain Helm and
+# Go template examples that use `{{` syntax inside
+# code fences, producing unavoidable false positives.
+# Could be re-added as a scoped regex that requires
+# the delimiters to appear outside `` / ``.
+#
+# - `ZgotmplZ` : Hugo's context-escape sentinel. Currently excluded
+# because a pre-existing bug emits it into
+# `data-influxdb-urls="#ZgotmplZ"` on ~4600 pages.
+# Re-add after that bug is fixed.
+
+set -euo pipefail
+
+TARGET="${1:-public}"
+
+if [[ ! -e "$TARGET" ]]; then
+ echo "::error::check-render-artifacts: target '$TARGET' does not exist. Did Hugo build succeed?"
+ exit 2
+fi
+
+# Forbidden patterns, format: "PATTERN|DESCRIPTION"
+#
+# Every pattern below is HTML-escaped chroma output (``,
+# ``, ``). Goldmark only produces those
+# escaped forms when it has interpreted legitimate chroma HTML as plain-text
+# content — i.e. when a render hook or wrapper shortcode leaked whitespace and
+# Goldmark re-wrapped the highlighted output as an indented code block.
+PATTERNS=(
+ "<div class="highlight"|Goldmark re-wrapped highlighted code as an indented code block (see #7079). Likely cause: whitespace leak in a render hook (layouts/_default/_markup/render-*.html) or a wrapper shortcode template."
+ "<pre tabindex="0"|Escaped chroma wrapper — same class of bug as #7079."
+ "<span class="line"><span class="cl"|Escaped chroma line span — same class of bug as #7079."
+)
+
+failed=0
+total_hits=0
+
+for entry in "${PATTERNS[@]}"; do
+ pattern="${entry%%|*}"
+ description="${entry#*|}"
+
+ if [[ -d "$TARGET" ]]; then
+ hits=$(grep -rlF --include='*.html' "$pattern" "$TARGET" 2>/dev/null || true)
+ else
+ hits=$(grep -lF "$pattern" "$TARGET" 2>/dev/null || true)
+ fi
+
+ if [[ -n "$hits" ]]; then
+ count=$(printf '%s\n' "$hits" | wc -l | tr -d ' ')
+ total_hits=$((total_hits + count))
+ failed=1
+
+ echo "::error::Found forbidden render artifact in $count file(s): '$pattern'"
+ echo " Cause: $description"
+ echo " First 10 affected files:"
+ printf '%s\n' "$hits" | head -10 | sed 's/^/ /'
+ echo ""
+ fi
+done
+
+if [[ $failed -eq 0 ]]; then
+ echo "✅ check-render-artifacts: no forbidden patterns found in '$TARGET'."
+ exit 0
+fi
+
+echo "❌ check-render-artifacts: found $total_hits rendering artifact(s) across the built site."
+echo ""
+echo "These signatures appear only when a render hook or wrapper shortcode leaks"
+echo "whitespace into its output and Goldmark re-wraps highlighted code as an"
+echo "indented code block. The fix is almost always to add {{- ... -}} whitespace"
+echo "trimming to every action tag in the offending template. See"
+echo "influxdata/docs-v2#7079 for the canonical case and fix pattern."
+exit 1
diff --git a/.ci/scripts/check-render-hook-whitespace.sh b/.ci/scripts/check-render-hook-whitespace.sh
new file mode 100755
index 0000000000..8e85fee3de
--- /dev/null
+++ b/.ci/scripts/check-render-hook-whitespace.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+#
+# check-render-hook-whitespace.sh
+#
+# Pre-commit lint for Hugo render hooks (layouts/_default/_markup/render-*.html).
+#
+# Enforces the invariant that every action tag inside a render hook must
+# use `{{- ... -}}` whitespace trimming. Bare `{{ ... }}` actions leak
+# their surrounding indent and trailing newline into the rendered output,
+# which causes Goldmark to interpret the result as an indented code block
+# and HTML-escape any leading HTML (see influxdata/docs-v2#7079 for the
+# canonical failure mode).
+#
+# The only exception is template comments (`{{/* ... */}}`), which
+# produce no output regardless of trimming.
+#
+# Usage:
+# .ci/scripts/check-render-hook-whitespace.sh [file...]
+#
+# Typical invocation from lefthook pre-commit:
+# glob: "layouts/_default/_markup/render-*.html"
+# run: .ci/scripts/check-render-hook-whitespace.sh {staged_files}
+#
+# Exit codes:
+# 0 All action tags in the provided files are whitespace-trimmed.
+# 1 At least one bare `{{ ... }}` action was found.
+
+set -euo pipefail
+
+if [[ $# -eq 0 ]]; then
+ exit 0
+fi
+
+failed=0
+
+for file in "$@"; do
+ [[ -f "$file" ]] || continue
+
+ # Match any line whose first non-whitespace `{{` is not followed by `-`
+ # (i.e. a bare opening action), AND whose matching `}}` is not preceded
+ # by `-` (bare closing action).
+ #
+ # Exclude template comments `{{/* ... */}}` since they produce no
+ # output and are unaffected by whitespace.
+ #
+ # Exclude lines where `{{` appears only inside a string literal (e.g.
+ # `print "...{{..."`), detected by requiring the action to start the
+ # line (after optional whitespace).
+ offenders=$(grep -nE '^\s*\{\{[^-/]' "$file" | grep -vE '\{\{/\*' || true)
+
+ # Also catch bare closing actions: `... }}` without the leading `-}}`.
+ # Only warn on lines that also start with an action (avoid false
+ # positives from string literals spanning multiple lines).
+ closing_offenders=$(grep -nE '[^-]\}\}\s*$' "$file" | grep -E '^\s*\{\{' | grep -vE '\{\{-' || true)
+
+ all_offenders=$(printf '%s\n%s\n' "$offenders" "$closing_offenders" | grep -v '^$' | sort -u || true)
+
+ if [[ -n "$all_offenders" ]]; then
+ failed=1
+ echo "❌ $file: found bare {{ ... }} action tag(s) in a render hook."
+ echo " Every action inside layouts/_default/_markup/render-*.html must"
+ echo " use whitespace-trimming delimiters ({{- ... -}}) to prevent"
+ echo " leaked whitespace from breaking Goldmark's HTML-block detection."
+ echo " See influxdata/docs-v2#7079 for the canonical failure."
+ echo ""
+ printf '%s\n' "$all_offenders" | sed 's/^/ /'
+ echo ""
+ fi
+done
+
+if [[ $failed -eq 1 ]]; then
+ echo "Fix: rewrite each offending action with {{- ... -}} trimming."
+ echo "Example: '{{ \$x := foo }}' → '{{- \$x := foo -}}'"
+ exit 1
+fi
+
+exit 0
diff --git a/.github/workflows/pr-render-check.yml b/.github/workflows/pr-render-check.yml
new file mode 100644
index 0000000000..40dbd91736
--- /dev/null
+++ b/.github/workflows/pr-render-check.yml
@@ -0,0 +1,101 @@
+name: Render Regression Check
+
+# Guards against the class of rendering bug reported in
+# influxdata/docs-v2#7079: whitespace leaks in Hugo render hooks or
+# wrapper shortcode templates that cause Goldmark to HTML-escape
+# highlighted code blocks on every affected page.
+#
+# Two layers:
+#
+# 1. check-artifacts: Site-wide grep of built HTML. Cheap, runs on
+# every PR, catches regressions anywhere in the site even on pages
+# nobody is thinking about. This is the backstop.
+#
+# 2. cypress-render: Cypress spec against content/example.md and a
+# curated set of representative product pages. Runs only when a
+# PR touches `layouts/` or `assets/` (i.e. the code paths that can
+# cause this class of bug). Provides DOM-level verification that
+# the pages actually look right, not just that they lack a string.
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+jobs:
+ check-artifacts:
+ name: Scan built HTML for forbidden render artifacts
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'yarn'
+
+ - name: Install dependencies
+ env:
+ CYPRESS_INSTALL_BINARY: 0
+ run: yarn install --frozen-lockfile
+
+ - name: Build Hugo site
+ run: npx hugo --quiet
+
+ - name: Scan built HTML for render artifacts
+ run: .ci/scripts/check-render-artifacts.sh public
+
+ cypress-render:
+ name: Cypress render-regression spec
+ runs-on: ubuntu-latest
+ # Only run when a PR touches code that could regress rendering.
+ # Content-only PRs don't need this layer — the site-wide grep above
+ # already catches any page-level impact they could have.
+ if: |
+ contains(github.event.pull_request.labels.*.name, 'render-regression') ||
+ github.event.pull_request.draft == false
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Detect layout/asset changes
+ id: detect
+ run: |
+ # Only run Cypress when files under layouts/, assets/, or
+ # the render-regression spec itself have changed. Content-only
+ # PRs rely on the site-wide grep job above for coverage.
+ CHANGED=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
+ "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.number }}/files" \
+ | jq -r '.[].filename')
+
+ echo "Changed files:"
+ echo "$CHANGED"
+
+ if echo "$CHANGED" | grep -qE '^(layouts/|assets/|cypress/e2e/content/render-regression\.cy\.js$|cypress/support/|\.ci/scripts/check-render-artifacts\.sh$|\.github/workflows/pr-render-check\.yml$)'; then
+ echo "should-run=true" >> $GITHUB_OUTPUT
+ echo "✅ Layout, asset, or render-check file changed — running Cypress"
+ else
+ echo "should-run=false" >> $GITHUB_OUTPUT
+ echo "ℹ️ No layout/asset/render-check file changed — skipping Cypress"
+ fi
+
+ - name: Setup Node.js
+ if: steps.detect.outputs.should-run == 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'yarn'
+
+ - name: Install dependencies
+ if: steps.detect.outputs.should-run == 'true'
+ run: yarn install --frozen-lockfile
+
+ - name: Run render-regression spec
+ if: steps.detect.outputs.should-run == 'true'
+ run: |
+ node cypress/support/run-e2e-specs.js \
+ --spec "cypress/e2e/content/render-regression.cy.js" \
+ --no-mapping
diff --git a/content/example.md b/content/example.md
index 4682bf6faf..35bb193d65 100644
--- a/content/example.md
+++ b/content/example.md
@@ -1471,3 +1471,108 @@ and all the rows with the `hum` field will be in another.
## Ask AI Link
Can't access your InfluxDB instance? {{< ask-ai-link link-text="Ask InfluxData AI" query="What's my InfluxDB version?" >}} for help.
+
+## Render-regression fixtures
+
+The sections below exercise the code-block render hook combinations that
+were broken in influxdata/docs-v2#7079. They exist to give the
+`cypress/e2e/content/render-regression.cy.js` spec something to assert
+against and to make this page a meaningful smoke test for any PR that
+touches `layouts/_default/_markup/render-codeblock.html` or any wrapper
+shortcode that contains fenced code. When adding coverage for a new
+render-hook attribute combination, add a matching fixture here.
+
+### Placeholder fence attribute
+
+```sh { placeholders="DATABASE_NAME|AUTH_TOKEN" }
+curl --request POST \
+ http://localhost:8181/api/v3/write_lp?db=DATABASE_NAME \
+ --header "Authorization: Bearer AUTH_TOKEN" \
+ --data-raw "home,room=Kitchen temp=22.1"
+```
+
+### Placeholder fence attribute with regex group
+
+```sh { placeholders="DATABASE_(TOKEN|NAME)" }
+influxctl write --token DATABASE_TOKEN --database DATABASE_NAME
+```
+
+### Callout fence attribute
+
+```sh { callout="--host" }
+influx query --host http://localhost:8086
+```
+
+### Callout fence attribute with explicit color
+
+```sh { callout="--host" callout-color="magenta" }
+influx query --host http://localhost:8086
+```
+
+### Placeholder and callout on the same fence
+
+```sh { placeholders="DATABASE_NAME" callout="--host" callout-color="orange" }
+influx query --host http://localhost:8086 --database DATABASE_NAME
+```
+
+### Placeholder fence inside a custom-timestamps wrapper
+
+The `influxdb/custom-timestamps` wrapper was one of the shortcodes
+affected by #7079 — it wraps code blocks that include timestamps so the
+UI can rewrite them. This fixture exercises a placeholder code fence
+inside that wrapper.
+
+{{% influxdb/custom-timestamps %}}
+
+```sh { placeholders="DATABASE_NAME|AUTH_TOKEN" }
+influxdb3 write --token AUTH_TOKEN --database DATABASE_NAME \
+ 'home,room=Kitchen temp=22.1 1641024000'
+```
+
+{{% /influxdb/custom-timestamps %}}
+
+### Placeholder fence inside code-tab-content and custom-timestamps
+
+The worst-case path: placeholders inside a code-tab-content section
+inside a code-tabs wrapper inside an expand wrapper. This is the exact
+shape that sample-data pages use.
+
+{{< expand-wrapper >}}
+{{% expand "Expand to see the tabbed, placeholder-enabled code block" %}}
+
+{{< code-tabs-wrapper >}}
+{{% code-tabs %}}
+[influxdb3](#)
+[v2 API](#)
+{{% /code-tabs %}}
+{{% code-tab-content %}}
+
+{{% influxdb/custom-timestamps %}}
+
+```sh { placeholders="DATABASE_NAME|AUTH_TOKEN" }
+influxdb3 write --token AUTH_TOKEN --database DATABASE_NAME \
+ 'home,room=Kitchen temp=22.1 1641024000'
+```
+
+{{% /influxdb/custom-timestamps %}}
+
+{{% /code-tab-content %}}
+{{% code-tab-content %}}
+
+{{% influxdb/custom-timestamps %}}
+
+```sh { placeholders="DATABASE_NAME|AUTH_TOKEN" }
+curl --request POST \
+ http://localhost:8086/api/v2/write?bucket=DATABASE_NAME \
+ --header "Authorization: Bearer AUTH_TOKEN" \
+ --data-binary "home,room=Kitchen temp=22.1 1641024000"
+```
+
+{{% /influxdb/custom-timestamps %}}
+
+{{% /code-tab-content %}}
+{{< /code-tabs-wrapper >}}
+
+{{% /expand %}}
+{{< /expand-wrapper >}}
+
diff --git a/cypress/e2e/content/render-regression.cy.js b/cypress/e2e/content/render-regression.cy.js
new file mode 100644
index 0000000000..49db3eff30
--- /dev/null
+++ b/cypress/e2e/content/render-regression.cy.js
@@ -0,0 +1,127 @@
+///
+
+/**
+ * Render regression tests.
+ *
+ * This spec guards against the class of bug reported in
+ * influxdata/docs-v2#7079: whitespace leaks in Hugo render hooks or
+ * wrapper shortcode templates causing Goldmark to HTML-escape
+ * highlighted code blocks into literal `<div
+ * class="highlight"…` fragments on the final page.
+ *
+ * Two layers of coverage:
+ *
+ * 1. SHORTCODE EXAMPLES PAGE (`/example/`)
+ * Exhaustive shortcode showcase. Any layout/render-hook change
+ * that breaks a documented shortcode will show up here, regardless
+ * of which product it affects. Required smoke test for any PR
+ * that touches `layouts/` or `assets/`.
+ *
+ * 2. REPRESENTATIVE PRODUCT PAGES
+ * One curated page per InfluxDB 3 edition, hand-picked to exercise
+ * the combination of shortcodes/attributes that were affected by
+ * #7079: placeholders, callouts, custom-timestamps, code-tabs,
+ * tab-content, and expand wrappers around fenced code blocks.
+ * These catch product-specific wiring bugs (site-specific layouts,
+ * `product-name` substitution, per-product Vale configs) that the
+ * example page can't.
+ *
+ * Each page is asserted against:
+ * a) No `pre > code` element contains the literal text
+ * `` or ``. These
+ * strings can only appear inside a rendered code block if
+ * Goldmark re-wrapped legitimate chroma output as an indented
+ * code block — the exact #7079 failure mode.
+ * b) At least one real, highlighted code block exists on the page
+ * (guards against accidentally deleting the test fixture).
+ * c) If the page has placeholder code blocks, the placeholder
+ * `` elements render with their expected attributes.
+ *
+ * When adding a new representative page, pick one that exercises a
+ * combination of shortcodes/attributes NOT already covered, and leave
+ * a comment explaining what it tests.
+ */
+
+const RENDER_ARTIFACTS = [
+ '',
+ ' code` element.
+ */
+function assertNoEscapedHighlightMarkup() {
+ cy.get('pre code').each(($code) => {
+ const text = $code.text();
+ RENDER_ARTIFACTS.forEach((artifact) => {
+ expect(
+ text,
+ `code block should not contain escaped chroma fragment "${artifact}"`
+ ).not.to.include(artifact);
+ });
+ });
+}
+
+/**
+ * Assert that the page actually contains highlighted code output.
+ * Guards against an empty or broken fixture silently passing.
+ */
+function assertHasHighlightedCodeBlock() {
+ cy.get(
+ 'pre code.language-bash, pre code.language-sh, pre code.language-js, pre code.language-python, pre code.language-sql, pre code.language-yaml, pre code.language-json',
+ { timeout: 10000 }
+ ).should('have.length.gte', 1);
+}
+
+describe('Shortcode examples page', () => {
+ // content/example.md is the exhaustive shortcode showcase used by
+ // the test:shortcode-examples npm script. Any layout change that
+ // breaks rendering will break this page.
+ it('renders /example/ with no escaped chroma fragments', () => {
+ cy.visit('/example/');
+ assertHasHighlightedCodeBlock();
+ assertNoEscapedHighlightMarkup();
+ });
+});
+
+describe('Representative product pages', () => {
+ // Each entry picks one page per InfluxDB 3 edition that exercises
+ // a distinct wrapper/attribute combination. These were all broken
+ // by #7079 in the PR preview build.
+ const pages = [
+ {
+ path: '/influxdb3/core/reference/sample-data/',
+ exercises:
+ 'placeholders inside code-tab-content + custom-timestamps wrapper',
+ },
+ {
+ path: '/influxdb3/enterprise/admin/backup-restore/',
+ exercises:
+ 'placeholders inside tab-content wrapper (the page that regressed the most in #7079)',
+ },
+ {
+ path: '/influxdb3/cloud-dedicated/reference/sample-data/',
+ exercises:
+ 'placeholders with DATABASE_(TOKEN|NAME) regex grouping inside custom-timestamps wrapper',
+ },
+ {
+ path: '/influxdb3/clustered/admin/users/add/',
+ exercises: 'placeholders + callouts in nested expand wrappers',
+ },
+ {
+ path: '/influxdb3/cloud-serverless/reference/sample-data/',
+ exercises:
+ 'post-migration placeholders fence attribute with mixed legacy/new syntax',
+ },
+ ];
+
+ pages.forEach(({ path, exercises }) => {
+ it(`${path} — ${exercises}`, () => {
+ cy.visit(path);
+ assertHasHighlightedCodeBlock();
+ assertNoEscapedHighlightMarkup();
+ });
+ });
+});
diff --git a/layouts/_default/_markup/render-codeblock.html b/layouts/_default/_markup/render-codeblock.html
index 3149954678..028f44456c 100644
--- a/layouts/_default/_markup/render-codeblock.html
+++ b/layouts/_default/_markup/render-codeblock.html
@@ -1,4 +1,4 @@
-{{ $result := transform.HighlightCodeBlock . }}
+{{- $result := transform.HighlightCodeBlock . -}}
{{- if or .Attributes.placeholders .Attributes.callout -}}
{{- $code := highlight .Inner .Type -}}
{{- if .Attributes.placeholders -}}
@@ -16,5 +16,5 @@
{{- if in $wrapped "tc-dynamic-values" -}}
{{- $wrapped = replace $wrapped "tc-dynamic-values" "tc-dynamic-values\" data-component=\"tc-dynamic-values" -}}
{{- end -}}
- {{ $wrapped | safeHTML }}
+ {{- $wrapped | safeHTML -}}
{{- end -}}
diff --git a/lefthook.yml b/lefthook.yml
index 3333340bcc..a6f243f41e 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -28,6 +28,11 @@ pre-commit:
tags: lint
glob: "content/**/*.md"
run: .ci/scripts/check-support-links.sh {staged_files}
+ check-render-hook-whitespace:
+ tags: lint
+ glob: "layouts/_default/_markup/render-*.html"
+ run: .ci/scripts/check-render-hook-whitespace.sh {staged_files}
+ fail_text: "Render hook has bare {{ ... }} action(s). Use {{- ... -}} trimming to prevent whitespace leaks (see influxdata/docs-v2#7079)."
eslint-debug-check:
glob: "assets/js/*.js"
run: yarn eslint {staged_files}
diff --git a/package.json b/package.json
index 54667ff8a3..fc74fb2de1 100644
--- a/package.json
+++ b/package.json
@@ -94,6 +94,8 @@
"test:codeblocks:v2": "docker compose run --rm --name v2-pytest v2-pytest",
"test:codeblocks:stop-monitors": "./test/scripts/monitor-tests.sh stop cloud-dedicated-pytest && ./test/scripts/monitor-tests.sh stop clustered-pytest",
"test:e2e": "node cypress/support/run-e2e-specs.js",
+ "test:render-regression": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/render-regression.cy.js\" --no-mapping",
+ "test:render-artifacts": ".ci/scripts/check-render-artifacts.sh public",
"test:shortcode-examples": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/index.cy.js\" content/example.md",
"sync-plugins": "cd helper-scripts/influxdb3-plugins && node port_to_docs.js",
"sync-plugins:dry-run": "cd helper-scripts/influxdb3-plugins && node port_to_docs.js --dry-run",