fronde 0.4.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ext/r18n.rb +20 -0
  3. data/lib/ext/time.rb +6 -16
  4. data/lib/ext/time_no_time.rb +23 -0
  5. data/lib/fronde/cli/commands.rb +16 -12
  6. data/lib/fronde/cli/data/gitignore +0 -1
  7. data/lib/fronde/cli/opt_parse.rb +4 -7
  8. data/lib/fronde/cli/throbber.rb +24 -13
  9. data/lib/fronde/cli.rb +3 -2
  10. data/lib/fronde/config/data/org-config.el +3 -2
  11. data/lib/fronde/config/data/ox-fronde.el +72 -35
  12. data/lib/fronde/config/data/themes/umaneti/css/htmlize.css +364 -0
  13. data/lib/fronde/config/data/themes/umaneti/css/style.css +250 -0
  14. data/lib/fronde/config/data/themes/umaneti/img/bottom.png +0 -0
  15. data/lib/fronde/config/data/themes/umaneti/img/content.png +0 -0
  16. data/lib/fronde/config/data/themes/umaneti/img/tic.png +0 -0
  17. data/lib/fronde/config/data/themes/umaneti/img/top.png +0 -0
  18. data/lib/fronde/config/helpers.rb +0 -18
  19. data/lib/fronde/config/lisp.rb +13 -3
  20. data/lib/fronde/config.rb +40 -26
  21. data/lib/fronde/emacs.rb +1 -1
  22. data/lib/fronde/index/data/all_tags.org +6 -1
  23. data/lib/fronde/index/data/template.org +8 -4
  24. data/lib/fronde/index/org_generator.rb +2 -0
  25. data/lib/fronde/index.rb +12 -15
  26. data/lib/fronde/org/file.rb +39 -27
  27. data/lib/fronde/org/file_extracter.rb +15 -12
  28. data/lib/fronde/org.rb +11 -9
  29. data/lib/fronde/slug.rb +39 -12
  30. data/lib/fronde/source/gemini.rb +0 -5
  31. data/lib/fronde/source/html.rb +5 -5
  32. data/lib/fronde/source.rb +13 -8
  33. data/lib/fronde/sync/neocities.rb +220 -0
  34. data/lib/fronde/sync/rsync.rb +46 -0
  35. data/lib/fronde/sync.rb +32 -0
  36. data/lib/fronde/templater.rb +18 -11
  37. data/lib/fronde/version.rb +1 -1
  38. data/lib/tasks/org.rake +12 -17
  39. data/lib/tasks/site.rake +10 -13
  40. data/lib/tasks/sync.rake +13 -36
  41. data/lib/tasks/tags.rake +2 -2
  42. data/locales/en.yml +1 -0
  43. data/locales/fr.yml +1 -0
  44. metadata +49 -10
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'yaml'
6
+ require 'net/http'
7
+ require 'fileutils'
8
+ require 'digest/sha1'
9
+
10
+ module Fronde
11
+ module Sync
12
+ # Everything needed to connect to neocities
13
+ class Neocities
14
+ PROTECTED_FILES = %w[index.html neocities.png not_found.html].freeze
15
+
16
+ def initialize(connection_spec, public_folder, verbose: false, &block)
17
+ @verbose = verbose
18
+ @endpoint = @website_name = @authorization = nil
19
+ extract_connection_details connection_spec
20
+ @public_folder = public_folder
21
+ @connection = init_connection
22
+ return unless block
23
+
24
+ yield self
25
+ finish
26
+ end
27
+
28
+ def remote_list
29
+ remote = call build_request('/list')
30
+ JSON.parse(remote.body)['files'].map do |stat|
31
+ stat['updated_at'] = Time.parse(stat['updated_at'])
32
+ stat
33
+ end
34
+ end
35
+
36
+ def local_list
37
+ Dir.chdir(@public_folder) do
38
+ Dir['**/*'].map { |file| neocities_stat(file) }
39
+ end
40
+ end
41
+
42
+ def info
43
+ info = call build_request('/info')
44
+ JSON.parse(info.body)['info']
45
+ end
46
+
47
+ def finish
48
+ @connection.finish if @connection.started?
49
+ end
50
+
51
+ def pull(test: false)
52
+ file_list = remote_list
53
+ finish
54
+ orphans = select_orphans(file_list, local_list) do |path|
55
+ warn "deleting #{path}" if @verbose
56
+
57
+ "#{@public_folder}/#{path}"
58
+ end
59
+ File.unlink(*orphans) unless test
60
+ download_all file_list, test: test
61
+ nil # Mute this method
62
+ end
63
+
64
+ def push(test: false)
65
+ file_list = local_list
66
+ remove_remote_orphans file_list, test: test
67
+ upload_all file_list, test: test
68
+ finish
69
+ end
70
+
71
+ private
72
+
73
+ def neocities_stat(file)
74
+ stat = File.stat(file)
75
+ data = {
76
+ 'path' => file,
77
+ 'is_directory' => stat.directory?,
78
+ 'updated_at' => stat.mtime.round.utc
79
+ }
80
+ return data if data['is_directory']
81
+
82
+ data['size'] = stat.size
83
+ data['sha1_hash'] = Digest::SHA1.hexdigest File.read(file)
84
+ data
85
+ end
86
+
87
+ def select_orphans(to_apply, current_list, &block)
88
+ paths_to_apply = to_apply.map { _1['path'] }
89
+ current_paths = current_list.map { _1['path'] }
90
+ (current_paths - paths_to_apply).filter_map(&block)
91
+ end
92
+
93
+ def remove_remote_orphans(file_list, test: false)
94
+ request = build_request '/delete', :post
95
+ orphan_paths = select_orphans(file_list, remote_list) do |path|
96
+ # Never remove the following files. If needed you can still
97
+ # overwrite them. And in any case, neocities will not allow
98
+ # the index.html file to be removed.
99
+ next if PROTECTED_FILES.include? path
100
+
101
+ warn "deleting #{path}" if @verbose
102
+ path
103
+ end
104
+ request.form_data = { 'filenames[]' => orphan_paths }
105
+ return if test
106
+
107
+ call request
108
+ end
109
+
110
+ def download_all(file_list, test: false)
111
+ publish_domain = "#{@website_name}.#{@endpoint.host}"
112
+ Dir.chdir(@public_folder) do
113
+ Net::HTTP.start(publish_domain, use_ssl: true) do |http|
114
+ file_list.each do |file_data|
115
+ path = file_data['path']
116
+ file_data['uri'] = "https://#{publish_domain}/#{path}"
117
+ download_file http, file_data, test: test
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ def download_file(http, file_data, test: false)
124
+ path = file_data['path']
125
+ if file_data['is_directory']
126
+ warn "#{path}/" if @verbose
127
+ FileUtils.mkdir_p path unless test
128
+ return
129
+ end
130
+
131
+ warn path if @verbose
132
+
133
+ content = fetch_file_content(
134
+ http, file_data['uri'], file_data['sha1_hash']
135
+ )
136
+ return unless content && !test
137
+
138
+ save_file path, content, file_data['updated_at']
139
+ end
140
+
141
+ def fetch_file_content(http, uri, sha1sum)
142
+ # Neocities redirect HTML file to location without extension and
143
+ # redirect index.html file to /
144
+ uri = uri.delete_suffix('index.html').delete_suffix('.html')
145
+ content = http.get(uri).body
146
+ check = Digest::SHA1.hexdigest content
147
+ return content if check == sha1sum
148
+
149
+ warn "SHA1 hash differ for #{uri}"
150
+ end
151
+
152
+ def save_file(path, content, updated_at)
153
+ File.write path, content
154
+ FileUtils.touch path, mtime: updated_at
155
+ path
156
+ end
157
+
158
+ def prepare_files_to_upload(file_list)
159
+ Dir.chdir(@public_folder) do
160
+ file_list.filter_map do |file_data|
161
+ # No need to push intermediary directories, they are created
162
+ # on the fly
163
+ next if file_data['is_directory']
164
+
165
+ path = file_data['path']
166
+ warn path if @verbose
167
+ [path, File.new(path)]
168
+ end
169
+ end
170
+ end
171
+
172
+ def upload_all(file_list, test: false)
173
+ form_data = prepare_files_to_upload file_list
174
+ return if test
175
+
176
+ request = build_request '/upload', :post
177
+ request.set_form form_data, 'multipart/form-data'
178
+ call request
179
+ end
180
+
181
+ def extract_connection_details(connection_spec)
182
+ # Do not put your password into the fronde config. The password
183
+ # is expectfed to be found in a specific config file (not to be
184
+ # shared).
185
+ @website_name, endpoint = connection_spec.split('@', 2)
186
+ endpoint ||= 'neocities.org'
187
+ @endpoint = URI("https://#{endpoint}/api")
188
+ # Will raise Errno::ENOENT if file does not exist
189
+ credentials = YAML.load_file('.credentials')
190
+ # Will raise KeyError if not set
191
+ password = credentials.fetch("#{@website_name}_neocities_pass")
192
+ authorization = [[@website_name, password].join(':')].pack('m0')
193
+ @authorization = "Basic #{authorization}"
194
+ end
195
+
196
+ def build_request(path, method = :get)
197
+ uri = @endpoint.dup
198
+ uri.path += path
199
+ klass = Kernel.const_get "Net::HTTP::#{method.to_s.capitalize}"
200
+ klass.new uri
201
+ end
202
+
203
+ def call(request)
204
+ request['Authorization'] = @authorization
205
+ @connection.start unless @connection.started?
206
+ outcome = @connection.request request
207
+ return outcome if outcome.is_a? Net::HTTPSuccess
208
+
209
+ raise JSON.parse(outcome.body).inspect
210
+ end
211
+
212
+ def init_connection
213
+ http = Net::HTTP.new @endpoint.host, 443
214
+ http.use_ssl = true
215
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
216
+ http
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config'
4
+
5
+ module Fronde
6
+ module Sync
7
+ # Everything needed to push or pull data with rsync
8
+ class Rsync
9
+ def initialize(remote_path, local_path, verbose: false)
10
+ @verbose = verbose
11
+ @remote_path = remote_path
12
+ @local_path = "#{local_path}/"
13
+ end
14
+
15
+ def pull(test: false)
16
+ run command(test: test) + [@remote_path, @local_path]
17
+ end
18
+
19
+ def push(test: false)
20
+ run command(test: test) + [@local_path, @remote_path]
21
+ end
22
+
23
+ private
24
+
25
+ def run(cmd)
26
+ warn cmd.join(' ') if @verbose
27
+ # Be precise about Kernel to allow mock in rspec
28
+ Kernel.system(*cmd)
29
+ end
30
+
31
+ def command(test: false)
32
+ rsync_command = Fronde::CONFIG.get('rsync')
33
+ return rsync_command unless rsync_command.nil?
34
+
35
+ optstring = []
36
+ optstring << 'n' if test
37
+ if @verbose
38
+ optstring << 'v'
39
+ else
40
+ optstring << 'q'
41
+ end
42
+ ['rsync', "-#{optstring.join}rlt", '--delete']
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'config'
4
+ require_relative 'sync/rsync'
5
+ require_relative 'sync/neocities'
6
+
7
+ module Fronde
8
+ # Entrypoint for synchronization with remote public server
9
+ module Sync
10
+ class Error < ::StandardError; end
11
+
12
+ ALLOWED_SYNCER = %w[rsync neocities].freeze
13
+
14
+ def self.extract_method_and_remote(type)
15
+ remote_path = Fronde::CONFIG.get("#{type}_remote")
16
+ raise Error, "No #{type} remote path set" if remote_path.nil?
17
+
18
+ method, remote = remote_path.split(':', 2)
19
+ return [method, remote] if ALLOWED_SYNCER.include?(method)
20
+
21
+ ['rsync', remote_path]
22
+ end
23
+
24
+ def self.pull_or_push(direction, type, test: false, verbose: false)
25
+ method, remote_path = extract_method_and_remote type
26
+ public_folder = Fronde::CONFIG.get("#{type}_public_folder")
27
+ klass = Kernel.const_get("::Fronde::Sync::#{method.capitalize}")
28
+ syncer = klass.new(remote_path, public_folder, verbose: verbose)
29
+ Thread.new { syncer.send(direction, test: test) }
30
+ end
31
+ end
32
+ end
@@ -103,6 +103,18 @@ module Fronde
103
103
  end
104
104
  end
105
105
 
106
+ def warn_no_element(source)
107
+ pub_folder = Fronde::CONFIG.get('html_public_folder').sub(
108
+ /^#{Dir.pwd}/, '.'
109
+ )
110
+ warn(
111
+ R18n.t.fronde.error.templater.no_element_found(
112
+ source: source, file: "#{pub_folder}#{@org_file.pub_file}"
113
+ )
114
+ )
115
+ '' # Return empty string
116
+ end
117
+
106
118
  def extract_content
107
119
  # We must either have a source or a content key
108
120
  source = @config.delete 'source'
@@ -111,19 +123,14 @@ module Fronde
111
123
  end
112
124
 
113
125
  node = @dom.css(source)
114
- # Do nothing if we don’t have a reliable content to work with
115
- unless node.any?
116
- warn(
117
- R18n.t.fronde.error.templater.no_element_found(
118
- source: source,
119
- file: Fronde::CONFIG.get('html_public_folder') + @org_file.pub_file
120
- )
121
- )
122
- return ''
126
+ if node.any?
127
+ # Put it back in config
128
+ @config['source'] = node
129
+ return node.to_s
123
130
  end
124
131
 
125
- @config['source'] = node
126
- node.to_s
132
+ # Do nothing if we don’t have a reliable content to work with
133
+ warn_no_element source
127
134
  end
128
135
 
129
136
  def check_path(file_name)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Fronde
4
4
  # @return [String] the version number of the current Fronde release.
5
- VERSION = '0.4.0'
5
+ VERSION = '0.5.0'
6
6
  end
data/lib/tasks/org.rake CHANGED
@@ -8,8 +8,7 @@ require_relative '../fronde/cli/throbber'
8
8
  require 'rake/clean'
9
9
 
10
10
  CLOBBER.push(
11
- 'var/lib/org-config.el', '.dir-locals.el',
12
- 'lib/htmlize.el'
11
+ 'var/lib/org-config.el', 'lib/htmlize.el'
13
12
  )
14
13
 
15
14
  namespace :org do
@@ -26,19 +25,16 @@ namespace :org do
26
25
  else
27
26
  Fronde::CLI::Throbber.run(download, R18n.t.fronde.tasks.org.downloading)
28
27
  end
29
- rescue RuntimeError
28
+ rescue RuntimeError, Interrupt
30
29
  warn R18n.t.fronde.tasks.org.no_download if verbose
31
30
  end
32
31
 
33
32
  desc 'Compile Org'
34
33
  multitask compile: ['var/tmp/org.tar.gz', 'lib'] do |task|
35
- begin
36
- # No need to force fetch last version as it is only interesting as
37
- # part of the upgrade task
38
- org_version = Fronde::Org.last_version
39
- rescue RuntimeError
40
- next
41
- end
34
+ # No need to force fetch last version as it is only interesting as
35
+ # part of the upgrade task
36
+ org_version = Fronde::Org.last_version
37
+
42
38
  org_dir = "lib/org-#{org_version}"
43
39
  next if Dir.exist?("#{org_dir}/lisp")
44
40
 
@@ -54,6 +50,8 @@ namespace :org do
54
50
  else
55
51
  Fronde::CLI::Throbber.run(build, R18n.t.fronde.tasks.org.installing)
56
52
  end
53
+ rescue RuntimeError, Interrupt
54
+ next
57
55
  end
58
56
 
59
57
  directory 'lib'
@@ -76,10 +74,6 @@ namespace :org do
76
74
  Fronde::CONFIG.write_org_lisp_config
77
75
  end
78
76
 
79
- file '.dir-locals.el' => 'var/lib/org-config.el' do
80
- Fronde::Config::Helpers.write_dir_locals
81
- end
82
-
83
77
  file '.gitignore' do
84
78
  next if File.exist? '.gitignore'
85
79
 
@@ -91,9 +85,10 @@ namespace :org do
91
85
 
92
86
  desc 'Install Org'
93
87
  multitask install: ['org:compile', '.gitignore'] do
94
- # I need a fully installed org mode to correctly generate the lisp
95
- # config
96
- Rake::Task['.dir-locals.el'].invoke
88
+ # lib/htmlize.el and lib/ox-gmi.el cannot be generated in parallel
89
+ # of org:compilation, as it will leads to a weird SSL error. Thus
90
+ # finishing file generation "manually" here.
91
+ Rake::Task['var/lib/org-config.el'].invoke
97
92
  sources = Fronde::CONFIG.sources
98
93
  sources.each { mkdir_p _1['path'] }
99
94
 
data/lib/tasks/site.rake CHANGED
@@ -10,7 +10,7 @@ namespace :site do
10
10
  task :build, [:force?] => ['var/lib/org-config.el'] do |_, args|
11
11
  args.with_defaults(force?: false)
12
12
  build_index = Thread.new do
13
- all_index = Fronde::Index.all_html_blog_index
13
+ all_index = Fronde::Index.all_blog_index
14
14
  all_index.each do |index|
15
15
  index.write_all_org(verbose: verbose)
16
16
  end
@@ -25,19 +25,11 @@ namespace :site do
25
25
  end
26
26
  all_indexes = build_index[:all_indexes]
27
27
 
28
- begin
29
- build_html = Thread.new do
30
- rm_r 'var/tmp/timestamps', force: true if args[:force?]
31
- Fronde::Emacs.new(verbose: verbose).publish
32
- end
33
- Fronde::CLI::Throbber.run(build_html, R18n.t.fronde.tasks.site.building)
34
-
35
- # :nocov:
36
- rescue RuntimeError
37
- warn R18n.t.fronde.tasks.site.aborting
38
- next
28
+ build_html = Thread.new do
29
+ rm_r 'var/tmp/timestamps', force: true if args[:force?]
30
+ Fronde::Emacs.new(verbose: verbose).publish
39
31
  end
40
- # :nocov:
32
+ Fronde::CLI::Throbber.run(build_html, R18n.t.fronde.tasks.site.building)
41
33
 
42
34
  if all_indexes.any?
43
35
  if verbose
@@ -65,6 +57,11 @@ namespace :site do
65
57
  Fronde::CLI::Throbber.run(
66
58
  customize_html, R18n.t.fronde.tasks.site.customizing
67
59
  )
60
+ # :nocov:
61
+ rescue RuntimeError, Interrupt
62
+ warn R18n.t.fronde.tasks.site.aborting
63
+ next
64
+ # :nocov:
68
65
  end
69
66
 
70
67
  desc 'Cleanup orphaned published files'
data/lib/tasks/sync.rake CHANGED
@@ -1,40 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../fronde/config'
4
+ require_relative '../fronde/sync'
4
5
  require_relative '../fronde/cli/throbber'
5
6
 
6
- module Fronde
7
- class SyncError < ::StandardError; end
8
- end
9
-
10
- def rsync_command(test = nil)
11
- rsync_command = Fronde::CONFIG.get('rsync')
12
- return rsync_command unless rsync_command.nil?
13
-
14
- optstring = []
15
- optstring << 'n' if test
16
- if verbose
17
- optstring << 'v'
18
- else
19
- optstring << 'q'
20
- end
21
- "rsync -#{optstring.join}rlt --delete"
22
- end
23
-
24
- def pull_or_push(direction, type, test)
25
- remote_path = Fronde::CONFIG.get("#{type}_remote")
26
- raise Fronde::SyncError, "No #{type} remote path set" if remote_path.nil?
27
-
28
- public_folder = Fronde::CONFIG.get("#{type}_public_folder")
29
- # Default is to push
30
- cmd = ["#{public_folder}/", remote_path]
31
- cmd.reverse! if direction == :pull
32
- rsync = rsync_command(test)
33
- Thread.new do
34
- sh "#{rsync} #{cmd.join(' ')}"
35
- end
36
- end
37
-
38
7
  def source_types
39
8
  Fronde::CONFIG.sources.map(&:type).uniq
40
9
  end
@@ -43,7 +12,9 @@ namespace :sync do
43
12
  desc 'Push changes to server'
44
13
  task :push, :test? do |_, args|
45
14
  source_types.each do |type|
46
- publish_thread = pull_or_push(:push, type, args[:test?])
15
+ publish_thread = Fronde::Sync.pull_or_push(
16
+ :push, type, test: args[:test?], verbose: verbose
17
+ )
47
18
  if verbose
48
19
  publish_thread.join
49
20
  else
@@ -51,16 +22,20 @@ namespace :sync do
51
22
  publish_thread, format('Publishing %<fmt>s:', fmt: type)
52
23
  )
53
24
  end
54
- rescue Fronde::SyncError => e
25
+ rescue Fronde::Sync::Error => e
55
26
  warn e
56
27
  next
57
28
  end
29
+ rescue RuntimeError, Interrupt
30
+ next
58
31
  end
59
32
 
60
33
  desc 'Pull changes from server'
61
34
  task :pull, :test? do |_, args|
62
35
  source_types.each do |type|
63
- pull_thread = pull_or_push(:pull, type, args[:test?])
36
+ pull_thread = Fronde::Sync.pull_or_push(
37
+ :pull, type, test: args[:test?], verbose: verbose
38
+ )
64
39
  if verbose
65
40
  pull_thread.join
66
41
  else
@@ -68,9 +43,11 @@ namespace :sync do
68
43
  pull_thread, format('Pulling %<fmt>s:', fmt: type)
69
44
  )
70
45
  end
71
- rescue Fronde::SyncError => e
46
+ rescue Fronde::Sync::Error => e
72
47
  warn e
73
48
  next
74
49
  end
50
+ rescue RuntimeError, Interrupt
51
+ next
75
52
  end
76
53
  end
data/lib/tasks/tags.rake CHANGED
@@ -5,7 +5,7 @@ require_relative '../fronde/index'
5
5
  namespace :tags do
6
6
  desc 'List all tags by name'
7
7
  task :name do
8
- Fronde::Index.all_html_blog_index do |index|
8
+ Fronde::Index.all_blog_index do |index|
9
9
  next if index.empty?
10
10
 
11
11
  puts index.sort_by(:name).join("\n")
@@ -14,7 +14,7 @@ namespace :tags do
14
14
 
15
15
  desc 'List all tags by weight'
16
16
  task :weight do
17
- Fronde::Index.all_html_blog_index do |index|
17
+ Fronde::Index.all_blog_index do |index|
18
18
  next if index.empty?
19
19
 
20
20
  puts index.sort_by(:weight).join("\n")
data/locales/en.yml CHANGED
@@ -3,6 +3,7 @@ fronde:
3
3
  bin:
4
4
  usage: 'Usage: fronde %1 [options]'
5
5
  done: done
6
+ interrupted: interrupted
6
7
  commands:
7
8
  cmd_title: Commands
8
9
  alias: Alias for ‘%1’.
data/locales/fr.yml CHANGED
@@ -3,6 +3,7 @@ fronde:
3
3
  bin:
4
4
  usage: 'Usage : fronde %1 [options]'
5
5
  done: fait
6
+ interrupted: arrêté
6
7
  commands:
7
8
  cmd_title: Commandes
8
9
  alias: Alias pour ‘%1’.