loading
Generated 2020-08-14T14:33:09+00:00

All Files ( 68.21% covered at 1.18 hits/line )

8 files in total.
758 relevant lines, 517 lines covered and 241 lines missed. ( 68.21% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
blog.rb 61.00 % 1276 618 377 241 1.17
spec/entry_spec.rb 100.00 % 22 12 12 0 1.00
spec/files_spec.rb 100.00 % 46 24 24 0 1.67
spec/math_spec.rb 100.00 % 35 15 15 0 1.40
spec/page_spec.rb 100.00 % 27 16 16 0 1.00
spec/runner_spec.rb 100.00 % 44 22 22 0 1.00
spec/tags_spec.rb 100.00 % 94 35 35 0 1.00
spec/text_spec.rb 100.00 % 40 16 16 0 1.38

blog.rb

61.0% lines covered

618 relevant lines. 377 lines covered and 241 lines missed.
    
  1. #!/usr/bin/env ruby
  2. # -*- mode: ruby -*-
  3. # frozen_string_literal: true
  4. 1 require 'date'
  5. 1 require 'fileutils'
  6. 1 require 'html-proofer'
  7. 1 require 'json'
  8. 1 require 'liquid'
  9. 1 require 'logger'
  10. 1 require 'open3'
  11. 1 require 'optparse'
  12. 1 require 'pathname'
  13. 1 require 'redcarpet'
  14. 1 require 'shellwords'
  15. 1 require 'singleton'
  16. 1 require 'time'
  17. 1 require 'yaml'
  18. begin
  19. 1 require 'liquid/debug'
  20. rescue LoadError # rubocop:disable Lint/SuppressedException
  21. end
  22. CONFIG = {
  23. 1 'author' => 'Alex Recker',
  24. 'email' => 'alex@reckerfamily.com',
  25. 'title' => 'Dear Journal',
  26. 'description' => 'Daily, public journal by Alex Recker',
  27. 'timezone' => 'CST',
  28. 'url' => 'https://www.alexrecker.com'
  29. }.freeze
  30. # Blog
  31. #
  32. # The greatest static site generator in the universe.
  33. 1 module Blog
  34. 1 def self.logger
  35. @logger ||= Logger.new(
  36. STDOUT,
  37. formatter: proc { |_sev, _dt, _name, msg| "blog: #{msg}\n" },
  38. level: Logger::INFO
  39. )
  40. end
  41. # Dates
  42. 1 module Dates
  43. 1 def slice_by_consecutive(dates)
  44. 20 dates.slice_when { |p, c| c != p - 1 && c != p + 1 }.to_a
  45. end
  46. 1 def calculate_streaks(dates)
  47. 2 slice_by_consecutive(dates).map do |pair|
  48. 4 first, last = pair.minmax
  49. {
  50. 8 'days' => (last - first).to_i,
  51. 'start' => first,
  52. 'end' => last
  53. }
  54. end
  55. end
  56. 1 def time_to_date(time)
  57. 1 ::Date.parse(time.strftime('%Y-%m-%d'))
  58. end
  59. 1 def date_to_time(date)
  60. 1 date.to_time.to_datetime
  61. end
  62. 1 def timezone
  63. 1 CONFIG.fetch('timezone', 'UTC')
  64. end
  65. 1 def to_uyd_date(date)
  66. 2 date.strftime('%A, %B %-d %Y')
  67. end
  68. 1 def today
  69. @today ||= Date.today.to_datetime.new_offset(offset).to_date
  70. end
  71. 1 def now
  72. @now ||= DateTime.now.new_offset(offset)
  73. end
  74. 1 def offset
  75. 1 Time.zone_offset(timezone)
  76. end
  77. 1 def parse_date(datestr)
  78. 1 ::Date.parse(datestr).to_datetime.new_offset(offset).to_date
  79. end
  80. end
  81. # Dependencies
  82. 1 module Dependencies
  83. 1 def self.images?
  84. require 'fastimage'
  85. require 'mini_magick'
  86. true
  87. rescue LoadError
  88. false
  89. end
  90. 1 def self.graphs?
  91. require 'gruff'
  92. true
  93. rescue LoadError
  94. false
  95. end
  96. 1 def self.server?
  97. require 'rack'
  98. require 'thin'
  99. true
  100. rescue LoadError
  101. false
  102. end
  103. end
  104. # Math
  105. 1 module Math
  106. 1 def average(numlist)
  107. 6 calc = numlist.inject { |sum, el| sum + el }.to_f / numlist.size
  108. 1 calc.round
  109. end
  110. 1 def total(numlist)
  111. 7 numlist.inject(0) { |sum, x| sum + x }
  112. end
  113. 1 def occurences(keys, targets)
  114. 1 results = Hash.new(0)
  115. 1 targets.each do |target|
  116. 12 results[target] += 1 if keys.include? target
  117. end
  118. 1 results
  119. end
  120. end
  121. # Files
  122. 1 module Files
  123. 1 def files(path)
  124. 8 Dir[File.join(path, '/**/*')].select { |o| File.file?(o) }
  125. end
  126. 1 def root
  127. 20 File.dirname(__FILE__)
  128. end
  129. 1 def path(*subpaths)
  130. 12 File.join(root, *subpaths)
  131. end
  132. 1 def relpath(root, path)
  133. 9 Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
  134. end
  135. 1 def webext(filename)
  136. 9 special_exts = { '.md' => '.html' }
  137. 9 newext = special_exts[File.extname(filename)]
  138. 9 filename = File.basename(filename, '.*') + newext unless newext.nil?
  139. 9 filename
  140. end
  141. 1 def webpath(path)
  142. 8 path = File.join(path, 'index.html') if File.extname(path).empty?
  143. 8 path = File.join(File.dirname(path), webext(File.basename(path)))
  144. 8 special_dirs = %w[pages entries] # treat these dirs like the root
  145. 8 parts = relpath(root, path).split('/')
  146. 8 parts = parts.drop(1) if special_dirs.include? parts.first
  147. 8 '/' + parts.join('/')
  148. end
  149. 1 def write(path, content)
  150. FileUtils.mkdir_p(File.dirname(path))
  151. File.open(path, 'w') { |f| f.write content }
  152. end
  153. end
  154. # Images
  155. 1 module Images
  156. 1 include Files
  157. 1 def image_extensions
  158. ['.jpg', '.jpeg', '.png', '.bmp', '.svg']
  159. end
  160. 1 def image?(filename)
  161. image_extensions.include? File.extname(filename)
  162. end
  163. 1 def images
  164. files(path('images')).select { |f| image?(f) }
  165. end
  166. 1 def dimensions(file)
  167. result = FastImage.size(file)
  168. if result.instance_of?(String)
  169. result.split('x').map(&:to_i)
  170. else
  171. result
  172. end
  173. end
  174. 1 def resize(file, dims)
  175. image = MiniMagick::Image.new(file)
  176. image.resize "#{dims.first}x#{dims.last}"
  177. end
  178. 1 def find_banner(basename)
  179. image_extensions.each do |ext|
  180. file = path('images/banners', basename + ext)
  181. return 'banners/' + File.basename(file) if File.exist? file
  182. end
  183. nil
  184. end
  185. end
  186. # Graphs
  187. 1 module Graphs
  188. 1 def self.generate_graphs(ctx)
  189. WordCount.new(ctx).write
  190. Swears.new(ctx).write
  191. end
  192. # Base Graph
  193. 1 module Base
  194. 1 include Files
  195. 1 attr_reader :ctx
  196. 1 def initialize(ctx)
  197. @ctx = ctx
  198. end
  199. 1 def graphs_join(path)
  200. path('images/graphs', path)
  201. end
  202. end
  203. # Word Count Graph
  204. 1 class WordCount
  205. 1 include Base
  206. 1 def posts
  207. ctx['entries'][0..6].reverse
  208. end
  209. 1 def word_counts
  210. posts.collect(&:word_count)
  211. end
  212. 1 def title
  213. format = '%m/%d/%y'
  214. first = posts.first.date.strftime(format)
  215. last = posts.last.date.strftime(format)
  216. "Word Count: #{first} - #{last}"
  217. end
  218. 1 def labels
  219. Hash[posts.each_with_index.map { |p, i| [i, p.date.strftime('%a')] }]
  220. end
  221. 1 def write
  222. g = ::Gruff::Line.new('800x600')
  223. g.theme = Gruff::Themes::PASTEL
  224. g.hide_legend = true
  225. g.labels = labels
  226. g.data :words, word_counts
  227. g.title = title
  228. g.x_axis_label = 'Day'
  229. g.y_axis_label = 'Word Count'
  230. g.minimum_value = 0
  231. g.write(graphs_join('words.png'))
  232. end
  233. end
  234. # Swears Chart
  235. 1 class Swears
  236. 1 include Base
  237. 1 def results
  238. data = ctx['stats'].swear_results.clone
  239. data.delete('total')
  240. data
  241. end
  242. 1 def write
  243. g = ::Gruff::Pie.new('800x600')
  244. g.theme = Gruff::Themes::PASTEL
  245. g.hide_legend = false
  246. g.legend_at_bottom = true
  247. g.minimum_value = 0
  248. results.each { |w, n| g.data w, n }
  249. g.write(graphs_join('swears.png'))
  250. end
  251. end
  252. end
  253. # Shell
  254. 1 module Shell
  255. 1 def self.run(cmd)
  256. out, err, status = Open3.capture3(cmd)
  257. return out if status.success?
  258. msg = <<~ERROR
  259. the command \`#{cmd}\` failed!
  260. --- exit
  261. #{status}
  262. --- stdout
  263. #{out}
  264. --- stderr
  265. #{err}
  266. ERROR
  267. raise msg
  268. end
  269. end
  270. # Text
  271. 1 module Text
  272. 1 def strip_metadata(txt)
  273. 3 txt.sub(/\A---(.|\n)*?---/, '').lstrip
  274. end
  275. 1 def parse_metadata(str)
  276. result = YAML.safe_load(str)
  277. if result.is_a? Hash
  278. result
  279. else
  280. {}
  281. end
  282. rescue Psych::Exception
  283. {}
  284. end
  285. 1 def extract_metadata(file)
  286. split = File.read(file).split('---')
  287. if split.count >= 3
  288. parse_metadata(split[1])
  289. else
  290. {}
  291. end
  292. end
  293. 1 def markdown_to_html(src)
  294. markdown.render(src)
  295. end
  296. 1 def markdown
  297. @markdown ||= ::Redcarpet::Markdown.new(
  298. Redcarpet::Render::HTML,
  299. fenced_code_blocks: true,
  300. space_after_headers: true
  301. )
  302. end
  303. 1 def to_words(str)
  304. str.split.map do |token|
  305. token.gsub!(/[^0-9a-z ']/i, '')
  306. token.downcase
  307. end
  308. end
  309. end
  310. # Logging
  311. 1 module Logging
  312. 1 def self.included(base)
  313. 3 base.extend(self)
  314. end
  315. 1 def logger
  316. ::Blog.logger
  317. end
  318. end
  319. # Git
  320. 1 module Git
  321. 1 def self.shorthead
  322. Shell.run('git rev-parse --short HEAD').chomp
  323. end
  324. 1 def self.head
  325. Shell.run('git rev-parse HEAD').chomp
  326. end
  327. 1 def self.commit_count
  328. Shell.run('git rev-list --count master').chomp
  329. end
  330. end
  331. # Template Compiler
  332. 1 class TemplateCompiler
  333. 1 include Files
  334. 1 include Singleton
  335. 1 def layouts
  336. @layouts ||= dir_as_template_hash(path('layouts'))
  337. end
  338. 1 def snippets
  339. 3 @snippets ||= dir_as_template_hash(path('snippets'))
  340. end
  341. 1 private
  342. 1 def dir_as_template_hash(dir)
  343. 1 results = {}
  344. 1 files(dir).each do |file|
  345. 7 contents = File.read(file)
  346. 7 results[File.basename(file)] = Liquid::Template.parse(
  347. contents, error_mode: :strict
  348. )
  349. end
  350. 1 results
  351. end
  352. end
  353. # Stats
  354. 1 class Stats
  355. 1 include Math
  356. 1 include Dates
  357. 1 attr_reader :entries
  358. 1 def initialize(entries)
  359. @entries = entries
  360. end
  361. 1 def to_liquid
  362. {
  363. 'total_words' => total(word_counts),
  364. 'average_words' => average(word_counts),
  365. 'total_posts' => entries.size,
  366. 'consecutive_posts' => consecutive_posts,
  367. 'swears' => swear_results
  368. }
  369. end
  370. 1 def swear_results
  371. @swear_results ||= calculate_swears
  372. end
  373. 1 private
  374. 1 def consecutive_posts
  375. calculate_streaks(entries.collect(&:date)).first['days']
  376. end
  377. 1 def word_counts
  378. @word_counts ||= entries.collect(&:word_count)
  379. end
  380. 1 def words
  381. entries.collect(&:words).flatten
  382. end
  383. 1 def calculate_swears
  384. results = Hash[count_swears]
  385. results['total'] = total(results.values)
  386. results
  387. end
  388. 1 def count_swears
  389. occurences(swears, words).reject { |_k, v| v.zero? }.sort_by { |_k, v| -v }
  390. end
  391. 1 def swears
  392. %w[
  393. ass
  394. asshole
  395. booger
  396. crap
  397. damn
  398. fart
  399. fuck
  400. hell
  401. jackass
  402. piss
  403. poop
  404. shit
  405. ]
  406. end
  407. end
  408. # Templating
  409. 1 module Templating
  410. 1 include Files
  411. 1 include Text
  412. 1 def template(str)
  413. ::Liquid::Template.parse(str, error_mode: :strict)
  414. end
  415. 1 def templating
  416. TemplateCompiler.instance
  417. end
  418. end
  419. # Filters
  420. 1 module Filters
  421. 1 def filename_to_alt(filename)
  422. 3 filename = filename.gsub('-', ' ')
  423. 3 filename = filename.gsub(/.png|.jpg|.jpeg/, '')
  424. 3 filename
  425. end
  426. 1 def pretty_number(num)
  427. num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
  428. end
  429. end
  430. 1 Liquid::Template.register_filter(Filters)
  431. # Tags
  432. 1 module Tags
  433. # Nickname
  434. 1 class Nickname < Liquid::Tag
  435. 1 def render(_context)
  436. [
  437. 'a sprawling mess of ruby'
  438. ].sample
  439. end
  440. end
  441. 1 Liquid::Template.register_tag('nickname', Nickname)
  442. # Link
  443. 1 class Link < Liquid::Tag
  444. 1 include Files
  445. 1 include Text
  446. 1 def initialize(name, markup, parse_context)
  447. 2 super
  448. 2 @page = markup.strip
  449. end
  450. 1 def render(_ctx)
  451. 2 href
  452. end
  453. 1 def href
  454. 2 metadata.fetch('permalink', webpath(target))
  455. end
  456. 1 def metadata
  457. extract_metadata(target)
  458. end
  459. 1 def target
  460. 2 @target ||= path('pages', @page)
  461. end
  462. end
  463. 1 Liquid::Template.register_tag('link', Link)
  464. # Image
  465. 1 class Image < Liquid::Tag
  466. 1 include Files
  467. 1 def initialize(name, markup, parse_context)
  468. 1 super
  469. 1 @image = markup
  470. end
  471. 1 def render(_context)
  472. 1 webpath(path('images', @image))
  473. end
  474. end
  475. 1 Liquid::Template.register_tag('image', Image)
  476. # Audio
  477. 1 class Audio < Liquid::Tag
  478. 1 include Files
  479. 1 def initialize(name, markup, parse_context)
  480. super
  481. @file = markup
  482. end
  483. 1 def render(_context)
  484. webpath(path('audio', @file))
  485. end
  486. end
  487. 1 Liquid::Template.register_tag('audio', Audio)
  488. # Asset
  489. 1 class Asset < Liquid::Tag
  490. 1 include Files
  491. 1 def initialize(name, markup, parse_context)
  492. 1 super
  493. 1 @file = markup
  494. end
  495. 1 def render(_context)
  496. webpath(path('assets', @file))
  497. end
  498. end
  499. 1 Liquid::Template.register_tag('asset', Asset)
  500. # Include
  501. 1 class Include < Liquid::Tag
  502. 1 include Files
  503. 1 def initialize(name, markup, parse_context)
  504. 3 super
  505. 3 @markup = markup.strip.gsub("\n", ' ')
  506. end
  507. 1 def render(context)
  508. 3 rendered = try_resolve_values(context, options)
  509. 3 TemplateCompiler.instance.snippets[filename].render(rendered)
  510. end
  511. 1 def options
  512. 7 @options ||= kwargs.map { |k| k.split('=') }.to_h
  513. end
  514. 1 def kwargs
  515. 3 Shellwords.split(@markup).drop(1)
  516. end
  517. 1 def filename
  518. 3 Shellwords.split(@markup).first
  519. end
  520. 1 def try_resolve_values(context, hash)
  521. # TODO: lol GROSS
  522. 3 rendered = {}
  523. 3 hash.each do |k, v|
  524. 4 result = context.find_variable(v)
  525. 4 if result.nil?
  526. 3 if v.include? '.'
  527. 3 parent = context.find_variable(v.split('.').first)
  528. 3 if parent.nil?
  529. 2 result = v
  530. else
  531. 2 result = v.split(".").drop(1).inject(parent) { |hash, key| hash[key] }
  532. end
  533. else
  534. result = v
  535. end
  536. end
  537. 4 rendered[k] = result
  538. end
  539. 3 rendered
  540. end
  541. end
  542. 1 Liquid::Template.register_tag('include', Include)
  543. end
  544. # Page
  545. 1 class Page
  546. 1 include Files
  547. 1 include Images
  548. 1 include Logging
  549. 1 include Templating
  550. 1 include Text
  551. 1 attr_reader :file, :site
  552. 1 def initialize(file)
  553. 5 @file = file
  554. end
  555. 1 def src_dir
  556. path('pages')
  557. end
  558. 1 def to_liquid
  559. metadata.merge(
  560. {
  561. 'description' => description,
  562. 'filename' => target_filename,
  563. 'permalink' => permalink,
  564. 'title' => title,
  565. 'url' => url,
  566. 'banner' => banner
  567. }
  568. )
  569. end
  570. 1 def filename
  571. 1 File.basename(@file)
  572. end
  573. 1 def target_filename
  574. webext(filename)
  575. end
  576. 1 def render!(ctx = {})
  577. logger.debug "rendering page #{file} -> #{target}"
  578. write(target, render(ctx))
  579. end
  580. 1 def content
  581. strip_metadata(File.read(file))
  582. end
  583. 1 def pre_layout(result)
  584. if File.extname(file) == '.md'
  585. markdown_to_html(result)
  586. else
  587. result
  588. end
  589. end
  590. 1 def render(ctx = {})
  591. full_ctx = ctx.merge(context)
  592. result = template(content).render(full_ctx)
  593. result = pre_layout(result)
  594. if layout == 'null'
  595. result
  596. else
  597. templating.layouts[layout].render(
  598. full_ctx.merge({ 'content' => result })
  599. )
  600. end
  601. end
  602. 1 def context
  603. {
  604. 'page' => self
  605. }
  606. end
  607. 1 def banner
  608. find_banner(File.basename(filename, '.md'))
  609. end
  610. 1 def title
  611. metadata['title']
  612. end
  613. 1 def description
  614. metadata['description']
  615. end
  616. 1 def layout
  617. metadata['layout'] || 'page.html'
  618. end
  619. 1 def target
  620. trg = if File.extname(permalink).empty?
  621. File.join(permalink, 'index.html')
  622. else
  623. permalink
  624. end
  625. path('site', trg)
  626. end
  627. 1 def permalink
  628. 3 metadata['permalink'] || webpath(file)
  629. end
  630. 1 def url
  631. CONFIG['url'] + permalink
  632. end
  633. 1 def metadata
  634. @metadata ||= extract_metadata(file)
  635. end
  636. end
  637. # Entry
  638. 1 class Entry < Page
  639. 1 include Dates
  640. 1 include Text
  641. 1 attr_writer :next
  642. 1 def self.list_from_files(files)
  643. entries = []
  644. entries << cur = new(files.shift) if files.any?
  645. while files.any?
  646. entries << nxt = new(files.shift, previous: cur)
  647. cur.next = nxt
  648. cur = nxt
  649. end
  650. entries
  651. end
  652. 1 def initialize(file, previous: nil)
  653. 2 super(file)
  654. 2 @previous = previous
  655. 2 @next = nil
  656. end
  657. 1 def src_dir
  658. path('entries')
  659. end
  660. 1 def description
  661. 1 metadata.fetch('title')
  662. end
  663. 1 def date
  664. 1 @date ||= parse_date(File.basename(filename, '.md'))
  665. end
  666. 1 def title
  667. 1 to_uyd_date(date)
  668. end
  669. 1 def to_liquid
  670. super.merge(
  671. {
  672. 'date' => date,
  673. 'datetime' => date_to_time(date),
  674. 'previous' => @previous,
  675. 'next' => @next
  676. }
  677. )
  678. end
  679. 1 def words
  680. to_words(content)
  681. end
  682. 1 def word_count
  683. @word_count ||= words.size
  684. end
  685. end
  686. # Builder
  687. 1 class Builder
  688. 1 include Files
  689. 1 include Images
  690. 1 include Logging
  691. 1 attr_reader :options
  692. 1 def self.generate_all!(options = {})
  693. context = {}
  694. builders = descendants.map { |b| b.new options }
  695. builders.each { |b| context.merge!(b.context) }
  696. builders.each { |b| b.generate(context) }
  697. builders.each { |b| b.validate(context) }
  698. end
  699. 1 def self.descendants
  700. ObjectSpace.each_object(Class).select { |klass| klass < self }
  701. end
  702. 1 def initialize(options)
  703. @options = options
  704. end
  705. 1 def generate(_ctx); end
  706. 1 def validate(_ctx); end
  707. 1 def context
  708. {}
  709. end
  710. end
  711. # Config Generator
  712. 1 class ConfigBuilder < Builder
  713. 1 def context
  714. { 'config' => CONFIG }
  715. end
  716. end
  717. # Date Builder
  718. 1 class DateBuilder < Builder
  719. 1 include Dates
  720. 1 def context
  721. {
  722. 'last_updated' => to_uyd_date(today),
  723. 'year' => today.year,
  724. }
  725. end
  726. end
  727. # Git Builder
  728. 1 class GitBuilder < Builder
  729. 1 def context
  730. {
  731. 'git' => {
  732. 'commit_count' => Git.commit_count,
  733. 'HEAD' => Git.head,
  734. 'shorthead' => Git.shorthead
  735. }
  736. }
  737. end
  738. end
  739. # Static Builder
  740. 1 class StaticBuilder < Builder
  741. 1 def dirs
  742. @dirs ||= %w[
  743. assets
  744. audio
  745. docs
  746. vids
  747. ].sort.select { |f| File.directory? path(f) }
  748. end
  749. 1 def generate(_ctx)
  750. logger.info "copying #{dirs.count} static dir(s) -> site/{#{dirs.join(',')}}"
  751. dirs.each do |dir|
  752. src = path(dir)
  753. trg = path('site', dir)
  754. logger.debug "copying #{src} -> #{trg}"
  755. FileUtils.copy_entry(src, trg)
  756. end
  757. end
  758. end
  759. # Docs Builder
  760. 1 class DocsBuilder < Builder
  761. 1 def generate(_ctx)
  762. logger.info "generating documentation -> site#{docs_permalink}"
  763. Shell.run "yard #{options} #{path('blog.rb')}"
  764. end
  765. 1 def options
  766. "-o #{target} -r #{path('README.md')} -q"
  767. end
  768. 1 def context
  769. {
  770. 'docs_permalink' => docs_permalink
  771. }
  772. end
  773. 1 def target
  774. path('site', docs_permalink)
  775. end
  776. 1 def docs_permalink
  777. '/docs/'
  778. end
  779. end
  780. # Coverage Builder
  781. 1 class CoverageBuilder < Builder
  782. 1 def context
  783. logger.info "generating coverage report -> site#{report_permalink}"
  784. Shell.run 'rspec'
  785. {
  786. 'coverage' => results['metrics'],
  787. 'coverage_permalink' => report_permalink
  788. }
  789. end
  790. 1 def report_permalink
  791. '/coverage/'
  792. end
  793. 1 def results
  794. @results ||= JSON.parse(File.read(expected_results_path))
  795. end
  796. 1 def expected_results_path
  797. path('tmp', 'coverage.json')
  798. end
  799. end
  800. # Image Builder
  801. 1 class ImageBuilder < Builder
  802. 1 def generate(_ctx)
  803. if resize?
  804. resizeable_images.each do |image|
  805. logger.info "resizing #{image}"
  806. resize(image, [800, 800])
  807. end
  808. else
  809. logger.info '(skipping image size scan)'
  810. end
  811. logger.info "caching #{images.count} image(s) -> site/images"
  812. cache_images
  813. end
  814. 1 def resize?
  815. options[:no_resize_images] != true && Dependencies.images?
  816. end
  817. 1 def cache_images
  818. FileUtils.copy_entry(src, target)
  819. end
  820. 1 def resizeable_images
  821. images.select { |i| too_big?(dimensions(i)) }
  822. end
  823. 1 def target
  824. path('site', 'images')
  825. end
  826. 1 def src
  827. path('images')
  828. end
  829. 1 def too_big?(dimensions)
  830. dimensions.first > 800 || dimensions.last > 800
  831. end
  832. end
  833. # Entry Builder
  834. 1 class EntryBuilder < Builder
  835. 1 def generate(ctx)
  836. logger.info "rendering #{entries.count} entries(s)"
  837. entries.each do |entry|
  838. entry.render!(ctx)
  839. end
  840. end
  841. 1 def context
  842. {
  843. 'entries' => entries,
  844. 'latest' => entries.first,
  845. 'stats' => Stats.new(entries)
  846. }
  847. end
  848. 1 def entries
  849. @entries ||= Entry.list_from_files(entry_files)
  850. end
  851. 1 private
  852. 1 def entry_files
  853. files(path('entries')).sort.reverse
  854. end
  855. end
  856. # Page Builder
  857. 1 class PageBuilder < Builder
  858. 1 def generate(ctx)
  859. logger.info "rendering #{pages.count} page(s)"
  860. pages.each do |page|
  861. page.render!(ctx)
  862. end
  863. end
  864. 1 def context
  865. {
  866. 'pages' => pages
  867. }
  868. end
  869. 1 def pages
  870. @pages ||= page_files.map { |f| Page.new(f) }
  871. end
  872. 1 private
  873. 1 def page_files
  874. files(path('pages')).sort
  875. end
  876. end
  877. # Feed Builder
  878. 1 class FeedBuilder < Builder
  879. 1 include Dates
  880. 1 include Templating
  881. 1 def target
  882. path('site', permalink)
  883. end
  884. 1 def permalink
  885. '/feed.xml'
  886. end
  887. 1 def generate(ctx)
  888. logger.info "rendering feed -> site#{permalink}"
  889. write(target, render(ctx))
  890. end
  891. 1 def render(ctx = {})
  892. template(content).render(ctx.merge(context))
  893. end
  894. 1 def context
  895. {
  896. 'feed_permalink' => permalink
  897. }
  898. end
  899. 1 def content
  900. <<~BOOYAKASHA
  901. <?xml version="1.0" encoding="utf-8"?>
  902. <feed xmlns="http://www.w3.org/2005/Atom">
  903. <generator uri="https://www.github.com/arecker/blog">
  904. blog
  905. </generator>
  906. <title>{{ config.title }}</title>
  907. <subtitle>{{ config.description }}</subtitle>
  908. <link href="{{ config.url }}{{ feed_permalink }}" rel="self"/>
  909. <updated>#{now.strftime}</updated>
  910. <author>
  911. <name>{{ config.author }}</name>
  912. <email>{{ config.email }}</email>
  913. </author>
  914. <id>{{ config.url }}/</id>
  915. {% for entry in entries limit:30 %}
  916. #{entry}
  917. {% endfor %}
  918. </feed>
  919. BOOYAKASHA
  920. end
  921. 1 def entry
  922. <<~XMLDADDY
  923. <entry>
  924. <title>{{ entry.title }}</title>
  925. <id>{{ entry.url }}</id>
  926. <link href="{{ entry.url }}" rel="alternate" type="text/html" title="{{ entry.description }}" />
  927. <published>{{ entry.datetime }}</published>
  928. <updated>{{ entry.datetime }}</updated>
  929. <summary><![CDATA[{{ entry.description }}]]></summary>
  930. </entry>
  931. XMLDADDY
  932. end
  933. end
  934. # Graph Builder
  935. 1 class GraphBuilder < Builder
  936. 1 def generate(ctx)
  937. if generate?
  938. logger.info 'generating graphs -> images/graphs'
  939. Graphs.generate_graphs(ctx)
  940. else
  941. logger.info '(skipping graph generation)'
  942. end
  943. end
  944. 1 def generate?
  945. options[:no_generate_graphs] != true && Dependencies.graphs?
  946. end
  947. end
  948. # Markup Validator
  949. 1 class MarkupValidator < Builder
  950. 1 def validate(ctx)
  951. @ctx = ctx
  952. if validate?
  953. logger.info "validating #{validate_files_count} pages(s) -> site/**/*.html"
  954. proof!
  955. else
  956. logger.info '(skipping HTML validation)'
  957. end
  958. end
  959. 1 def proof!
  960. logger.debug("ignoring: #{ignore_files}")
  961. HTMLProofer.check_directory(
  962. path('site'),
  963. file_ignore: ignore_files,
  964. disable_external: true,
  965. log_level: :error
  966. ).run
  967. end
  968. 1 def validate?
  969. options[:no_validate_html] != true
  970. end
  971. 1 def validate_files_count
  972. files(path('site')).count - ignore_files.count
  973. end
  974. 1 def docs
  975. files(path('site', @ctx['docs_permalink']))
  976. end
  977. 1 def coverage
  978. files(path('site', @ctx['coverage_permalink']))
  979. end
  980. 1 def ignore_files
  981. @ignore_files ||= coverage + docs
  982. end
  983. end
  984. # Runner
  985. 1 class Runner
  986. 1 include Files
  987. 1 include Logging
  988. 1 attr_reader :subcommand
  989. 1 ALLOWED_SUBCOMMANDS = [
  990. 'build',
  991. 'serve',
  992. 'watch'
  993. ]
  994. 1 def initialize(argv)
  995. 9 parser.parse(argv)
  996. 9 @subcommand = argv.pop
  997. end
  998. 1 def valid?
  999. 3 ALLOWED_SUBCOMMANDS.include? subcommand
  1000. end
  1001. 1 def verbose?
  1002. 4 options[:verbose] == true
  1003. end
  1004. 1 def banner
  1005. 9 "Usage: blog.rb -v <#{ALLOWED_SUBCOMMANDS.join('|')}>"
  1006. end
  1007. 1 def options
  1008. 7 @options ||= {}
  1009. end
  1010. 1 def parser
  1011. 18 @parser ||= OptionParser.new { |opts| make_options(opts) }
  1012. end
  1013. 1 def run!
  1014. preflight!
  1015. case subcommand
  1016. when 'build'
  1017. build!
  1018. when 'serve'
  1019. bail!('server dependenices not installed!') unless Dependencies.server?
  1020. build!
  1021. serve!
  1022. end
  1023. end
  1024. 1 def build!
  1025. pave!
  1026. Builder.generate_all!(options)
  1027. end
  1028. 1 def pave!
  1029. FileUtils.rm_rf path('site')
  1030. FileUtils.mkdir_p path('site')
  1031. end
  1032. 1 def serve!
  1033. Rack::Handler::Thin.run(
  1034. Rack::Builder.new do
  1035. use(Rack::Static, urls: [''], root: 'site', index: 'index.html')
  1036. run ->(_env) { [200, {}, ['hello!']] }
  1037. end, Host: '0.0.0.0', Port: 4000
  1038. )
  1039. end
  1040. 1 private
  1041. 1 def preflight!
  1042. bail! unless valid?
  1043. Blog.logger.level = ::Logger::DEBUG if verbose?
  1044. end
  1045. 1 def bail!(message = nil)
  1046. puts message || banner
  1047. exit(-1)
  1048. end
  1049. 1 def make_options(opts)
  1050. 9 opts.banner = banner
  1051. 12 opts.on('-v', '--verbose') { |_t| options[:verbose] = true }
  1052. 9 opts.on('--no-validate-html') { |_o| options[:no_validate_html] = true }
  1053. 9 opts.on('--no-resize-images') { |_o| options[:no_resize_images] = true }
  1054. 9 opts.on('--no-generate-graphs') { |_o| options[:no_generate_graphs] = true }
  1055. end
  1056. end
  1057. 1 def self.run!
  1058. parser = Runner.new(ARGV)
  1059. parser.run!
  1060. end
  1061. end
  1062. 1 Blog.run! if __FILE__ == $PROGRAM_NAME

spec/entry_spec.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'spec_helper'
  3. 1 describe Blog::Entry do
  4. 1 include Blog::Files
  5. 1 describe '#title' do
  6. 1 it 'should return the formatted date' do
  7. 1 entry = Blog::Entry.new('2020-01-01.md')
  8. 1 expect(entry.title).to eq('Wednesday, January 1 2020')
  9. end
  10. end
  11. 1 describe '#description' do
  12. 1 it 'should return the page title' do
  13. 1 entry = Blog::Entry.new('2020-01-01.md')
  14. 1 expect(entry).to receive(:metadata).and_return({ 'title' => 'eggs, farts, and drama mamas' })
  15. 1 expect(entry.description).to eq('eggs, farts, and drama mamas')
  16. end
  17. end
  18. end

spec/files_spec.rb

100.0% lines covered

24 relevant lines. 24 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'spec_helper'
  3. 1 describe Blog::Files do
  4. 15 let(:k) { Class.new { extend ::Blog::Files } }
  5. 3 let(:root) { File.expand_path(File.join(File.dirname(__FILE__), '../')) }
  6. 1 describe '#path' do
  7. 1 it 'should join paths to the repo root' do
  8. 1 expect(k.path('pages', 'index.html')).to eq(File.join(root, 'pages/index.html'))
  9. end
  10. 1 it 'should join directories to the repo root' do
  11. 1 expect(k.path('site')).to eq(File.join(root, 'site'))
  12. end
  13. end
  14. 1 describe '#relpath' do
  15. 1 it 'should calculate a relative path from an absolulte path' do
  16. 1 expected = 'old/index.html'
  17. 1 actual = k.relpath('/code/repo/', '/code/repo/old/index.html')
  18. 1 expect(actual).to eq(expected)
  19. end
  20. end
  21. 1 describe '#webpath' do
  22. 1 it 'should add a / to the beginning of the path' do
  23. 1 expect(k.webpath(k.path('assets/site.css'))).to eq('/assets/site.css')
  24. end
  25. 1 it 'should subtract special dirs from the path' do
  26. 1 expect(k.webpath(k.path('pages/index.html'))).to eq('/index.html')
  27. end
  28. 1 it 'should add index.html to bare directories' do
  29. 1 expect(k.webpath(k.path('pages/old/'))).to eq('/old/index.html')
  30. end
  31. end
  32. 1 describe '#webext' do
  33. 1 it 'should replace .md with .html' do
  34. 1 expect(k.webext('2020-06-01.md')).to eq('2020-06-01.html')
  35. end
  36. end
  37. end

spec/math_spec.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative 'spec_helper.rb'
  3. 1 describe Blog::Math do
  4. 7 let(:k) { Class.new { extend ::Blog::Math } }
  5. 1 describe '#average' do
  6. 1 it 'should average a list of integers' do
  7. 1 expect(k.average([1, 1, 2, 2, 3, 3])).to eq(2)
  8. end
  9. end
  10. 1 describe '#total' do
  11. 1 it 'should total a list of integers' do
  12. 1 expect(k.total([1, 1, 2, 2, 3, 3])).to eq(12)
  13. end
  14. end
  15. 1 describe '#occurences' do
  16. 1 it 'should count occurances of keys in a list of targets' do
  17. 1 keys = %w[Kelly Alex Sarah Frank]
  18. 1 text = 'Kelly and Alex and Sarah are siblings. Kelly and Sarah are sisters'
  19. expected = {
  20. 1 'Kelly' => 2,
  21. 'Alex' => 1,
  22. 'Sarah' => 2
  23. }
  24. 1 expect(k.occurences(keys, text.split)).to eq(expected)
  25. end
  26. end
  27. end

spec/page_spec.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'spec_helper'
  3. 1 describe Blog::Page do
  4. 1 include Blog::Files
  5. 1 describe '#permalink' do
  6. 1 it 'should return a permalink specified in the metadata' do
  7. 1 page = Blog::Page.new(path('pages/test.html'))
  8. 1 expect(page).to receive(:metadata).and_return({ 'permalink' => '/test/thing/' })
  9. 1 expect(page.permalink).to eq('/test/thing/')
  10. end
  11. 1 it 'should correctly construct a permalink to an html file' do
  12. 1 page = Blog::Page.new(path('pages/test.html'))
  13. 1 allow(page).to receive(:metadata).and_return({})
  14. 1 expect(page.permalink).to eq('/test.html')
  15. end
  16. 1 it 'should correctly construct a permalink to a markdown file' do
  17. 1 page = Blog::Page.new(path('pages/test.md'))
  18. 1 allow(page).to receive(:metadata).and_return({})
  19. 1 expect(page.permalink).to eq('/test.html')
  20. end
  21. end
  22. end

spec/runner_spec.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'spec_helper'
  3. 1 describe Blog::Runner do
  4. 1 describe '#subcommand' do
  5. 1 it 'should return the subcommand' do
  6. 1 expect(Blog::Runner.new(['build']).subcommand).to eq('build')
  7. end
  8. 1 it 'should return nil if nothing was passed' do
  9. 1 expect(Blog::Runner.new([]).subcommand).to eq(nil)
  10. end
  11. end
  12. 1 describe '#verbose?' do
  13. 1 it 'should true if -v was passed first' do
  14. 1 expect(Blog::Runner.new(['-v', 'build']).verbose?).to eq true
  15. end
  16. 1 it 'should true if -v or --verbose was passed last' do
  17. 1 expect(Blog::Runner.new(['build', '-v']).verbose?).to eq true
  18. 1 expect(Blog::Runner.new(['build', '--verbose']).verbose?).to eq true
  19. end
  20. 1 it 'should default to false' do
  21. 1 expect(Blog::Runner.new(['build']).verbose?).to eq false
  22. end
  23. end
  24. 1 describe '#valid?' do
  25. 1 it 'should return true when a valid subcommand is used' do
  26. 1 expect(Blog::Runner.new(['build']).valid?).to eq true
  27. end
  28. 1 it 'should return false when an invalid subcommand is used' do
  29. 1 expect(Blog::Runner.new(['boop']).valid?).to eq false
  30. end
  31. 1 it 'should return false no subcommand is used' do
  32. 1 expect(Blog::Runner.new([]).valid?).to eq false
  33. end
  34. end
  35. end

spec/tags_spec.rb

100.0% lines covered

35 relevant lines. 35 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'spec_helper'
  3. 1 describe Blog::Tags do
  4. 1 describe 'Include' do
  5. 1 it 'should just goddamn work' do
  6. 1 TEMPLATE = <<~BOOYAH
  7. {% include figure.html filename='wip-blog.png' caption="Work in
  8. progress. In lack of a more creative name, I'm just calling it 'blog'
  9. for now." %}
  10. BOOYAH
  11. 1 actual = Liquid::Template.parse(TEMPLATE.strip).render.gsub(/\s+/, ' ')
  12. 1 expected = <<~GROSS
  13. <figure>
  14. <a href="/images/wip-blog.png">
  15. <img alt="wip blog" src="/images/wip-blog.png" />
  16. </a>
  17. <figcaption>
  18. <p>Work in
  19. progress. In lack of a more creative name, I'm just calling it 'blog'
  20. for now.</p>
  21. </figcaption>
  22. </figure>
  23. GROSS
  24. 1 expect(actual).to eq(expected.gsub(/\s+/, ' '))
  25. end
  26. 1 it 'should resolve bare variables' do
  27. 1 template = <<~BOOYAH
  28. {% include figure.html filename=my_filename %}
  29. BOOYAH
  30. 1 actual = Liquid::Template.parse(template.strip).render({ 'my_filename' => 'test.png' }).gsub(/\s+/, ' ')
  31. 1 expected = <<~GROSS
  32. <figure>
  33. <a href="/images/test.png">
  34. <img alt="test" src="/images/test.png" />
  35. </a>
  36. </figure>
  37. GROSS
  38. 1 expect(actual).to eq(expected.gsub(/\s+/, ' '))
  39. end
  40. 1 it 'should resolve nested variables' do
  41. 1 template = <<~BOOYAH
  42. {% include figure.html filename=latest.image %}
  43. BOOYAH
  44. 1 latest = double('page')
  45. 1 expect(latest).to receive(:to_liquid).and_return({'image' => 'latest-image.png' })
  46. 1 actual = Liquid::Template.parse(template.strip).render({ 'latest' => latest }).gsub(/\s+/, ' ')
  47. 1 expected = <<~GROSS
  48. <figure>
  49. <a href="/images/latest-image.png">
  50. <img alt="latest image" src="/images/latest-image.png" />
  51. </a>
  52. </figure>
  53. GROSS
  54. 1 expect(actual).to eq(expected.gsub(/\s+/, ' '))
  55. end
  56. end
  57. 1 describe 'Link' do
  58. 1 it 'should link to the permalink if it is specified' do
  59. 1 metadata = { 'permalink' => '/our-new-sid-meiers-civilization-inspired-budget/' }
  60. 1 expect_any_instance_of(::Blog::Tags::Link).to receive(:metadata).and_return(metadata)
  61. 1 actual = Liquid::Template.parse('{% link old/civ-budget.html %}').render.strip
  62. 1 expect(actual).to eq('/our-new-sid-meiers-civilization-inspired-budget/')
  63. end
  64. 1 it 'should return the webpath of the page by default' do
  65. 1 metadata = { }
  66. 1 expect_any_instance_of(::Blog::Tags::Link).to receive(:metadata).and_return(metadata)
  67. 1 actual = Liquid::Template.parse('{% link archives.html %}').render.strip
  68. 1 expect(actual).to eq('/archives.html')
  69. end
  70. end
  71. 1 describe 'Image' do
  72. 1 it 'should render the web path to the image' do
  73. 1 actual = Liquid::Template.parse('{% image banners/thing.png %}').render.strip
  74. 1 expect(actual).to eq '/images/banners/thing.png'
  75. end
  76. end
  77. end

spec/text_spec.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'spec_helper'
  3. # rubocop:disable Metrics/BlockLength
  4. 1 describe Blog::Files do
  5. 7 let(:k) { Class.new { extend ::Blog::Text } }
  6. 1 describe '#strip_metadata' do
  7. 1 it 'should remove metadata from text' do
  8. 1 text = <<~FILE
  9. ---
  10. name: some page
  11. ---
  12. HELLO THERE!
  13. FILE
  14. 1 expected = <<~FILE
  15. HELLO THERE!
  16. FILE
  17. 1 actual = k.strip_metadata(text)
  18. 1 expect(actual.strip).to eq(expected.strip)
  19. end
  20. 1 it 'should do nothing to text without metadata' do
  21. 1 text = <<~FILE
  22. HELLO THERE!
  23. FILE
  24. 1 expected = <<~FILE
  25. HELLO THERE!
  26. FILE
  27. 1 actual = k.strip_metadata(text)
  28. 1 expect(actual).to eq(expected)
  29. end
  30. 1 it 'should still work for an empty string' do
  31. 1 expect(k.strip_metadata('')).to eq('')
  32. end
  33. end
  34. end
  35. # rubocop:enable Metrics/BlockLength