loading
Generated 2020-11-24T15:30:00+00:00

All Files ( 64.89% covered at 1.08 hits/line )

8 files in total.
712 relevant lines, 462 lines covered and 250 lines missed. ( 64.89% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
blog.rb 57.63 % 1225 590 340 250 1.04
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 % 28 17 17 0 1.00
spec/text_spec.rb 100.00 % 40 16 16 0 1.38

blog.rb

57.63% lines covered

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

17 relevant lines. 17 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 'Link' do
  5. 1 it 'should link to the permalink if it is specified' do
  6. 1 metadata = { 'permalink' => '/our-new-sid-meiers-civilization-inspired-budget/' }
  7. 1 expect_any_instance_of(::Blog::Tags::Link).to receive(:metadata).and_return(metadata)
  8. 1 actual = Liquid::Template.parse('{% link old/civ-budget.html %}').render.strip
  9. 1 expect(actual).to eq('/our-new-sid-meiers-civilization-inspired-budget/')
  10. end
  11. 1 it 'should return the webpath of the page by default' do
  12. 1 metadata = { }
  13. 1 expect_any_instance_of(::Blog::Tags::Link).to receive(:metadata).and_return(metadata)
  14. 1 actual = Liquid::Template.parse('{% link archives.html %}').render.strip
  15. 1 expect(actual).to eq('/archives.html')
  16. end
  17. end
  18. 1 describe 'Image' do
  19. 1 it 'should render the web path to the image' do
  20. 1 actual = Liquid::Template.parse('{% image banners/thing.png %}').render.strip
  21. 1 expect(actual).to eq '/images/banners/thing.png'
  22. end
  23. end
  24. 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