archival 0.0.2 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,43 +3,74 @@
3
3
  require 'liquid'
4
4
  require 'tomlrb'
5
5
  require 'tags/layout'
6
+ require 'redcarpet'
6
7
 
7
8
  Liquid::Template.error_mode = :strict
8
9
  Liquid::Template.register_tag('layout', Layout)
9
10
 
10
11
  module Archival
12
+ class DuplicateKeyError < StandardError
13
+ end
14
+
11
15
  class Builder
12
16
  attr_reader :page_templates
13
17
 
14
18
  def initialize(config, *_args)
15
- @config = Config.new(config)
19
+ @config = config
20
+ @markdown = Redcarpet::Markdown.new(
21
+ Redcarpet::Render::HTML.new(prettify: true,
22
+ hard_wrap: true), no_intra_emphasis: true,
23
+ fenced_code_blocks: true,
24
+ autolink: true,
25
+ strikethrough: true,
26
+ underline: true
27
+ )
16
28
  refresh_config
17
29
  end
18
30
 
19
31
  def refresh_config
20
32
  @file_system = Liquid::LocalFileSystem.new(
21
- @config.root, '%s.liquid'
33
+ File.join(@config.root, @config.pages_dir), '%s.liquid'
22
34
  )
23
35
  @variables = {}
24
36
  @object_types = {}
25
37
  @page_templates = {}
38
+ @dynamic_pages = Set.new
39
+ @dynamic_templates = {}
26
40
 
27
- Liquid::Template.file_system = @file_system
41
+ Liquid::Template.file_system = Liquid::LocalFileSystem.new(
42
+ File.join(@config.root, @config.pages_dir), '_%s.liquid'
43
+ )
28
44
 
29
45
  objects_definition_file = File.join(@config.root,
30
46
  'objects.toml')
31
47
  if File.file? objects_definition_file
32
- @object_types = read_toml(objects_definition_file)
48
+ @object_types = Tomlrb.load_file(objects_definition_file)
33
49
  end
34
50
 
35
- update_pages
36
51
  update_objects
52
+ update_pages
53
+ end
54
+
55
+ def full_rebuild
56
+ Layout.reset_cache
57
+ refresh_config
37
58
  end
38
59
 
39
60
  def update_pages
40
61
  do_update_pages(File.join(@config.root, @config.pages_dir))
41
62
  end
42
63
 
64
+ def dynamic?(file)
65
+ @dynamic_pages.include? File.basename(file, '.liquid')
66
+ end
67
+
68
+ def template_for_page(template_file)
69
+ content = @file_system.read_template_file(template_file)
70
+ content += dev_mode_content if @config.dev_mode
71
+ Liquid::Template.parse(content)
72
+ end
73
+
43
74
  def do_update_pages(dir, prefix = nil)
44
75
  add_prefix = lambda { |entry|
45
76
  prefix ? File.join(prefix, entry) : entry
@@ -53,16 +84,13 @@ module Archival
53
84
  add_prefix(entry))
54
85
  end
55
86
  elsif File.file? File.join(dir, entry)
56
- if entry.end_with?('.liquid') && !(entry.start_with? '_')
57
- page_name = File.basename(entry,
58
- '.liquid')
59
- template_file = File.join(
60
- @config.pages_dir,
61
- add_prefix.call(page_name)
62
- )
63
- content = @file_system.read_template_file(template_file)
64
- @page_templates[add_prefix.call(page_name)] =
65
- Liquid::Template.parse(content)
87
+ page_name = File.basename(entry, '.liquid')
88
+ template_file = add_prefix.call(page_name)
89
+ if dynamic? entry
90
+ @dynamic_templates[template_file] = template_for_page(template_file)
91
+ elsif entry.end_with?('.liquid') && !(entry.start_with? '_')
92
+ @page_templates[template_file] =
93
+ template_for_page(template_file)
66
94
  end
67
95
  end
68
96
  end
@@ -75,30 +103,53 @@ module Archival
75
103
 
76
104
  def do_update_objects(dir)
77
105
  objects = {}
78
- @object_types.each do |name, _definition|
79
- objects[name] = []
106
+ @object_types.each do |name, definition|
107
+ objects[name] = {}
80
108
  obj_dir = File.join(dir, name)
81
109
  if File.directory? obj_dir
82
110
  Dir.foreach(obj_dir) do |file|
83
111
  if file.end_with? '.toml'
84
- object = read_toml(File.join(
85
- obj_dir, file
86
- ))
112
+ object = Tomlrb.load_file(File.join(
113
+ obj_dir, file
114
+ ))
87
115
  object[:name] =
88
116
  File.basename(file, '.toml')
89
- objects[name].push object
117
+ objects[name][object[:name]] = parse_object(object, definition)
118
+ if definition.key? 'template'
119
+ @dynamic_pages << definition['template']
120
+ end
90
121
  end
91
122
  end
92
123
  end
93
- objects[name] = objects[name].sort do |a, b|
94
- (a['order'] || a[:name]).to_s <=> (b['order'] || b[:name]).to_s
95
- end
124
+ objects[name] = sort_objects(objects[name])
96
125
  end
97
126
  @variables['objects'] = objects
98
127
  end
99
128
 
100
- def read_toml(file_path)
101
- Tomlrb.load_file(file_path)
129
+ def sort_objects(objects)
130
+ # Sort by either 'order' key or object name, depending on what is
131
+ # available.
132
+ sorted_by_keys = objects.sort_by do |name, obj|
133
+ obj.key?('order') ? obj['order'].to_s : name
134
+ end
135
+ sorted_objects = Archival::TemplateArray.new
136
+ sorted_by_keys.each do |d|
137
+ raise DuplicateKeyError if sorted_objects.key?(d[0])
138
+
139
+ sorted_objects.push(d[1])
140
+ sorted_objects[d[0]] = d[1]
141
+ end
142
+ sorted_objects
143
+ end
144
+
145
+ def parse_object(object, definition)
146
+ definition.each do |name, type|
147
+ case type
148
+ when 'markdown'
149
+ object[name] = @markdown.render(object[name]) if object[name]
150
+ end
151
+ end
152
+ object
102
153
  end
103
154
 
104
155
  def set_var(name, value)
@@ -110,18 +161,48 @@ module Archival
110
161
  template.render(@variables)
111
162
  end
112
163
 
164
+ def render_dynamic(page, obj)
165
+ template = @dynamic_templates[page]
166
+ template.render(@variables.merge({ page => obj }))
167
+ end
168
+
113
169
  def write_all
114
170
  Dir.mkdir(@config.build_dir) unless File.exist? @config.build_dir
115
171
  @page_templates.each_key do |template|
116
172
  out_dir = File.join(@config.build_dir,
117
173
  File.dirname(template))
118
174
  Dir.mkdir(out_dir) unless File.exist? out_dir
119
- out_path = File.join(@config.build_dir,
175
+ out_path = File.join(out_dir,
120
176
  "#{template}.html")
121
177
  File.open(out_path, 'w+') do |file|
122
178
  file.write(render(template))
123
179
  end
124
180
  end
181
+ @dynamic_pages.each do |page|
182
+ out_dir = File.join(@config.build_dir, page)
183
+ Dir.mkdir(out_dir) unless File.exist? out_dir
184
+ objects = @variables['objects'][page]
185
+ objects.each do |obj|
186
+ out_path = File.join(out_dir, "#{obj[:name]}.html")
187
+ obj['path'] = out_path
188
+ File.open(out_path, 'w+') do |file|
189
+ file.write(render_dynamic(page, obj))
190
+ end
191
+ end
192
+ end
193
+ return if @config.dev_mode
194
+
195
+ # in production, also copy all assets to the dist folder.
196
+ @config.assets_dirs.each do |asset_dir|
197
+ FileUtils.copy_entry File.join(@config.root, asset_dir),
198
+ File.join(@config.build_dir, asset_dir)
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ def dev_mode_content
205
+ "<script src=\"http://localhost:#{@config.helper_port}/js/archival-helper.js\" type=\"application/javascript\"></script>" # rubocop:disable Layout/LineLength
125
206
  end
126
207
  end
127
208
  end
@@ -1,16 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tomlrb'
4
+
3
5
  module Archival
4
6
  class Config
5
- attr_reader :pages_dir, :objects_dir, :root, :build_dir
7
+ attr_reader :pages_dir, :objects_dir, :assets_dirs, :root, :build_dir,
8
+ :helper_port, :dev_mode
6
9
 
7
- def initialize(config)
8
- @pages_dir = config['pages'] || 'pages'
9
- @objects_dir = config['objects'] || 'objects'
10
+ def initialize(config = {})
10
11
  @root = config['root'] || Dir.pwd
11
- @build_dir = config['build_dir'] || File.join(
12
+ manifest = load_manifest
13
+ @pages_dir = config['pages'] || manifest['pages'] || 'pages'
14
+ @objects_dir = config['objects'] || manifest['objects'] || 'objects'
15
+ @build_dir = config['build_dir'] || manifest['build_dir'] || File.join(
12
16
  @root, 'dist'
13
17
  )
18
+ @helper_port = config['helper_port'] || manifest['helper_port'] || 2701
19
+ @assets_dirs = config['assets_dirs'] || manifest['assets'] || []
20
+ @dev_mode = config[:dev_mode] || false
21
+ end
22
+
23
+ def load_manifest
24
+ manifest_file = File.join(@root, 'manifest.toml')
25
+ return Tomlrb.load_file(manifest_file) if File.file? manifest_file
26
+
27
+ {}
14
28
  end
15
29
  end
16
30
  end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'open-uri'
5
+
6
+ module Archival
7
+ class HelperServer
8
+ attr_reader :page
9
+
10
+ def initialize(port, build_dir)
11
+ @port = port
12
+ @build_dir = build_dir
13
+ @helper_dir = File.expand_path(File.join(File.dirname(__FILE__),
14
+ '../../helper'))
15
+ end
16
+
17
+ def start
18
+ server = TCPServer.new @port
19
+ loop do
20
+ Thread.start(server.accept) do |client|
21
+ req = ''
22
+ method = nil
23
+ path = nil
24
+ while (line = client.gets) && (line != "\r\n")
25
+ unless method
26
+ req_info = line.split
27
+ method = req_info[0]
28
+ path = req_info[1]
29
+ end
30
+ req += line
31
+ end
32
+ client.close unless req
33
+ handle_request(client, req, method, path)
34
+ end
35
+ end
36
+ end
37
+
38
+ def refresh_client
39
+ ws_sendmessage('refresh')
40
+ end
41
+
42
+ private
43
+
44
+ MAGIC_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
45
+
46
+ def handle_request(client, req, method, path)
47
+ if method == 'GET' && path.start_with?('/js/')
48
+ # For static paths, just serve the files they refer to.
49
+ http_response(client, type: 'application/javascript') do
50
+ serve_static(client, path)
51
+ end
52
+ client.close
53
+ elsif (matches = req.match(/^Sec-WebSocket-Key: (\S+)/))
54
+ websocket_key = matches[1]
55
+ # puts "Websocket handshake detected with key: #{websocket_key}"
56
+ connect_socket(client, websocket_key)
57
+ else
58
+ client.close
59
+ end
60
+ end
61
+
62
+ def connect_socket(client, websocket_key)
63
+ @socket = client
64
+ response_key = Digest::SHA1.base64digest([websocket_key,
65
+ MAGIC_GUID].join)
66
+ # puts "Responding to handshake with key: #{response_key}"
67
+
68
+ @socket.write "HTTP/1.1 101 Switching Protocols\r\n"
69
+ @socket.write "Upgrade: websocket\r\n"
70
+ @socket.write "Connection: Upgrade\r\n"
71
+ @socket.write "Sec-WebSocket-Accept: #{response_key}\r\n"
72
+ @socket.write "\r\n"
73
+
74
+ # puts 'Handshake completed.'
75
+ ws_loop
76
+ end
77
+
78
+ def ws_loop
79
+ loop do
80
+ msg = ws_getmessage
81
+ next unless msg
82
+
83
+ if msg == 'connected'
84
+ ws_sendmessage('ready')
85
+ elsif msg.start_with?('page:')
86
+ page_path = Pathname.new(msg.sub(/^page:/, ''))
87
+ @page = page_path.relative_path_from(@build_dir)
88
+ ws_sendmessage('ok')
89
+ end
90
+ end
91
+ end
92
+
93
+ def validate_ws_message
94
+ first_byte = @socket.getbyte
95
+ return unless first_byte
96
+
97
+ fin = first_byte & 0b10000000
98
+ opcode = first_byte & 0b00001111
99
+
100
+ # Our server only supports single-frame, text messages.
101
+ # Raise an exception if the client tries to send anything else.
102
+ raise "We don't support continuations" unless fin
103
+ raise 'We only support opcode 1' unless opcode == 1
104
+
105
+ second_byte = @socket.getbyte
106
+ is_masked = second_byte & 0b10000000
107
+ payload_size = second_byte & 0b01111111
108
+
109
+ raise 'frame masked incorrectly' unless is_masked
110
+ raise 'payload must be < 126 bytes in length' unless payload_size < 126
111
+
112
+ payload_size
113
+ end
114
+
115
+ def ws_getmessage
116
+ payload_size = validate_ws_message
117
+ return unless payload_size
118
+
119
+ # warn "Payload size: #{payload_size} bytes"
120
+
121
+ mask = 4.times.map { @socket.getbyte }
122
+ # warn "Got mask: #{mask.inspect}"
123
+
124
+ data = payload_size.times.map { @socket.getbyte }
125
+ # warn "Got masked data: #{data.inspect}"
126
+
127
+ unmasked_data = data.each_with_index.map do |byte, i|
128
+ byte ^ mask[i % 4]
129
+ end
130
+ # warn "Unmasked the data: #{unmasked_data.inspect}"
131
+
132
+ unmasked_data.pack('C*').force_encoding('utf-8')
133
+ end
134
+
135
+ def ws_sendmessage(message)
136
+ return unless @socket
137
+
138
+ output = [0b10000001, message.size, message]
139
+ @socket.write output.pack("CCA#{message.size}")
140
+ end
141
+
142
+ def serve_static(client, path)
143
+ buffer = File.open(File.join(@helper_dir, path)).read
144
+ buffer.sub! '$PORT', @port.to_s
145
+ client.print buffer
146
+ end
147
+
148
+ def http_response(client, config)
149
+ status = config[:status] ||= 200
150
+ type = config[:type] ||= 'text/html'
151
+ client.print "HTTP/1.1 #{status}\r\n"
152
+ client.print "Content-Type: #{type}\r\n"
153
+ client.print "\r\n"
154
+ yield
155
+ end
156
+ end
157
+ end
@@ -4,40 +4,83 @@ require 'listen'
4
4
  require 'pathname'
5
5
 
6
6
  module Archival
7
- def self.child?(parent, child)
8
- path = Pathname.new(child)
9
- return true if path.fnmatch?(File.join(parent, '**'))
10
-
11
- false
7
+ def listen(config = {})
8
+ @config = Config.new(config.merge(dev_mode: true))
9
+ builder = Builder.new(@config)
10
+ Logger.benchmark('built') do
11
+ builder.write_all
12
+ end
13
+ ignore = %r{/dist/}
14
+ listener = Listen.to(@config.root,
15
+ ignore: ignore) do |modified, added, removed|
16
+ updated_pages = []
17
+ updated_objects = []
18
+ updated_assets = []
19
+ (modified + added + removed).each do |file|
20
+ case change_type(file)
21
+ when :pages
22
+ updated_pages << file
23
+ when :objects
24
+ updated_objects << file
25
+ when :assets
26
+ updated_assets << file
27
+ end
28
+ end
29
+ @server.refresh_client if rebuild?(builder, updated_objects,
30
+ updated_pages, updated_assets)
31
+ end
32
+ listener.start
33
+ serve_helpers
12
34
  end
13
35
 
14
- def self.process_change?(file, builder)
15
- if child?(File.join(@config.root, @config.pages_dir), file)
36
+ module_function :listen
37
+
38
+ class << self
39
+ private
40
+
41
+ def child?(parent, child)
42
+ path = Pathname.new(child)
43
+ return true if path.fnmatch?(File.join(parent, '**'))
44
+
45
+ false
46
+ end
47
+
48
+ def change_type(file)
16
49
  # a page was modified, rebuild the pages.
17
- builder.update_pages
18
- return true
19
- elsif child?(File.join(@config.root, @config.objects_dir), file)
50
+ return :pages if child?(File.join(@config.root, @config.pages_dir),
51
+ file)
20
52
  # an object was modified, rebuild the objects.
21
- builder.update_objects
22
- return true
53
+ return :objects if child?(File.join(@config.root, @config.objects_dir),
54
+ file)
55
+
56
+ # layout and other assets. For now, this is everything.
57
+ @config.assets_dirs.each do |dir|
58
+ return :assets if child?(File.join(@config.root, dir), file)
59
+ end
60
+ return :assets if child?(File.join(@config.root, 'layout'), file)
61
+ return :assets if ['manifest.toml',
62
+ 'objects.toml'].include? File.basename(file)
63
+
64
+ :none
23
65
  end
24
- false
25
- end
26
66
 
27
- def listen(config)
28
- @config = Config.new(config)
29
- builder = Builder.new(config)
30
- builder.write_all
31
- listener = Listen.to(@config.root) do |modified, added, removed|
32
- needs_update = false
33
- (modified + added + removed).each do |file|
34
- needs_update = true if process_change?(file, builder)
67
+ def rebuild?(builder, updated_objects, updated_pages, updated_assets)
68
+ if updated_pages.empty? && updated_objects.empty? && updated_assets.empty?
69
+ return false
70
+ end
71
+
72
+ Logger.benchmark('rebuilt') do
73
+ builder.update_objects if updated_objects.length
74
+ builder.update_pages if updated_pages.length
75
+ builder.full_rebuild if updated_assets.length
76
+ builder.write_all
35
77
  end
36
- builder.write_all if needs_update
78
+ true
37
79
  end
38
- listener.start
39
- listener
40
- end
41
80
 
42
- module_function :listen
81
+ def serve_helpers
82
+ @server = HelperServer.new(@config.helper_port, @config.build_dir)
83
+ @server.start
84
+ end
85
+ end
43
86
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'benchmark'
4
+
5
+ module Archival
6
+ class Logger
7
+ def self.benchmark(message, &block)
8
+ Benchmark.bm do |bm|
9
+ bm.report(message, &block)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -27,19 +27,15 @@ class RakeTasks
27
27
  build_dir = Dir.pwd
28
28
 
29
29
  task 'build' do
30
- builder = Archival::Builder.new('root' => build_dir)
31
- builder.write_all
30
+ Archival::Logger.benchmark('built') do
31
+ config = Archival::Config.new('root' => build_dir)
32
+ builder = Archival::Builder.new(config)
33
+ builder.write_all
34
+ end
32
35
  end
33
36
 
34
37
  task 'run' do
35
- Archival.listen(build_dir)
36
- begin
37
- sleep
38
- rescue Interrupt
39
- # Don't print a stack when a user interrupts, as this is the right way
40
- # to stop the development server.
41
- puts ''
42
- end
38
+ Archival.listen('root' => build_dir)
43
39
  end
44
40
 
45
41
  RakeTasks.instance = self
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archival
4
+ class TemplateArray < Array
5
+ alias subscript_access []
6
+ alias subscript_write []=
7
+
8
+ def initialize(*args)
9
+ super(*args)
10
+ @data = {}
11
+ end
12
+
13
+ def [](*args)
14
+ key = args[0]
15
+ return @data[key] if key.is_a? String
16
+ return @data[key] if key.is_a? Symbol
17
+
18
+ subscript_access(*args)
19
+ end
20
+
21
+ def []=(*args)
22
+ key = args[0]
23
+ if key.is_a?(String) || key.is_a?(Symbol)
24
+ @data[key] = args[1]
25
+ return
26
+ end
27
+ subscript_write(*args)
28
+ end
29
+
30
+ def key?(key)
31
+ @data.key?(key)
32
+ end
33
+ end
34
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Archival
4
- VERSION = '0.0.2'
4
+ VERSION = '0.0.7'
5
5
  end
data/lib/archival.rb CHANGED
@@ -5,6 +5,9 @@ module Archival
5
5
  end
6
6
 
7
7
  require 'archival/version'
8
+ require 'archival/template_array'
9
+ require 'archival/logger'
8
10
  require 'archival/config'
11
+ require 'archival/helper_server'
9
12
  require 'archival/builder'
10
13
  require 'archival/listen'
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archival",
3
- "version": "0.0.2",
3
+ "version": "0.0.7",
4
4
  "description": "An incredibly simple CMS for durable websites",
5
5
  "bin": "build.rb",
6
6
  "directories": {
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: archival
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jesse Ditson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-11-05 00:00:00.000000000 Z
11
+ date: 2021-11-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: liquid
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 3.7.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: redcarpet
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 3.5.1
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 3.5.1
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: tomlrb
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -76,17 +90,22 @@ files:
76
90
  - bin/ldiff
77
91
  - bin/listen
78
92
  - bin/rake
93
+ - bin/redcarpet
79
94
  - bin/rspec
80
95
  - bin/rubocop
81
96
  - bin/ruby-parse
82
97
  - bin/ruby-rewrite
83
98
  - bin/setup
84
99
  - exe/archival
100
+ - helper/js/archival-helper.js
85
101
  - lib/archival.rb
86
102
  - lib/archival/builder.rb
87
103
  - lib/archival/config.rb
104
+ - lib/archival/helper_server.rb
88
105
  - lib/archival/listen.rb
106
+ - lib/archival/logger.rb
89
107
  - lib/archival/rake_tasks.rb
108
+ - lib/archival/template_array.rb
90
109
  - lib/archival/version.rb
91
110
  - lib/tags/layout.rb
92
111
  - package.json