neruda 0.0.9 → 0.2.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 (45) hide show
  1. checksums.yaml +5 -5
  2. data/bin/pablo +135 -238
  3. data/lib/neruda/config.rb +137 -0
  4. data/lib/neruda/config/lisp_config.rb +254 -0
  5. data/lib/neruda/config/org-config.el +18 -0
  6. data/lib/neruda/config/ox-neruda.el +114 -0
  7. data/lib/neruda/emacs.rb +44 -0
  8. data/lib/neruda/index.rb +122 -0
  9. data/lib/neruda/index/atom_generator.rb +86 -0
  10. data/lib/neruda/index/org_generator.rb +115 -0
  11. data/lib/neruda/org_file.rb +299 -0
  12. data/lib/neruda/org_file/class_methods.rb +72 -0
  13. data/lib/neruda/org_file/extracter.rb +72 -0
  14. data/lib/neruda/org_file/htmlizer.rb +53 -0
  15. data/lib/neruda/preview.rb +55 -0
  16. data/lib/neruda/templater.rb +112 -0
  17. data/lib/neruda/utils.rb +212 -0
  18. data/lib/neruda/version.rb +6 -0
  19. data/lib/tasks/org.rake +84 -0
  20. data/lib/tasks/site.rake +86 -0
  21. data/lib/tasks/sync.rake +34 -0
  22. data/lib/tasks/tags.rake +19 -0
  23. data/locales/en.yml +37 -0
  24. data/locales/fr.yml +37 -0
  25. data/themes/default/css/htmlize.css +346 -0
  26. data/themes/default/css/style.css +153 -0
  27. data/themes/default/img/bottom.png +0 -0
  28. data/themes/default/img/tic.png +0 -0
  29. data/themes/default/img/top.png +0 -0
  30. metadata +153 -43
  31. data/README.md +0 -98
  32. data/docs/Rakefile.example +0 -4
  33. data/docs/config.yml.example +0 -17
  34. data/lib/assets/chapter.slim +0 -14
  35. data/lib/assets/index.slim +0 -13
  36. data/lib/assets/layout.slim +0 -17
  37. data/lib/assets/style.css +0 -199
  38. data/lib/neruda.rb +0 -106
  39. data/lib/neruda/chapter.rb +0 -26
  40. data/lib/neruda/url.rb +0 -14
  41. data/lib/tasks/book.rake +0 -60
  42. data/lib/tasks/capistrano/chapters.rake +0 -60
  43. data/lib/tasks/capistrano/sinatra.rake +0 -18
  44. data/lib/tasks/chapters.rake +0 -132
  45. data/lib/tasks/sinatra.rake +0 -36
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open-uri'
4
+ require 'neruda/version'
5
+
6
+ module Neruda
7
+ # This module contains utilitary methods to ease ~org-config.el~
8
+ # file generation
9
+ module LispConfig
10
+ # Fetch and return the last published version of Org.
11
+ #
12
+ # @return [String] the new x.x.x version string of Org
13
+ def org_last_version
14
+ return @org_version if @org_version
15
+ if File.exist?('tmp/__last_org_version__')
16
+ @org_version = IO.read('tmp/__last_org_version__')
17
+ return @org_version
18
+ end
19
+ index = URI('https://orgmode.org/index.html').open.read
20
+ last_ver = index.match(/https:\/\/orgmode\.org\/org-([0-9.]+)\.tar\.gz/)
21
+ # :nocov:
22
+ if last_ver.nil?
23
+ warn 'Org last version not found'
24
+ return nil
25
+ end
26
+ FileUtils.mkdir_p 'tmp'
27
+ IO.write('tmp/__last_org_version__', last_ver[1])
28
+ # :nocov:
29
+ @org_version = last_ver[1]
30
+ end
31
+
32
+ # Generate emacs lisp configuration file for Org and write it.
33
+ #
34
+ # This method saves the generated configuration in the file
35
+ # ~org-config.el~ at the root of your project, overwriting it if it
36
+ # existed already.
37
+ #
38
+ # @return [Integer] the length written (as returned by the
39
+ # underlying ~IO.write~ method call)
40
+ def write_org_lisp_config(with_tags: false)
41
+ projects = org_generate_projects(with_tags: with_tags)
42
+ workdir = Dir.pwd
43
+ content = IO.read(File.expand_path('./org-config.el', __dir__))
44
+ .gsub('__VERSION__', Neruda::VERSION)
45
+ .gsub('__WORK_DIR__', workdir)
46
+ .gsub('__NERUDA_DIR__', __dir__)
47
+ .gsub('__ORG_VER__', org_last_version)
48
+ .gsub('__ALL_PROJECTS__', all_projects(projects))
49
+ .gsub('__THEME_CONFIG__', org_default_theme_config)
50
+ .gsub('__ALL_PROJECTS_NAMES__', project_names(projects))
51
+ .gsub('__LONG_DATE_FMT__', r18n_full_datetime_format)
52
+ .gsub('__AUTHOR_EMAIL__', settings['author_email'] || '')
53
+ .gsub('__AUTHOR_NAME__', settings['author'])
54
+ IO.write("#{workdir}/org-config.el", content)
55
+ end
56
+
57
+ # Generate emacs directory variables file.
58
+ #
59
+ # This method generate the file ~.dir-locals.el~, which is
60
+ # responsible to load neruda Org settings when visiting an Org file
61
+ # of this neruda instance.
62
+ #
63
+ # @return [Integer] the length written (as returned by the
64
+ # underlying ~IO.write~ method call)
65
+ def write_dir_locals
66
+ workdir = Dir.pwd
67
+ IO.write(
68
+ "#{workdir}/.dir-locals.el",
69
+ "((org-mode . ((eval . (load-file \"#{workdir}/org-config.el\")))))"
70
+ )
71
+ end
72
+
73
+ private
74
+
75
+ def r18n_full_datetime_format
76
+ locale = R18n.get.locale
77
+ date_fmt = R18n.t.neruda.index.full_date_format(
78
+ date: locale.full_format
79
+ )
80
+ date_fmt = locale.year_format.sub('_', date_fmt)
81
+ time_fmt = locale.time_format.delete('_').strip
82
+ R18n.t.neruda.index.full_date_with_time_format(
83
+ date: date_fmt, time: time_fmt
84
+ )
85
+ end
86
+
87
+ def ruby_to_lisp_boolean(value)
88
+ return 't' if value == true
89
+ 'nil'
90
+ end
91
+
92
+ def project_names(projects)
93
+ names = projects.keys.map do |p|
94
+ ["\"#{p}\"", "\"#{p}-assets\""]
95
+ end.flatten
96
+ names << "\"theme-#{settings['theme']}\""
97
+ sources.each do |s|
98
+ next unless s['theme'] && s['theme'] != settings['theme']
99
+ theme = "\"theme-#{s['theme']}\""
100
+ next if names.include? theme
101
+ names << theme
102
+ end
103
+ names.join(' ')
104
+ end
105
+
106
+ def all_projects(projects)
107
+ projects.values.join("\n").strip
108
+ .gsub(/\n\s*\n/, "\n")
109
+ .gsub(/\n/, "\n ")
110
+ end
111
+
112
+ # Return the full path to the publication path of a given project
113
+ # configuration.
114
+ #
115
+ # @param project [Hash] a project configuration (as extracted from
116
+ # the ~sources~ key)
117
+ # @return [String] the full path to the target dir of this project
118
+ def publication_path(project)
119
+ publish_in = [Dir.pwd, settings['public_folder']]
120
+ publish_in << project['target'] unless project['target'] == '.'
121
+ publish_in.join('/')
122
+ end
123
+
124
+ def org_project(project_name, opts)
125
+ publish_in = publication_path(opts)
126
+ other_lines = [
127
+ format(':recursive %<value>s',
128
+ value: ruby_to_lisp_boolean(opts['recursive']))
129
+ ]
130
+ if opts['exclude']
131
+ other_lines << format(':exclude "%<value>s"',
132
+ value: opts['exclude'])
133
+ end
134
+ themeconf = org_theme_config(opts['theme']) || ''
135
+ <<~ORGPROJECT
136
+ ("#{project_name}"
137
+ :base-directory "#{opts['path']}"
138
+ :base-extension "org"
139
+ #{other_lines.join("\n ")}
140
+ :publishing-directory "#{publish_in}"
141
+ :publishing-function org-html-publish-to-html
142
+ :section-numbers nil
143
+ :with-toc nil
144
+ #{opts['org_headers']})
145
+ ("#{project_name}-assets"
146
+ :base-directory "#{opts['path']}"
147
+ :base-extension "jpg\\\\\\|gif\\\\\\|png\\\\\\|svg\\\\\\|pdf"
148
+ #{other_lines[0]}
149
+ :publishing-directory "#{publish_in}"
150
+ :publishing-function org-publish-attachment)
151
+ #{themeconf}
152
+ ORGPROJECT
153
+ end
154
+
155
+ def org_default_postamble
156
+ <<~POSTAMBLE
157
+ <p><span class="author">#{R18n.t.neruda.org.postamble.written_by}</span>
158
+ #{R18n.t.neruda.org.postamble.with_emacs}</p>
159
+ <p class="date">#{R18n.t.neruda.org.postamble.last_modification}</p>
160
+ <p class="validation">%v</p>
161
+ POSTAMBLE
162
+ end
163
+
164
+ def org_default_html_head
165
+ <<~HTMLHEAD
166
+ <link rel="stylesheet" type="text/css" media="screen"
167
+ href="__DOMAIN__/assets/__THEME__/css/style.css">
168
+ <link rel="stylesheet" type="text/css" media="screen"
169
+ href="__DOMAIN__/assets/__THEME__/css/htmlize.css">
170
+ __ATOM_FEED__
171
+ HTMLHEAD
172
+ end
173
+
174
+ def org_default_html_options
175
+ { 'html-head' => org_default_html_head,
176
+ 'html-postamble' => org_default_postamble,
177
+ 'html-head-include-default-style' => 't',
178
+ 'html-head-include-scripts' => 'nil' }
179
+ end
180
+
181
+ def expand_vars_in_html_head(head, project)
182
+ curtheme = project['theme'] || settings['theme']
183
+ # Head may be frozen when coming from settings
184
+ head = head.gsub('__THEME__', curtheme)
185
+ .gsub('__DOMAIN__', settings['domain'])
186
+ return head.gsub('__ATOM_FEED__', '') unless project['is_blog']
187
+ atomfeed = <<~ATOMFEED
188
+ <link rel="alternate" type="application/atom+xml" title="Atom 1.0"
189
+ href="#{settings['domain']}/feeds/index.xml" />
190
+ ATOMFEED
191
+ head.gsub('__ATOM_FEED__', atomfeed)
192
+ end
193
+
194
+ def build_project_org_headers(project)
195
+ orgtplopts = org_default_html_options.merge(
196
+ settings['org-html'] || {}, project['org-html'] || {}
197
+ )
198
+ orgtpl = []
199
+ orgtplopts.each do |k, v|
200
+ v = expand_vars_in_html_head(v, project) if k == 'html-head'
201
+ val = v.strip.gsub(/"/, '\"')
202
+ if ['t', 'nil', '1'].include? val
203
+ orgtpl << ":#{k} #{val}"
204
+ else
205
+ orgtpl << ":#{k} \"#{val}\""
206
+ end
207
+ end
208
+ orgtpl.join("\n ")
209
+ end
210
+
211
+ def org_generate_projects(with_tags: false)
212
+ projects = {}
213
+ projects_sources = sources
214
+ if with_tags
215
+ tags_conf = build_source('tags')
216
+ tags_conf['recursive'] = false
217
+ projects_sources << tags_conf
218
+ end
219
+ projects_sources.each do |opts|
220
+ opts['org_headers'] = build_project_org_headers(opts)
221
+ projects[opts['name']] = org_project(opts['name'], opts)
222
+ end
223
+ projects
224
+ end
225
+
226
+ def org_default_theme_config
227
+ org_theme_config(settings['theme']).split("\n").map do |line|
228
+ if line[0] == '('
229
+ line
230
+ else
231
+ " #{line}"
232
+ end
233
+ end.join("\n")
234
+ end
235
+
236
+ def org_theme_config(theme)
237
+ return nil if theme.nil?
238
+ workdir = Dir.pwd
239
+ if theme == 'default'
240
+ sourcedir = File.expand_path('../../../', __dir__)
241
+ else
242
+ sourcedir = workdir
243
+ end
244
+ <<~THEMECONFIG
245
+ ("theme-#{theme}"
246
+ :base-directory "#{sourcedir}/themes/#{theme}"
247
+ :base-extension "jpg\\\\\\|gif\\\\\\|png\\\\\\|js\\\\\\|css\\\\\\|otf\\\\\\|ttf\\\\\\|woff2?"
248
+ :recursive t
249
+ :publishing-directory "#{workdir}/#{settings['public_folder']}/assets/#{theme}"
250
+ :publishing-function org-publish-attachment)
251
+ THEMECONFIG
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,18 @@
1
+ ;; Add org-mode to load path
2
+ (add-to-list 'load-path (expand-file-name "org-__ORG_VER__/lisp" "__WORK_DIR__"))
3
+ ;; Load last version of htmlize.el
4
+ (load-file (expand-file-name "htmlize.el" "__WORK_DIR__"))
5
+
6
+ ;; Current project options
7
+ (setq neruda/version "__VERSION__"
8
+ neruda/current-work-dir "__WORK_DIR__"
9
+ user-mail-address "__AUTHOR_EMAIL__"
10
+ user-full-name "__AUTHOR_NAME__"
11
+ org-html-metadata-timestamp-format "__LONG_DATE_FMT__"
12
+ org-publish-project-alist
13
+ `(__ALL_PROJECTS__
14
+ __THEME_CONFIG__
15
+ ("website" :components (__ALL_PROJECTS_NAMES__))))
16
+
17
+ ;; Load neruda lib
18
+ (load-file (expand-file-name "ox-neruda.el" "__NERUDA_DIR__"))
@@ -0,0 +1,114 @@
1
+ ;;; ox-neruda.el --- Neruda Gem specific helpers for Org Export Engine -*- lexical-binding: t; -*-
2
+
3
+ ;; Copyright (C) 2011-2019 Free Software Foundation, Inc.
4
+
5
+ ;; Author: Étienne Deparis <etienne at depar dot is>
6
+ ;; Keywords: org, export
7
+
8
+ ;; This file is not part of GNU Emacs.
9
+
10
+ ;; GNU Emacs is free software: you can redistribute it and/or modify
11
+ ;; it under the terms of the GNU General Public License as published by
12
+ ;; the Free Software Foundation, either version 3 of the License, or
13
+ ;; (at your option) any later version.
14
+
15
+ ;; GNU Emacs is distributed in the hope that it will be useful,
16
+ ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
17
+ ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
+ ;; GNU General Public License for more details.
19
+
20
+ ;; You should have received a copy of the GNU General Public License
21
+ ;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
22
+
23
+ ;;; Commentary:
24
+
25
+ ;; This library implements specific helpers function, needed by the Ruby
26
+ ;; Gem Neruda, which offers an easy way to publish a static website
27
+ ;; using Org files as sources.
28
+
29
+ ;;; Code:
30
+
31
+ (require 'org)
32
+ (require 'ox-html)
33
+
34
+ ;;; Function Declarations
35
+
36
+ (defvar neruda/version ""
37
+ "Version of the current neruda installation")
38
+
39
+ (defvar neruda/current-work-dir nil
40
+ "Location of the current neruda website base directory.")
41
+
42
+ (defvar neruda/org-temp-dir nil
43
+ "Location of the local Org temporary directory (where to place
44
+ org timestamps and id locations).")
45
+
46
+ (defun neruda/org-html-format-spec (upstream info)
47
+ "Return format specification for preamble and postamble.
48
+ INFO is a plist used as a communication channel."
49
+ (let ((output (funcall upstream info)))
50
+ (push `(?A . ,(format "<span class=\"author\">%s</span>"
51
+ (org-export-data (plist-get info :author) info)))
52
+ output)
53
+ (push `(?k . ,(org-export-data (plist-get info :keywords) info)) output)
54
+ (push `(?K . ,(format "<ul class=\"keywords-list\">%s</ul>"
55
+ (mapconcat
56
+ (lambda (k) (format "<li class=\"keyword\">%s</li>" k))
57
+ (split-string (or (plist-get info :keywords) "") ",+ *")
58
+ "\n")))
59
+ output)
60
+ (push `(?l . ,(org-export-data (plist-get info :language) info)) output)
61
+ (push `(?n . ,(format "Neruda %s" neruda/version)) output)
62
+ (push `(?N . ,(format "<a href=\"https://git.umaneti.net/neruda/about/\">Neruda</a> %s" neruda/version)) output)
63
+ (push `(?x . ,(org-export-data (plist-get info :description) info)) output)
64
+ (push `(?X . ,(format "<p>%s</p>"
65
+ (org-export-data (plist-get info :description) info)))
66
+ output)))
67
+
68
+ (defun neruda/org-i18n-export (link description format)
69
+ "Export a i18n link"
70
+ (let* ((splitted-link (split-string link "|"))
71
+ (path (car splitted-link))
72
+ (desc (or description path))
73
+ (lang (cadr splitted-link)))
74
+ (pcase format
75
+ (`html (if lang
76
+ (format "<a href=\"%s\" hreflang=\"%s\">%s</a>"
77
+ path lang desc)
78
+ (format "<a href=\"%s\">%s</a>" path desc)))
79
+ (`latex (format "\\href{%s}{%s}" path desc))
80
+ (`ascii (format "%s (%s)" desc path))
81
+ (_ path))))
82
+
83
+ (defun neruda/org-i18n-follow (link)
84
+ "Visit a i18n link"
85
+ (browse-url (car (split-string link "|"))))
86
+
87
+ (org-link-set-parameters "i18n"
88
+ :export #'neruda/org-i18n-export
89
+ :follow #'neruda/org-i18n-follow)
90
+
91
+
92
+ ;;; Set configuration options
93
+
94
+ (setq neruda/org-temp-dir (expand-file-name "tmp" neruda/current-work-dir)
95
+ org-publish-timestamp-directory (expand-file-name "timestamps/" neruda/org-temp-dir)
96
+ org-id-locations-file (expand-file-name "id-locations.el" neruda/org-temp-dir)
97
+ make-backup-files nil
98
+ enable-local-variables :all
99
+ org-confirm-babel-evaluate nil
100
+ org-export-with-broken-links t
101
+ org-html-doctype "html5"
102
+ org-html-html5-fancy t
103
+ org-html-htmlize-output-type 'css
104
+ org-html-text-markup-alist '((bold . "<strong>%s</strong>")
105
+ (code . "<code>%s</code>")
106
+ (italic . "<em>%s</em>")
107
+ (strike-through . "<del>%s</del>")
108
+ (underline . "<span class=\"underline\">%s</span>")
109
+ (verbatim . "<code>%s</code>")))
110
+ (advice-add 'org-html-format-spec :around #'neruda/org-html-format-spec)
111
+
112
+ (provide 'ox-neruda)
113
+
114
+ ;;; ox-neruda.el ends here
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'neruda/config'
4
+
5
+ module Neruda
6
+ # Wraps Gnu/Emacs calls
7
+ class Emacs
8
+ def initialize(file_path: nil, verbose: false)
9
+ @file = file_path
10
+ @verbose = verbose
11
+ end
12
+
13
+ def publish
14
+ if @file.nil?
15
+ emacs_args = ['--eval \'(org-publish "website")\'']
16
+ else
17
+ emacs_args = ['-f org-publish-current-file']
18
+ end
19
+ call_emacs emacs_args
20
+ end
21
+
22
+ private
23
+
24
+ def emacs_command(arguments = [])
25
+ default_emacs = Neruda::Config.settings['emacs']
26
+ emacs_cmd = [default_emacs || 'emacs -Q --batch -nw']
27
+ emacs_cmd << '--eval \'(setq enable-dir-local-variables nil)\''
28
+ emacs_cmd << '--eval \'(setq inhibit-message t)\'' unless @verbose
29
+ emacs_cmd << '-l ./org-config.el'
30
+ emacs_cmd << "--eval '(find-file \"#{@file}\")'" unless @file.nil?
31
+ emacs_cmd.concat(arguments)
32
+ emacs_cmd.join(' ')
33
+ end
34
+
35
+ def call_emacs(arguments = [])
36
+ command = emacs_command arguments
37
+ if @verbose
38
+ warn command
39
+ return system(command, exception: true)
40
+ end
41
+ system command, out: '/dev/null', err: '/dev/null', exception: true
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'digest/md5'
5
+ require 'neruda/config'
6
+ require 'neruda/org_file'
7
+ require 'neruda/index/atom_generator'
8
+ require 'neruda/index/org_generator'
9
+
10
+ module Neruda
11
+ # Generates website indexes and atom feeds for all the org documents
12
+ # keywords.
13
+ class Index
14
+ attr_reader :date
15
+
16
+ include Neruda::IndexAtomGenerator
17
+ include Neruda::IndexOrgGenerator
18
+
19
+ def initialize
20
+ @pubdir = Neruda::Config.settings['public_folder']
21
+ @index = { 'index' => [] }
22
+ @projects = {}
23
+ @tags_names = {}
24
+ @date = DateTime.now
25
+ feed
26
+ sort!
27
+ end
28
+
29
+ def entries
30
+ @index.keys.reject { |k| k == 'index' }
31
+ end
32
+
33
+ def empty?
34
+ @index['index'].empty?
35
+ end
36
+
37
+ def write_all(verbose: true)
38
+ @index.each_key do |k|
39
+ write_org(k)
40
+ warn "Generated index file for #{k}" if verbose
41
+ write_atom(k)
42
+ warn "Generated atom feed for #{k}" if verbose
43
+ end
44
+ write_all_blog_home(verbose)
45
+ end
46
+
47
+ def sort_by(kind)
48
+ if [:name, :weight].include?(kind)
49
+ tags_sorted = sort_tags_by_name_and_weight["by_#{kind}".to_sym]
50
+ # Reverse in order to have most important or A near next prompt
51
+ # and avoid to scroll to find the beginning of the list.
52
+ return tags_sorted.map do |k|
53
+ @tags_names[k] + " (#{@index[k].length})"
54
+ end.reverse
55
+ end
56
+ raise ArgumentError, "#{kind} not in [:name, :weight]"
57
+ end
58
+
59
+ private
60
+
61
+ def feed
62
+ Neruda::Config.sources.each do |project|
63
+ next unless project['is_blog']
64
+ if project['recursive']
65
+ file_pattern = '**/*.org'
66
+ else
67
+ file_pattern = '*.org'
68
+ end
69
+ Dir.glob(file_pattern, base: project['path']).map do |s|
70
+ org_file = File.join(project['path'], s)
71
+ add_to_indexes(
72
+ Neruda::OrgFile.new(org_file, project: project)
73
+ )
74
+ end
75
+ end
76
+ end
77
+
78
+ def add_to_project_index(article)
79
+ project = article.project
80
+ @projects[project['name']] ||= []
81
+ @projects[project['name']] << article
82
+ end
83
+
84
+ def add_to_indexes(article)
85
+ @index['index'] << article
86
+ add_to_project_index article
87
+ article.keywords.each do |k|
88
+ slug = Neruda::OrgFile.slug k
89
+ @tags_names[slug] = k # Overwrite is permitted
90
+ @index[slug] ||= []
91
+ @index[slug] << article
92
+ end
93
+ end
94
+
95
+ def sort!
96
+ @index.each do |k, i|
97
+ @index[k] = i.sort { |a, b| b.timekey <=> a.timekey }
98
+ end
99
+ @projects.each do |k, i|
100
+ @projects[k] = i.sort { |a, b| b.timekey <=> a.timekey }
101
+ end
102
+ end
103
+
104
+ def sort_tags_by_name_and_weight
105
+ tags_sorted = {}
106
+ all_keys = entries
107
+ tags_sorted[:by_name] = all_keys.sort
108
+ tags_sorted[:by_weight] = all_keys.sort do |a, b|
109
+ @index[b].length <=> @index[a].length
110
+ end
111
+ tags_sorted
112
+ end
113
+
114
+ def save?
115
+ return true unless empty?
116
+ Neruda::Config.sources.each do |project|
117
+ return true if project['is_blog'] && Dir.exist?(project['path'])
118
+ end
119
+ false
120
+ end
121
+ end
122
+ end