purity 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/exe/purity +6 -0
- data/lib/purity/cli.rb +96 -0
- data/lib/purity/context.rb +57 -0
- data/lib/purity/data_store.rb +82 -0
- data/lib/purity/plugins/feed.rb +30 -0
- data/lib/purity/plugins/sitemap.rb +18 -0
- data/lib/purity/render_context.rb +64 -0
- data/lib/purity/scaffold.rb +77 -0
- data/lib/purity/server.rb +83 -0
- data/lib/purity/site.rb +197 -0
- data/lib/purity/template.rb +47 -0
- data/lib/purity/version.rb +3 -0
- data/lib/purity.rb +19 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: aa91bae52c808d7124074905c5ab0b1f69c1d46e8e7390f5e1bf0392d66c5112
|
|
4
|
+
data.tar.gz: 2a8794641f2a09e56f80a1d878b6f72fb21e99cc3f7fd397b958f0ae0c472bd2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6824c06ce5a20b9209a43ad4d6cb23593f0b015117d55761205dd839da144740cee3ecac321ebbe2fb0b25ba12187d9c7928efb83cf70eb9663410c2b6a0c86e
|
|
7
|
+
data.tar.gz: c7569a15cdb6e316e78b310199a362022510c2cffab07ac21d4e51d810af07d82256d9941a55587c880c9a0f0b8fbfe98ad33d47ea8b8ba53d5dab57b435d973
|
data/exe/purity
ADDED
data/lib/purity/cli.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purity
|
|
4
|
+
|
|
5
|
+
class CLI
|
|
6
|
+
class << self
|
|
7
|
+
def run(args = ARGV.dup)
|
|
8
|
+
include_drafts = !!args.delete("--drafts")
|
|
9
|
+
|
|
10
|
+
case args[0]
|
|
11
|
+
when "new", "n"
|
|
12
|
+
new_site(args[1])
|
|
13
|
+
when "serve", "s"
|
|
14
|
+
ENV["PURITY_ENV"] ||= "development"
|
|
15
|
+
port = args[1] ? args[1].to_i : 4567
|
|
16
|
+
Site.new.serve(port: port)
|
|
17
|
+
when "watch", "w"
|
|
18
|
+
ENV["PURITY_ENV"] ||= "development"
|
|
19
|
+
port = args[1] ? args[1].to_i : 4567
|
|
20
|
+
Site.new.watch(port: port)
|
|
21
|
+
when "help", "h", "-h", "--help"
|
|
22
|
+
help
|
|
23
|
+
when "version", "-v", "--version"
|
|
24
|
+
puts("purity #{VERSION}")
|
|
25
|
+
else
|
|
26
|
+
ENV["PURITY_ENV"] ||= "production"
|
|
27
|
+
Site.new.build(include_drafts: include_drafts)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def new_site(dir)
|
|
34
|
+
unless dir
|
|
35
|
+
puts("usage: purity new <directory>")
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
Scaffold.new(dir).run
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def help
|
|
42
|
+
puts("purity #{VERSION}")
|
|
43
|
+
puts("")
|
|
44
|
+
puts("usage: purity [command] [options]")
|
|
45
|
+
puts("")
|
|
46
|
+
puts("commands:")
|
|
47
|
+
puts(" new <dir> create a new site")
|
|
48
|
+
puts(" (none) build the site")
|
|
49
|
+
puts(" serve, s build and serve on localhost")
|
|
50
|
+
puts(" watch, w build, serve, and rebuild on changes")
|
|
51
|
+
puts(" help, h this message")
|
|
52
|
+
puts(" version show version")
|
|
53
|
+
puts("")
|
|
54
|
+
puts("options:")
|
|
55
|
+
puts(" --drafts include pages with draft: true")
|
|
56
|
+
puts(" [port] port for serve/watch (default: 4567)")
|
|
57
|
+
puts("")
|
|
58
|
+
puts("template variables:")
|
|
59
|
+
puts(" site.title from _site.yml")
|
|
60
|
+
puts(" page.title from front matter")
|
|
61
|
+
puts(" page.url clean URL path of the current page")
|
|
62
|
+
puts(" page.content rendered page body (in layouts)")
|
|
63
|
+
puts(" data.nav from _data/nav.yml")
|
|
64
|
+
puts(" data.posts configured collection")
|
|
65
|
+
puts(" data.pages all non-standalone, non-draft pages")
|
|
66
|
+
puts("")
|
|
67
|
+
puts("front matter:")
|
|
68
|
+
puts(" layout: name use _name.html (default: _layout.html)")
|
|
69
|
+
puts(" layout: false copy file as-is, no layout")
|
|
70
|
+
puts(" draft: true skip unless --drafts")
|
|
71
|
+
puts(" date: YYYY-MM-DD date for feed generation")
|
|
72
|
+
puts(" permalink: /p/ custom output path")
|
|
73
|
+
puts("")
|
|
74
|
+
puts("config (_site.yml):")
|
|
75
|
+
puts(" url: https://... enables sitemap + feed generation")
|
|
76
|
+
puts(" site_name: My Site used in feed title")
|
|
77
|
+
puts(" clean_urls: true rewrite to clean URLs (default)")
|
|
78
|
+
puts(" strict_variables: true raise on undefined variables")
|
|
79
|
+
puts(" collections: directory-based collections")
|
|
80
|
+
puts(" posts:")
|
|
81
|
+
puts(" sort_by: date")
|
|
82
|
+
puts(" order: desc")
|
|
83
|
+
puts(" environments: per-environment overrides")
|
|
84
|
+
puts(" production:")
|
|
85
|
+
puts(" strict_variables: false")
|
|
86
|
+
puts("")
|
|
87
|
+
puts("plugins (src/_plugins/*.rb):")
|
|
88
|
+
puts(" hook :after_parse { |config, pages| }")
|
|
89
|
+
puts(" hook :before_render { |ctx, rel| }")
|
|
90
|
+
puts(" hook :before_layout { |content, meta, ctx| content }")
|
|
91
|
+
puts(" hook :after_render { |html, ctx, rel| html }")
|
|
92
|
+
puts(" hook :after_build { |config, dest| }")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purity
|
|
4
|
+
class UndefinedVariableError < StandardError; end
|
|
5
|
+
|
|
6
|
+
class Context
|
|
7
|
+
def initialize(data, strict: false)
|
|
8
|
+
@data = data || {}
|
|
9
|
+
@strict = strict
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def [](key)
|
|
13
|
+
wrap(@data[key.to_s])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def []=(key, value)
|
|
17
|
+
@data[key.to_s] = value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def key?(key)
|
|
21
|
+
@data.key?(key.to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def merge(other)
|
|
25
|
+
self.class.new(@data.merge(other.transform_keys(&:to_s)), strict: @strict)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def method_missing(name, *args)
|
|
29
|
+
key = name.to_s
|
|
30
|
+
if @data.key?(key)
|
|
31
|
+
wrap(@data[key])
|
|
32
|
+
elsif @strict
|
|
33
|
+
raise UndefinedVariableError, "undefined variable: #{key}"
|
|
34
|
+
else
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def respond_to_missing?(name, include_private = false)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_s
|
|
44
|
+
""
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def wrap(val)
|
|
50
|
+
case val
|
|
51
|
+
when Hash then self.class.new(val, strict: @strict)
|
|
52
|
+
when Array then val.map { |v| v.is_a?(Hash) ? self.class.new(v, strict: @strict) : v }
|
|
53
|
+
else val
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purity
|
|
4
|
+
class DataStore
|
|
5
|
+
def initialize(dir, strict: false)
|
|
6
|
+
@dir = dir
|
|
7
|
+
@cache = {}
|
|
8
|
+
@strict = strict
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def [](key)
|
|
12
|
+
raw = raw_get(key.to_s)
|
|
13
|
+
wrap(raw)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def []=(key, value)
|
|
17
|
+
@cache[key.to_s] = value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def key?(key)
|
|
21
|
+
@cache.key?(key.to_s) || !!find_file(key.to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def empty?
|
|
25
|
+
!Dir.exist?(@dir) || Dir.glob(File.join(@dir, "**/*.{yml,yaml,json}")).empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
""
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def method_missing(name, *args)
|
|
33
|
+
key = name.to_s
|
|
34
|
+
if @cache.key?(key) || find_file(key)
|
|
35
|
+
self[key]
|
|
36
|
+
elsif @strict
|
|
37
|
+
raise UndefinedVariableError, "undefined data key: #{key}"
|
|
38
|
+
else
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def respond_to_missing?(name, include_private = false)
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def raw_get(key)
|
|
50
|
+
return @cache[key] if @cache.key?(key)
|
|
51
|
+
path = find_file(key)
|
|
52
|
+
return nil unless path
|
|
53
|
+
@cache[key] = parse(path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find_file(key)
|
|
57
|
+
base = File.join(@dir, key.tr(".", "/"))
|
|
58
|
+
%w[.yml .yaml .json].each do |ext|
|
|
59
|
+
path = "#{base}#{ext}"
|
|
60
|
+
return path if File.exist?(path)
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse(path)
|
|
66
|
+
content = File.read(path)
|
|
67
|
+
if path.end_with?(".json")
|
|
68
|
+
JSON.parse(content)
|
|
69
|
+
else
|
|
70
|
+
YAML.safe_load(content, permitted_classes: [Date])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wrap(val)
|
|
75
|
+
case val
|
|
76
|
+
when Hash then Context.new(val, strict: @strict)
|
|
77
|
+
when Array then val.map { |v| v.is_a?(Hash) ? Context.new(v, strict: @strict) : v }
|
|
78
|
+
else val
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
hook :after_build do |config, dest|
|
|
2
|
+
next unless config["url"]
|
|
3
|
+
|
|
4
|
+
url = config["url"].chomp("/")
|
|
5
|
+
posts = config["data"]["pages"]
|
|
6
|
+
.select { |p| p["date"] }
|
|
7
|
+
.sort_by { |p| p["date"].to_s }
|
|
8
|
+
.reverse
|
|
9
|
+
.first(20)
|
|
10
|
+
|
|
11
|
+
doc = REXML::Document.new
|
|
12
|
+
doc << REXML::XMLDecl.new("1.0", "UTF-8")
|
|
13
|
+
rss = doc.add_element("rss", "version" => "2.0")
|
|
14
|
+
channel = rss.add_element("channel")
|
|
15
|
+
channel.add_element("title").add_text(config["site_name"].to_s)
|
|
16
|
+
channel.add_element("link").add_text(url)
|
|
17
|
+
channel.add_element("description").add_text(config["description"].to_s)
|
|
18
|
+
|
|
19
|
+
posts.each do |p|
|
|
20
|
+
item = channel.add_element("item")
|
|
21
|
+
item.add_element("title").add_text(p["title"].to_s)
|
|
22
|
+
item.add_element("link").add_text("#{url}#{p["url"]}")
|
|
23
|
+
item.add_element("pubDate").add_text(p["date"].to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
out = +""
|
|
27
|
+
doc.write(out, 2)
|
|
28
|
+
File.write(File.join(dest, "feed.xml"), out << "\n")
|
|
29
|
+
puts(" feed.xml")
|
|
30
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
hook :after_build do |config, dest|
|
|
2
|
+
next unless config["url"]
|
|
3
|
+
|
|
4
|
+
url = config["url"].chomp("/")
|
|
5
|
+
pages = config["data"]["pages"]
|
|
6
|
+
|
|
7
|
+
doc = REXML::Document.new
|
|
8
|
+
doc << REXML::XMLDecl.new("1.0", "UTF-8")
|
|
9
|
+
urlset = doc.add_element("urlset", "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9")
|
|
10
|
+
pages.each do |page|
|
|
11
|
+
urlset.add_element("url").add_element("loc").add_text("#{url}#{page["url"]}")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
out = +""
|
|
15
|
+
doc.write(out, 2)
|
|
16
|
+
File.write(File.join(dest, "sitemap.xml"), out << "\n")
|
|
17
|
+
puts(" sitemap.xml")
|
|
18
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purity
|
|
4
|
+
class RenderContext
|
|
5
|
+
def initialize(site:, context: {}, content_for_blocks: {}, strict: false)
|
|
6
|
+
@_site = site
|
|
7
|
+
@_context = context
|
|
8
|
+
@_content_for = content_for_blocks
|
|
9
|
+
@_strict = strict
|
|
10
|
+
@_erbout = +""
|
|
11
|
+
extend(site.helpers_module) if site.helpers_module
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def content_for(name, &block)
|
|
15
|
+
key = name.to_s
|
|
16
|
+
if block
|
|
17
|
+
@_content_for[key] = capture(&block)
|
|
18
|
+
""
|
|
19
|
+
else
|
|
20
|
+
@_content_for[key] || ""
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def content_for?(name)
|
|
25
|
+
@_content_for.key?(name.to_s) && !@_content_for[name.to_s].empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def partial(name, **locals)
|
|
29
|
+
path = File.join(@_site.src, name)
|
|
30
|
+
return "" unless File.exist?(path)
|
|
31
|
+
_, body = @_site.send(:parse, path)
|
|
32
|
+
merged = @_context.dup
|
|
33
|
+
merged[:page] = (merged[:page] || {}).merge(locals.transform_keys(&:to_s))
|
|
34
|
+
@_site.send(:erb_render, body, context: merged, content_for_blocks: @_content_for)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_binding
|
|
38
|
+
binding
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def method_missing(name, *args)
|
|
42
|
+
if @_strict
|
|
43
|
+
raise UndefinedVariableError, "undefined variable: #{name}"
|
|
44
|
+
else
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def respond_to_missing?(name, include_private = false)
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def capture(&block)
|
|
56
|
+
old = @_erbout
|
|
57
|
+
@_erbout = +""
|
|
58
|
+
block.call
|
|
59
|
+
result = @_erbout
|
|
60
|
+
@_erbout = old
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purity
|
|
4
|
+
|
|
5
|
+
class Scaffold
|
|
6
|
+
def initialize(dir)
|
|
7
|
+
@dir = File.expand_path(dir)
|
|
8
|
+
@src = File.join(@dir, "src")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run
|
|
12
|
+
if Dir.exist?(@dir) && !Dir.empty?(@dir)
|
|
13
|
+
puts("#{@dir} already exists and is not empty")
|
|
14
|
+
return false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
create_directories
|
|
18
|
+
create_gemfile
|
|
19
|
+
create_site_config
|
|
20
|
+
create_layout
|
|
21
|
+
create_index
|
|
22
|
+
puts("created #{@dir}")
|
|
23
|
+
puts(" cd #{File.basename(@dir)} && purity watch")
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def create_directories
|
|
30
|
+
FileUtils.mkdir_p(File.join(@src, "_plugins"))
|
|
31
|
+
FileUtils.mkdir_p(File.join(@src, "_helpers"))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_gemfile
|
|
35
|
+
File.write(File.join(@dir, "Gemfile"), <<~RUBY)
|
|
36
|
+
source "https://rubygems.org"
|
|
37
|
+
|
|
38
|
+
gem "purity"
|
|
39
|
+
RUBY
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create_site_config
|
|
43
|
+
File.write(File.join(@src, "_site.yml"), <<~YAML)
|
|
44
|
+
site_name: my site
|
|
45
|
+
description: a site built with purity
|
|
46
|
+
YAML
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_layout
|
|
50
|
+
File.write(File.join(@src, "_layout.html"), <<~'HTML')
|
|
51
|
+
<!doctype html>
|
|
52
|
+
<html lang="en">
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="utf-8">
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
56
|
+
<title><%= page.title %></title>
|
|
57
|
+
<%= content_for :head %>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<%= page.content %>
|
|
61
|
+
<%= content_for :scripts %>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
64
|
+
HTML
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def create_index
|
|
68
|
+
File.write(File.join(@src, "index.html"), <<~'HTML')
|
|
69
|
+
---
|
|
70
|
+
title: home
|
|
71
|
+
---
|
|
72
|
+
<h1><%= page.title %></h1>
|
|
73
|
+
<p>edit src/index.html to get started.</p>
|
|
74
|
+
HTML
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purity
|
|
4
|
+
|
|
5
|
+
module Server
|
|
6
|
+
def serve(port: 4567)
|
|
7
|
+
build
|
|
8
|
+
server = make_server(port: port)
|
|
9
|
+
puts("serving at http://localhost:#{port}")
|
|
10
|
+
trap("INT") { server.shutdown }
|
|
11
|
+
server.start
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def watch(port: 4567)
|
|
15
|
+
@livereload = true
|
|
16
|
+
build
|
|
17
|
+
@build_time = Time.now.to_i
|
|
18
|
+
server = make_server(port: port)
|
|
19
|
+
server.mount_proc("/__livereload") do |_, res|
|
|
20
|
+
res.content_type = "text/plain"
|
|
21
|
+
res.body = @build_time.to_s
|
|
22
|
+
end
|
|
23
|
+
puts("watching #{src}/ and serving at http://localhost:#{port}")
|
|
24
|
+
start_watcher
|
|
25
|
+
trap("INT") { server.shutdown }
|
|
26
|
+
server.start
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def make_server(port:)
|
|
32
|
+
dest_dir = dest
|
|
33
|
+
server = WEBrick::HTTPServer.new(Port: port, Logger: WEBrick::Log.new("/dev/null"), AccessLog: [])
|
|
34
|
+
server.mount_proc("/") do |req, res|
|
|
35
|
+
path = req.path.chomp("/")
|
|
36
|
+
base = File.join(dest_dir, path)
|
|
37
|
+
found = [base, "#{base}.html", File.join(base, "index.html")].find { |f| File.file?(f) }
|
|
38
|
+
if found
|
|
39
|
+
res.body = File.binread(found)
|
|
40
|
+
res.content_type = WEBrick::HTTPUtils.mime_type(found, WEBrick::HTTPUtils::DefaultMimeTypes)
|
|
41
|
+
else
|
|
42
|
+
res.status = 404
|
|
43
|
+
custom_404 = File.join(dest_dir, "404.html")
|
|
44
|
+
if File.file?(custom_404)
|
|
45
|
+
res.body = File.binread(custom_404)
|
|
46
|
+
res.content_type = "text/html"
|
|
47
|
+
else
|
|
48
|
+
res.body = "not found"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
server
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start_watcher
|
|
56
|
+
mtimes = {}
|
|
57
|
+
Dir.glob(File.join(src, "**/*")).select { |f| File.file?(f) }.each { |f| mtimes[f] = File.mtime(f) }
|
|
58
|
+
Thread.new do
|
|
59
|
+
loop do
|
|
60
|
+
sleep(1)
|
|
61
|
+
changed = false
|
|
62
|
+
Dir.glob(File.join(src, "**/*")).select { |f| File.file?(f) }.each do |f|
|
|
63
|
+
mt = File.mtime(f)
|
|
64
|
+
if mtimes[f] != mt
|
|
65
|
+
mtimes[f] = mt
|
|
66
|
+
changed = true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
if changed
|
|
70
|
+
puts("rebuilding...")
|
|
71
|
+
build
|
|
72
|
+
@build_time = Time.now.to_i
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def inject_livereload(html:)
|
|
79
|
+
script = "<script>(function(){var t;setInterval(function(){fetch('/__livereload').then(function(r){return r.text()}).then(function(s){if(t&&s!==t)location.reload();t=s})},1000)})()</script>"
|
|
80
|
+
html.sub("</body>", "#{script}\n</body>")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/purity/site.rb
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purity
|
|
4
|
+
|
|
5
|
+
class Site
|
|
6
|
+
include Template
|
|
7
|
+
include Server
|
|
8
|
+
|
|
9
|
+
attr_reader :src, :dest, :config, :helpers_module
|
|
10
|
+
|
|
11
|
+
def initialize(src: nil, dest: nil)
|
|
12
|
+
@src = File.expand_path(src || "src")
|
|
13
|
+
@dest_override = dest
|
|
14
|
+
@hooks = Hash.new { |h, k| h[k] = [] }
|
|
15
|
+
@livereload = false
|
|
16
|
+
@build_time = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def hook(name, &block)
|
|
20
|
+
@hooks[name] << block
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build(include_drafts: false)
|
|
24
|
+
load_plugins
|
|
25
|
+
load_helpers
|
|
26
|
+
@config = load_site
|
|
27
|
+
@dest = File.expand_path(@dest_override || @config.delete("dest") || "build")
|
|
28
|
+
env = ENV.fetch("PURITY_ENV", "development")
|
|
29
|
+
@config["env"] = env
|
|
30
|
+
merge_env_config(env)
|
|
31
|
+
strict = @config.fetch("strict_variables", false)
|
|
32
|
+
@config["data"] = DataStore.new(File.join(@src, "_data"), strict: strict)
|
|
33
|
+
|
|
34
|
+
parsed = collect_pages(include_drafts: include_drafts)
|
|
35
|
+
|
|
36
|
+
all_pages = parsed.filter_map do |rel, meta, _|
|
|
37
|
+
next if meta["layout"].to_s == "false"
|
|
38
|
+
next if meta["draft"]
|
|
39
|
+
path = output_path(rel: rel, meta: meta)
|
|
40
|
+
page_url = "/#{path}".sub(/index\.html$/, "")
|
|
41
|
+
meta.merge("rel" => rel, "url" => page_url)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@config["data"]["pages"] = all_pages
|
|
45
|
+
build_collections(parsed: parsed)
|
|
46
|
+
|
|
47
|
+
@hooks[:after_parse].each { |fn| fn.call(@config, parsed) }
|
|
48
|
+
|
|
49
|
+
parsed.each do |rel, meta, body|
|
|
50
|
+
path = output_path(rel: rel, meta: meta)
|
|
51
|
+
page_url = "/#{path}".sub(/index\.html$/, "")
|
|
52
|
+
|
|
53
|
+
page_hash = meta.merge(
|
|
54
|
+
"url" => page_url,
|
|
55
|
+
"og_title" => meta["og_title"] || meta["title"],
|
|
56
|
+
"og_description" => meta["og_description"] || meta["description"]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
ctx = { site: @config, page: page_hash, data: @config["data"] }
|
|
60
|
+
@hooks[:before_render].each { |fn| fn.call(ctx, rel) }
|
|
61
|
+
html = render_page(meta: meta, body: body, context: ctx)
|
|
62
|
+
html = @hooks[:after_render].reduce(html) { |h, fn| fn.call(h, ctx, rel) }
|
|
63
|
+
html = inject_livereload(html: html) if @livereload
|
|
64
|
+
write_output(html: html, rel: path)
|
|
65
|
+
label = meta["layout"].to_s == "false" ? " (standalone)" : ""
|
|
66
|
+
puts(" #{path}#{label}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
copied = copy_assets
|
|
70
|
+
@hooks[:after_build].each { |fn| fn.call(@config, @dest) }
|
|
71
|
+
stats = "built #{parsed.length} pages"
|
|
72
|
+
stats += ", copied #{copied} assets" if copied > 0
|
|
73
|
+
puts(stats)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
attr_reader :hooks
|
|
79
|
+
|
|
80
|
+
def load_plugins
|
|
81
|
+
@hooks.clear
|
|
82
|
+
builtin = File.join(File.dirname(__FILE__), "plugins")
|
|
83
|
+
Dir.glob(File.join(builtin, "*.rb")).sort.each { |f| instance_eval(File.read(f), f) }
|
|
84
|
+
Dir.glob(File.join(@src, "_plugins", "*.rb")).sort.each { |f| instance_eval(File.read(f), f) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load_helpers
|
|
88
|
+
@helpers_module = Module.new
|
|
89
|
+
Dir.glob(File.join(@src, "_helpers", "*.rb")).sort.each do |f|
|
|
90
|
+
@helpers_module.module_eval(File.read(f), f)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def load_site
|
|
95
|
+
path = File.join(@src, "_site.yml")
|
|
96
|
+
config = File.exist?(path) ? YAML.safe_load(File.read(path), permitted_classes: [Date]) || {} : {}
|
|
97
|
+
config
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def merge_env_config(env)
|
|
101
|
+
envs = @config.delete("environments") || {}
|
|
102
|
+
env_config = envs[env] || {}
|
|
103
|
+
@config.merge!(env_config)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse(path)
|
|
107
|
+
raw = File.read(path)
|
|
108
|
+
return [{}, raw] unless raw.start_with?("---\n")
|
|
109
|
+
|
|
110
|
+
parts = raw.split("---\n", 3)
|
|
111
|
+
[YAML.safe_load(parts[1], permitted_classes: [Date]) || {}, parts[2] || ""]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def collect_pages(include_drafts:)
|
|
115
|
+
Dir.glob(File.join(@src, "**/*.{html,md}"))
|
|
116
|
+
.reject { |f| File.basename(f).start_with?("_") }
|
|
117
|
+
.sort
|
|
118
|
+
.filter_map do |path|
|
|
119
|
+
rel = path.sub("#{@src}/", "")
|
|
120
|
+
meta, body = parse(path)
|
|
121
|
+
meta["format"] = File.extname(rel).delete_prefix(".")
|
|
122
|
+
rel = rel.sub(/\.md$/, ".html")
|
|
123
|
+
next if meta["draft"] && !include_drafts
|
|
124
|
+
meta["excerpt"] ||= extract_excerpt(body)
|
|
125
|
+
[rel, meta, body]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def extract_excerpt(body)
|
|
130
|
+
if body.include?("<!-- more -->")
|
|
131
|
+
body.split("<!-- more -->", 2).first.strip
|
|
132
|
+
else
|
|
133
|
+
first_para = body.strip.split(/\n\n/, 2).first
|
|
134
|
+
first_para&.strip || ""
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_collections(parsed:)
|
|
139
|
+
collections_config = @config.fetch("collections", {})
|
|
140
|
+
return unless collections_config
|
|
141
|
+
|
|
142
|
+
collections_config.each do |name, opts|
|
|
143
|
+
opts ||= {}
|
|
144
|
+
items = parsed.filter_map do |rel, meta, _|
|
|
145
|
+
next unless rel.start_with?("#{name}/")
|
|
146
|
+
next if meta["draft"]
|
|
147
|
+
path = output_path(rel: rel, meta: meta)
|
|
148
|
+
page_url = "/#{path}".sub(/index\.html$/, "")
|
|
149
|
+
meta.merge("rel" => rel, "url" => page_url)
|
|
150
|
+
end
|
|
151
|
+
if opts["sort_by"]
|
|
152
|
+
items.sort_by! { |p| p[opts["sort_by"]].to_s }
|
|
153
|
+
items.reverse! if opts["order"] == "desc"
|
|
154
|
+
end
|
|
155
|
+
@config["data"][name] = items
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def output_path(rel:, meta:)
|
|
160
|
+
if meta["permalink"]
|
|
161
|
+
p = meta["permalink"].sub(/^\//, "")
|
|
162
|
+
return "index.html" if p.empty?
|
|
163
|
+
return p.end_with?("/") ? "#{p}index.html" : p
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return rel unless @config.fetch("clean_urls", true)
|
|
167
|
+
return rel if File.basename(rel) == "index.html"
|
|
168
|
+
return rel if File.basename(rel, ".html").match?(/\A\d{3}\z/)
|
|
169
|
+
|
|
170
|
+
rel.sub(/\.html$/, "/index.html")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def write_output(html:, rel:)
|
|
174
|
+
out = File.join(@dest, rel)
|
|
175
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
176
|
+
File.write(out, html)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def copy_assets
|
|
180
|
+
count = 0
|
|
181
|
+
Dir.glob(File.join(@src, "**/*"), File::FNM_DOTMATCH)
|
|
182
|
+
.select { |f| File.file?(f) }
|
|
183
|
+
.each do |path|
|
|
184
|
+
rel = path.sub("#{@src}/", "")
|
|
185
|
+
next if rel.end_with?(".html", ".md")
|
|
186
|
+
next if rel.split("/").any? { |seg| seg.start_with?("_") }
|
|
187
|
+
|
|
188
|
+
out = File.join(@dest, rel)
|
|
189
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
190
|
+
FileUtils.cp(path, out)
|
|
191
|
+
count += 1
|
|
192
|
+
end
|
|
193
|
+
count
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purity
|
|
4
|
+
module Template
|
|
5
|
+
def render(body:, context:)
|
|
6
|
+
erb_render(body, context: context)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def render_page(meta:, body:, context:)
|
|
10
|
+
return body if meta["layout"].to_s == "false"
|
|
11
|
+
|
|
12
|
+
captured = {}
|
|
13
|
+
context[:page]["content"] = erb_render(body, context: context, content_for_blocks: captured)
|
|
14
|
+
context[:page]["content"] = @hooks[:before_layout].reduce(context[:page]["content"]) { |c, fn| fn.call(c, meta, context) }
|
|
15
|
+
apply_layout(meta: meta, context: context, content_for_blocks: captured)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def erb_render(body, context:, content_for_blocks: {})
|
|
21
|
+
strict = (context[:site] || {}).fetch("strict_variables", false)
|
|
22
|
+
ctx = RenderContext.new(site: self, context: context, content_for_blocks: content_for_blocks, strict: strict)
|
|
23
|
+
b = ctx.get_binding
|
|
24
|
+
b.local_variable_set(:site, Context.new(context[:site] || {}, strict: strict))
|
|
25
|
+
b.local_variable_set(:page, Context.new(context[:page] || {}, strict: strict))
|
|
26
|
+
b.local_variable_set(:data, context[:data]) if context[:data]
|
|
27
|
+
ERB.new(body, trim_mode: "-", eoutvar: "@_erbout").result(b)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def apply_layout(meta:, context:, content_for_blocks: {})
|
|
31
|
+
layout_name = meta.key?("layout") ? meta["layout"] : "layout"
|
|
32
|
+
lpath = File.join(src, "_#{layout_name}.html")
|
|
33
|
+
return context[:page]["content"] unless File.exist?(lpath)
|
|
34
|
+
|
|
35
|
+
lmeta, lbody = parse(lpath)
|
|
36
|
+
result = erb_render(lbody, context: context, content_for_blocks: content_for_blocks)
|
|
37
|
+
while lmeta["layout"]
|
|
38
|
+
context[:page]["content"] = result
|
|
39
|
+
lp = File.join(src, "_#{lmeta['layout']}.html")
|
|
40
|
+
break unless File.exist?(lp)
|
|
41
|
+
lmeta, lbody = parse(lp)
|
|
42
|
+
result = erb_render(lbody, context: context, content_for_blocks: content_for_blocks)
|
|
43
|
+
end
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/purity.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "date"
|
|
6
|
+
require "json"
|
|
7
|
+
require "webrick"
|
|
8
|
+
require "erb"
|
|
9
|
+
require "rexml/document"
|
|
10
|
+
|
|
11
|
+
require "purity/version"
|
|
12
|
+
require "purity/context"
|
|
13
|
+
require "purity/data_store"
|
|
14
|
+
require "purity/render_context"
|
|
15
|
+
require "purity/template"
|
|
16
|
+
require "purity/server"
|
|
17
|
+
require "purity/site"
|
|
18
|
+
require "purity/scaffold"
|
|
19
|
+
require "purity/cli"
|
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: purity
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Josh Brody
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: webrick
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: templates, partials, conditionals, loops, layouts, blog, plugins. nothing
|
|
27
|
+
else.
|
|
28
|
+
email: josh@josh.mn
|
|
29
|
+
executables:
|
|
30
|
+
- purity
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- exe/purity
|
|
35
|
+
- lib/purity.rb
|
|
36
|
+
- lib/purity/cli.rb
|
|
37
|
+
- lib/purity/context.rb
|
|
38
|
+
- lib/purity/data_store.rb
|
|
39
|
+
- lib/purity/plugins/feed.rb
|
|
40
|
+
- lib/purity/plugins/sitemap.rb
|
|
41
|
+
- lib/purity/render_context.rb
|
|
42
|
+
- lib/purity/scaffold.rb
|
|
43
|
+
- lib/purity/server.rb
|
|
44
|
+
- lib/purity/site.rb
|
|
45
|
+
- lib/purity/template.rb
|
|
46
|
+
- lib/purity/version.rb
|
|
47
|
+
homepage: https://github.com/joshmn/purity
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata: {}
|
|
51
|
+
rdoc_options: []
|
|
52
|
+
require_paths:
|
|
53
|
+
- lib
|
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '2.7'
|
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
requirements: []
|
|
65
|
+
rubygems_version: 4.0.1
|
|
66
|
+
specification_version: 4
|
|
67
|
+
summary: a simple static site generator
|
|
68
|
+
test_files: []
|