Code Expander Block

The Code Expander block is a versatile component designed to enhance code display and interaction on web pages. It provides syntax highlighting for various programming languages, line numbers for JavaScript, and offers a convenient copy-to-clipboard functionality with customizable appearance and improved user interaction.

Features

  1. Syntax highlighting for multiple languages:
    • JavaScript (with line numbers)
    • CSS
    • HTML
    • JSON
    • Markdown
    • Terminal commands
  2. Automatic language detection based on code content
  3. Copy-to-clipboard functionality with visual feedback
  4. Line numbers for JavaScript code
  5. Expand/collapse functionality for long code snippets (more than 80 lines)
  6. Accessibility considerations (ARIA labels)
  7. Responsive design
  8. Customizable appearance for buttons and code background
  9. Visual feedback for hover and focus states on buttons

Usage

To use the Code Expander block in your Franklin project:

  1. Place the code-expander.js file in the blocks/code-expander/ directory.
  2. Mark text with a courier new font in the document, triggering autoblocking.
  3. Use the CodeExpander block once in the document

The component will automatically detect the language and apply appropriate styling and functionality.

Authoring

When creating content in Google Docs or Microsoft Word, use the following structure, once in the document. I usually put this at the end:

Examples

Text Examples

This is a bit of text
“This is quoted text”

CSS Example

.code-expander {
background-color: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1rem;
font-family: 'Courier New', monospace;
}

.code-expander pre {
margin: 0;
white-space: pre-wrap;
}

HTML Example

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code Expander Demo</title>
</head>

<body>
<h1>Welcome to Code Expander</h1>
<div class="code-expander">
<!-- Your code here -->
</div>
</body>

</html>

JSON Example

{
"name": "Code Expander",
"version": "1.0.0",
"description": "A versatile code display component",
"features":
["Syntax highlighting",
"Copy to clipboard",
"Expand/collapse long snippets"],
"supported_languages":
["JavaScript", "CSS", "HTML", "JSON", "Markdown"]
}

Markdown Example

# Markdown Example
## Features
Syntax highlighting
- Copy to clipboard
- Expand/collapse long snippets
## Usage
1. Add the code-expander.js file to your project
2. Use the autoblock - courier font in your Google Doc.
3. Add the code-expander block to the foot of your document
Enjoy the enhanced code display!

Terminal Commands Example

ls index.html styles.css script.js

Long Code Example (with expand/collapse)

// This is a long code example to demonstrate the expand/collapse
function generateFibonacciSequence(n) {
const sequence = [0, 1];
for (let i = 2; i < n; i++) {
sequence[i] = sequence[i - 1] + sequence[i - 2];
}
return sequence;
}
function isPrime(num) {
if (num <= 1) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
function generatePrimes(n) {
const primes = [];
let num = 2;
while (primes.length < n) {
if (isPrime(num)) {
primes.push(num);
}
num++;
}
return primes;
}
console.log("Fibonacci Sequence (first 20 numbers):");
console.log(generateFibonacciSequence(20));
console.log("Prime Numbers (first 20 primes):");
console.log(generatePrimes(20));
function isPrime(num) {
if (num <= 1) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
function generatePrimes(n) {
const primes = [];
let num = 2;
while (primes.length < n) {
if (isPrime(num)) {
primes.push(num);
}
num++;
}
return primes;
}
console.log("Fibonacci Sequence (first 20 numbers):");
console.log(generateFibonacciSequence(20));
console.log("Prime Numbers (first 20 primes):");
console.log(generatePrimes(20));
function isPrime(num) {
if (num <= 1) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
function generatePrimes(n) {
const primes = [];
let num = 2;
while (primes.length < n) {
if (isPrime(num)) {
primes.push(num);
}
num++;
}
return primes;
}
console.log("Fibonacci Sequence (first 20 numbers):");
console.log(generateFibonacciSequence(20));
console.log("Prime Numbers (first 20 primes):");
console.log(generatePrimes(20));
function isPrime(num) {
if (num <= 1) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
function generatePrimes(n) {
const primes = [];
let num = 2;
while (primes.length < n) {
if (isPrime(num)) {
primes.push(num);
}
num++;
}
return primes;
}
console.log("Fibonacci Sequence (first 20 numbers):");
console.log(generateFibonacciSequence(20));
console.log("Prime Numbers (first 20 primes):");
console.log(generatePrimes(20));

This demo showcases the Code Expander block's ability to handle various programming languages, provide syntax highlighting, and offer expand/collapse functionality for long code snippets. Users can interact with the copy buttons and expand/collapse features to experience the full functionality of the block.

Here are the files

CSS, in /blocks/code-expander/code-expander.css

:root {
  --code-expander-button-bg: #4a90e2;
  --code-expander-button-text: #ffffff;
  --code-expander-button-border: #3a7bc8;
  --code-expander-button-hover-bg: #3a7bc8;
  --code-expander-code-bg: #f8f8f8;
  --code-expander-code-text: #333;
  --code-expander-button-focus-outline: #4d90fe;
  --code-expander-pre-border-color: #0a0909;
  --code-expander-pre-border-width: 2px;
  --code-expander-line-number-color: #888;
  --code-expander-markdown-link-color: #0366d6;
  --code-expander-font-size-small: 0.8rem;
  --code-expander-font-size-normal: 0.9rem;
  --code-expander-font-size-large: 1rem;

  /* syntax highlighting */
  --code-expander-css-property: #ff0000;
  --code-expander-css-value: #0000ff;
  --code-expander-css-selector: #800000;
  --code-expander-css-comment: #008000;
  --code-expander-js-string: #a31515;
  --code-expander-js-keyword: #0000ff;
  --code-expander-json-string: #a31515;
  --code-expander-json-key: #0451a5;
  --code-expander-json-boolean: #0000ff;
  --code-expander-json-number: #098658;
  --code-expander-terminal-command: #000000;
  --code-expander-terminal-comment: #008000;
  --code-expander-terminal-output: #000000;

  /* Markdown syntax highlighting */
  --code-expander-markdown-heading: #0000FF;
  --code-expander-markdown-list-item: #008000;
  --code-expander-markdown-blockquote: #808080;
  --code-expander-markdown-code: #D2691E;
  --code-expander-markdown-link: #1E90FF;

  --code-expander-json-null: #0000FF;
}

pre[class^="language-"] {
  position: relative;
  margin-bottom: 1rem;
  padding: 40px 10px 10px;
  overflow-x: auto;
  background-color: var(--code-expander-code-bg);
  color: var(--code-expander-code-text);
  font-family: monospace;
  border: 1px solid var(--code-expander-button-border);
  border-radius: 4px;
}

.code-expander-wrapper {
  position: relative;
  margin: 1em 0;
  border: 1px solid #e1e4e8;
  border-radius: 6px;
  overflow: hidden;
}

.code-expander-wrapper pre {
  margin: 0;
  padding: 2.5em 1em 1em;
  overflow-x: auto;
  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
  font-size: 14px;
  line-height: 1.45;
}

.code-expander-copy,
.code-expander-expand-collapse {
  position: absolute;
  padding: 0.2em 0.5em;
  background-color: var(--code-expander-button-bg);
  color: var(--code-expander-button-text);
  border: 1px solid var(--code-expander-button-border);
  border-radius: 3px;
  font-size: var(--code-expander-font-size-small);
  cursor: pointer;
  z-index: 2;
  transition: background-color 0.3s ease, box-shadow 0.3s ease;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  top: 0;
}

.code-expander-copy {
  right: 0;
}

.code-expander-expand-collapse {
  display: inline-block;
  width: auto;
  min-width: 60px;
  text-align: center;
}

.code-expander-expand-collapse.top {
  left: 0;
}

.code-expander-expand-collapse.bottom {
  top: auto;
  bottom: 0;
  left: 0;
  min-width: 50px;
  text-align: center;
}

.code-expander-copy:hover,
.code-expander-expand-collapse:hover {
  background-color: var(--code-expander-button-hover-bg);
}

.code-expander-copy:focus,
.code-expander-expand-collapse:focus {
  outline: 2px solid var(--code-expander-button-focus-outline);
  outline-offset: 2px;
}

pre[class^="language-"].collapsible {
  max-height: 300px;
  overflow-y: hidden;
  transition: max-height 0.3s ease-out;
}

pre[class^="language-"].expanded {
  max-height: none;
}

/* JavaScript syntax highlighting */
.language-javascript .keyword { color: var(--code-expander-js-keyword); }
.language-javascript .string { color: var(--code-expander-js-string); }
.language-javascript .comment { color: var(--code-expander-css-comment); }
.language-javascript .boolean { color: var(--code-expander-json-boolean); }
.language-javascript .number { color: var(--code-expander-json-number); }

/* JSON syntax highlighting */
.language-json .key { color: var(--code-expander-json-key); }
.language-json .string { color: var(--code-expander-json-string); }
.language-json .boolean { color: var(--code-expander-json-boolean); }
.language-json .number { color: var(--code-expander-json-number); }
.language-json .null { color: var(--code-expander-json-null); }

/* HTML syntax highlighting */
.language-html .tag { color: var(--code-expander-css-selector); }
.language-html .string { color: var(--code-expander-js-string); }
.language-html .comment { color: var(--code-expander-css-comment); }

/* CSS syntax highlighting */
.language-css .property { color: var(--code-expander-css-property); }
.language-css .value { color: var(--code-expander-css-value); }

/* Markdown syntax highlighting */
.language-markdown .heading { color: var(--code-expander-markdown-heading); font-weight: bold; }
.language-markdown .list-item { color: var(--code-expander-markdown-list-item); }
.language-markdown .blockquote { color: var(--code-expander-markdown-blockquote); font-style: italic; }
.language-markdown .code { color: var(--code-expander-markdown-code); background-color: #f0f0f0; padding: 2px 4px; border-radius: 3px; }
.language-markdown .link { color: var(--code-expander-markdown-link); text-decoration: underline; }

/* Terminal syntax highlighting */
.language-shell .command { color: var(--code-expander-terminal-command); }
.language-shell .comment { color: var(--code-expander-terminal-comment); }
.language-shell .output { color: var(--code-expander-terminal-output); }

/* General syntax highlighting */
.comment { color: #6a737d; }
.string { color: #032f62; }
.keyword { color: #d73a49; }
.boolean { color: #005cc5; }
.number { color: #005cc5; }
.property { color: #005cc5; }
.value { color: #032f62; }
.tag { color: #22863a; }
.heading { color: #005cc5; }
.list-item { color: #e36209; }
.blockquote { color: #6a737d; }
.code { color: #24292e; background-color: #f6f8fa; }
.link { color: #032f62; }

Javascript in /blocks/code-expander/code-expander.js

const LONG_DOCUMENT_THRESHOLD = 40;
const COPY_BUTTON_RESET_DELAY = 2000;

export default async function decorate(block) {
  const codeElements = document.querySelectorAll('pre code');
  
  function detectLanguage(code) {
    // Check if the code starts with a quotation mark, indicating it's likely text
    if (code.trim().startsWith('"') || code.trim().startsWith("'")) {
      return 'text';
    }
    
    // Check for shell commands
    if (/^(ls|cd|pwd|mkdir|rm|cp|mv|cat|echo|grep|sed|awk|curl|wget|ssh|git|npm|yarn|docker|kubectl)\s/.test(code)) {
      return 'shell';
    }
    
    if (code.includes('function') || code.includes('var') || code.includes('const')) return 'javascript';
    if (code.includes('{') && code.includes('}')) {
      // Check for CSS-specific patterns
      if (code.match(/[a-z-]+\s*:\s*[^;]+;/)) return 'css';
      // If not CSS, then it's likely JSON
      if (code.includes(':')) return 'json';
    }
    if (code.includes('<') && code.includes('>') && (code.includes('</') || code.includes('/>'))) return 'html';
    
    // Check for Markdown
    if (code.match(/^(#{1,6}\s|\*\s|-\s|\d+\.\s|\[.*\]\(.*\))/m)) return 'markdown';
    
    // Check for shell (existing check)
    if (code.startsWith('$') || code.startsWith('#')) return 'shell';
    
    return 'text';
  }

  function highlightSyntax(code, language) {
    switch (language) {
      case 'javascript':
        // Highlight JavaScript syntax
        return code.replace(
          /(\/\/.*|\/\*[\s\S]*?\*\/|'(?:\\.|[^\\'])*'|"(?:\\.|[^\\"])*"|`(?:\\.|[^\\`])*`|\b(?:function|var|const|let|if|else|for|while|return|class|import|export)\b|\b(?:true|false|null|undefined)\b|\b\d+\b)/g,
          match => {
            // Highlight comments
            if (/^\/\//.test(match)) return `<span class="comment">${match}</span>`;
            if (/^\/\*/.test(match)) return `<span class="comment">${match}</span>`;
            // Highlight strings
            if (/^['"`]/.test(match)) return `<span class="string">${match}</span>`;
            // Highlight keywords
            if (/^(function|var|const|let|if|else|for|while|return|class|import|export)$/.test(match)) return `<span class="keyword">${match}</span>`;
            // Highlight boolean values and null/undefined
            if (/^(true|false|null|undefined)$/.test(match)) return `<span class="boolean">${match}</span>`;
            // Highlight numbers
            if (/^\d+$/.test(match)) return `<span class="number">${match}</span>`;
            return match;
          }
        );
      case 'json':
        // Highlight and indent JSON syntax
        try {
          const parsedJson = JSON.parse(code);
          const indentedJson = JSON.stringify(parsedJson, null, 2);
          return indentedJson.replace(
            /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
            match => {
              let cls = 'number';
              if (/^"/.test(match)) {
                if (/:$/.test(match)) {
                  cls = 'key';
                } else {
                  cls = 'string';
                }
              } else if (/true|false/.test(match)) {
                cls = 'boolean';
              } else if (/null/.test(match)) {
                cls = 'null';
              }
              return `<span class="${cls}">${match}</span>`;
            }
          ).replace(/\n/g, '<br>').replace(/ /g, '&nbsp;');
        } catch (error) {
          // eslint-disable-next-line no-console
          console.error('Error parsing JSON:', error);
          // If parsing fails, apply basic highlighting without parsing
          return code.replace(
            /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
            match => {
              let cls = 'number';
              if (/^"/.test(match)) {
                if (/:$/.test(match)) {
                  cls = 'key';
                } else {
                  cls = 'string';
                }
              } else if (/true|false/.test(match)) {
                cls = 'boolean';
              } else if (/null/.test(match)) {
                cls = 'null';
              }
              return `<span class="${cls}">${match}</span>`;
            }
          );
        }
      case 'html':
        // Highlight HTML syntax
        return code.replace(/&/g, '&amp;')
                   .replace(/</g, '&lt;')
                   .replace(/>/g, '&gt;')
                   // Highlight strings
                   .replace(/(".*?")/g, '<span class="string">$1</span>')
                   // Highlight opening tags
                   .replace(/(&lt;[^\s!?/]+)/g, '<span class="tag">$1</span>')
                   // Highlight closing tags
                   .replace(/(&lt;\/[^\s!?/]+)/g, '<span class="tag">$1</span>')
                   // Highlight comments
                   .replace(/(&lt;!--.*?--&gt;)/g, '<span class="comment">$1</span>');
      case 'css':
        // Highlight CSS syntax
        return code.replace(
          /([\w-]+\s*:)|(#[\da-f]{3,6})/gi,
          match => {
            // Highlight property names
            if (/:$/.test(match)) return `<span class="property">${match}</span>`;
            // Highlight color values
            return `<span class="value">${match}</span>`;
          }
        );
      case 'markdown':
        // Highlight Markdown syntax
        return code.replace(
          /(^#{1,6}\s.*$)|(^[*-]\s.*$)|(^>\s.*$)|(`{1,3}[^`\n]+`{1,3})|(\[.*?\]\(.*?\))/gm,
          match => {
            // Highlight headings
            if (/^#{1,6}/.test(match)) return `<span class="heading">${match}</span>`;
            // Highlight list items
            if (/^[*-]\s+/.test(match)) return `<span class="list-item">${match}</span>`;
            // Highlight blockquotes
            if (/^>\s+/.test(match)) return `<span class="blockquote">${match}</span>`;
            // Highlight inline code
            if (/`{1,3}[^`\n]+`{1,3}/.test(match)) return `<span class="code">${match}</span>`;
            // Highlight links
            if (/\[.*?\]\(.*?\)/.test(match)) return `<span class="link">${match}</span>`;
            return match;
          }
        );
      case 'text':
        // For text, we'll just escape HTML entities to prevent XSS
        return code.replace(/&/g, '&amp;')
                 .replace(/</g, '&lt;')
                 .replace(/>/g, '&gt;')
                 .replace(/"/g, '&quot;')
                 .replace(/'/g, '&#039;');
      default:
        // If language is not recognized, return the code without highlighting
        return code;
    }
  }

  await Promise.all(Array.from(codeElements).map(async (codeElement, index) => {
    const code = codeElement.textContent;
    const language = detectLanguage(code);  
    // Create a wrapper div
    const wrapper = document.createElement('div');
    wrapper.className = 'code-expander-wrapper';
    
    // Move the pre element into the wrapper
    const preElement = codeElement.parentNode;
    preElement.parentNode.insertBefore(wrapper, preElement);
    wrapper.appendChild(preElement);
    
    preElement.className = `language-${language}`;
    codeElement.innerHTML = highlightSyntax(code, language);
    
    const copyButton = document.createElement('button');
    copyButton.className = 'code-expander-copy';
    copyButton.textContent = `Copy ${language === 'shell' ? 'terminal' : language === 'text' ? 'text' : language} to clipboard`;
    wrapper.insertBefore(copyButton, preElement);
    
    const lines = code.split('\n');
    if (lines.length > LONG_DOCUMENT_THRESHOLD) {
      preElement.classList.add('collapsible');
      
      // Create top expand/collapse button
      const topExpandButton = document.createElement('button');
      topExpandButton.className = 'code-expander-expand-collapse top';
      topExpandButton.textContent = 'Expand';
      
      // Create bottom expand/collapse button
      const bottomExpandButton = document.createElement('button');
      bottomExpandButton.className = 'code-expander-expand-collapse bottom';
      bottomExpandButton.textContent = '....';
      
      // Function to toggle expansion
      const toggleExpansion = () => {
        preElement.classList.toggle('expanded');
        const isExpanded = preElement.classList.contains('expanded');
        topExpandButton.textContent = isExpanded ? 'Collapse' : 'Expand';
        bottomExpandButton.textContent = isExpanded ? 'Close' : '....';
      };
      
      // Add click event listeners to both buttons
      topExpandButton.onclick = toggleExpansion;
      bottomExpandButton.onclick = toggleExpansion;
      
      // Add buttons to the wrapper
      wrapper.insertBefore(topExpandButton, preElement);
      wrapper.appendChild(bottomExpandButton);
    }

    copyButton.addEventListener('click', () => {
      navigator.clipboard.writeText(code)
        .then(() => {
          copyButton.textContent = 'Copied!';
          setTimeout(() => {
            copyButton.textContent = `Copy ${language === 'shell' ? 'terminal' : language} to clipboard`;
          }, COPY_BUTTON_RESET_DELAY);
        })
        .catch(err => {
          // eslint-disable-next-line no-console
          console.error('Error copying content:', err);
        });
    });
  }));
}

Explanation

The complex part of the javascript is the highlightsyntax function, This function is designed to provide basic syntax highlighting for different programming languages. It takes two parameters: code (the string of code to highlight) and language (the programming language of the code).

The function uses a switch statement to apply different highlighting rules based on the language:

JavaScript:

JSON:

HTML:

CSS:

Markdown:

For each language, the function uses regular expressions to find and wrap matching patterns in <span> tags with appropriate class names. This allows for styling these elements differently using CSS. If the language is not recognized, the function returns the original code without any highlighting. This approach provides a simple, lightweight syntax highlighting solution without relying on external libraries. However, it's worth noting that this method may not cover all edge cases or complex language features that more robust syntax highlighting libraries might handle. It is designed for simple documentation, following EDS best practice. Consider developing your own expander for your own use.

Interested in AI consultancy, tailor-made AI training files, mentoring, or audits? Contact us to explore how we can help you leverage AI in your development processes.

<hr>

About

About the Author: Tom Cranstoun is a seasoned AEM expert with over 12 years of experience, including roles as a Global Architecture Director and solution architect for major projects like Nissan and Renault's Helios initiative. Now running his own consultancy, Tom continues to work with top brands and agencies, bringing his expertise in AEM and emerging technologies to the forefront of digital experiences.

Thanks for reading.

Digital Domain Technologies provides expert Adobe Experience Manager (AEM) consultancy. We have collaborated with some of the world’s leading brands across various AEM platforms, including AEM Cloud, on-premise solutions, Adobe Managed Services, and Edge Delivery Services. Our portfolio includes partnerships with prominent companies such as Twitter (now X), EE, Nissan/Renault Alliance Ford, Jaguar Land Rover, McLaren Sports Cars, Hyundai Genesis, and many others.

/fragments/ddt/proposition

Related Articles