fronde 0.4.0 → 0.5.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.
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’.