fronde 0.3.4 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/bin/fronde +15 -30
  3. data/lib/ext/nil_time.rb +25 -0
  4. data/lib/ext/r18n.rb +37 -0
  5. data/lib/ext/time.rb +39 -0
  6. data/lib/ext/time_no_time.rb +23 -0
  7. data/lib/fronde/cli/commands.rb +97 -104
  8. data/lib/fronde/cli/data/Rakefile +8 -0
  9. data/lib/fronde/cli/data/config.yml +13 -0
  10. data/lib/fronde/cli/data/gitignore +6 -0
  11. data/lib/fronde/cli/data/zsh_completion +37 -0
  12. data/lib/fronde/cli/helpers.rb +55 -0
  13. data/lib/fronde/cli/opt_parse.rb +140 -0
  14. data/lib/fronde/cli/throbber.rb +110 -0
  15. data/lib/fronde/cli.rb +42 -42
  16. data/lib/fronde/config/data/org-config.el +25 -0
  17. data/lib/fronde/config/data/ox-fronde.el +158 -0
  18. data/lib/fronde/config/data/themes/umaneti/css/htmlize.css +364 -0
  19. data/lib/fronde/config/data/themes/umaneti/css/style.css +250 -0
  20. data/lib/fronde/config/data/themes/umaneti/img/bottom.png +0 -0
  21. data/lib/fronde/config/data/themes/umaneti/img/content.png +0 -0
  22. data/lib/fronde/config/data/themes/umaneti/img/tic.png +0 -0
  23. data/lib/fronde/config/data/themes/umaneti/img/top.png +0 -0
  24. data/lib/fronde/config/helpers.rb +62 -0
  25. data/lib/fronde/config/lisp.rb +80 -0
  26. data/lib/fronde/config.rb +148 -98
  27. data/lib/fronde/emacs.rb +23 -20
  28. data/lib/fronde/index/atom_generator.rb +55 -66
  29. data/lib/fronde/index/data/all_tags.org +19 -0
  30. data/lib/fronde/index/data/template.org +26 -0
  31. data/lib/fronde/index/data/template.xml +37 -0
  32. data/lib/fronde/index/org_generator.rb +72 -88
  33. data/lib/fronde/index.rb +57 -86
  34. data/lib/fronde/org/file.rb +299 -0
  35. data/lib/fronde/org/file_extracter.rb +101 -0
  36. data/lib/fronde/org.rb +105 -0
  37. data/lib/fronde/preview.rb +43 -39
  38. data/lib/fronde/slug.rb +54 -0
  39. data/lib/fronde/source/gemini.rb +34 -0
  40. data/lib/fronde/source/html.rb +67 -0
  41. data/lib/fronde/source.rb +209 -0
  42. data/lib/fronde/sync/neocities.rb +220 -0
  43. data/lib/fronde/sync/rsync.rb +46 -0
  44. data/lib/fronde/sync.rb +32 -0
  45. data/lib/fronde/templater.rb +101 -71
  46. data/lib/fronde/version.rb +1 -1
  47. data/lib/tasks/cli.rake +33 -0
  48. data/lib/tasks/org.rake +58 -43
  49. data/lib/tasks/site.rake +66 -31
  50. data/lib/tasks/sync.rake +37 -40
  51. data/lib/tasks/tags.rake +11 -7
  52. data/locales/en.yml +61 -14
  53. data/locales/fr.yml +69 -14
  54. metadata +77 -95
  55. data/lib/fronde/config/lisp_config.rb +0 -340
  56. data/lib/fronde/config/org-config.el +0 -19
  57. data/lib/fronde/config/ox-fronde.el +0 -121
  58. data/lib/fronde/org_file/class_methods.rb +0 -72
  59. data/lib/fronde/org_file/extracter.rb +0 -72
  60. data/lib/fronde/org_file/htmlizer.rb +0 -43
  61. data/lib/fronde/org_file.rb +0 -298
  62. data/lib/fronde/utils.rb +0 -229
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fronde
4
+ class Source
5
+ # Specific settings for HTML {Fronde::Source}
6
+ class Html < Source
7
+ def org_config
8
+ config = super
9
+ config[0]['theme'] = @config['theme']
10
+ config
11
+ end
12
+
13
+ class << self
14
+ def org_default_postamble
15
+ <<~POSTAMBLE
16
+ <p><span class="author">#{R18n.t.fronde.org.postamble.written_by}</span>
17
+ #{R18n.t.fronde.org.postamble.with_emacs_html}</p>
18
+ <p class="date">#{R18n.t.fronde.org.postamble.last_modification}</p>
19
+ <p class="validation">%v</p>
20
+ POSTAMBLE
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def fill_in_specific_config
27
+ @config.merge!(
28
+ 'type' => 'html', 'ext' => '.html', 'mime_type' => 'text/html',
29
+ 'folder' => CONFIG.get('html_public_folder')
30
+ )
31
+ end
32
+
33
+ def org_publish_options
34
+ if @config['is_blog']
35
+ @config['atom_feed'] = <<~ATOMFEED
36
+ <link rel="alternate" type="application/atom+xml" title="#{@config['title']}"
37
+ href="#{@config['domain']}#{public_absolute_path}feeds/index.xml" />
38
+ ATOMFEED
39
+ end
40
+ super
41
+ end
42
+
43
+ def org_default_options
44
+ defaults = {
45
+ 'publishing-function' => 'org-html-publish-to-html',
46
+ 'html-head-include-default-style' => 't',
47
+ 'html-head-include-scripts' => 't',
48
+ 'html-head' => '%F',
49
+ 'html-postamble' => Html.org_default_postamble
50
+ }
51
+ return defaults if @config['theme'] == 'default'
52
+
53
+ defaults.merge(
54
+ 'html-head-include-default-style' => 'nil',
55
+ 'html-head-include-scripts' => 'nil',
56
+ 'html-head' => <<~HTMLHEAD
57
+ <link rel="stylesheet" type="text/css" media="screen"
58
+ href="%h/assets/%o/css/style.css">
59
+ <link rel="stylesheet" type="text/css" media="screen"
60
+ href="%h/assets/%o/css/htmlize.css">
61
+ %F
62
+ HTMLHEAD
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fronde
4
+ # Wrapper for each possible project source.
5
+ # Must be subclassed by specific source type (like Gemini or HTML)
6
+ class Source
7
+ def initialize(source_config)
8
+ @config = {
9
+ 'recursive' => true, 'is_blog' => false,
10
+ 'domain' => CONFIG.get('domain'), 'atom_feed' => '',
11
+ 'org-options' => {
12
+ 'section-numbers' => 'nil', 'with-toc' => 'nil'
13
+ }
14
+ }.merge(source_config)
15
+ clean_config
16
+ org_publish_options
17
+ render_heading
18
+ end
19
+
20
+ def [](key)
21
+ @config[key]
22
+ end
23
+
24
+ def []=(key, value)
25
+ @config[key] = value
26
+ end
27
+
28
+ def type
29
+ @config['type']
30
+ end
31
+
32
+ def recursive?
33
+ !!@config['recursive']
34
+ end
35
+
36
+ def blog?
37
+ !!@config['is_blog']
38
+ end
39
+
40
+ def to_h
41
+ @config
42
+ end
43
+
44
+ def source_for?(file_name)
45
+ relative_file_path = file_name.delete_prefix "#{@config['path']}/"
46
+ # file_name does not begin with source path.
47
+ return false if relative_file_path == file_name
48
+
49
+ # Looks like a file at a deeper level, but current source is not
50
+ # recursive.
51
+ return false if relative_file_path.include?('/') && !recursive?
52
+
53
+ # We don’t check file if the file really exist as the current
54
+ # check may be done before the file is actually written.
55
+ true
56
+ end
57
+
58
+ def source_for(file_name)
59
+ relative_file_path = file_name.delete_prefix "#{publication_path}/"
60
+ # file_name does not begin with source path.
61
+ return nil if relative_file_path == file_name
62
+
63
+ # Looks like a file at a deeper level, but current source is not
64
+ # recursive.
65
+ return nil if relative_file_path.include?('/') && !recursive?
66
+
67
+ # Looks like a match. But does a source file for this one actually
68
+ # exists?
69
+ relative_source_path = relative_file_path.sub(
70
+ /#{@config['ext']}\z/, '.org'
71
+ )
72
+ source_path = File.join(@config['path'], relative_source_path)
73
+ return nil unless File.file?(source_path)
74
+
75
+ source_path
76
+ end
77
+
78
+ def target_for(file_name)
79
+ target = File.expand_path file_name
80
+ target.delete_prefix! "#{Dir.pwd}/"
81
+ target.sub!(/\.org\z/, @config['ext'])
82
+ project_relative_path = @config['path'].delete_prefix("#{Dir.pwd}/")
83
+ target.delete_prefix! "#{project_relative_path}/"
84
+ public_absolute_path + target
85
+ end
86
+
87
+ def exclude_file?(file_name)
88
+ # Obviously excluding index itself for blogs
89
+ return true if file_name == File.join(@config['path'], 'index.org')
90
+
91
+ exclusion_rules = @config['exclude']
92
+ return false unless exclusion_rules
93
+
94
+ file_name.match? exclusion_rules
95
+ end
96
+
97
+ def org_config
98
+ name = @config['name']
99
+ [{ 'name' => name, 'attributes' => org_project_config },
100
+ { 'name' => "#{name}-assets", 'attributes' => org_assets_config }]
101
+ end
102
+
103
+ # Return the publication absolute path on file system.
104
+ #
105
+ # The returned string never end with a slash (/).
106
+ #
107
+ # Use {Fronde::Source#public_absolute_path} to get the absolute path
108
+ # of this project, as seen from a web browser.
109
+ #
110
+ # @return [String] the absolute path to the target dir of this project
111
+ def publication_path
112
+ return @config['publication_path'] if @config['publication_path']
113
+
114
+ publish_in = [File.expand_path(@config['folder']), @config['target']]
115
+ @config['publication_path'] = publish_in.join('/').delete_suffix('/')
116
+ end
117
+
118
+ # Return the absolute path as seen in User Agent.
119
+ #
120
+ # The returned string always end with a slash (/).
121
+ #
122
+ # Use {Fronde::Source#publication_path} to locate published file on
123
+ # the file system.
124
+ #
125
+ # @return [String] the absolute path to this project
126
+ def public_absolute_path
127
+ return @config['public_absolute_path'] if @config['public_absolute_path']
128
+
129
+ @config['public_absolute_path'] = "/#{@config['target']}/".squeeze('/')
130
+ end
131
+
132
+ class << self
133
+ def canonical_config(config)
134
+ config = { 'path' => config } if config.is_a?(String)
135
+ config['type'] ||= 'html'
136
+ config
137
+ end
138
+
139
+ def new_from_config(config)
140
+ klass_name = config['type'].capitalize
141
+ klass = Kernel.const_get("::Fronde::Source::#{klass_name}")
142
+ klass.new(config)
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def clean_config
149
+ fill_in_specific_config
150
+ @config['name'] ||= @config['path'].sub(%r{^[.~]*/}, '').tr('/.', '-')
151
+ @config['title'] ||= @config['path']
152
+ @config['target'] ||= File.basename(@config['path']).delete_prefix '.'
153
+ @config['target'] = '' if @config['target'] == '.'
154
+ @config['path'] = File.expand_path(@config['path'])
155
+ @config['theme'] ||= CONFIG.get('theme', 'default')
156
+ # Blog are necessarily recursive to allow publication of tags and feeds
157
+ @config['recursive'] = true if @config['is_blog']
158
+ end
159
+
160
+ def org_publish_options
161
+ type = @config['type']
162
+ options = org_default_options.merge(
163
+ @config['org-options'],
164
+ CONFIG.get("org-#{type}", {}),
165
+ @config["org-#{type}"] || {}
166
+ )
167
+ @config['org-options'] = options
168
+ end
169
+
170
+ def render_heading
171
+ %w[head head-extra preamble postamble].each do |kind|
172
+ heading_key = "#{@config['type']}-#{kind}"
173
+ heading = @config.dig 'org-options', heading_key
174
+ next unless heading
175
+
176
+ @config['org-options'][heading_key] =
177
+ heading.gsub('%F', @config['atom_feed'])
178
+ .gsub('%h', @config['domain'])
179
+ .gsub('%o', @config['theme'])
180
+ end
181
+ end
182
+
183
+ def org_project_config
184
+ attributes = {
185
+ 'base-directory' => @config['path'],
186
+ 'base-extension' => 'org',
187
+ 'publishing-directory' => publication_path,
188
+ 'recursive' => @config['recursive'],
189
+ 'fronde-base-uri' => "#{@config['domain']}#{public_absolute_path}"
190
+ }.merge(@config['org-options'])
191
+ exclude = @config['exclude']
192
+ attributes['exclude'] = exclude if exclude
193
+ attributes.sort.to_h # Have lisp config sorted
194
+ end
195
+
196
+ def org_assets_config
197
+ {
198
+ 'base-directory' => @config['path'],
199
+ 'base-extension' => %w[gif jpg png svg pdf].join('\\\\|'),
200
+ 'publishing-directory' => publication_path,
201
+ 'publishing-function' => 'org-publish-attachment',
202
+ 'recursive' => @config['recursive']
203
+ }
204
+ end
205
+ end
206
+ end
207
+
208
+ require_relative 'source/gemini'
209
+ require_relative 'source/html'
@@ -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