better mastodon link
[iankelling.org] / b.rb
1 # encoding: utf-8
2 # Copyright (C) 2016 Ian Kelling
3
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation, either version 2 of the License, or
7 # (at your option) any later version.
8
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13
14 # You should have received a copy of the GNU General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 module B # blog module
17 require 'fileutils'
18 require 'time'
19 require 'safe_yaml'
20 require 'pygments'
21 require 'sqlite3'
22 require 'redcarpet'
23 JS_INFO = "<p>All JavaScript has <a href=\"https://www.gnu.org/software/librejs/index.html\">LibreJS</a> support.</p>"
24
25 DAY = 60*60*24
26 DN = 'iankelling'
27 FQDN = DN + '.org'
28 DURL = 'https://' + FQDN
29 DESCRIPTION = "Ian Kelling's personal site and blog on software"
30 DATE_LEN = 'YYYY-MM-DD'.length
31 NOW = Time.now.to_f
32 WAIT_DATE = NOW - 60*60*24*1
33
34 def db_init
35 SQLite3::Database.new('../proposed-comments/comments.sqlite')
36 end
37
38 # from the redcarpet readme, then a bunch of googling to figure
39 # out what to do on exception.
40 class HTMLwithPygments < Redcarpet::Render::HTML
41 def block_code(code, language)
42 begin
43 Pygments.highlight(code, lexer: language)
44 rescue MentosError
45 # when language detection fails
46 Pygments.highlight(code, lexer: 'text')
47 end
48 end
49 end
50
51 def fwrite(output_path, string)
52 output_path = File.join('./', output_path)
53 FileUtils.mkdir_p(File.dirname(output_path))
54 File.write(output_path, string)
55 end
56
57 def fskel(rel_path, title, content, o={})
58 head = <<EOF
59 <link rel="canonical" href="#{DURL}/#{rel_path}">
60 EOF
61 if rel_path =~ %r{^/blog/|^blog.html}
62 head += <<EOF
63 <link rel="alternate" type="application/atom+xml" title="#{DN}" href="#{DURL}/feed.xml">
64 EOF
65 end
66 o[:head] = head
67 fwrite(rel_path, skel(title, content, o))
68 end
69 def skel(title, content, o={})
70 # got meta viewport from jekyll's default later. It's for better
71 # mobile viewing.
72 output = <<EOF
73 <!DOCTYPE html>
74 <html lang="en-US">
75 <head>
76 <meta charset="utf-8">
77 <title>#{title}</title>
78 <link rel="stylesheet" href="/css/main.css">
79 <link rel="shortcut icon" href="/assets/favicon.png" />
80 <meta name="description" content="#{o[:description] || DESCRIPTION}">
81 #{o[:head]}
82 <meta name="viewport" content="width=device-width, initial-scale=1">
83 </head>
84 <body>
85 <header class="page_header">
86 <h3><a href="/">iankelling.org</a>#{o[:header]}</h3>
87 </header>
88 <div class="main-content-stripe">
89 <div class="#{o[:prose] ? "prose" : "content"}">
90 #{content}
91 </div>
92 </div>
93 <div class="comment-stripe">
94 #{o[:comments]}
95 </div>
96 <footer>
97 #{o[:footer]}
98 <p>Sources in <a href="/git/?p=iankelling.org;a=summary">git</a>. Mostly markdown files. Default is <a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/"><img class="cc-by-sa" alt="Creative Commons License" src="/assets/cc-by-sa-4.0-80x15.png" /></a></p>
99 <p><address><a href="mailto:ian@iankelling.org">ian@iankelling.org</a> let me know what you think</address></p>
100 </footer>
101 </body>
102 </html>
103 EOF
104 output
105 end
106
107 def stdpage(page_name, content)
108 fskel("#{page_name}.html",
109 "#{DN}/#{page_name}",
110 content,
111 header: " / <a href=\"/#{page_name}.html\">#{page_name}</a>")
112 end
113
114 def md_to_html(md)
115 # Using redcarpet over kramdown because syntax highlighting is
116 # simpler. kramdown uses some crap highlighter by default,
117 # supports using rouge, but then the classes are all screwy
118 # for what pygments css expects, rouge has a pygments compatibility mode,
119 # but that is a pita to get working, then it doesn't even work right.
120 # kramdown is jekyll's default markdown parser, but it doesn't use
121 # it for code blocks, it strips them out using custom templating
122 # extension class, then uses rouge, then wraps it in some
123 # custom html for pygments compatibility. It's a complicated mess.
124 Redcarpet::Markdown.new(HTMLwithPygments, fenced_code_blocks: true).render(md)
125 end
126
127 def comment_html(comment, date)
128 # I tried putting the time, %I:%M %p UTC, but it looks kinda
129 # clunky, going against my simple theme.
130 user_input = Redcarpet::Markdown.new(Redcarpet::Render::Safe,
131 fenced_code_blocks: true).render(comment)
132 <<EOF
133 <div class="comment">
134 #{user_input}
135 <p class="comment-date">#{Time.at(date).strftime("%b %-d '%y")}</p>
136 </div>
137 EOF
138 end
139
140
141 def techpost(file)
142
143 b = File.basename(file,'.md')
144 # double dash for one dash, single dash for space
145 title = b.gsub(/--|-/, '--' => '-', '-' => ' ')
146
147 md = File.read(file)
148 page_html = "<h1>#{title}</h1><b>Contents</b>"
149 renderer = Redcarpet::Render::HTML_TOC.new(nesting_level: 2)
150 page_html += Redcarpet::Markdown.new(renderer, fenced_code_blocks: true).render(md)
151
152 renderer = HTMLwithPygments.new(with_toc_data: true)
153 page_html += Redcarpet::Markdown.new(renderer, fenced_code_blocks: true).render(md)
154
155 header_rel = ' / <a href="/technical-notes.html">technical notes</a> /'
156 fskel("/technical-notes/#{b}.html", title, page_html,
157 header: header_rel,
158 prose: true)
159
160 technotes_index_entry = "<li><a href=\"/technical-notes/#{b}.html\">#{title}</a></li>"
161 return technotes_index_entry
162
163 end
164
165 def post(file, build_time=false)
166 content = File.read(file)
167 content =~ %r{\A(---\s*\n.*?\n?)^((---)\s*$\n?)}m # yaml front matter
168 # stuff after last match. jekyll uses $POSTMATCH,
169 # but it's nil for me, I don't know what magic they are using.,
170 # but only $' is listed here http://ruby-doc.org/core-2.3.1/doc/globals_rdoc.html,
171 content = $'
172
173 front = SafeYAML.load(Regexp.last_match(1))
174 title = front['title']
175 $page_title = "#{title} | #{DN}"
176 header_rel = ' / <a href="/blog.html">blog</a> /'
177
178 footer_extra = <<-EOF
179 <p><a class="icon-rss" href="/feed.xml">Subscribe</a></p>
180 EOF
181 footer_extra += JS_INFO if content =~ /<script/
182
183
184
185 b = File.basename(file,'.md')
186 # date is in the format: YYYY-MM-DD-
187 date = Time.parse(b[0..DATE_LEN])
188 rel_path = "/blog/#{b[(DATE_LEN + 1)..-1]}.html"
189 comments = $db.execute <<-SQL, [WAIT_DATE]
190 select comment, date from c
191 where page = '#{rel_path}' and (
192 state = 'picked' or state = 'known' or state = 'timed'
193 or (state = 'waiting' and date < ?))
194 SQL
195 # get earliest comment. earlier ones stored in git will also be
196 # published. This get's us easily sharable comments, and allows us
197 # to expire unpublished comments and ip addresses which are PII and
198 # should never be kept around indefinitely.
199 sql_start_date = $db.execute('select min(date) from c')[0][0] || NOW
200 comment_file_dir = "../comments/#{rel_path}"
201 old_comments = Dir["#{comment_file_dir}/*"].reduce([]) do |memo, f|
202 dt = File.basename(f).to_f
203 if dt < sql_start_date
204 memo << [File.read(f), dt]
205 else
206 FileUtils.rm(f) if build_time
207 memo
208 end
209 end
210 if build_time
211 FileUtils.mkdir_p comment_file_dir
212 comments.each do |c, c_date|
213 # fyi: there is an extremely small chance of 2 comments having
214 # the same floating point time and thus overwriting each other.
215 # Small enough that it won't happen at my site's scale.
216 File.write(File.join(comment_file_dir, c_date.to_s), c)
217 end
218 # Im slow at updating this site, it gets low traffic,
219 # https://piwik.org/docs/privacy/ suggests 3-6 months, so
220 # this cant be too bad.
221 $db.execute("delete from c where date < #{NOW - DAY*180}")
222 end
223 comments = old_comments + comments
224 pending_comments = $db.execute(<<-SQL, [WAIT_DATE])[0][0]
225 select count(*) from c
226 where page = '#{rel_path}' and
227 (state = 'waiting' and date > ? or state = 'suspect')
228 SQL
229
230 feed_html = md_to_html(content)
231 page_html = <<-EOF
232 <header class="post-header">
233 <h1 class="post-title">#{title}</h1>
234 <p class="post-date">#{date.strftime("%b %-d, %Y")}</p>
235 </header>
236 #{feed_html}
237 EOF
238 com_list = ''
239 comments.each { |c, date| com_list += comment_html(c, date) }
240 if pending_comments > 0
241 if pending_comments >= 2
242 text = "are #{pending_comments} new comments"
243 else
244 text = 'is 1 new comment'
245 end
246 com_list +=
247 comment_html("Note: there #{text} pending approval.", NOW)
248 end
249 com_section = <<-EOF
250 <form action="/cgi/comment" method="post">
251 <input class="misc-comment-input" type="text" name="url">
252 <input name="goto" type="hidden" value="#{rel_path}">
253 <textarea rows="10" name="comment" maxlength="1000"></textarea>
254 <input type="submit" value="Add a comment (markdown works)">
255 </form>
256 <div id="comments">
257 #{com_list}
258 </div>
259 EOF
260 links = front['comment_links']
261 if links
262 link_html = links.map { |name,url| "<a href=\"#{url}\">#{name}</a>" }
263 .join(', ')
264 com_section += (<<EOF)
265 <p>More comments at #{link_html}</p>
266 EOF
267 end
268
269 blog_toc_entry = "<li><a href=\"#{rel_path}\">#{title}</a></li>"
270
271 com_section = <<EOF
272 <div id="comment-section" class="comment-section">
273 #{com_section}
274 </div>
275 EOF
276
277
278 if front['description']
279 description = front['description']
280 else
281 # the first 300 saves ~ 1 ms
282 # regex for striping html from liquid template src
283 description = feed_html[0..300].gsub(/<script.*?<\/script>/m, '').
284 gsub(/<!--.*?-->/m, '').gsub(/<style.*?<\/style>/m, '').
285 gsub(/<.*?>/m, '')
286 if description.length > 160
287 description = description[0..156] + '...'
288 end
289 end
290
291 head = <<EOF
292 EOF
293
294 fskel(rel_path, title, page_html,
295 header: header_rel,
296 footer: footer_extra,
297 # We call the main content prose because it can contain
298 # code, so we want to left justify it, so we dont have
299 # to cram all the code into the middle and can use
300 # a wider area.
301 prose: true,
302 comments: com_section,
303 description: description)
304 url="#{DURL}#{rel_path}"
305
306
307 # following from https://creativecommons.org/choose,
308 # with the addition of "unless otherwise noted", for js licenses.
309 feed_copyright = <<-EOF
310 <a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/"><img alt="Creative Commons License" style="border-width:0" src="https://i.creativecommons.org/l/by-sa/4.0/88x31.png" /></a><br /><span xmlns:dct="http://purl.org/dc/terms/" href="http://purl.org/dc/dcmitype/Text" property="dct:title" rel="dct:type">#{title}</span> by <a xmlns:cc="http://creativecommons.org/ns#" href="#{url}" property="cc:attributionName" rel="cc:attributionURL">Ian Kelling</a> unless otherwise noted is licensed under a <a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>.
311 EOF
312
313 feed_entry = <<EOF
314 <entry>
315 <title>#{title}</title>
316 <link rel="alternate" href="#{url}"/>
317 <id>#{url}</id>
318 <updated>#{date.to_datetime.rfc3339}</updated>
319 <content type="html" xml:lang="en-us" xml:base="#{DURL}/blog">
320 <![CDATA[
321 #{feed_html}
322 ]]>
323 </content>
324 <rights>
325 #{feed_copyright}
326 </rights>
327 </entry>
328 EOF
329 return [feed_entry, blog_toc_entry]
330 end
331 end