From f442f7a57b3b6b988f1d914b4ef5c53993b1fa90 Mon Sep 17 00:00:00 2001
From: Paul Duncan <pabs@pablotron.org>
Date: Mon, 18 Jul 2016 21:16:59 -0400
Subject: add theme packer

---
 data/themes/sample-theme/files/css/style.css       |   1 +
 data/themes/sample-theme/files/js/script.js        |   1 +
 data/themes/sample-theme/guff-manifest.json        |  23 ++++
 .../sample-theme/templates/blog-list-item.html     |   9 ++
 shard.yml                                          |   2 +
 src/guff.cr                                        |   1 +
 src/guff/cli.cr                                    |  25 ++++
 src/guff/config.cr                                 |  71 ++++++++---
 src/guff/theme/assets.cr                           |  15 +++
 src/guff/theme/file-info.cr                        |  18 +++
 src/guff/theme/source-manifest.cr                  | 135 +++++++++++++++++++++
 11 files changed, 287 insertions(+), 14 deletions(-)
 create mode 100644 data/themes/sample-theme/files/css/style.css
 create mode 100644 data/themes/sample-theme/files/js/script.js
 create mode 100644 data/themes/sample-theme/guff-manifest.json
 create mode 100644 data/themes/sample-theme/templates/blog-list-item.html
 create mode 100644 src/guff/theme/assets.cr
 create mode 100644 src/guff/theme/file-info.cr
 create mode 100644 src/guff/theme/source-manifest.cr

diff --git a/data/themes/sample-theme/files/css/style.css b/data/themes/sample-theme/files/css/style.css
new file mode 100644
index 0000000..913c182
--- /dev/null
+++ b/data/themes/sample-theme/files/css/style.css
@@ -0,0 +1 @@
+/* sample style */
diff --git a/data/themes/sample-theme/files/js/script.js b/data/themes/sample-theme/files/js/script.js
new file mode 100644
index 0000000..aebb122
--- /dev/null
+++ b/data/themes/sample-theme/files/js/script.js
@@ -0,0 +1 @@
+/* sample script */
diff --git a/data/themes/sample-theme/guff-manifest.json b/data/themes/sample-theme/guff-manifest.json
new file mode 100644
index 0000000..407ac40
--- /dev/null
+++ b/data/themes/sample-theme/guff-manifest.json
@@ -0,0 +1,23 @@
+{
+  "format": 1,
+  "metadata": {
+    "name": "Sample Theme",
+    "version": "1.0",
+    "date": "2016-07-18"
+  },
+
+  "files": [
+    "css/style.css",
+    "js/script.js"
+  ],
+
+  "assets": {
+    "scripts": [
+      "js/script.js"
+    ],
+
+    "styles": [
+      "css/style.css"
+    ]
+  }
+}
diff --git a/data/themes/sample-theme/templates/blog-list-item.html b/data/themes/sample-theme/templates/blog-list-item.html
new file mode 100644
index 0000000..7e9dddf
--- /dev/null
+++ b/data/themes/sample-theme/templates/blog-list-item.html
@@ -0,0 +1,9 @@
+<div id='post-%{post_id}' class='post'>
+  <div class='title'>%{name|h}</div>
+  <div class='sub-title'>
+    By <span class='created-by'>%{user_name|h}</span>
+    at <span class='posted-at'>%{created_at|h}</span>
+  </div>
+
+  <div class='body'>%{body}</div>
+</div>
diff --git a/shard.yml b/shard.yml
index e5c3b72..e9b7cd0 100644
--- a/shard.yml
+++ b/shard.yml
@@ -9,3 +9,5 @@ license: MIT
 dependencies:
   sqlite3:
       github: manastech/crystal-sqlite3
+  libzip-crystal:
+      github: pablotron/libzip-crystal
diff --git a/src/guff.cr b/src/guff.cr
index cfd020c..f0573a3 100644
--- a/src/guff.cr
+++ b/src/guff.cr
@@ -4,6 +4,7 @@ require "json"
 require "yaml"
 require "secure_random"
 require "sqlite3"
+require "libzip-crystal/zip"
 
 module Guff
   # TODO: think about versioning a bit (semantic, datestamp, etc)
diff --git a/src/guff/cli.cr b/src/guff/cli.cr
index c3fb4d6..15ff5a4 100644
--- a/src/guff/cli.cr
+++ b/src/guff/cli.cr
@@ -103,6 +103,21 @@ module Guff::CLI
         end
       end
     end
+
+    class ThemeListAction < Action
+      def run
+        puts "TODO: not implemented"
+      end
+    end
+
+    class ThemePackAction < Action
+      def run
+        Theme::SourceManifest.save(
+          src_dir:  @config.theme_src_dir.not_nil!,
+          dst_path: @config.theme_dst_path.not_nil!,
+        )
+      end
+    end
   end
 
   def self.run(app : String, args : Array(String))
@@ -119,6 +134,16 @@ module Guff::CLI
         Actions::InitAction.run(config)
       when "run"
         Actions::RunAction.run(config)
+      when "theme"
+        case config.theme_action
+        when "list"
+          Actions::ThemeListAction.run(config)
+        when "pack"
+          Actions::ThemePackAction.run(config)
+        else
+          # never reached
+          raise "unknown theme action: #{config.theme_action}"
+        end
       when "help"
         # do nothing
       else
diff --git a/src/guff/config.cr b/src/guff/config.cr
index 534e796..205c65d 100644
--- a/src/guff/config.cr
+++ b/src/guff/config.cr
@@ -1,27 +1,36 @@
 require "option_parser"
 
 class Guff::Config
-  property :mode, :env, :host, :port, :data_dir, :system_dir
+  property :mode, :env, :host, :port, :data_dir, :system_dir, 
+           :theme_action, :theme_src_dir, :theme_dst_path
 
   DEFAULTS = {
-    mode:       "help",
-    env:        "production",
-    host:       "127.0.0.1",
-    port:       "8989",
-    data_dir:   "./data",
-    system_dir: "/usr/local/share/guff",
+    mode:         "help",
+    env:          "production",
+    host:         "127.0.0.1",
+    port:         "8989",
+    data_dir:     "./data",
+    system_dir:   "/usr/local/share/guff",
+    theme_action: "list"
   }
 
+  @theme_action   : String
+  @theme_src_dir  : String?
+  @theme_dst_path : String?
+
   def initialize
-    @mode        = DEFAULTS[:mode] as String
-    @env         = (ENV["GUFF_ENVIRONMENT"]? || DEFAULTS[:env]) as String
-    @host        = (ENV["GUFF_HOST"]? || DEFAULTS[:host]) as String
-    @port        = (ENV["GUFF_PORT"]? || DEFAULTS[:port]) as String
-    @data_dir    = (ENV["GUFF_DATA_DIR"]? || DEFAULTS[:data_dir]) as String
-    @system_dir  = (ENV["GUFF_SYSTEM_DIR"]? || DEFAULTS[:system_dir]) as String
+    @mode         = DEFAULTS[:mode] as String
+    @env          = (ENV["GUFF_ENVIRONMENT"]? || DEFAULTS[:env]) as String
+    @host         = (ENV["GUFF_HOST"]? || DEFAULTS[:host]) as String
+    @port         = (ENV["GUFF_PORT"]? || DEFAULTS[:port]) as String
+    @data_dir     = (ENV["GUFF_DATA_DIR"]? || DEFAULTS[:data_dir]) as String
+    @system_dir   = (ENV["GUFF_SYSTEM_DIR"]? || DEFAULTS[:system_dir]) as String
+    @theme_action = DEFAULTS[:theme_action]
   end
 
-  VALID_MODES = %w{init run help}
+  VALID_MODES = %w{init run help theme}
+
+  VALID_THEME_ACTIONS = %w{list pack}
 
   def mode=(mode : String)
     raise "unknown mode: \"#{mode}\"" unless VALID_MODES.includes?(mode)
@@ -120,12 +129,46 @@ class Guff::Config
       p.on("-h", "--help", "Print usage.") do
         r.mode = "help"
       end
+
+      p.separator
+      p.separator("
+Examples:
+    # init new data directory
+    guff init some-app
+
+    # run using directory some-app
+    guff run -D some-app
+
+    # pack theme in directory /path/to/some-theme as ./some-theme.zip
+    guff theme pack /path/to/some-theme
+
+    # pack theme in directory /path/to/some-theme as /other/path/output.zip
+    guff theme pack /path/to/some-theme /other/path/output.zip
+    ")
     end
 
     case r.mode
     when "init"
       # shortcut for -D parameter
       r.data_dir = args.shift if args.size > 0
+    when "theme"
+      # get theme action
+      r.theme_action = args.shift if args.size > 0
+      unless VALID_THEME_ACTIONS.includes?(r.theme_action)
+        raise "unknown theme action: \"#{r.theme_action}\""
+      end
+
+      case r.theme_action
+      when "pack"
+        raise "missing theme directory" unless args.size > 0
+        r.theme_src_dir = args.shift
+
+        if args.size > 0
+          r.theme_dst_path = args.shift
+        else
+          r.theme_dst_path = File.basename(r.theme_src_dir.not_nil!) + ".zip"
+        end
+      end
     when "help"
       # print help
       puts p
diff --git a/src/guff/theme/assets.cr b/src/guff/theme/assets.cr
new file mode 100644
index 0000000..5f15106
--- /dev/null
+++ b/src/guff/theme/assets.cr
@@ -0,0 +1,15 @@
+class Guff::Theme::Assets
+  getter :scripts, :styles
+
+  JSON.mapping(
+    scripts:  Array(String),
+    styles:   Array(String),
+  )
+
+  def to_json(io)
+    {
+      scripts: @scripts,
+      styles:  @styles,
+    }.to_json(io)
+  end
+end
diff --git a/src/guff/theme/file-info.cr b/src/guff/theme/file-info.cr
new file mode 100644
index 0000000..914be75
--- /dev/null
+++ b/src/guff/theme/file-info.cr
@@ -0,0 +1,18 @@
+class Guff::Theme::FileInfo
+  getter :size, :hash
+
+  def initialize(abs_path : String, @path : String)
+    @size = File.size(abs_path)
+
+    d = OpenSSL::Digest.new("SHA1")
+    @hash = d.file(abs_path).hexdigest as String
+  end
+
+  def to_json(io)
+    {
+      name: @path,
+      size: @size,
+      hash: @hash,
+    }.to_json(io)
+  end
+end
diff --git a/src/guff/theme/source-manifest.cr b/src/guff/theme/source-manifest.cr
new file mode 100644
index 0000000..5dc1065
--- /dev/null
+++ b/src/guff/theme/source-manifest.cr
@@ -0,0 +1,135 @@
+class Guff::Theme::SourceManifest
+  getter :format, :metadata, :assets, :files
+
+  JSON.mapping(
+    format:   Int32,
+    metadata: Hash(String, String),
+    assets:   Assets,
+    files:    Array(String),
+  )
+
+  REQUIRED_METADATA_FIELDS = {
+    "name"    => /\S+/,
+    "version" => /\S+/,
+    "date"    => /^\d{4}-\d{2}-\d{2}$/,
+  }
+
+  def self.save(src_dir : String, dst_path : String)
+    load(src_dir).save(dst_path, src_dir)
+  end
+
+  def self.load(src_dir : String) : SourceManifest
+    r = from_json(File.read(File.join(src_dir, "guff-manifest.json")))
+
+    # check format version
+    unless r.format == 1
+      raise "unknown theme format: #{r.format}"
+    end
+
+    # check for required manifest files
+    REQUIRED_METADATA_FIELDS.each do |key, re|
+      unless r.metadata[key]?
+        # missing field
+        raise "missing required metadata field: #{key}"
+      end
+
+      unless r.metadata[key].match(re)
+        # invalid field format
+        raise "metadata field format mismatch: #{key}"
+      end
+    end
+
+    # build path to files dir
+    base_dir = File.join(src_dir, "files")
+
+    # check manifest for missing files
+    missing_files = r.files.select do |path|
+      !File.exists?(File.join(base_dir, path))
+    end
+
+    # raise error if there are missing files
+    if missing_files.size > 0
+      raise "Missing files: #{missing_files.join(", ")}"
+    end
+
+    # build path lut
+    lut = r.files.reduce({} of String => Bool) do |rl, path|
+      rl[path] = true
+      rl
+    end
+
+    # check assets
+    {
+      scripts: r.assets.scripts,
+      styles: r.assets.styles,
+    }.each do |type, paths|
+      # find assets specified in manifest but not in 
+      missing = paths.select { |path| !lut[path]? }
+
+      if missing.size > 0
+        raise "Missing #{type} in manifest: #{missing.join(", ")}"
+      end
+    end
+
+    # check files directory for entries missing from manifest
+    Dir.glob(File.join(base_dir, "**")).each do |path|
+      unless File.directory?(path)
+        # build relative path
+        rel_path = path.gsub(base_dir, "")
+        raise "File missing from manifest: #{path}" unless lut[rel_path]?
+      end
+    end
+
+    # return result
+    r
+  end
+
+  def to_json(io : IO, src_dir : String)
+    # build directory paths
+    files_dir = File.join(src_dir, "files")
+    templates_dir = File.join(src_dir, "templates")
+
+    # return output json
+    {
+      # format version
+      format: 1,
+
+      # theme metadata
+      metadata: @metadata,
+
+      # theme files
+      files: @files.map { |path|
+        FileInfo.new(File.join(files_dir, path), path)
+      },
+
+      # theme templates
+      templates: Dir.glob(File.join(templates_dir, "**")).select { |path|
+        !File.directory?(path)
+      }.reduce({} of String => String) do |r, path|
+        r[path.gsub(templates_dir + "/", "")] = File.read(path)
+        r
+      end,
+
+      # theme assets (css and js files)
+      assets: @assets,
+    }.to_json(io)
+  end
+
+  def save(dst_path : String, src_dir : String)
+    Zip::Archive.create(dst_path) do |zip|
+      # add manifest
+      zip.add("guff-manifest.json", String.build do |io|
+        to_json(io, src_dir)
+      end)
+
+      # add files
+      @files.each do |path|
+        # build path
+        dst_path = File.join("files", path)
+
+        # add file to archive
+        zip.add_file(dst_path, File.join(src_dir, dst_path))
+      end
+    end
+  end
+end
-- 
cgit v1.2.3