jekylljekyll-extensions

How to bundle multiple static files as zip archive for download


My Jekyll page is meant to provide sample solutions for a coding book's exercises. The solutions are a bunch of .cpp files (C++ code files) stored in a folder inside my Jekyll project so that I can open the same folder in an IDE.

I've managed to auto-generate one page per book chapter which displays the relevant solutions as a list of code blocks (one per each exercise). I do that by looping through site.static_files and identifying the files by numbers in their filenames (e.g. two solutions for chapter 1: 01_1_FirstSolution.cpp, 01_2_SecondSolution.cpp)

Now, I want to also provide a zip archive per book chapter containing the relevant .cpp files. I don't want to make the zip file manually because then I would not be able to simply change one of the code files anymore. Ideally, I would like to build a zip file while looping through site.static_files and filtering for the relevant files.

When searching for this, I mainly found speed-optimization plugins for bundling and compressing assets. I am running Jekyll on Windows.


Solution

  • There is the jekyll-zip-bundler plugin for this.

    How it is used

    Filenames as multiple parameters:

    {% zip archiveToCreate.zip file1.txt file2.txt %}

    Spaces in filenames:

    {% zip archiveToCreate.zip file1.txt folder/file2.txt 'file with spaces.txt' %}

    A variable to contain a list of files is also possible:

    {% zip ziparchiveToCreate.zip {{ chapter_code_files }} %}

    The plugin code

    # frozen_string_literal: true
    
    # Copyright 2021 by Philipp Hasper
    # MIT License
    # https://github.com/PhilLab/jekyll-zip-bundler
    
    require 'jekyll'
    require 'zip'
    # ~ gem 'rubyzip', '~>2.3.0'
    
    module Jekyll
      # Valid syntax:
      # {% zip archiveToCreate.zip file1.txt file2.txt %}
      # {% zip archiveToCreate.zip file1.txt folder/file2.txt 'file with spaces.txt' %}
      # {% zip {{ variableName }} file1.txt 'folder/file with spaces.txt' {{ otherVariableName }} %}
      # {% zip {{ variableName }} {{ VariableContainingAList }} %}
      class ZipBundlerTag < Liquid::Tag
        VARIABLE_SYNTAX = /[^{]*(\{\{\s*[\w\-.]+\s*(\|.*)?\}\}[^\s{}]*)/mx.freeze
        CACHE_FOLDER = '.jekyll-cache/zip_bundler/'
    
        def initialize(tag_name, markup, tokens)
          super
          # Split by spaces but only if the text following contains an even number of '
          # Based on https://stackoverflow.com/a/11566264
          # Extended to also not split between the curly brackets of Liquid
          # In addition, make sure the strings are stripped and not empty
          @files = markup.strip.split(/\s(?=(?:[^'}]|'[^']*'|{{[^}]*}})*$)/)
                         .map(&:strip)
                         .reject(&:empty?)
        end
    
        def render(context)
          # First file is the target zip archive path
          target, files = resolve_parameters(context)
          abort 'zip tag must be called with at least two files' if files.empty?
    
          zipfile_path = CACHE_FOLDER + target
          FileUtils.makedirs(File.dirname(zipfile_path))
    
          # Create the archive. Delete file, if it already exists
          File.delete(zipfile_path) if File.exist?(zipfile_path)
          Zip::File.open(zipfile_path, Zip::File::CREATE) do |zipfile|
            files.each do |file|
              # Two arguments:
              # - The name of the file as it will appear in the archive
              # - The original file, including the path to find it
              zipfile.add(File.basename(file), file)
            end
          end
          puts "Created archive #{zipfile_path}"
    
          # Add the archive to the site's static files
          site = context.registers[:site]
          site.static_files << Jekyll::StaticFile.new(site, "#{site.source}/#{CACHE_FOLDER}",
                                                      File.dirname(target),
                                                      File.basename(zipfile_path))
          # No rendered output
          ''
        end
    
        def resolve_parameters(context)
          # Resolve the given parameters to a file list
          target, files = @files.map do |file|
            next file unless file.match(VARIABLE_SYNTAX)
    
            # This is a variable. Look it up.
            context[file]
          end
    
          [target, files]
        end
      end
    end
    
    Liquid::Template.register_tag('zip', Jekyll::ZipBundlerTag)