fronde 0.3.4 → 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 (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