Skip to content

Publication transformers

Hooks that run in Silex when you publish, before the files go to the forge. They do not run in the CI build.

Overview

Publication transformers are five hooks that run during the publication process:

  1. renderComponent — Override how GrapesJS renders components to HTML
  2. renderCssRule — Override how GrapesJS renders CSS rules
  3. transformFile — Modify files (HTML, CSS, assets) before publishing
  4. transformPermalink — Rewrite URLs in HTML and CSS
  5. transformPath — Change where files are published on the server

Transformers are chained: each one receives the output of the previous, allowing multiple transformers to compose.

Prerequisites

  • Understanding of client config
  • Familiarity with publication flow
  • Basic JavaScript knowledge

PublicationTransformer interface

export interface PublicationTransformer {
  renderComponent?(
    component: Component,
    toHtml: () => string
  ): string | undefined

  renderCssRule?(
    rule: CssRule,
    initialRule: () => StyleProps
  ): StyleProps | undefined

  transformFile?(file: ClientSideFile): ClientSideFile

  transformPermalink?(
    link: string,
    type: ClientSideFileType,
    initiator: Initiator
  ): string

  transformPath?(
    path: string,
    type: ClientSideFileType
  ): string
}

Use cases

Minify HTML and CSS

const minifyHtml = require('html-minifier').minify
const postcss = require('postcss')
const cssnano = require('cssnano')

export default async function (config) {
  config.addPublicationTransformers({
    transformFile(file) {
      // Minify HTML
      if (file.type === 'html') {
        file.content = minifyHtml(file.content, {
          removeComments: true,
          collapseWhitespace: true,
          minifyCSS: true,
        })
      }

      // Minify CSS
      if (file.type === 'css') {
        const result = postcss([cssnano()]).process(file.content)
        file.content = result.css
      }

      return file
    },
  })
}

Add cache busting to assets

export default async function (config) {
  const assetHashes = new Map()

  config.addPublicationTransformers({
    transformFile(file) {
      // Generate hash for assets
      if (file.type === 'asset') {
        const crypto = require('crypto')
        const hash = crypto
          .createHash('md5')
          .update(file.content)
          .digest('hex')
          .slice(0, 8)

        const newName = file.name.replace(
          /(\.[^.]+)$/,
          `.${hash}$1`
        )
        assetHashes.set(file.name, newName)
        file.name = newName
      }

      return file
    },

    transformPermalink(link, type, initiator) {
      // Rewrite asset references in HTML/CSS
      if (type === 'asset') {
        const hashed = assetHashes.get(link)
        return hashed ? hashed : link
      }
      return link
    },
  })
}

Rewrite URLs for a CDN

export default async function (config) {
  config.addPublicationTransformers({
    transformPermalink(link, type, initiator) {
      // Skip external URLs
      if (link.startsWith('http')) {
        return link
      }

      // Point assets to CDN
      if (type === 'asset') {
        return `https://cdn.example.com${link}`
      }

      // Keep HTML links local
      return link
    },
  })
}

Change file structure

export default async function (config) {
  config.addPublicationTransformers({
    transformPath(path, type) {
      // Move CSS to a different directory
      if (type === 'css') {
        return path.replace(/^\/css/, '/styles')
      }

      // Flatten file structure
      if (type === 'asset') {
        return '/' + path.split('/').pop()
      }

      return path
    },

    transformPermalink(link, type, initiator) {
      // Update references in HTML/CSS
      if (type === 'css') {
        link = link.replace(/^\/css/, '/styles')
      }

      if (type === 'asset' && initiator === 'css') {
        // Assets referenced in CSS: adjust relative path
        const newPath = '/' + link.split('/').pop()
        // Go up one level from CSS directory
        return '../' + newPath
      }

      return link
    },
  })
}

Strip HTML comments for production

export default async function (config) {
  config.addPublicationTransformers({
    transformFile(file) {
      if (file.type === 'html' && process.env.NODE_ENV === 'production') {
        // Remove HTML comments
        file.content = file.content.replace(/<!--[\s\S]*?-->/g, '')
      }
      return file
    },
  })
}

Add analytics scripts

export default async function (config) {
  config.addPublicationTransformers({
    transformFile(file) {
      if (file.type === 'html') {
        const analyticsScript = `
          <script async src="https://analytics.example.com/track.js"></script>
          <script>
            window.trackingId = '${process.env.TRACKING_ID}'
          </script>
        `

        // Inject before closing </head>
        file.content = file.content.replace(
          /<\/head>/,
          analyticsScript + '</head>'
        )
      }
      return file
    },
  })
}

Transformer APIs

renderComponent

Intercept component rendering before GrapesJS converts to HTML:

export default async function (config) {
  config.addPublicationTransformers({
    renderComponent(component, toHtml) {
      // Check component type
      if (component.get('type') === 'image') {
        // Custom logic for images
        const src = component.get('attributes').src
        console.log(`Rendering image: ${src}`)
      }

      // Call the default renderer
      return toHtml()
    },
  })
}

Returning undefined uses the default renderer.

renderCssRule

Intercept CSS rule rendering:

export default async function (config) {
  config.addPublicationTransformers({
    renderCssRule(rule, initialRule) {
      // Get the CSS rule
      const selector = rule.getSelector()
      const style = initialRule()

      // Transform the style
      if (selector.includes('button')) {
        style['cursor'] = 'pointer'
      }

      return style
    },
  })
}

Returning undefined uses the default styles.

transformFile

Modify the file object (content, name, type, path):

export interface ClientSideFile {
  name: string                    // Filename
  type: ClientSideFileType        // 'html', 'css', 'asset'
  content: string | Buffer        // File content
  path?: string                   // File path on server
}

Example:

export default async function (config) {
  config.addPublicationTransformers({
    transformFile(file) {
      // Rename file
      if (file.type === 'css') {
        file.name = file.name + '.min'
      }

      // Transform content
      if (file.type === 'html') {
        file.content = file.content.toUpperCase()
      }

      return file
    },
  })
}

Rewrite URLs referenced in HTML and CSS:

export default async function (config) {
  config.addPublicationTransformers({
    transformPermalink(link, type, initiator) {
      // type: 'html', 'css', 'asset'
      // initiator: 'html' (in HTML file), 'css' (in CSS file)

      // Example: Make all links absolute
      if (!link.startsWith('http')) {
        return 'https://example.com' + (link.startsWith('/') ? link : '/' + link)
      }

      return link
    },
  })
}

transformPath

Change where files are published:

export default async function (config) {
  config.addPublicationTransformers({
    transformPath(path, type) {
      // type: 'html', 'css', 'asset'

      // Example: Prefix all files with '/v1/'
      return '/v1' + (path.startsWith('/') ? path : '/' + path)
    },
  })
}

Chaining transformers

Multiple transformers compose:

export default async function (config) {
  // Transformer 1: Add hashes
  config.addPublicationTransformers({
    transformFile(file) {
      if (file.type === 'css') {
        file.name = file.name + '.hash'
      }
      return file
    },
  })

  // Transformer 2: Minify
  config.addPublicationTransformers({
    transformFile(file) {
      if (file.type === 'css') {
        file.content = minifyCSS(file.content)
      }
      return file
    },
  })
}

Transformers run in order. The output of transformer 1 becomes input for transformer 2.

Common patterns

Environment-based transforms

export default async function (config) {
  if (process.env.NODE_ENV === 'production') {
    config.addPublicationTransformers({
      transformFile(file) {
        // Minify in production only
        if (file.type === 'html') {
          file.content = minifyHtml(file.content)
        }
        return file
      },
    })
  }
}

Conditional transforms based on file

export default async function (config) {
  config.addPublicationTransformers({
    transformFile(file) {
      // Only transform specific files
      if (file.name === 'index.html') {
        // Special handling for homepage
        file.content = file.content.replace(/OLD_TEXT/g, 'NEW_TEXT')
      }
      return file
    },
  })
}

Logging and debugging

export default async function (config) {
  config.addPublicationTransformers({
    transformFile(file) {
      console.log(`Processing ${file.name} (${file.type})`)
      return file
    },

    transformPath(path, type) {
      console.log(`Path: ${path} → Type: ${type}`)
      return path
    },
  })
}

Performance considerations

Transformers run for every publication. Optimize:

// Cache expensive operations
const styleCache = new Map()

export default async function (config) {
  config.addPublicationTransformers({
    transformFile(file) {
      if (file.type === 'css') {
        // Check cache first
        if (styleCache.has(file.name)) {
          return { ...file, content: styleCache.get(file.name) }
        }

        // Expensive operation
        const processed = processCSS(file.content)
        styleCache.set(file.name, processed)
        file.content = processed
      }

      return file
    },
  })
}

Troubleshooting

Transformer not applied

Check:

  1. Transformer is added in client config: config.addPublicationTransformers(...)
  2. Syntax is correct (all optional methods)
  3. No errors in browser console
  4. Transformer runs on the right files (check type parameter)

Add logging to debug:

config.addPublicationTransformers({
  transformFile(file) {
    console.log('Transforming:', file.name, file.type)
    return file
  },
})

URLs are broken after transform

Ensure transformPermalink matches transformPath:

config.addPublicationTransformers({
  transformPath(path, type) {
    if (type === 'css') {
      return '/styles/' + path.split('/').pop()
    }
    return path
  },

  transformPermalink(link, type, initiator) {
    // If CSS is in /styles/, update references
    if (type === 'css' && initiator === 'html') {
      return '/styles/' + link.split('/').pop()
    }
    return link
  },
})

Performance issues

If publication is slow:

  1. Profile with DevTools
  2. Cache expensive operations
  3. Minimize file reads/writes
  4. Avoid blocking operations

See also

Edit this page on GitLab