#!/usr/bin/env ruby

# About This Script:
# =================
#
# This is a quilted script torn and patched from a number of different projects I wrote here and there.
#
# I wrote this script specifically to handle the GitHub pages environment, so I could post static pages.
#
# I wouldn't recommend trying to read the code, it will only give you a headache...
#
# ...even I'm not sure what it does or how. I just threw it together to have something that works.

require 'date'
require 'erb'
require 'fileutils'
require 'yaml'
require 'set'
require 'json'

# Gems and stuff we use in this script
require 'redcarpet'
require 'slim'
require 'sassc'
require 'iodine'
require 'rouge'
require 'rouge/plugins/redcarpet'

# The folder for source files
SOURCE_ROOT = File.dirname(__FILE__)
# The output folder for the static site
STATIC_ROOT = File.join(File.dirname(__FILE__), "..")
# File / folder names to be excluded from the script
EXCLUDE = [File.basename(__FILE__), "layout.html.slim", "layout.erb", "Gemfile", "layouts"]
# Constants for Rack and HTTP headers for Iodine's X-Sendfile support
PATH_INFO = 'PATH_INFO'.freeze
X_SENDFILE = 'X-Sendfile'.freeze
# Default Layout for pages
DEFAULT_LAYOUT = "layouts/layout.html.slim" # "layouts/layout.html.slim"
DEFAULT_SIDEBAR = "_versions.md"
# By default, produce Table Oof Contents?
TOC_IS_DEFAULT = false
# # We don't need this, but we might
# unless File.directory?(STATIC_ROOT)
#   Dir.mkdir(STATIC_ROOT, 0777)
# end

# # A README.md file that will be placed in the static site's folder
README = <<EOS
# Contributing to the Website / Documentation

Thank you for your interest in contributing to the facil.io website and documentation.

NOTICE: `_SOURCE` is the folder that contains the actual documentation files. Edits to the documentation should be placed in this folder.

Anything outside the `_SOURCE` folder (including this file) is created automatically by the `server` script and shouldn't be edited.

If you want to contribute to the documentation, please do so by opening a Pull Request (PR) with updates to the files in the `_SOURCE` folder.

## Running the website locally

It's possible to run a local version of the website using Ruby (make sure to have Ruby and Ruby gems available on your system).

Open the terminal window and go to the `_SOURCE` folder. Than run (currently runs on macOS and Linux):

    $ bundle install
    $ ./server

EOS
IO.binwrite(File.join(STATIC_ROOT, "README.md"), README)

# Schema Description for the layout template
SCHEMA_ABOUT = "facil.io - a light web application framework in C, with support for HTTP, WebSockets and Pub/Sub out of the box.".freeze

# Schema JSON for the layout template
SCHEMA_AUTHOR = {
  '@type' => 'Person',
  name: 'Boaz Segev)',
  url: 'http://bowild.com',
  email: 'bo(at)facil.io'
}

# Schema JSON for the layout template
SCHEMA_ORG = {
  '@context' => 'http://schema.org',
  '@type' => 'WebSite',
  url: 'http://facil.io',
  name: 'facil.io',
  description: SCHEMA_ABOUT,
  keywords: 'C, web, framework, websockets, websocket, realtime, real-time, easy',
  image: 'http://facil.io/website/logo/facil-io.svg',
  # potentialAction: {
  #     "@type" => "SearchAction",
  #     target: "http://example.com/search?&q={query}",
  #     "query-input" => "required",
  #   },
  author: [ SCHEMA_AUTHOR ],
  sourceOrganization: {
    '@context' => 'http://schema.org',
    '@type' => 'Organization',
    name: 'facil.io',
    url: 'http://facil.io',
    description: SCHEMA_ABOUT,
    logo: 'http://facil.io/website/logo/facil-io.svg',
    image: 'http://facil.io/website/logo/facil-io.svg',
    email: 'bo(at)facil.io',
    member: [ SCHEMA_AUTHOR ]
  }
}.to_json




# The Rack application - this is where things get messy.
#
# This module does it all - it "bakes" pages into static pages as well as allows Rack to serve the updated version.
#
# In production mode (which we don't really use), the static pages will be served directly once they were baked (no live updates).
module APP

  # for the sitemap data
  @sitemap = {}.to_set
  # This HashMap will map file extensions to a Proc that will render the file
  @extensions = {}
  # File extensions that might require a page to be rendered (unlike jpeg, which is passed through)
  @bakers = %w{.css .html .js}.to_set


  # Converts templates to static pages and saves the pages to the static location.
  def self.bake_all
    @sitemap.clear
    # test each file in the SOURCE_ROOT folder and sub-folders. Should it be rendered or copied to the STATIC_ROOT tree?
    Dir[File.join SOURCE_ROOT, '**', "*"].each do |pt|
      file_basename = File.basename(pt)
      file_extension = File.extname(pt)
      # stuff we skip
      next if file_basename.start_with?('_') || EXCLUDE.include?(file_basename) || File.directory?(pt) # || File.expand_path(pt) == File.expand_path(__FILE__)
      if(@extensions[file_extension])
        # should be rendered - route to application rendering path
        begin
          url_path = pt[SOURCE_ROOT.length..(-1-file_extension.length)]
          env = {PATH_INFO => url_path}
          APP.call(env) # will create the file (in production, file is onnly created if missing)
          puts "INFO: pre-baked: #{url_path}"
          @sitemap << url_path
        rescue => e
          puts "WARN: couldn't pre-bake #{pt}: #{e.message}"
          raise e
        end
      else
        # should be copied
        begin
          target = pt[SOURCE_ROOT.length..-1]
          save2static target, IO.binread(pt)
          puts "INFO: copied #{target}"
          # @sitemap << target
        rescue => e
          puts "WARN: copy failed at #{pt}: #{e.message}"
        end
      end
    end
  end

# define different Rack application methods, depending on the environment.
if ENV['RACK_ENV'] == 'production'
  # No live updates mean that this shouldn't have been called (maybe except to result in 404 errors)
  def self.call env
    puts "WARN: render was requested for #{env[PATH_INFO]}" unless env.count == 1
    if (File.directory?( "#{STATIC_ROOT}#{env[PATH_INFO]}"))
      if (env[PATH_INFO][-1] == '/')
        env[PATH_INFO] << 'index.html'.freeze
      else
        env[PATH_INFO] << '/index.html'.freeze
      end
    end
    env[PATH_INFO] << ".html" if(File.extname(env[PATH_INFO]) == "")
    # use iodine's X-Sendfile support
    [200, {X_SENDFILE => "#{STATIC_ROOT}#{env[PATH_INFO]}"}, ["".freeze]]
  end
else
  # Live update file on disk and send with X-Sendfile
  def self.call env
    original_path = env[PATH_INFO]
    env[PATH_INFO] = File.join(SOURCE_ROOT, original_path).to_s
    if (File.directory?(env[PATH_INFO]))
      if (original_path[-1] == '/')
        original_path << 'index.html'.freeze
        env[PATH_INFO] << 'index.html'.freeze
      else
        original_path << '/index.html'.freeze
        env[PATH_INFO] << '/index.html'.freeze
      end
    end
    if(File.extname(original_path) == "".freeze)
      original_path << ".html"
      env[PATH_INFO] << ".html"
    end
    data = render(env[PATH_INFO]) if(@bakers.include?(File.extname(env[PATH_INFO])))
    # use iodine's X-Sendfile support to send file from disk
    puts "SENDFILE: #{STATIC_ROOT}#{original_path}"
    [200, {X_SENDFILE => "#{STATIC_ROOT}#{original_path}"}, [data]]
  end

end

  # Render a template / resource
  def self.render name
    base = name[0..(-1-(File.extname(name).length))]
    data = nil
    @extensions.keys.each { |k| data = try_name(name, k) || try_name(base, k); break if data }
    save2static name, data if data
  end

  # Attempt rendering for a specific extension
  def self.try_name name, ext
    name = "#{name}#{ext}"
    return nil unless File.exist?(name)
    @extensions[ext].call(name)
  end

  # saves a compressed variation of the file.
  def self.save_compressed_variant filename
    # gzip resources for gzipped responses (TODO? brotli)
    if(File.file?(filename) && File.size?(filename).to_i >= 16384)
      `gzip -kf -9 #{filename}`
      if(File.file?("#{filename}.gz") && File.size?("#{filename}.gz").to_i + 8192 >= File.size?(filename).to_i)
        File.delete("#{filename}.gz")
      end
    end
  end

  # Save a (rendered) result
  def self.save2static path, data
    return unless data
    path = "#{STATIC_ROOT}/#{path.gsub /^#{SOURCE_ROOT}\//, ''}"
    FileUtils.mkpath File.dirname(path)
    IO.binwrite path, data
    save_compressed_variant(path)
    data
  end

  # slim rendering (support variables and code blocks)
  @extensions['.slim'] = proc do |name, vars, block|
    engine = (Slim::Template.new { IO.binread(name).force_encoding(::Encoding::UTF_8) })
    if(block)
      engine.render((vars || {}), &block)
    else
      engine.render((vars || {}))
    end
  end

  # Common SASS options
  SASS_OPTIONS = {load_paths: [SOURCE_ROOT], source_map_file: nil, filename: nil, style: (ENV['SASS_STYLE'] || ((ENV['ENV'] || ENV['RACK_ENV']) == 'production' ? :compressed : :nested)) }.dup

  # SASS rendering
  @extensions['.scss'] = @extensions['.sass'] = proc do |name|
    opt = SASS_OPTIONS.dup
    opt[:filename] = name.gsub /^#{SOURCE_ROOT}\//, ''
    opt[:load_paths] = opt[:load_paths].dup
    opt[:load_paths] << File.dirname(name)
    opt[:source_map_file] = opt[:filename].gsub(/s[ac]ss$/, 'map')
    # opt[:source_map_file] = map_name.gsub /^#{SOURCE_ROOT}/, ''
    eng = SassC::Engine.new(IO.binread(name), opt)
    css = eng.render()
    map = eng.source_map()
    save2static name.gsub(/s[ac]ss$/, 'map'), map
    css
  end

  # mustache rendering (supports variables and a single code blocks - but no embedded code)
  @extensions['.mustache'] = proc do |name, vars, block|
    vars ||= {}
    vars[:yield] = block if(block)
    Iodine::Mustache.render(name, vars)
  end

  # supports bindings in erb rendering (support variables and code blocks)
  class Wrapper
    attr_accessor :page, :block
    def initialize vars, &block
      @page = OpenStruct.new(vars)
      @block = block
    end
    def binding &block
      @block = block
      super
    end
    def yield
      if(@block)
        @block.call
      else
        warn "WARNING: yield called without a block"
      end
    end
  end

  # erb rendering (support variables and code blocks)
  @extensions['.erb'] = proc do |name, vars, block|
    ctx = Wrapper.new(vars, &block)
    engine = ::ERB.new(IO.binread(name).force_encoding(::Encoding::UTF_8))
    if(block)
      engine.result(ctx.binding(&block), &block)
    else
      engine.result
    end
  end

  # The rest of the code in this module is the Markdown rendering code,
  # which gets more complicated to allow for mustache, fron-matter and layout options.

  MD_EXTENSIONS = { with_toc_data: true, strikethrough: true, autolink: true, fenced_code_blocks: true, no_intra_emphasis: true, tables: true, footnotes: true, underline: true, highlight: true }.freeze
  class RedcarpetWithRouge < ::Redcarpet::Render::HTML
    include Rouge::Plugins::Redcarpet
    def block_code(code, language)
      language ||= 'c'
      super
    end
  end
  MD_RENDERER = ::Redcarpet::Markdown.new RedcarpetWithRouge.new(MD_EXTENSIONS.dup), MD_EXTENSIONS.dup
  MD_RENDERER_TOC = Redcarpet::Markdown.new Redcarpet::Render::HTML_TOC.new(MD_EXTENSIONS.dup), MD_EXTENSIONS.dup
  YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze
  SAFE_TYPES = [Symbol, ::Date, ::Time, ::DateTime, Encoding, Struct, Regexp, Range, Set].freeze

  @extensions['.md'] = proc do |name|
    # read file
    data = IO.binread(name)

    # collect YAML front-matter
    vars = {}
    front = data.match YAML_FRONT_MATTER_REGEXP
    if(front)
      vars = YAML.safe_load(front[1], SAFE_TYPES) || {}
      data = front.post_match
    end

    # some sane defaults, even if there's no front matter
    unless File.basename(name).start_with?('_')
      vars['layout'] ||= DEFAULT_LAYOUT
      vars['sidebar'] ||= DEFAULT_SIDEBAR
      vars['toc'] = TOC_IS_DEFAULT if vars['toc'].nil?
    end

    # try mustache template rendering before rendering the Markdown
    begin
      data = Iodine::Mustache.render(name, vars, data)
    rescue Exception => e
      puts "mustache error: #{name}"
      p vars
      raise e
    end

    # Render the markdown
    if(vars['toc'])
      data = "<div class='toc'>#{MD_RENDERER_TOC.render(data)}</div>#{MD_RENDERER.render(data)}"
    else
      data = MD_RENDERER.render(data)
    end

    # Attach any side-bar / layout required
    layout = File.join(SOURCE_ROOT, vars['layout'].to_s).to_s
    if(vars['layout'] && File.exist?(layout) && @extensions[File.extname(layout)])
      # render sidebar to a String
      sidebar = File.join(SOURCE_ROOT, vars['sidebar'].to_s).to_s
      if(vars['sidebar'] && File.exist?(sidebar) && @extensions[File.extname(sidebar)])
        vars['sidebar'] = @extensions[File.extname(sidebar)].call(sidebar)
      elsif vars['sidebar']
          puts "can't find #{vars['sidebar']} (extension #{File.extname(sidebar)})?"
      end
      # Return the layout with the data
      block = proc { data }
      @extensions[File.extname(layout)].call(layout, vars, block)
    else
      # Return the data as is (no layout)
      data
    end
  end


end

# Copy the updated CHANGELOG.md file to the source folder
FileUtils.cp(File.join(STATIC_ROOT, '..', 'CHANGELOG.md'), File.join(SOURCE_ROOT, 'changelog.md')) rescue nil
# "Bake" the templates and copy the data
APP.bake_all
# Remove the changelog from the source folder
FileUtils.rm(File.join(SOURCE_ROOT, 'changelog.md')) rescue nil


# Setup iodine to serve static files and to run the Rack application `APP`
Iodine.listen service: :http, handler: APP, log: true, public: ((ENV['RACK_ENV'] == 'production') ? STATIC_ROOT : SOURCE_ROOT)
# If no threads / processes were setup, use half the cores for multi-threading and a single process
if(Iodine.threads == 0 && Iodine.workers == 0)
  Iodine.threads =-2;
  Iodine.workers =1;
end
# Start up the server
Iodine.start
