diff --git a/.gitignore b/.gitignore
index 7e741688..20916767 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,8 @@ src/static/*
!src/static/bloblang-docs.json
test-results.*
test-results-*.*
-.playwright-mcp/
\ No newline at end of file
+.playwright-mcp/
+
+# Test screenshots
+*.png
+!/src/img/*.png
\ No newline at end of file
diff --git a/WHATS-NEW-SYSTEM.md b/WHATS-NEW-SYSTEM.md
new file mode 100644
index 00000000..24907710
--- /dev/null
+++ b/WHATS-NEW-SYSTEM.md
@@ -0,0 +1,194 @@
+# What's New System Documentation
+
+The What's New system allows components to define their latest features and have them displayed automatically on component landing pages and aggregated on the Data Platform umbrella page.
+
+## How It Works
+
+### For Component Writers
+
+Define What's New items in your **component's home/landing page** (e.g., `modules/ROOT/pages/index.adoc` or `antora.yml`):
+
+```asciidoc
+= Component Home Page
+
+// What's New Item 1
+:component-whats-new-1-title: Redpanda SQL
+:component-whats-new-1-desc: Query your streaming data in real-time with SQL. Run analytical queries directly on topics without ETL.
+:component-whats-new-1-link: sql:index.adoc
+:component-whats-new-1-tag: Cloud BYOC
+
+// What's New Item 2
+:component-whats-new-2-title: Enhanced Monitoring
+:component-whats-new-2-desc: New Grafana dashboards with real-time metrics
+:component-whats-new-2-link: manage:monitoring.adoc
+:component-whats-new-2-tag: All Tiers
+
+// Up to 10 items supported (component-whats-new-3-*, component-whats-new-4-*, etc.)
+```
+
+### Attribute Reference
+
+Each What's New item requires 4 attributes (where N = 1, 2, 3... up to 10):
+
+| Attribute | Required | Description | Example |
+|-----------|----------|-------------|---------|
+| `component-whats-new-N-title` | Yes | Feature title (short, punchy) | `Redpanda SQL` |
+| `component-whats-new-N-desc` | Yes | Feature description (1-2 sentences) | `Query streaming data in real-time with SQL` |
+| `component-whats-new-N-link` | Yes | Link to feature docs (Antora xref format) | `sql:index.adoc` |
+| `component-whats-new-N-tag` | No | Badge text (platform/tier info) | `Cloud BYOC`, `Enterprise`, `All Tiers` |
+
+### Display Locations
+
+#### 1. Component Landing Pages (Automatic)
+
+The What's New section automatically appears on component landing pages that use the `component-home-v2` or `component-home-v3` layout.
+
+No template changes needed - just define the attributes and they'll show up!
+
+**Example components:**
+- `cloud-data-platform/modules/ROOT/pages/index.adoc`
+- `streaming/modules/home/pages/index.adoc`
+- `connect/modules/ROOT/pages/index.adoc`
+
+#### 2. Data Platform Umbrella Page (Aggregated)
+
+The Data Platform home aggregates What's New from multiple components:
+
+In `data-platform/modules/ROOT/partials/data-platform.hbs`:
+```handlebars
+{{#with (aggregate-whats-new site "cloud-data-platform" "streaming" "connect")}}
+ {{> whats-new-section items=items}}
+{{/with}}
+```
+
+This pulls items from each specified component and displays them together.
+
+## Technical Details
+
+### Helpers
+
+**`get-whats-new-items.js`**
+- Reads `component-whats-new-*` attributes from current page
+- Used automatically by component landing pages
+- Returns: `{ items: [], componentName: string, hasItems: boolean }`
+
+**`aggregate-whats-new.js`**
+- Collects What's New from multiple components
+- Searches each component's home page for attributes
+- Used by Data Platform umbrella page
+- Returns: `{ items: [], hasItems: boolean }`
+
+Each item includes:
+```javascript
+{
+ title: string,
+ desc: string,
+ link: string, // Antora resource reference
+ tag: string, // Optional badge text
+ componentName: string,
+ componentColor: string, // Hex color for styling
+ index: number
+}
+```
+
+### Partial
+
+**`whats-new-section.hbs`**
+- Reusable section template
+- Supports both modes:
+ - Automatic (reads from current page)
+ - Aggregated (passed items array)
+
+### Styling
+
+Styles defined in `src/css/whats-new.css` (create this):
+- `.whats-new-compact` - Main container
+- `.whats-new-badge` - NEW badge
+- `.whats-new-item` - Individual item card
+- `.whats-new-tag` - Platform/tier badge
+
+## Examples
+
+### Cloud Component Home Page
+
+```asciidoc
+= Redpanda Cloud
+:page-layout: component-home-v2
+
+// What's New - displays automatically
+:component-whats-new-1-title: Redpanda SQL
+:component-whats-new-1-desc: Query streaming topics with real-time SQL analytics (BYOC only)
+:component-whats-new-1-link: sql:index.adoc
+:component-whats-new-1-tag: Cloud BYOC
+
+:component-whats-new-2-title: Serverless Autoscaling
+:component-whats-new-2-desc: Automatic capacity adjustments based on workload
+:component-whats-new-2-link: manage:autoscaling.adoc
+:component-whats-new-2-tag: Serverless
+```
+
+### Streaming Component
+
+```asciidoc
+= Redpanda Streaming
+:page-layout: component-home-v3
+
+:component-whats-new-1-title: Kubernetes Operator 2.0
+:component-whats-new-1-desc: Enhanced scaling, monitoring, and Day 2 operations
+:component-whats-new-1-link: deploy:kubernetes/operator.adoc
+
+:component-whats-new-2-title: ARM64 Support
+:component-whats-new-2-desc: Run Redpanda natively on ARM64 processors
+:component-whats-new-2-link: deploy:arm64.adoc
+```
+
+### Data Platform Umbrella
+
+The Data Platform page automatically shows items from all specified components:
+
+```handlebars
+{{!-- In data-platform.hbs --}}
+
elements containing blocks within the document.
- * The IDs are generated based on a hash of the URL combined with an index. This ensures that the IDs are consistent
+ * The IDs are generated based on a hash of the URL combined with an index. This ensures that the IDs are consistent
* across page loads, which is useful for maintaining references and bookmarks to specific code blocks.
* These IDs are required for the linkable line numbers feature in Prism.
* https://prismjs.com/plugins/line-highlight/
*/
-(function () {
+;(function() {
'use strict'
// Event listener for when the DOM content is fully loaded
- window.addEventListener('DOMContentLoaded', function (event) {
+ window.addEventListener('DOMContentLoaded', function(event) {
/**
* Generates a hash value for a given string.
* @param {string} str - The input string to hash.
* @returns {number} - The hash value of the input string.
*/
function hashString(str) {
- let hash = 0, i, chr;
- if (str.length === 0) return hash;
+ let hash = 0,
+ i,
+ chr
+ if (str.length === 0) return hash
for (i = 0; i < str.length; i++) {
- chr = str.charCodeAt(i);
- hash = ((hash << 5) - hash) + chr;
- hash |= 0; // Convert to 32bit integer
+ chr = str.charCodeAt(i)
+ hash = (hash << 5) - hash + chr
+ hash |= 0 // Convert to 32bit integer
}
- return hash;
+ return hash
}
/**
@@ -36,35 +38,35 @@
* @returns {string} - The generated unique ID.
*/
function generateUniqueId(index) {
- const urlHash = hashString(window.location.href);
- return `code-${urlHash}-${index}`;
+ const urlHash = hashString(window.location.href)
+ return `code-${urlHash}-${index}`
}
// Select all elements that are direct children of two nested elements
- var preElements = document.querySelectorAll('div > div > pre');
+ var preElements = document.querySelectorAll('div > div > pre')
// Iterate over each element
- preElements.forEach(function (preElem, index) {
+ preElements.forEach(function(preElem, index) {
// If the first child of the element is a block, assign a unique ID
if (preElem.firstElementChild && preElem.firstElementChild.tagName === 'CODE') {
- preElem.id = generateUniqueId(index);
+ preElem.id = generateUniqueId(index)
}
- var grandparent = preElem.parentElement.parentElement;
+ var grandparent = preElem.parentElement.parentElement
// Check if the grandparent element has a class that starts with 'lines'
- Array.from(grandparent.classList).forEach(function (className) {
+ Array.from(grandparent.classList).forEach(function(className) {
if (className.startsWith('lines')) {
- var matches = className.match(/(\d+(-\d+)?)/g);
+ var matches = className.match(/(\d+(-\d+)?)/g)
if (matches) {
- var attributeValue = matches.join(',');
- preElem.setAttribute('data-line', attributeValue);
+ var attributeValue = matches.join(',')
+ preElem.setAttribute('data-line', attributeValue)
}
}
if (className.startsWith('line-numbers')) {
- preElem.classList.add('linkable-line-numbers');
+ preElem.classList.add('linkable-line-numbers')
}
- });
- });
- });
-})();
+ })
+ })
+ })
+})()
diff --git a/src/js/10-cloud-api-feedback.js b/src/js/10-cloud-api-feedback.js
index 6a8fd885..51a3dc8d 100644
--- a/src/js/10-cloud-api-feedback.js
+++ b/src/js/10-cloud-api-feedback.js
@@ -1,4 +1,4 @@
-(function () {
+;(function () {
'use strict'
document.addEventListener('DOMContentLoaded', function () {
diff --git a/src/js/11-editable-placeholders.js b/src/js/11-editable-placeholders.js
index 72288331..491fd18a 100644
--- a/src/js/11-editable-placeholders.js
+++ b/src/js/11-editable-placeholders.js
@@ -1,279 +1,279 @@
/* eslint-disable */
-const REGEX_EDITABLE_SPAN = /<.[^&A-Z]+>/g;
-const REGEX_ESCAPE = /[\\^$*+?.()|[\]{}]/g;
-const REGEX_HTML_TAG = /<[^>]*>/g;
-const REGEX_LT_GT = /<|>/g;
-const REGEX_PREPROCESS_PUNCTUATION = /(\()<\/span>|(\))<\/span>/g;
-const REGEX_CONUM_SPAN = /(\s\((\d+)<\/span>\)|(\s)\((\d+)\))$/gm;
+const REGEX_EDITABLE_SPAN = /<.[^&A-Z]+>/g
+const REGEX_ESCAPE = /[\\^$*+?.()|[\]{}]/g
+const REGEX_HTML_TAG = /<[^>]*>/g
+const REGEX_LT_GT = /<|>/g
+const REGEX_PREPROCESS_PUNCTUATION = /(\()<\/span>|(\))<\/span>/g
+const REGEX_CONUM_SPAN = /(\s\((\d+)<\/span>\)|(\s)\((\d+)\))$/gm
function unnestPlaceholders() {
- const editables = document.querySelectorAll('[contenteditable="true"]');
-
- editables.forEach(editable => {
+ const editables = document.querySelectorAll('[contenteditable="true"]')
+ editables.forEach((editable) => {
// Remove empty siblings, but don't remove conum elements
- let nextSibling = editable.nextElementSibling;
+ let nextSibling = editable.nextElementSibling
while (nextSibling && nextSibling.innerHTML.trim() === '') {
// Check if the sibling has a conum class, if so, skip removal
if (!nextSibling.classList.contains('conum')) {
- const siblingToRemove = nextSibling;
- nextSibling = nextSibling.nextElementSibling;
- siblingToRemove.remove();
+ const siblingToRemove = nextSibling
+ nextSibling = nextSibling.nextElementSibling
+ siblingToRemove.remove()
} else {
// If it's a conum, move to the next sibling without removing it
- nextSibling = nextSibling.nextElementSibling;
+ nextSibling = nextSibling.nextElementSibling
}
}
-
- let parent = editable.parentElement;
+ let parent = editable.parentElement
// If the parent is also contenteditable, move the child out of the nested structure
while (parent && parent.getAttribute('contenteditable') === 'true') {
- const grandParent = parent.parentElement;
+ const grandParent = parent.parentElement
// Move the current editable element before the parent to "unnest" it
- grandParent.insertBefore(editable, parent);
+ grandParent.insertBefore(editable, parent)
// If the parent becomes empty, remove the parent element
if (parent.childNodes.length === 0) {
- parent.remove();
+ parent.remove()
}
// Continue checking up the chain if the parent is also contenteditable
- parent = grandParent;
+ parent = grandParent
}
- });
+ })
}
-(function () {
- 'use strict';
+;(function() {
+ 'use strict'
// Check if Prism is available, exit if not
- if (!Prism.highlightAll) {
- return;
+ if (typeof Prism === 'undefined' || !Prism.highlightAll) {
+ return
}
function observeCodeBlocksForConumRestoration() {
- const codeElems = document.querySelectorAll('code');
+ const codeElems = document.querySelectorAll('code')
- codeElems.forEach(code => {
- let mutationInProgress = false;
+ codeElems.forEach((code) => {
+ let mutationInProgress = false
- const observer = new MutationObserver(mutations => {
- if (mutationInProgress) return; // Prevent recursion
- mutationInProgress = true;
+ const observer = new MutationObserver((mutations) => {
+ if (mutationInProgress) return // Prevent recursion
+ mutationInProgress = true
- mutations.forEach(mutation => {
+ mutations.forEach((mutation) => {
// Check for removed nodes that are conum elements
- mutation.removedNodes.forEach(removedNode => {
+ mutation.removedNodes.forEach((removedNode) => {
if (removedNode.nodeType === Node.ELEMENT_NODE && removedNode.classList.contains('conum')) {
// Only reinsert if it was actually removed and not reinserted elsewhere
if (!mutation.target.querySelector(`i.conum[data-value="${removedNode.getAttribute('data-value')}"]`)) {
- mutation.target.appendChild(removedNode);
+ mutation.target.appendChild(removedNode)
}
}
- });
+ })
// Optionally, check for added nodes to ensure conum elements were correctly reinserted
- mutation.addedNodes.forEach(addedNode => {
+ mutation.addedNodes.forEach((addedNode) => {
if (addedNode.nodeType === Node.ELEMENT_NODE && addedNode.classList.contains('conum')) {
- const dataValue = addedNode.getAttribute('data-value');
- const duplicates = mutation.target.querySelectorAll(`i.conum[data-value="${dataValue}"]`);
+ const dataValue = addedNode.getAttribute('data-value')
+ const duplicates = mutation.target.querySelectorAll(`i.conum[data-value="${dataValue}"]`)
if (duplicates.length > 1) {
// Remove duplicates, keeping the first one
duplicates.forEach((dup, index) => {
- if (index > 0) dup.remove();
- });
+ if (index > 0) dup.remove()
+ })
}
}
- });
- });
- mutationInProgress = false;
- });
- observer.observe(code, { childList: true, subtree: true });
- });
+ })
+ })
+ mutationInProgress = false
+ })
+ observer.observe(code, { childList: true, subtree: true })
+ })
}
- window.addEventListener('DOMContentLoaded', function () {
+ window.addEventListener('DOMContentLoaded', function() {
try {
observeCodeBlocksForConumRestoration()
- makePlaceholdersEditable();
- Prism && Prism.highlightAll();
+ makePlaceholdersEditable()
+ Prism && Prism.highlightAll()
} catch (error) {
- console.error('An error occurred while making placeholders editable:', error);
+ console.error('An error occurred while making placeholders editable:', error)
}
- });
+ })
function makePlaceholdersEditable(element) {
- createEditablePlaceholders(element);
- unnestPlaceholders();
- addClasses(element);
- addEvents(element);
+ createEditablePlaceholders(element)
+ unnestPlaceholders()
+ addClasses(element)
+ addEvents(element)
}
function createEditablePlaceholders(parentElement) {
- const baseElement = parentElement || document;
- const codeElements = baseElement.querySelectorAll("pre > code");
+ const baseElement = parentElement || document
+ const codeElements = baseElement.querySelectorAll('pre > code')
- codeElements.forEach(codeElement => {
- const preElement = codeElement.parentElement;
- const contentDivElement = preElement.parentElement;
- const listingBlockElement = contentDivElement.parentElement;
+ codeElements.forEach((codeElement) => {
+ const preElement = codeElement.parentElement
+ const contentDivElement = preElement.parentElement
+ const listingBlockElement = contentDivElement.parentElement
if (listingBlockElement.classList.contains('no-placeholders')) {
- return;
+ return
}
- codeElement.classList.add('keep-markup');
- preprocessParentheses(codeElement);
- addConumSpans(codeElement);
+ codeElement.classList.add('keep-markup')
+ preprocessParentheses(codeElement)
+ addConumSpans(codeElement)
if (!['xml', 'html', 'rust', 'coffeescript', 'text', 'bloblang', 'blobl'].includes(codeElement.dataset.lang)) {
- addEditableSpan(/<.[^&A-Z]+>/g, codeElement);
+ addEditableSpan(/<.[^&A-Z]+>/g, codeElement)
}
- });
+ })
}
if (!RegExp.escape) {
RegExp.escape = function(s) {
- return s.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
- };
+ return s.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&')
+ }
}
function addEditableSpan(regex, element) {
if (!element || !element.textContent) {
- return;
+ return
}
- const text = element.innerHTML;
- const placeholders = text.match(regex) || [];
- const processed = new Set();
- let newHTML = text;
+ const text = element.innerHTML
+ const placeholders = text.match(regex) || []
+ const processed = new Set()
+ let newHTML = text
placeholders
.sort((a, b) => b.length - a.length)
- .forEach(placeholder => {
- const cleanedPlaceholder = placeholder.replace(/<[^>]*>/g, '').replace(/<|>/g, '');
+ .forEach((placeholder) => {
+ const cleanedPlaceholder = placeholder.replace(/<[^>]*>/g, '').replace(/<|>/g, '')
if (processed.has(placeholder) || cleanedPlaceholder.trim() === 'none') {
- return;
+ return
}
- const regexString = RegExp.escape(placeholder);
- const globalRegex = new RegExp(regexString, 'g');
- newHTML = newHTML.replace(globalRegex, `<${cleanedPlaceholder}>`);
- processed.add(placeholder);
- });
-
- element.innerHTML = newHTML;
+ const regexString = RegExp.escape(placeholder)
+ const globalRegex = new RegExp(regexString, 'g')
+ newHTML = newHTML.replace(
+ globalRegex,
+ `<${cleanedPlaceholder}>`
+ )
+ processed.add(placeholder)
+ })
+
+ element.innerHTML = newHTML
}
function preprocessParentheses(element) {
if (!element || !element.textContent) {
- return;
+ return
}
- const pattern = /(\()<\/span>|(\))<\/span>/g;
+ const pattern = /(\()<\/span>|(\))<\/span>/g
element.innerHTML = element.innerHTML.replace(pattern, (match, openParen, closeParen) => {
- return openParen || closeParen || match;
- });
+ return openParen || closeParen || match
+ })
}
function addConumSpans(element) {
if (!element || !element.textContent) {
- return;
+ return
}
// Handle standalone numbers in parentheses, avoiding function-like patterns
- const standalonePattern = /(? {
- return ``;
- });
- const complexPattern = /(\s\((\d+)<\/span>\)|(\s)\((\d+)\))$/gm;
+ return ``
+ })
+ const complexPattern = /(\s\((\d+)<\/span>\)|(\s)\((\d+)\))$/gm
element.innerHTML = element.innerHTML.replace(complexPattern, (match, p1, p2, p3, p4) => {
- return p3 ? `${p3}` : ``;
- });
+ return p3 ? `${p3}` : ``
+ })
}
function addClasses(parentElement) {
- const baseElement = parentElement || document;
- const editablePlaceholders = baseElement.querySelectorAll('[contenteditable="true"]');
+ const baseElement = parentElement || document
+ const editablePlaceholders = baseElement.querySelectorAll('[contenteditable="true"]')
- editablePlaceholders.forEach(placeholder => {
- placeholder.classList.add('editable');
- });
+ editablePlaceholders.forEach((placeholder) => {
+ placeholder.classList.add('editable')
+ })
}
function addEvents(parentElement) {
- const baseElement = parentElement || document;
- const editablePlaceholders = baseElement.querySelectorAll('[contenteditable="true"]');
+ const baseElement = parentElement || document
+ const editablePlaceholders = baseElement.querySelectorAll('[contenteditable="true"]')
- editablePlaceholders.forEach(placeholder => {
- const dataType = placeholder.getAttribute('data-type');
+ editablePlaceholders.forEach((placeholder) => {
+ const dataType = placeholder.getAttribute('data-type')
if (!dataType) {
- console.info('Data type attribute is missing on the placeholder.');
- return;
+ console.info('Data type attribute is missing on the placeholder.')
+ return
}
- placeholder.addEventListener('input', handleInputEvent);
- placeholder.addEventListener('keydown', handleEnterKey);
- placeholder.addEventListener('blur', handleBlurEvent);
- placeholder.addEventListener('focus', handleFocusEvent);
-
- const savedText = sessionStorage.getItem(dataType);
- placeholder.textContent = savedText ? savedText : `<${dataType}>`;
- });
+ placeholder.addEventListener('input', handleInputEvent)
+ placeholder.addEventListener('keydown', handleEnterKey)
+ placeholder.addEventListener('blur', handleBlurEvent)
+ placeholder.addEventListener('focus', handleFocusEvent)
+
+ const savedText = sessionStorage.getItem(dataType)
+ placeholder.textContent = savedText ? savedText : `<${dataType}>`
+ })
}
function handleInputEvent(event) {
- const dataType = event.target.dataset.type;
- const newText = event.target.textContent;
+ const dataType = event.target.dataset.type
+ const newText = event.target.textContent
- document.querySelectorAll(`[data-type="${dataType}"][contenteditable="true"]`).forEach(span => {
+ document.querySelectorAll(`[data-type="${dataType}"][contenteditable="true"]`).forEach((span) => {
if (!isWithinHiddenTab(span) && span !== event.target) {
- span.textContent = newText;
+ span.textContent = newText
}
- });
+ })
}
function handleEnterKey(event) {
if (event.key === 'Enter') {
- event.preventDefault();
- event.target.blur();
+ event.preventDefault()
+ event.target.blur()
}
}
function handleBlurEvent() {
-
- const currentText = this.textContent.trim();
- const dataType = this.getAttribute('data-type');
+ const currentText = this.textContent.trim()
+ const dataType = this.getAttribute('data-type')
if (!currentText) {
- const defaultText = `<${dataType}>`;
- this.textContent = defaultText;
+ const defaultText = `<${dataType}>`
+ this.textContent = defaultText
- document.querySelectorAll(`[data-type="${dataType}"][contenteditable="true"]`).forEach(span => {
- span.textContent = defaultText;
- });
+ document.querySelectorAll(`[data-type="${dataType}"][contenteditable="true"]`).forEach((span) => {
+ span.textContent = defaultText
+ })
} else {
- sessionStorage.setItem(dataType, currentText);
+ sessionStorage.setItem(dataType, currentText)
}
}
function handleFocusEvent() {
// Select all text inside the placeholder when it receives focus
- const range = document.createRange();
- const selection = window.getSelection();
- range.selectNodeContents(this);
- selection.removeAllRanges();
- selection.addRange(range);
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.selectNodeContents(this)
+ selection.removeAllRanges()
+ selection.addRange(range)
}
function isWithinHiddenTab(element) {
- let ancestor = element.parentElement;
+ let ancestor = element.parentElement
while (ancestor) {
if (ancestor.classList.contains('tabpanel') && ancestor.classList.contains('is-hidden')) {
- return true;
+ return true
}
- ancestor = ancestor.parentElement;
+ ancestor = ancestor.parentElement
}
- return false;
+ return false
}
-})();
+})()
diff --git a/src/js/12-activate-tooltips.js b/src/js/12-activate-tooltips.js
index 4e49cc84..163727ed 100644
--- a/src/js/12-activate-tooltips.js
+++ b/src/js/12-activate-tooltips.js
@@ -1,5 +1,5 @@
/* global tippy */
-(function () {
+;(function () {
'use strict'
document.addEventListener('DOMContentLoaded', function () {
diff --git a/src/js/13-open-nested-tabs.js b/src/js/13-open-nested-tabs.js
index 3c9b25b5..8d34e357 100644
--- a/src/js/13-open-nested-tabs.js
+++ b/src/js/13-open-nested-tabs.js
@@ -13,8 +13,8 @@
* - Re-run Prism for syntax highlighting when tab content becomes visible.
* - Scroll to the selected tab.
*/
-(function () {
- 'use strict';
+;(function() {
+ 'use strict'
/**
* Debounce utility function to limit the rate at which a function can fire.
@@ -23,11 +23,11 @@
* @returns {Function} - The debounced function.
*/
function debounce(func, wait) {
- let timeout;
- return function (...args) {
- clearTimeout(timeout);
- timeout = setTimeout(() => func.apply(this, args), wait);
- };
+ let timeout
+ return function(...args) {
+ clearTimeout(timeout)
+ timeout = setTimeout(() => func.apply(this, args), wait)
+ }
}
/**
@@ -38,19 +38,19 @@
const debouncedHighlight = debounce((codeElements) => {
if (typeof Prism !== 'undefined' && Prism.highlightElement) {
requestAnimationFrame(() => {
- codeElements.forEach(pre => {
- const code = pre.querySelector('code');
+ codeElements.forEach((pre) => {
+ const code = pre.querySelector('code')
if (code) {
// https://prismjs.com/docs/Prism.html#.highlightElement
- Prism.highlightElement(code, true);
+ Prism.highlightElement(code, true)
Prism.plugins.lineNumbers.resize(code)
}
- });
+ })
})
} else {
- console.warn('Prism.highlightElement() is not available. Ensure Prism.js is correctly loaded.');
+ console.warn('Prism.highlightElement() is not available. Ensure Prism.js is correctly loaded.')
}
- }, 100);
+ }, 100)
/**
* Simulates a click on the specified tab element.
@@ -58,7 +58,7 @@
*/
function simulateTabClick(tabElement) {
if (tabElement) {
- tabElement.click();
+ tabElement.click()
}
}
@@ -69,8 +69,8 @@
*/
function stripTabParamIfHashPresent(url) {
if (url.hash && url.searchParams.has('tab')) {
- url.searchParams.delete('tab');
- window.history.replaceState(null, '', url);
+ url.searchParams.delete('tab')
+ window.history.replaceState(null, '', url)
}
}
@@ -78,35 +78,35 @@
* Handles deep linking by activating the appropriate tab based on the URL's query parameter or hash fragment.
*/
function handleDeepLinking() {
- const url = new URL(window.location.href);
- stripTabParamIfHashPresent(url);
+ const url = new URL(window.location.href)
+ stripTabParamIfHashPresent(url)
// Re-parse the URL after potential modification
- const updatedUrl = new URL(window.location.href);
- const updatedTabParam = updatedUrl.searchParams.get('tab');
- const hash = updatedUrl.hash.substring(1); // Remove the '#' character
+ const updatedUrl = new URL(window.location.href)
+ const updatedTabParam = updatedUrl.searchParams.get('tab')
+ const hash = updatedUrl.hash.substring(1) // Remove the '#' character
// Define a valid hash pattern (adjust as needed)
- const validHashPattern = /^[a-zA-Z0-9-_]+$/;
+ const validHashPattern = /^[a-zA-Z0-9-_]+$/
// Prioritize hash fragment over query parameter
if (hash && validHashPattern.test(hash)) {
- const targetTab = document.getElementById(hash);
+ const targetTab = document.getElementById(hash)
if (targetTab) {
- simulateTabClick(targetTab);
- return;
+ simulateTabClick(targetTab)
+ return
} else {
- console.warn(`No tab found for hash "${hash}".`);
+ console.warn(`No tab found for hash "${hash}".`)
}
}
if (updatedTabParam && validHashPattern.test(updatedTabParam)) {
- const targetTab = document.getElementById(updatedTabParam);
+ const targetTab = document.getElementById(updatedTabParam)
if (targetTab) {
- simulateTabClick(targetTab);
- return;
+ simulateTabClick(targetTab)
+ return
} else {
- console.warn(`No tab found for query parameter "${updatedTabParam}".`);
+ console.warn(`No tab found for query parameter "${updatedTabParam}".`)
}
}
}
@@ -116,41 +116,42 @@
* Utilizes an external library to manage synchronized tab activation.
*/
function setupTabListeners() {
- const tabs = document.querySelectorAll('li.tab');
+ const tabs = document.querySelectorAll('li.tab')
- tabs.forEach(function (tab) {
- tab.addEventListener('click', function (event) {
- event.preventDefault();
- const clickedTab = event.currentTarget;
- const tabId = clickedTab.id;
+ tabs.forEach(function(tab) {
+ tab.addEventListener('click', function(event) {
+ event.preventDefault()
+ const clickedTab = event.currentTarget
+ const tabId = clickedTab.id
// Let the external library handle synchronization
// After synchronization, highlight code blocks within active tabs
// Use a short timeout to allow the external library to activate tabs
setTimeout(() => {
- const activePanels = document.querySelectorAll('.tabpanel:not(.is-hidden)');
- const codeBlocks = Array.from(activePanels).flatMap(panel => Array.from(panel.querySelectorAll('pre.highlight, pre.line-numbers.highlight')));
+ const activePanels = document.querySelectorAll('.tabpanel:not(.is-hidden)')
+ const codeBlocks = Array.from(activePanels).flatMap((panel) =>
+ Array.from(panel.querySelectorAll('pre.highlight, pre.line-numbers.highlight'))
+ )
if (codeBlocks.length > 0) {
- debouncedHighlight(codeBlocks);
+ debouncedHighlight(codeBlocks)
}
- }, 100); // Adjust delay as needed based on external library's behavior
- });
- });
+ }, 100) // Adjust delay as needed based on external library's behavior
+ })
+ })
}
/**
* Initializes the tab synchronization and deep linking on page load.
*/
function initializeTabs() {
- setupTabListeners();
- handleDeepLinking();
+ setupTabListeners()
+ handleDeepLinking()
}
- window.addEventListener('DOMContentLoaded', initializeTabs);
+ window.addEventListener('DOMContentLoaded', initializeTabs)
/**
* Handle browser navigation (back/forward buttons) to maintain tab state.
*/
- window.addEventListener('popstate', handleDeepLinking);
-
-})();
+ window.addEventListener('popstate', handleDeepLinking)
+})()
diff --git a/src/js/14-markdown-dropdown.js b/src/js/14-markdown-dropdown.js
index 5eb1727e..b9728e86 100644
--- a/src/js/14-markdown-dropdown.js
+++ b/src/js/14-markdown-dropdown.js
@@ -12,7 +12,15 @@
;(function () {
'use strict'
- document.addEventListener('DOMContentLoaded', init)
+ // Run init when DOM is ready - handle both cases:
+ // 1. If DOM is still loading, wait for DOMContentLoaded
+ // 2. If DOM is already ready (interactive or complete), run immediately
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init)
+ } else {
+ // DOM already ready, run immediately
+ setTimeout(init, 0)
+ }
function init () {
const dropdowns = document.querySelectorAll('.markdown-dropdown')
@@ -126,7 +134,8 @@
*/
function handleCopy (markdownUrl, button) {
// Fetch markdown content and copy to clipboard
- window.fetch(markdownUrl)
+ window
+ .fetch(markdownUrl)
.then(function (response) {
if (!response.ok) throw new Error('Failed to fetch')
return response.text()
@@ -157,20 +166,25 @@
* Handle Ask AI about this doc
*/
function handleAskAI () {
- var kapa = window.Kapa
+ // Find and click the "Ask AI" button in the top nav to open the chat drawer
+ var askAiBtn = document.querySelector('[data-action="open-chat"]')
- if (kapa) {
- // Get page title for context
- var pageTitle = document.querySelector('h1.page')?.textContent || 'this page'
- var aiPromptText = 'I have a question about the documentation page: ' + pageTitle
+ if (askAiBtn) {
+ askAiBtn.click()
- kapa.open({
- mode: 'ai',
- query: aiPromptText,
- submit: false,
- })
+ // Optional: Pre-fill the chat input with context about the page
+ // Wait a moment for the drawer to open, then set the input value
+ setTimeout(function () {
+ var pageTitle = document.querySelector('h1.page')?.textContent || 'this page'
+ var chatInput = document.querySelector('#chat-panel-input')
+
+ if (chatInput) {
+ chatInput.value = 'I have a question about the documentation page: ' + pageTitle
+ chatInput.focus()
+ }
+ }, 100)
} else {
- console.warn('Kapa AI is not available.')
+ console.warn('Ask AI drawer is not available.')
}
}
diff --git a/src/js/15-optimize-images.js b/src/js/15-optimize-images.js
index 2fbcd8e0..a68e4b4c 100644
--- a/src/js/15-optimize-images.js
+++ b/src/js/15-optimize-images.js
@@ -4,7 +4,7 @@
* - Add async decoding for better rendering performance
* - Add explicit dimensions to prevent layout shifts
*/
-(function () {
+;(function () {
'use strict'
// Wait for DOM to be ready
@@ -40,9 +40,13 @@
setImageDimensions(img)
} else {
// Otherwise wait for load event
- img.addEventListener('load', function () {
- setImageDimensions(this)
- }, { once: true })
+ img.addEventListener(
+ 'load',
+ function () {
+ setImageDimensions(this)
+ },
+ { once: true }
+ )
}
}
})
@@ -79,14 +83,18 @@
img.setAttribute('height', height)
// Log warning if image is significantly oversized (developer tool)
- if (naturalWidth > 0 && displayedWidth > 0 &&
- (naturalWidth > displayedWidth * 2 || naturalHeight > displayedHeight * 2)) {
- const savings = Math.round(((naturalWidth * naturalHeight) - (displayedWidth * displayedHeight)) /
- (naturalWidth * naturalHeight) * 100)
+ if (
+ naturalWidth > 0 &&
+ displayedWidth > 0 &&
+ (naturalWidth > displayedWidth * 2 || naturalHeight > displayedHeight * 2)
+ ) {
+ const savings = Math.round(
+ ((naturalWidth * naturalHeight - displayedWidth * displayedHeight) / (naturalWidth * naturalHeight)) * 100
+ )
console.info(
`Image oversized: ${img.src.split('/').pop()} ` +
- `(${naturalWidth}×${naturalHeight} displayed as ${displayedWidth}×${displayedHeight}, ` +
- `~${savings}% potential savings)`
+ `(${naturalWidth}×${naturalHeight} displayed as ${displayedWidth}×${displayedHeight}, ` +
+ `~${savings}% potential savings)`
)
}
}
diff --git a/src/js/16-bloblang-interactive.js b/src/js/16-bloblang-interactive.js
index 12e3976c..7cc84246 100644
--- a/src/js/16-bloblang-interactive.js
+++ b/src/js/16-bloblang-interactive.js
@@ -8,13 +8,13 @@
* - Quick actions (copy, share, format)
*/
-(function() {
- 'use strict';
+;(function() {
+ 'use strict'
// State
- let bloblangDocs = null;
- let docsLoading = false;
- let docsLoadQueue = [];
+ let bloblangDocs = null
+ let docsLoading = false
+ let docsLoadQueue = []
/**
* Parse a Bloblang snippet into mapping, input, and metadata sections.
@@ -22,94 +22,97 @@
* # Out: lines are ignored.
*/
function parseBloblangSnippet(rawSnippet) {
- const mappingLines = [];
- const inputLines = [];
- const metaLines = [];
+ const mappingLines = []
+ const inputLines = []
+ const metaLines = []
- let inSeen = false;
- let metaSeen = false;
- let ignoreAll = false;
- let skip = false;
- let currentSection = 'mapping';
+ let inSeen = false
+ let metaSeen = false
+ let ignoreAll = false
+ let skip = false
+ let currentSection = 'mapping'
- const lines = rawSnippet.split('\n');
+ const lines = rawSnippet.split('\n')
for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- const trimmed = line.trim();
+ const line = lines[i]
+ const trimmed = line.trim()
- if (ignoreAll) continue;
+ if (ignoreAll) continue
// Check for # Skip: directive (disables Try It button)
if (trimmed.startsWith('# Skip:')) {
- const value = trimmed.slice('# Skip:'.length).trim().toLowerCase();
+ const value = trimmed
+ .slice('# Skip:'.length)
+ .trim()
+ .toLowerCase()
if (value === 'true' || value === '1' || value === 'yes') {
- skip = true;
+ skip = true
}
- continue;
+ continue
}
// Ignore # Out: and # Output: lines
- if (trimmed.startsWith('# Out:') || trimmed.startsWith('# Output:')) continue;
+ if (trimmed.startsWith('# Out:') || trimmed.startsWith('# Output:')) continue
// Check for # In: or # Input: directive
if (trimmed.startsWith('# In:') || trimmed.startsWith('# Input:')) {
if (!inSeen) {
- inSeen = true;
- currentSection = 'in';
+ inSeen = true
+ currentSection = 'in'
// Extract content after the directive (handle both # In: and # Input:)
- const colonIndex = trimmed.indexOf(':');
- const afterDirective = trimmed.slice(colonIndex + 1).trim();
- if (afterDirective) inputLines.push(afterDirective);
+ const colonIndex = trimmed.indexOf(':')
+ const afterDirective = trimmed.slice(colonIndex + 1).trim()
+ if (afterDirective) inputLines.push(afterDirective)
} else {
- ignoreAll = true;
+ ignoreAll = true
}
- continue;
+ continue
}
// Check for # Meta: directive
if (trimmed.startsWith('# Meta:')) {
if (!metaSeen) {
- metaSeen = true;
- currentSection = 'meta';
- const afterMeta = trimmed.slice('# Meta:'.length).trim();
- if (afterMeta) metaLines.push(afterMeta);
+ metaSeen = true
+ currentSection = 'meta'
+ const afterMeta = trimmed.slice('# Meta:'.length).trim()
+ if (afterMeta) metaLines.push(afterMeta)
} else {
- ignoreAll = true;
+ ignoreAll = true
}
- continue;
+ continue
}
switch (currentSection) {
case 'mapping':
- mappingLines.push(line);
- break;
+ mappingLines.push(line)
+ break
case 'in':
// Support multi-line commented input (# prefix on each line)
if (trimmed.startsWith('#')) {
// Strip the # and any leading whitespace
- const content = trimmed.slice(1).trim();
- if (content) inputLines.push(content);
+ const content = trimmed.slice(1).trim()
+ if (content) inputLines.push(content)
} else if (trimmed === '') {
// Allow empty lines within the block
- continue;
+ continue
} else {
// Non-comment line - we've exited the input section
- currentSection = 'mapping';
- mappingLines.push(line);
+ currentSection = 'mapping'
+ mappingLines.push(line)
}
- break;
+ break
case 'meta':
// Support multi-line commented metadata (# prefix on each line)
if (trimmed.startsWith('#')) {
- const content = trimmed.slice(1).trim();
- if (content) metaLines.push(content);
+ const content = trimmed.slice(1).trim()
+ if (content) metaLines.push(content)
} else if (trimmed === '') {
- continue;
+ continue
} else {
- currentSection = 'mapping';
- mappingLines.push(line);
+ currentSection = 'mapping'
+ mappingLines.push(line)
}
- break;
+ break
}
}
@@ -117,8 +120,8 @@
mapping: mappingLines.join('\n').trim(),
input: inputLines.join('\n').trim(),
meta: metaLines.join('\n').trim(),
- skip: skip
- };
+ skip: skip,
+ }
}
/**
@@ -126,18 +129,18 @@
*/
async function tryFetchConnectJSON(version) {
try {
- const url = `/redpanda-connect/components/_attachments/connect-${version}.json`;
- const response = await fetch(url);
+ const url = `/connect/components/_attachments/connect-${version}.json`
+ const response = await fetch(url)
if (!response.ok) {
- return null;
+ return null
}
- const data = await response.json();
- return data;
+ const data = await response.json()
+ return data
} catch (e) {
// Silent fail - expected when trying fallback versions
- return null;
+ return null
}
}
@@ -147,21 +150,22 @@
function transformConnectData(data) {
const docs = {
functions: {},
- methods: {}
- };
+ methods: {},
+ }
// Transform Bloblang functions
if (Array.isArray(data['bloblang-functions'])) {
- data['bloblang-functions'].forEach(fn => {
- const params = fn.params && fn.params.named ? fn.params.named.map(p => ({
- name: p.name,
- type: p.type || 'any',
- description: p.description || ''
- })) : [];
-
- const paramSignature = params.length > 0
- ? params.map(p => p.name).join(', ')
- : '';
+ data['bloblang-functions'].forEach((fn) => {
+ const params =
+ fn.params && fn.params.named
+ ? fn.params.named.map((p) => ({
+ name: p.name,
+ type: p.type || 'any',
+ description: p.description || '',
+ }))
+ : []
+
+ const paramSignature = params.length > 0 ? params.map((p) => p.name).join(', ') : ''
docs.functions[fn.name] = {
signature: `${fn.name}(${paramSignature})`,
@@ -169,31 +173,34 @@
parameters: params,
returns: 'any',
category: fn.category || 'general',
- url: `https://docs.redpanda.com/redpanda-connect/guides/bloblang/functions/#${fn.name.toLowerCase().replace(/_/g, '-')}`
- };
+ url: `https://docs.redpanda.com/connect/guides/bloblang/functions/#${fn.name
+ .toLowerCase()
+ .replace(/_/g, '-')}`,
+ }
// Add example if available
if (fn.examples && fn.examples.length > 0) {
- const example = fn.examples[0];
+ const example = fn.examples[0]
if (example.mapping) {
- docs.functions[fn.name].example = example.mapping;
+ docs.functions[fn.name].example = example.mapping
}
}
- });
+ })
}
// Transform Bloblang methods
if (Array.isArray(data['bloblang-methods'])) {
- data['bloblang-methods'].forEach(method => {
- const params = method.params && method.params.named ? method.params.named.map(p => ({
- name: p.name,
- type: p.type || 'any',
- description: p.description || ''
- })) : [];
-
- const paramSignature = params.length > 0
- ? params.map(p => p.name).join(', ')
- : '';
+ data['bloblang-methods'].forEach((method) => {
+ const params =
+ method.params && method.params.named
+ ? method.params.named.map((p) => ({
+ name: p.name,
+ type: p.type || 'any',
+ description: p.description || '',
+ }))
+ : []
+
+ const paramSignature = params.length > 0 ? params.map((p) => p.name).join(', ') : ''
docs.methods[method.name] = {
signature: `${method.name}(${paramSignature})`,
@@ -201,20 +208,22 @@
parameters: params,
returns: 'any',
category: method.categories && method.categories.length > 0 ? method.categories[0].Category : 'general',
- url: `https://docs.redpanda.com/redpanda-connect/guides/bloblang/methods/#${method.name.toLowerCase().replace(/_/g, '-')}`
- };
+ url: `https://docs.redpanda.com/connect/guides/bloblang/methods/#${method.name
+ .toLowerCase()
+ .replace(/_/g, '-')}`,
+ }
// Add example if available
if (method.examples && method.examples.length > 0) {
- const example = method.examples[0];
+ const example = method.examples[0]
if (example.mapping) {
- docs.methods[method.name].example = example.mapping;
+ docs.methods[method.name].example = example.mapping
}
}
- });
+ })
}
- return docs;
+ return docs
}
/**
@@ -222,16 +231,16 @@
* Caches in localStorage for 1 hour to avoid repeated fetches
*/
async function getConnectVersion() {
- var CACHE_KEY = 'bloblang-connect-version';
- var CACHE_TTL = 60 * 60 * 1000; // 1 hour
+ var CACHE_KEY = 'bloblang-connect-version'
+ var CACHE_TTL = 60 * 60 * 1000 // 1 hour
// Check cache first
try {
- var cached = localStorage.getItem(CACHE_KEY);
+ var cached = localStorage.getItem(CACHE_KEY)
if (cached) {
- var parsed = JSON.parse(cached);
+ var parsed = JSON.parse(cached)
if (Date.now() - parsed.timestamp < CACHE_TTL) {
- return parsed.version;
+ return parsed.version
}
}
} catch (e) {
@@ -240,29 +249,32 @@
// Fetch from antora.yml (no rate limits, CDN-served)
try {
- var resp = await fetch('https://raw.githubusercontent.com/redpanda-data/rp-connect-docs/main/antora.yml');
+ var resp = await fetch('https://raw.githubusercontent.com/redpanda-data/rp-connect-docs/main/antora.yml')
if (resp.ok) {
- var yaml = await resp.text();
- var match = yaml.match(/latest-connect-version:\s*['"]?(\d+\.\d+\.\d+)/);
+ var yaml = await resp.text()
+ var match = yaml.match(/latest-connect-version:\s*['"]?(\d+\.\d+\.\d+)/)
if (match) {
- var version = match[1];
+ var version = match[1]
// Cache the result
try {
- localStorage.setItem(CACHE_KEY, JSON.stringify({
- version: version,
- timestamp: Date.now()
- }));
+ localStorage.setItem(
+ CACHE_KEY,
+ JSON.stringify({
+ version: version,
+ timestamp: Date.now(),
+ })
+ )
} catch (e) {
// localStorage not available
}
- return version;
+ return version
}
}
} catch (e) {
// Silent fail - will use fallback versions
}
- return null;
+ return null
}
/**
@@ -270,74 +282,74 @@
*/
async function loadBloblangDocs() {
if (bloblangDocs) {
- return bloblangDocs;
+ return bloblangDocs
}
if (docsLoading) {
return new Promise((resolve) => {
- docsLoadQueue.push(resolve);
- });
+ docsLoadQueue.push(resolve)
+ })
}
- docsLoading = true;
+ docsLoading = true
try {
- var data = null;
- var isDocsUiPreview = window.location.hostname.includes('docs-ui.netlify.app');
+ var data = null
+ var isDocsUiPreview = window.location.hostname.includes('docs-ui.netlify.app')
// Skip remote fetches on docs-ui preview site - JSON files don't exist there
if (!isDocsUiPreview) {
// Try to get latest version from cached antora.yml
- var latestVersion = await getConnectVersion();
+ var latestVersion = await getConnectVersion()
if (latestVersion) {
- data = await tryFetchConnectJSON(latestVersion);
+ data = await tryFetchConnectJSON(latestVersion)
}
// Fallback: try known recent versions
if (!data) {
- var fallbackVersions = ['4.79.0', '4.78.0', '4.77.0', '4.76.0', '4.75.0'];
+ var fallbackVersions = ['4.79.0', '4.78.0', '4.77.0', '4.76.0', '4.75.0']
for (var i = 0; i < fallbackVersions.length; i++) {
- data = await tryFetchConnectJSON(fallbackVersions[i]);
- if (data) break;
+ data = await tryFetchConnectJSON(fallbackVersions[i])
+ if (data) break
}
}
}
// Transform data to our format
if (data) {
- bloblangDocs = transformConnectData(data);
+ bloblangDocs = transformConnectData(data)
} else {
// Fallback to static file if available (or primary source on localhost)
try {
- var response = await fetch(uiRootPath + '/bloblang-docs.json');
- bloblangDocs = await response.json();
+ var response = await fetch(uiRootPath + '/bloblang-docs.json')
+ bloblangDocs = await response.json()
} catch (err) {
// No documentation available - tooltips will be disabled
- bloblangDocs = { functions: {}, methods: {} };
+ bloblangDocs = { functions: {}, methods: {} }
}
}
- docsLoading = false;
+ docsLoading = false
// Resolve all queued promises
for (const resolve of docsLoadQueue) {
- resolve(bloblangDocs);
+ resolve(bloblangDocs)
}
- docsLoadQueue = [];
+ docsLoadQueue = []
- return bloblangDocs;
+ return bloblangDocs
} catch (error) {
// Unexpected error - tooltips will be disabled
- docsLoading = false;
- bloblangDocs = { functions: {}, methods: {} };
+ docsLoading = false
+ bloblangDocs = { functions: {}, methods: {} }
// Resolve all queued promises with fallback
for (const resolve of docsLoadQueue) {
- resolve(bloblangDocs);
+ resolve(bloblangDocs)
}
- docsLoadQueue = [];
+ docsLoadQueue = []
- return bloblangDocs;
+ return bloblangDocs
}
}
@@ -349,72 +361,76 @@
${escapeHtml(doc.signature)}
${formatDescription(doc.description)}
- `;
+ `
if (doc.parameters && doc.parameters.length > 0) {
- html += 'Parameters:';
- doc.parameters.forEach(param => {
- html += `${escapeHtml(param.name)} (${escapeHtml(param.type)}): ${formatDescription(param.description)} `;
- });
- html += '
';
+ html += 'Parameters:'
+ doc.parameters.forEach((param) => {
+ html += `${escapeHtml(param.name)} (${escapeHtml(param.type)}): ${formatDescription(
+ param.description
+ )} `
+ })
+ html += '
'
}
if (doc.returns) {
- html += `Returns: ${escapeHtml(doc.returns)}`;
+ html += `Returns: ${escapeHtml(doc.returns)}`
}
if (doc.example) {
- html += `${escapeHtml(doc.example)}
`;
+ html += `${escapeHtml(doc.example)}
`
}
if (doc.url) {
- html += `View full documentation →`;
+ html += `View full documentation →`
}
- html += '';
- return html;
+ html += '
'
+ return html
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
+ const div = document.createElement('div')
+ div.textContent = text
+ return div.innerHTML
}
/**
* Format description text - escape HTML but convert backticks to tags
*/
function formatDescription(text) {
- if (!text) return '';
+ if (!text) return ''
// First escape HTML
- const escaped = escapeHtml(text);
+ const escaped = escapeHtml(text)
// Then convert backtick-wrapped text to tags
- return escaped.replace(/`([^`]+)`/g, '$1');
+ return escaped.replace(/`([^`]+)`/g, '$1')
}
/**
* Check if device is touch-based
*/
function isTouchDevice() {
- return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
+ return 'ontouchstart' in window || navigator.maxTouchPoints > 0
}
/**
* Add tooltips to Bloblang tokens
*/
function addDocumentationTooltips(codeBlock) {
- const isTouch = isTouchDevice();
+ const isTouch = isTouchDevice()
if (!window.tippy) {
- console.warn('Tippy.js not loaded, skipping Bloblang tooltips');
- return;
+ console.warn('Tippy.js not loaded, skipping Bloblang tooltips')
+ return
}
- loadBloblangDocs().then(docs => {
- if (!docs) return;
+ loadBloblangDocs().then((docs) => {
+ if (!docs) return
// Tippy configuration - different trigger for touch vs mouse
const getTippyConfig = (doc) => ({
@@ -431,129 +447,131 @@
hideOnClick: isTouch ? 'toggle' : true,
onShow(instance) {
// Hide other tooltips
- document.querySelectorAll('.tippy-box').forEach(box => {
+ document.querySelectorAll('.tippy-box').forEach((box) => {
if (box !== instance.popper) {
- box._tippy && box._tippy.hide();
+ box._tippy && box._tippy.hide()
}
- });
- }
- });
+ })
+ },
+ })
// Add tooltips to functions
- codeBlock.querySelectorAll('.token.function').forEach(el => {
- const functionName = el.textContent.trim();
- const doc = docs.functions[functionName];
+ codeBlock.querySelectorAll('.token.function').forEach((el) => {
+ const functionName = el.textContent.trim()
+ const doc = docs.functions[functionName]
if (doc) {
- el.classList.add('has-documentation');
- el.style.cursor = 'help';
- el.setAttribute('tabindex', '0');
- el.setAttribute('role', 'button');
- el.setAttribute('aria-label', `${functionName} function documentation`);
+ el.classList.add('has-documentation')
+ el.style.cursor = 'help'
+ el.setAttribute('tabindex', '0')
+ el.setAttribute('role', 'button')
+ el.setAttribute('aria-label', `${functionName} function documentation`)
if (isTouch) {
- el.setAttribute('aria-haspopup', 'dialog');
+ el.setAttribute('aria-haspopup', 'dialog')
}
- tippy(el, getTippyConfig(doc));
+ tippy(el, getTippyConfig(doc))
}
- });
+ })
// Add tooltips to methods
- codeBlock.querySelectorAll('.token.method').forEach(el => {
- const methodText = el.textContent.trim();
- const methodName = methodText.replace(/^\./, '').replace(/\(\)$/, '');
- const doc = docs.methods[methodName];
+ codeBlock.querySelectorAll('.token.method').forEach((el) => {
+ const methodText = el.textContent.trim()
+ const methodName = methodText.replace(/^\./, '').replace(/\(\)$/, '')
+ const doc = docs.methods[methodName]
if (doc) {
- el.classList.add('has-documentation');
- el.style.cursor = 'help';
- el.setAttribute('tabindex', '0');
- el.setAttribute('role', 'button');
- el.setAttribute('aria-label', `${methodName} method documentation`);
+ el.classList.add('has-documentation')
+ el.style.cursor = 'help'
+ el.setAttribute('tabindex', '0')
+ el.setAttribute('role', 'button')
+ el.setAttribute('aria-label', `${methodName} method documentation`)
if (isTouch) {
- el.setAttribute('aria-haspopup', 'dialog');
+ el.setAttribute('aria-haspopup', 'dialog')
}
- tippy(el, getTippyConfig(doc));
+ tippy(el, getTippyConfig(doc))
}
- });
+ })
// Add keyboard accessibility
- codeBlock.querySelectorAll('.has-documentation').forEach(el => {
+ codeBlock.querySelectorAll('.has-documentation').forEach((el) => {
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- el._tippy && el._tippy.show();
+ e.preventDefault()
+ el._tippy && el._tippy.show()
} else if (e.key === 'Escape') {
- el._tippy && el._tippy.hide();
+ el._tippy && el._tippy.hide()
}
- });
- });
- });
+ })
+ })
+ })
}
/**
* Add "Try It" button to code block
*/
function addTryItButton(listingBlock, code) {
- const toolbox = listingBlock.querySelector('.source-toolbox');
- if (!toolbox) return;
+ const toolbox = listingBlock.querySelector('.source-toolbox')
+ if (!toolbox) return
// Check if already has button
- if (toolbox.querySelector('.try-bloblang-button')) return;
+ if (toolbox.querySelector('.try-bloblang-button')) return
// Parse the code block to extract mapping, input, and metadata from comments
- const parsed = parseBloblangSnippet(code);
+ const parsed = parseBloblangSnippet(code)
// Skip if # Skip: true directive is present
- if (parsed.skip) return;
+ if (parsed.skip) return
// Helper to get attribute from listingblock, pre, or code element
const getDataAttr = (name) => {
- const pre = listingBlock.querySelector('pre');
- const codeEl = listingBlock.querySelector('code');
- return listingBlock.getAttribute(name) ||
- (pre && pre.getAttribute(name)) ||
- (codeEl && codeEl.getAttribute(name));
- };
+ const pre = listingBlock.querySelector('pre')
+ const codeEl = listingBlock.querySelector('code')
+ return listingBlock.getAttribute(name) || (pre && pre.getAttribute(name)) || (codeEl && codeEl.getAttribute(name))
+ }
// Use parsed values, with fallbacks from data attributes or defaults
- const mapping = parsed.mapping || code;
- const inputData = parsed.input || getDataAttr('data-bloblang-input') || '{}';
- const metadata = parsed.meta || getDataAttr('data-bloblang-metadata') || '{}';
+ const mapping = parsed.mapping || code
+ const inputData = parsed.input || getDataAttr('data-bloblang-input') || '{}'
+ const metadata = parsed.meta || getDataAttr('data-bloblang-metadata') || '{}'
- const button = document.createElement('button');
- button.className = 'try-bloblang-button';
- button.textContent = 'Try It';
- button.setAttribute('aria-label', 'Try this Bloblang mapping');
+ const button = document.createElement('button')
+ button.className = 'try-bloblang-button'
+ button.textContent = 'Try It'
+ button.setAttribute('aria-label', 'Try this Bloblang mapping')
button.addEventListener('click', () => {
- openBloblangPlayground(mapping, inputData, metadata);
- });
+ openBloblangPlayground(mapping, inputData, metadata)
+ })
// Preload WASM and scripts on hover for faster "Try It" experience
- button.addEventListener('mouseenter', () => {
- loadRequiredScripts().then(() => loadBloblangWasm());
- }, { once: true });
+ button.addEventListener(
+ 'mouseenter',
+ () => {
+ loadRequiredScripts().then(() => loadBloblangWasm())
+ },
+ { once: true }
+ )
// Add to toolbox
- toolbox.appendChild(button);
+ toolbox.appendChild(button)
// Initialize tooltip if tippy is available (skip on touch devices)
if (window.tippy && !isTouchDevice()) {
- button.setAttribute('data-tippy-content', 'Execute this mapping in a mini-playground');
+ button.setAttribute('data-tippy-content', 'Execute this mapping in a mini-playground')
tippy(button, {
- delay: [200, 0]
- });
+ delay: [200, 0],
+ })
}
}
// WASM and script loading state
- let wasmLoading = false;
- let wasmLoaded = false;
- let wasmLoadPromise = null;
- let scriptsLoaded = false;
- let scriptsLoadPromise = null;
+ let wasmLoading = false
+ let wasmLoaded = false
+ let wasmLoadPromise = null
+ let scriptsLoaded = false
+ let scriptsLoadPromise = null
/**
* Dynamically load a script
@@ -562,16 +580,16 @@
return new Promise((resolve, reject) => {
// Check if already loaded
if (document.querySelector(`script[src="${src}"]`)) {
- resolve();
- return;
+ resolve()
+ return
}
- const script = document.createElement('script');
- script.src = src;
- script.onload = resolve;
- script.onerror = () => reject(new Error(`Failed to load ${src}`));
- document.head.appendChild(script);
- });
+ const script = document.createElement('script')
+ script.src = src
+ script.onload = resolve
+ script.onerror = () => reject(new Error(`Failed to load ${src}`))
+ document.head.appendChild(script)
+ })
}
/**
@@ -579,36 +597,38 @@
*/
function loadRequiredScripts() {
if (scriptsLoaded) {
- return Promise.resolve();
+ return Promise.resolve()
}
if (scriptsLoadPromise) {
- return scriptsLoadPromise;
+ return scriptsLoadPromise
}
- const rootPath = typeof uiRootPath !== 'undefined' ? uiRootPath : '/_';
+ const rootPath = typeof uiRootPath !== 'undefined' ? uiRootPath : '/_'
scriptsLoadPromise = Promise.all([
// Load wasm_exec.js if Go is not defined
typeof Go === 'undefined' ? loadScript(rootPath + '/js/vendor/wasm_exec.js') : Promise.resolve(),
// Load Ace if not defined
- typeof ace === 'undefined' ? loadScript(rootPath + '/js/vendor/ace/ace.js') : Promise.resolve()
- ]).then(() => {
- // Load Ace dependencies after ace.js
- const acePromises = [];
- if (typeof ace !== 'undefined') {
- acePromises.push(
- loadScript(rootPath + '/js/vendor/ace/theme-github.js'),
- loadScript(rootPath + '/js/vendor/ace/mode-json.js'),
- loadScript(rootPath + '/js/vendor/ace/mode-bloblang.js')
- );
- }
- return Promise.all(acePromises);
- }).then(() => {
- scriptsLoaded = true;
- });
+ typeof ace === 'undefined' ? loadScript(rootPath + '/js/vendor/ace/ace.js') : Promise.resolve(),
+ ])
+ .then(() => {
+ // Load Ace dependencies after ace.js
+ const acePromises = []
+ if (typeof ace !== 'undefined') {
+ acePromises.push(
+ loadScript(rootPath + '/js/vendor/ace/theme-github.js'),
+ loadScript(rootPath + '/js/vendor/ace/mode-json.js'),
+ loadScript(rootPath + '/js/vendor/ace/mode-bloblang.js')
+ )
+ }
+ return Promise.all(acePromises)
+ })
+ .then(() => {
+ scriptsLoaded = true
+ })
- return scriptsLoadPromise;
+ return scriptsLoadPromise
}
/**
@@ -616,59 +636,74 @@
*/
function loadBloblangWasm() {
if (wasmLoaded && window.blobl) {
- return Promise.resolve();
+ return Promise.resolve()
}
if (wasmLoading && wasmLoadPromise) {
- return wasmLoadPromise;
+ return wasmLoadPromise
}
- wasmLoading = true;
+ wasmLoading = true
// WASM path varies by build type:
// - Production/Netlify: /blobl.wasm (site root)
// - UI Preview (local dev): /_/blobl.wasm
// Use isUiPreview variable set in head-scripts.hbs to determine correct path
- const rootPath = typeof uiRootPath !== 'undefined' ? uiRootPath : '/_';
- const siteRoot = typeof siteRootPath !== 'undefined' ? siteRootPath : '';
- const isPreview = typeof isUiPreview !== 'undefined' ? isUiPreview : false;
+ const rootPath = typeof uiRootPath !== 'undefined' ? uiRootPath : '/_'
+ const siteRoot = typeof siteRootPath !== 'undefined' ? siteRootPath : ''
+ const isPreview = typeof isUiPreview !== 'undefined' ? isUiPreview : false
// Select the correct path based on environment - no fallback to avoid 404s
- const wasmPath = isPreview ? rootPath + '/blobl.wasm' : siteRoot + '/blobl.wasm';
+ const wasmPath = isPreview ? rootPath + '/blobl.wasm' : siteRoot + '/blobl.wasm'
wasmLoadPromise = new Promise((resolve, reject) => {
loadRequiredScripts()
.then(() => {
if (typeof Go === 'undefined') {
- reject(new Error('Go WASM runtime not available'));
- return;
+ // Reset state before rejecting to allow retry
+ wasmLoading = false
+ wasmLoadPromise = null
+ wasmLoaded = false
+ reject(new Error('Go WASM runtime not available'))
+ return
}
- const go = new Go();
+ const go = new Go()
fetch(wasmPath)
.then((response) => {
if (!response.ok) {
- throw new Error('WASM file not found at ' + wasmPath);
+ throw new Error('WASM file not found at ' + wasmPath)
}
- const responseClone = response.clone();
- return WebAssembly.instantiateStreaming(response, go.importObject)
- .catch(async () => {
- const bytes = await responseClone.arrayBuffer();
- return WebAssembly.instantiate(bytes, go.importObject);
- });
+ const responseClone = response.clone()
+ return WebAssembly.instantiateStreaming(response, go.importObject).catch(async () => {
+ const bytes = await responseClone.arrayBuffer()
+ return WebAssembly.instantiate(bytes, go.importObject)
+ })
})
.then((result) => {
- go.run(result.instance);
- wasmLoaded = true;
- wasmLoading = false;
- resolve();
+ go.run(result.instance)
+ wasmLoaded = true
+ wasmLoading = false
+ resolve()
+ })
+ .catch((error) => {
+ // Reset state before rejecting to allow retry
+ wasmLoading = false
+ wasmLoadPromise = null
+ wasmLoaded = false
+ reject(error)
})
- .catch(reject);
})
- .catch(reject);
- });
+ .catch((error) => {
+ // Reset state before rejecting to allow retry
+ wasmLoading = false
+ wasmLoadPromise = null
+ wasmLoaded = false
+ reject(error)
+ })
+ })
- return wasmLoadPromise;
+ return wasmLoadPromise
}
/**
@@ -676,22 +711,22 @@
*/
function initAceEditor(elementId, mode, readOnly, initialValue, options = {}) {
if (typeof ace === 'undefined') {
- return null;
+ return null
}
- const editor = ace.edit(elementId);
+ const editor = ace.edit(elementId)
// Set theme - use github if available, otherwise use a built-in light theme
try {
- editor.setTheme('ace/theme/github');
+ editor.setTheme('ace/theme/github')
} catch (e) {
// Fallback - textmate is a built-in light theme
- console.warn('GitHub theme not available, using default');
+ console.warn('GitHub theme not available, using default')
}
- editor.session.setMode(mode);
- editor.setReadOnly(readOnly);
- editor.setValue(initialValue || '', -1);
+ editor.session.setMode(mode)
+ editor.setReadOnly(readOnly)
+ editor.setValue(initialValue || '', -1)
// Build editor options - if maxLines is null, don't set it (use CSS height instead)
const editorOptions = {
@@ -702,24 +737,24 @@
tabSize: 2,
useSoftTabs: true,
wrap: true,
- minLines: options.minLines || 4,
- useWorker: false // Disable workers to avoid 404s for missing worker files
- };
+ minLines: options.minLines || 5,
+ useWorker: false, // Disable workers to avoid 404s for missing worker files
+ }
// Only set maxLines if not explicitly null (null = use CSS height with scrollbar)
if (options.maxLines !== null) {
- editorOptions.maxLines = options.maxLines !== undefined ? options.maxLines : 15;
+ editorOptions.maxLines = options.maxLines !== undefined ? options.maxLines : 15
}
- editor.setOptions(editorOptions);
+ editor.setOptions(editorOptions)
// Force light background via CSS as fallback
- const container = document.getElementById(elementId);
+ const container = document.getElementById(elementId)
if (container) {
- container.style.backgroundColor = '#fff';
+ container.style.backgroundColor = '#fff'
}
- return editor;
+ return editor
}
/**
@@ -727,32 +762,32 @@
* Matches the encoding used by the main playground.
*/
function encodeBase64(str) {
- const utf8Bytes = new TextEncoder().encode(str);
- let binaryStr = '';
+ const utf8Bytes = new TextEncoder().encode(str)
+ let binaryStr = ''
for (let i = 0; i < utf8Bytes.length; i++) {
- binaryStr += String.fromCharCode(utf8Bytes[i]);
+ binaryStr += String.fromCharCode(utf8Bytes[i])
}
- return window.btoa(binaryStr);
+ return window.btoa(binaryStr)
}
/**
* Build the full playground URL with encoded parameters
*/
function buildPlaygroundUrl(input, mapping, meta) {
- const baseUrl = '/redpanda-connect/guides/bloblang/playground/';
- const params = new URLSearchParams();
+ const baseUrl = '/connect/guides/bloblang/playground/'
+ const params = new URLSearchParams()
if (input) {
- params.set('input', encodeBase64(input));
+ params.set('input', encodeBase64(input))
}
if (mapping) {
- params.set('map', encodeBase64(mapping));
+ params.set('map', encodeBase64(mapping))
}
// Always include meta to prevent main playground from showing "Loading..."
- params.set('meta', encodeBase64(meta || '{}'));
+ params.set('meta', encodeBase64(meta || '{}'))
- const queryString = params.toString();
- return queryString ? `${baseUrl}?${queryString}` : baseUrl;
+ const queryString = params.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
}
/**
@@ -760,14 +795,14 @@
*/
function openBloblangPlayground(mapping, inputData, metadata) {
// Create modal overlay
- const overlay = document.createElement('div');
- overlay.className = 'bloblang-playground-overlay';
- overlay.setAttribute('role', 'dialog');
- overlay.setAttribute('aria-modal', 'true');
- overlay.setAttribute('aria-labelledby', 'playground-title');
+ const overlay = document.createElement('div')
+ overlay.className = 'bloblang-playground-overlay'
+ overlay.setAttribute('role', 'dialog')
+ overlay.setAttribute('aria-modal', 'true')
+ overlay.setAttribute('aria-labelledby', 'playground-title')
// Build initial playground URL with current data
- const initialPlaygroundUrl = buildPlaygroundUrl(inputData || '{}', mapping, metadata || '{}');
+ const initialPlaygroundUrl = buildPlaygroundUrl(inputData || '{}', mapping, metadata || '{}')
overlay.innerHTML = `
@@ -799,253 +834,264 @@
+ I can answer questions about Redpanda docs, write quickstarts, and help you troubleshoot. +
+ {suggestions.length > 0 && ( +