archival 0.0.0 → 0.0.5

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.
data/bin/ruby-rewrite ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'ruby-rewrite' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("../bundle", __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("parser", "ruby-rewrite")
data/bin/setup ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
data/exe/archival ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'archival'
5
+
6
+ VALID_COMMANDS = %w[
7
+ build
8
+ run
9
+ ].freeze
10
+
11
+ command = ARGV[0]
12
+
13
+ unless !command || VALID_COMMANDS.include?(command)
14
+ raise StandardError,
15
+ "Invalid command #{command}"
16
+ end
17
+
18
+ build_dir = Dir.pwd
19
+
20
+ case command
21
+ when 'build'
22
+ Archival::Logger.benchmark('built') do
23
+ config = Archival::Config.new('root' => build_dir)
24
+ builder = Archival::Builder.new(config)
25
+ builder.write_all
26
+ end
27
+ when 'run'
28
+ Archival.listen('root' => build_dir)
29
+ else
30
+ # print help
31
+ puts 'archival [command]'
32
+ puts ''
33
+ puts 'Commands:'
34
+ puts ' build Builds the current directory as an archival website.'
35
+ puts ' run Runs the current directory in development mode, '
36
+ + 'recompiling when files change.'
37
+ end
@@ -0,0 +1,91 @@
1
+ /**
2
+ * When running archival in development mode (archival run), this file is
3
+ * injected into all pages, and is responsible for reloading the page when the
4
+ * source has changed.
5
+ */
6
+
7
+ (function () {
8
+ const remotePort = $PORT;
9
+ const CONNECTING_COLOR = "#bd270d";
10
+ const CONNECTED_COLOR = "#19bd0d";
11
+ const CHECK_INTERVAL = 500;
12
+ const DISCONNECTED_INTERVAL = 1000;
13
+ const connectionDot = document.createElement("div");
14
+ connectionDot.style = `position: absolute; z-index: 9999; bottom: 10px; right: 10px; background-color: ${CONNECTING_COLOR}; width: 15px; height: 15px; border-radius: 50%; opacity: 0.8;`;
15
+ connectionDot.setAttribute("title", "Archival Dev Server: Connecting");
16
+ connectionDot.addEventListener(
17
+ "mouseenter",
18
+ () => (connectionDot.style.opacity = 0.2)
19
+ );
20
+ connectionDot.addEventListener(
21
+ "mouseleave",
22
+ () => (connectionDot.style.opacity = 0.8)
23
+ );
24
+
25
+ let lastContact = -1;
26
+ let isConnecting = false;
27
+ let connection;
28
+
29
+ function connectionLoop() {
30
+ connection.send(`page:${window.location.pathname}`);
31
+ if (Date.now() - lastContact > DISCONNECTED_INTERVAL) {
32
+ setConnected(false);
33
+ connectSocket();
34
+ }
35
+ setTimeout(connectionLoop, CHECK_INTERVAL);
36
+ }
37
+
38
+ function setConnected(connected) {
39
+ connectionDot.style.backgroundColor = connected
40
+ ? CONNECTED_COLOR
41
+ : CONNECTING_COLOR;
42
+ connectionDot.setAttribute(
43
+ "title",
44
+ `Archival Dev Server: ${connected ? "Connected" : "Disconnected"}`
45
+ );
46
+ }
47
+
48
+ window.onload = () => {
49
+ connectSocket(true);
50
+ };
51
+
52
+ function connectSocket(init) {
53
+ if (isConnecting) {
54
+ return;
55
+ }
56
+ isConnecting = true;
57
+ console.log(
58
+ `${init ? "connecting" : "reconnecting"} to archival dev server...`
59
+ );
60
+ document.body.appendChild(connectionDot);
61
+ connection = new WebSocket(`ws://localhost:${remotePort}`);
62
+ connection.onerror = () => {
63
+ isConnecting = false;
64
+ };
65
+
66
+ connection.onopen = () => {
67
+ isConnecting = false;
68
+ connection.send("connected");
69
+ if (init) {
70
+ connectionLoop();
71
+ }
72
+ };
73
+ connection.onmessage = (event) => {
74
+ lastContact = Date.now();
75
+ switch (event.data) {
76
+ case "ready":
77
+ console.log("connected to archival dev server.");
78
+ break;
79
+ case "ok":
80
+ setConnected(true);
81
+ break;
82
+ case "refresh":
83
+ window.location.reload();
84
+ break;
85
+ default:
86
+ console.log(`receieved unexpected message ${event.data}`);
87
+ break;
88
+ }
89
+ };
90
+ }
91
+ })();
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'liquid'
4
+ require 'tomlrb'
5
+ require 'tags/layout'
6
+ require 'redcarpet'
7
+
8
+ Liquid::Template.error_mode = :strict
9
+ Liquid::Template.register_tag('layout', Layout)
10
+
11
+ module Archival
12
+ class Builder
13
+ attr_reader :page_templates
14
+
15
+ def initialize(config, *_args)
16
+ @config = config
17
+ @markdown = Redcarpet::Markdown.new(
18
+ Redcarpet::Render::HTML.new(prettify: true,
19
+ hard_wrap: true), no_intra_emphasis: true,
20
+ fenced_code_blocks: true,
21
+ autolink: true,
22
+ strikethrough: true,
23
+ underline: true
24
+ )
25
+ refresh_config
26
+ end
27
+
28
+ def refresh_config
29
+ @file_system = Liquid::LocalFileSystem.new(
30
+ File.join(@config.root, @config.pages_dir), '%s.liquid'
31
+ )
32
+ @variables = {}
33
+ @object_types = {}
34
+ @page_templates = {}
35
+
36
+ Liquid::Template.file_system = Liquid::LocalFileSystem.new(
37
+ File.join(@config.root, @config.pages_dir), '_%s.liquid'
38
+ )
39
+
40
+ objects_definition_file = File.join(@config.root,
41
+ 'objects.toml')
42
+ if File.file? objects_definition_file
43
+ @object_types = Tomlrb.load_file(objects_definition_file)
44
+ end
45
+
46
+ update_pages
47
+ update_objects
48
+ end
49
+
50
+ def full_rebuild
51
+ Layout.reset_cache
52
+ refresh_config
53
+ end
54
+
55
+ def update_pages
56
+ do_update_pages(File.join(@config.root, @config.pages_dir))
57
+ end
58
+
59
+ def do_update_pages(dir, prefix = nil)
60
+ add_prefix = lambda { |entry|
61
+ prefix ? File.join(prefix, entry) : entry
62
+ }
63
+ Dir.foreach(dir) do |entry|
64
+ if File.directory? entry
65
+ unless [
66
+ '.', '..'
67
+ ].include?(entry)
68
+ update_pages(File.join(dir, entry),
69
+ add_prefix(entry))
70
+ end
71
+ elsif File.file? File.join(dir, entry)
72
+ if entry.end_with?('.liquid') && !(entry.start_with? '_')
73
+ page_name = File.basename(entry,
74
+ '.liquid')
75
+ template_file = add_prefix.call(page_name)
76
+ content = @file_system.read_template_file(template_file)
77
+ content += dev_mode_content if @config.dev_mode
78
+ @page_templates[add_prefix.call(page_name)] =
79
+ Liquid::Template.parse(content)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def update_objects
86
+ do_update_objects(File.join(@config.root,
87
+ @config.objects_dir))
88
+ end
89
+
90
+ def do_update_objects(dir)
91
+ objects = {}
92
+ @object_types.each do |name, definition|
93
+ objects[name] = {}
94
+ obj_dir = File.join(dir, name)
95
+ if File.directory? obj_dir
96
+ Dir.foreach(obj_dir) do |file|
97
+ if file.end_with? '.toml'
98
+ object = Tomlrb.load_file(File.join(
99
+ obj_dir, file
100
+ ))
101
+ object[:name] =
102
+ File.basename(file, '.toml')
103
+ objects[name][object[:name]] = parse_object(object, definition)
104
+ end
105
+ end
106
+ end
107
+ objects[name] = sort_objects(objects[name])
108
+ end
109
+ @variables['objects'] = objects
110
+ end
111
+
112
+ def sort_objects(objects)
113
+ # Since objects are hashes but we'd like them to be iterable based on
114
+ # arbitrary "order" keys, and in ruby hashes enumerate in insert order,
115
+ # we just need to re-insert in the correct order.
116
+ sorted_keys = objects.sort_by do |name, obj|
117
+ obj['order'].to_s || name
118
+ end
119
+ sorted_objects = {}
120
+ sorted_keys.each do |d|
121
+ sorted_objects[d[0]] = d[1]
122
+ end
123
+ sorted_objects
124
+ end
125
+
126
+ def parse_object(object, definition)
127
+ definition.each do |name, type|
128
+ case type
129
+ when 'markdown'
130
+ object[name] = @markdown.render(object[name]) if object[name]
131
+ end
132
+ end
133
+ object
134
+ end
135
+
136
+ def set_var(name, value)
137
+ @variables[name] = value
138
+ end
139
+
140
+ def render(page)
141
+ template = @page_templates[page]
142
+ template.render(@variables)
143
+ end
144
+
145
+ def write_all
146
+ Dir.mkdir(@config.build_dir) unless File.exist? @config.build_dir
147
+ @page_templates.each_key do |template|
148
+ out_dir = File.join(@config.build_dir,
149
+ File.dirname(template))
150
+ Dir.mkdir(out_dir) unless File.exist? out_dir
151
+ out_path = File.join(@config.build_dir,
152
+ "#{template}.html")
153
+ File.open(out_path, 'w+') do |file|
154
+ file.write(render(template))
155
+ end
156
+ end
157
+ return if @config.dev_mode
158
+
159
+ # in production, also copy all assets to the dist folder.
160
+ @config.assets_dirs.each do |asset_dir|
161
+ FileUtils.copy_entry File.join(@config.root, asset_dir),
162
+ File.join(@config.build_dir, asset_dir)
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def dev_mode_content
169
+ "<script src=\"http://localhost:#{@config.helper_port}/js/archival-helper.js\" type=\"application/javascript\"></script>" # rubocop:disable Layout/LineLength
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tomlrb'
4
+
5
+ module Archival
6
+ class Config
7
+ attr_reader :pages_dir, :objects_dir, :assets_dirs, :root, :build_dir,
8
+ :helper_port, :dev_mode
9
+
10
+ def initialize(config = {})
11
+ @root = config['root'] || Dir.pwd
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(
16
+ @root, 'dist'
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
+ {}
28
+ end
29
+ end
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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'listen'
4
+ require 'pathname'
5
+
6
+ module Archival
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
34
+ end
35
+
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)
49
+ # a page was modified, rebuild the pages.
50
+ return :pages if child?(File.join(@config.root, @config.pages_dir),
51
+ file)
52
+ # an object was modified, rebuild the objects.
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
65
+ end
66
+
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
77
+ end
78
+ true
79
+ end
80
+
81
+ def serve_helpers
82
+ @server = HelperServer.new(@config.helper_port, @config.build_dir)
83
+ @server.start
84
+ end
85
+ end
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
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require 'rake/tasklib'
5
+ require 'archival'
6
+
7
+ class RakeTasks
8
+ # Common tasks for archival.
9
+ #
10
+ # To include, just add
11
+ #
12
+ # require 'archival/rake_tasks'
13
+ #
14
+ # to your Rakefile.
15
+ include Rake::DSL if defined? Rake::DSL
16
+
17
+ class << self
18
+ # set when install'd.
19
+ attr_accessor :instance
20
+
21
+ def install_tasks
22
+ new.install
23
+ end
24
+ end
25
+
26
+ def install
27
+ build_dir = Dir.pwd
28
+
29
+ task 'build' do
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
35
+ end
36
+
37
+ task 'run' do
38
+ Archival.listen('root' => build_dir)
39
+ end
40
+
41
+ RakeTasks.instance = self
42
+ end
43
+ end
44
+
45
+ RakeTasks.install_tasks
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archival
4
+ VERSION = '0.0.5'
5
+ end
data/lib/archival.rb CHANGED
@@ -1,3 +1,12 @@
1
+ # frozen_string_literal: true
1
2
 
2
- class Archival
3
- end
3
+ module Archival
4
+ # Main Archival module. See https://archival.dev for docs.
5
+ end
6
+
7
+ require 'archival/version'
8
+ require 'archival/logger'
9
+ require 'archival/config'
10
+ require 'archival/helper_server'
11
+ require 'archival/builder'
12
+ require 'archival/listen'