diff --git a/LICENSE b/LICENSE
index d0c07f2..b240a91 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2023 Alex Kotov
+Copyright (c) 2023-2024 Alex Kotov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/exe/repubmark b/exe/repubmark
new file mode 100755
index 0000000..fca1e0c
--- /dev/null
+++ b/exe/repubmark
@@ -0,0 +1,46 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+lib = File.expand_path('../lib', __dir__).freeze
+$LOAD_PATH.unshift lib unless $LOAD_PATH.include? lib
+
+require 'bundler/setup'
+
+require 'repubmark'
+
+FORMATS = %w[word_count html gemtext].freeze
+
+$format = String(ARGV[0]).freeze
+raise "Invalid format: #{$format.inspect}" unless FORMATS.include? $format
+
+$template = $stdin.read.freeze
+
+$config = Repubmark::Config.new(
+ base_url: 'https://causa-arcana.com',
+ css_class_annotation: 'nice-annotation',
+ css_class_blockquote_figure: 'nice-blockquote',
+ css_class_figure_self: 'nice-figure',
+ css_class_figure_wrap: 'd-flex justify-content-center',
+ css_class_figures_left: 'col-xl-6',
+ css_class_figures_right: 'col-xl-6',
+ css_class_figures_wrap: 'row',
+ css_class_iframe_wrap: 'ratio ratio-16x9',
+ current_path: '/xx/blog/xxxx/xx/xx/xxx.xxx',
+ relative_urls: false,
+)
+
+$article = Repubmark::Elems::Article.new $config
+$article.tap do |article| # rubocop:disable Lint/UnusedBlockArgument
+ eval $template # rubocop:disable Security/Eval
+end
+
+case $format
+when 'word_count'
+ puts $article.word_count
+when 'html'
+ puts $article.to_html.strip
+when 'gemtext'
+ puts $article.to_gemtext.strip
+else
+ raise 'Unknown blog format'
+end
diff --git a/lib/repubmark.rb b/lib/repubmark.rb
new file mode 100644
index 0000000..ea23389
--- /dev/null
+++ b/lib/repubmark.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'cgi'
+require 'forwardable'
+require 'open3'
+require 'pathname'
+require 'uri'
+
+require_relative 'repubmark/config'
+require_relative 'repubmark/highlight'
+require_relative 'repubmark/setup'
+require_relative 'repubmark/titled_ref'
+
+require_relative 'repubmark/elems/base'
+
+# Top-level element
+require_relative 'repubmark/elems/article'
+
+# Always inside Article
+require_relative 'repubmark/elems/annotation'
+# Always inside Article, Chapter
+require_relative 'repubmark/elems/chapter'
+# Always inside Annotation, Blockquote, Chapter
+require_relative 'repubmark/elems/canvas'
+
+# Always inside Canvas
+require_relative 'repubmark/elems/blockquote'
+require_relative 'repubmark/elems/code_block'
+require_relative 'repubmark/elems/figures'
+require_relative 'repubmark/elems/iframe'
+require_relative 'repubmark/elems/paragraph'
+require_relative 'repubmark/elems/separator'
+
+# Always inside Canvas, Figures
+require_relative 'repubmark/elems/figure'
+# Always inside Canvas, ListItem
+require_relative 'repubmark/elems/list'
+# Always inside List
+require_relative 'repubmark/elems/list_item'
+
+# Always inside Caption, Quote
+require_relative 'repubmark/elems/joint'
+# Always inside Blockquote, Figure, ListItem, Paragraph
+require_relative 'repubmark/elems/caption'
+# Always inside Caption, Joint, Quote
+require_relative 'repubmark/elems/quote'
+
+# Always inside Joint
+require_relative 'repubmark/elems/abbrev'
+require_relative 'repubmark/elems/code_inline'
+require_relative 'repubmark/elems/fraction'
+require_relative 'repubmark/elems/note'
+require_relative 'repubmark/elems/section'
+require_relative 'repubmark/elems/special'
+require_relative 'repubmark/elems/text'
+require_relative 'repubmark/elems/link'
diff --git a/lib/repubmark/config.rb b/lib/repubmark/config.rb
new file mode 100644
index 0000000..b9d14de
--- /dev/null
+++ b/lib/repubmark/config.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Repubmark
+ class Config
+ OPTIONAL_KEYS = %i[
+ base_url
+ css_class_annotation
+ css_class_blockquote_figure
+ css_class_blockquote_blockquote
+ css_class_blockquote_figcaption
+ css_class_figure_self
+ css_class_figure_wrap
+ css_class_figures_left
+ css_class_figures_right
+ css_class_figures_wrap
+ css_class_iframe_wrap
+ current_path
+ relative_urls
+ ].freeze
+
+ def initialize(**kwargs)
+ raise unless (kwargs.keys.sort - OPTIONAL_KEYS).empty?
+
+ @kwargs = kwargs.freeze
+ end
+
+ def [](key)
+ OPTIONAL_KEYS.include?(key) ? @kwargs[key] : @kwargs.fetch(key)
+ end
+ end
+end
diff --git a/lib/repubmark/elems/abbrev.rb b/lib/repubmark/elems/abbrev.rb
new file mode 100644
index 0000000..b329b34
--- /dev/null
+++ b/lib/repubmark/elems/abbrev.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Abbrev < Base
+ parents :Joint
+
+ attr_reader :abbrev, :transcript
+
+ def initialize(parent, abbrev, transcript)
+ super parent
+
+ self.abbrev = abbrev
+ self.transcript = transcript
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = 1
+
+ def to_html
+ %(#{escape_abbrev}).freeze
+ end
+
+ def to_gemtext = abbrev
+
+ private
+
+ def abbrev=(abbrev)
+ @abbrev =
+ String(abbrev).split.join(' ').freeze.tap do |new_abbrev|
+ raise 'Empty string' if new_abbrev.empty?
+ end
+ end
+
+ def transcript=(transcript)
+ @transcript =
+ String(transcript).split.join(' ').freeze.tap do |new_transcript|
+ raise 'Empty string' if new_transcript.empty?
+ end
+ end
+
+ def escape_abbrev = CGI.escape_html(abbrev).freeze
+
+ def escape_transcript = CGI.escape_html(transcript).freeze
+ end
+ end
+end
diff --git a/lib/repubmark/elems/annotation.rb b/lib/repubmark/elems/annotation.rb
new file mode 100644
index 0000000..46b6db3
--- /dev/null
+++ b/lib/repubmark/elems/annotation.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Annotation < Base
+ parents :Article
+
+ def initialize(parent)
+ super parent
+ @canvas = Canvas.new self
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = @canvas.word_count
+
+ def to_html
+ [
+ %(
\n),
+ @canvas.to_html,
+ "
\n",
+ ].join.freeze
+ end
+
+ def to_gemtext = @canvas.to_gemtext
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def respond_to_missing?(method_name, _include_private)
+ @canvas.respond_to?(method_name) || super
+ end
+
+ def method_missing(method_name, ...)
+ if @canvas.respond_to? method_name
+ @canvas.public_send(method_name, ...)
+ else
+ super
+ end
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/article.rb b/lib/repubmark/elems/article.rb
new file mode 100644
index 0000000..8c7561e
--- /dev/null
+++ b/lib/repubmark/elems/article.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Article < Base
+ attr_reader :config
+
+ def initialize(config) # rubocop:disable Lint/MissingSuper
+ @parent = nil
+ self.config = config
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count
+ (@annotation&.word_count || 0) +
+ (@chapter&.word_count || 0)
+ end
+
+ def to_html
+ [
+ setup.prologue,
+ @annotation&.to_html,
+ @chapter&.to_html,
+ setup.epilogue,
+ ].compact.join.freeze
+ end
+
+ def to_gemtext
+ [
+ setup.prologue,
+ @annotation&.to_gemtext,
+ @chapter&.to_gemtext,
+ setup.epilogue,
+ ].compact.join("\n\n\n").freeze
+ end
+
+ #################
+ # Setup methods #
+ #################
+
+ def setup(&)
+ @setup ||= Setup.new(&)
+ end
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def annotation
+ raise 'Annotation already exists' if @annotation
+ raise 'Annotation after chapter' if @chapter
+
+ @annotation = Annotation.new self
+ yield @annotation
+ nil
+ end
+
+ def respond_to_missing?(method_name, _include_private)
+ chapter = @chapter || Chapter.new(self)
+ chapter.respond_to?(method_name) || super
+ end
+
+ def method_missing(method_name, ...)
+ chapter = @chapter || Chapter.new(self)
+ if chapter.respond_to? method_name
+ @chapter = chapter
+ @chapter.public_send(method_name, ...)
+ else
+ super
+ end
+ end
+
+ private
+
+ def config=(config)
+ unless config.instance_of? Config
+ raise TypeError, "Expected #{Config}, got #{config.class}"
+ end
+
+ @config = config
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/base.rb b/lib/repubmark/elems/base.rb
new file mode 100644
index 0000000..6feb062
--- /dev/null
+++ b/lib/repubmark/elems/base.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Base
+ attr_reader :parent
+
+ def initialize(parent)
+ self.parent = parent
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = 0
+
+ def to_html
+ raise NotImplementedError, "#{self.class}#to_html"
+ end
+
+ def to_gemtext
+ raise NotImplementedError, "#{self.class}#to_gemtext"
+ end
+
+ ##################
+ # Helper methods #
+ ##################
+
+ def config = parent.config
+
+ def setup = parent.setup
+
+ class << self
+ def parents(*args)
+ if @parents
+ raise ArgumentError, 'Invalid args' unless args.empty?
+
+ return @parents
+ end
+
+ @parents = args.map { |arg| "#{Elems}::#{arg}".freeze }.to_a.freeze
+ nil
+ end
+
+ def parent?(klass)
+ unless klass.instance_of? Class
+ raise TypeError, "Expected #{Class}, got #{klass.class}"
+ end
+
+ parents.include? klass.name
+ end
+ end
+
+ private
+
+ def parent=(parent)
+ unless parent.is_a? Base
+ raise TypeError, "Expected #{Base}, got #{parent.class}"
+ end
+
+ unless self.class.parent? parent.class
+ raise TypeError,
+ "Expected #{self.class.parents.join(', ')}, got #{parent.class}"
+ end
+
+ @parent = parent
+ end
+
+ def own_url(path)
+ config[:relative_urls] ? relative_url(path) : absolute_url(path)
+ end
+
+ def absolute_url(path)
+ base_url = String(config[:base_url]).strip.freeze
+ raise 'Invalid base URL' if base_url.empty?
+
+ path = "/#{path}" unless path.start_with? '/'
+ "#{base_url}#{path}"
+ end
+
+ def relative_url(path)
+ current_path = String(config[:current_path]).strip.freeze
+ raise 'Invalid current path URL' if current_path.empty?
+
+ Pathname
+ .new(path)
+ .relative_path_from("/#{current_path}/..")
+ .to_s
+ .freeze
+ end
+
+ def html_class(key)
+ if (value = config[:"css_class_#{key}"])
+ %( class="#{value}").freeze
+ else
+ ''
+ end
+ end
+
+ def count_words(str) = str.split.count
+ end
+ end
+end
diff --git a/lib/repubmark/elems/blockquote.rb b/lib/repubmark/elems/blockquote.rb
new file mode 100644
index 0000000..c019e50
--- /dev/null
+++ b/lib/repubmark/elems/blockquote.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Blockquote < Base
+ parents :Canvas
+
+ def initialize(parent)
+ super parent
+
+ @canvas = Canvas.new self
+ @caption = nil
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = @canvas.word_count + (@caption&.word_count || 0)
+
+ def to_html
+ [
+ "\n",
+ ].join.freeze
+ end
+
+ def to_gemtext
+ [
+ @canvas.to_gemtext.split("\n").map { |s| "> #{s}\n" }.join,
+ caption_gemtext,
+ ].join.freeze
+ end
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def caption
+ @caption = Caption.new self
+ yield @caption
+ nil
+ end
+
+ def respond_to_missing?(method_name, _include_private)
+ @canvas.respond_to?(method_name) || super
+ end
+
+ def method_missing(method_name, ...)
+ if @canvas.respond_to? method_name
+ raise 'Paragraph after caption' unless @caption.nil?
+
+ @canvas.public_send(method_name, ...)
+ else
+ super
+ end
+ end
+
+ private
+
+ def caption_html
+ return if @caption.nil?
+
+ [
+ "\n",
+ @caption.to_html,
+ "\n",
+ ].join.freeze
+ end
+
+ def caption_gemtext
+ "#{@caption.to_gemtext}\n".freeze unless @caption.nil?
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/canvas.rb b/lib/repubmark/elems/canvas.rb
new file mode 100644
index 0000000..41e463a
--- /dev/null
+++ b/lib/repubmark/elems/canvas.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Canvas < Base
+ parents :Annotation, :Blockquote, :Chapter
+
+ def initialize(parent)
+ super parent
+ @items = []
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = @items.sum(&:word_count)
+
+ def to_html = @items.map(&:to_html).join.freeze
+
+ def to_gemtext = @items.map(&:to_gemtext).join("\n").freeze
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def blockquote
+ blockquote = Blockquote.new self
+ @items << blockquote
+ yield blockquote
+ nil
+ end
+
+ def code_block(*args, **kwargs)
+ code_block = CodeBlock.new self, *args, **kwargs
+ @items << code_block
+ nil
+ end
+
+ def iframe(*args, **kwargs)
+ iframe = Iframe.new self, *args, **kwargs
+ @items << iframe
+ nil
+ end
+
+ def links_list
+ list = List.new self, links: true, ordered: false
+ @items << list
+ yield list
+ nil
+ end
+
+ def nice_figure(name, alt)
+ figure = Figure.new self, name, alt
+ @items << figure
+ yield figure if block_given?
+ nil
+ end
+
+ def nice_figures
+ figures = Figures.new self
+ @items << figures
+ yield figures
+ nil
+ end
+
+ def olist
+ list = List.new self, links: false, ordered: true
+ @items << list
+ yield list
+ nil
+ end
+
+ def paragraph
+ paragraph = Paragraph.new self
+ @items << paragraph
+ yield paragraph
+ nil
+ end
+
+ def separator
+ @items << Separator.new(self)
+ nil
+ end
+
+ def ulist
+ list = List.new self, links: false, ordered: false
+ @items << list
+ yield list
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/caption.rb b/lib/repubmark/elems/caption.rb
new file mode 100644
index 0000000..cf724b4
--- /dev/null
+++ b/lib/repubmark/elems/caption.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Caption < Base
+ include Joint::ForwardingBuilders
+
+ parents :Blockquote, :Figure, :ListItem, :Paragraph
+
+ def initialize(parent)
+ super parent
+ @items = []
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = @items.sum(&:word_count)
+
+ def to_html
+ [
+ '',
+ *@items.map(&:to_html),
+ '',
+ ].map { |s| "#{s}\n" }.join.freeze
+ end
+
+ def to_gemtext = @items.map(&:to_gemtext).join(' ').strip.freeze
+
+ ##################
+ # Helper methods #
+ ##################
+
+ def empty? = @items.empty?
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def joint
+ joint = Joint.new self
+ yield joint
+ @items << joint
+ nil
+ end
+
+ def quote(str = nil)
+ quote = Quote.new self
+ case [!!str, block_given?]
+ when [true, false] then quote.text str
+ when [false, true] then yield quote
+ else
+ raise 'Invalid args'
+ end
+ @items << quote
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/chapter.rb b/lib/repubmark/elems/chapter.rb
new file mode 100644
index 0000000..3d48161
--- /dev/null
+++ b/lib/repubmark/elems/chapter.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Chapter < Base
+ parents :Article, :Chapter
+
+ def initialize(parent, level = 1, title = nil)
+ super parent
+ self.level = level
+ self.title = title
+ verify!
+
+ @canvas = Canvas.new self
+ @chapters = []
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count
+ (@title ? count_words(@title) : 0) +
+ @canvas.word_count +
+ @chapters.sum(&:word_count)
+ end
+
+ def to_html
+ [
+ build_title_html,
+ @canvas.to_html,
+ *@chapters.map(&:to_html),
+ ].join.freeze
+ end
+
+ def to_gemtext
+ [
+ build_title_gemtext,
+ @canvas.to_gemtext,
+ *@chapters.map(&:to_gemtext),
+ ].join.freeze
+ end
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def chapter(title)
+ chapter = Chapter.new self, @level + 1, title
+ @chapters << chapter
+ yield chapter
+ nil
+ end
+
+ def respond_to_missing?(method_name, _include_private)
+ @canvas.respond_to?(method_name) || super
+ end
+
+ def method_missing(method_name, ...)
+ if @canvas.respond_to? method_name
+ raise 'Intro after chapters' unless @chapters.empty?
+
+ @canvas.public_send(method_name, ...)
+ else
+ super
+ end
+ end
+
+ private
+
+ def level=(level)
+ level = Integer level
+ raise unless level.positive?
+
+ @level = level
+ end
+
+ def title=(title)
+ return @title = nil if title.nil?
+
+ title = String(title).strip.freeze
+ raise 'Empty title' if title.empty?
+
+ @title = title
+ end
+
+ def verify!
+ raise 'Non-empty title for level 1' if @level == 1 && @title
+ raise 'Empty title for level >= 2' if @level != 1 && @title.nil?
+ end
+
+ def build_title_html
+ "#@title\n" if @level != 1
+ end
+
+ def build_title_gemtext
+ "\n#{'#' * @level} #@title\n\n" if @level != 1
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/code_block.rb b/lib/repubmark/elems/code_block.rb
new file mode 100644
index 0000000..350e1ac
--- /dev/null
+++ b/lib/repubmark/elems/code_block.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class CodeBlock < Base
+ parents :Canvas
+
+ attr_reader :syntax, :str, :indent
+
+ def initialize(parent, syntax, str, indent: 0)
+ super parent
+
+ self.syntax = syntax
+ self.str = str
+ self.indent = indent
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html = highlighted_str
+
+ def to_gemtext
+ [
+ "```\n",
+ "#{str.strip}\n",
+ "```\n",
+ ].join.freeze
+ end
+
+ private
+
+ def syntax=(syntax)
+ return @syntax = nil if syntax.nil?
+
+ unless syntax.instance_of? Symbol
+ raise TypeError, "Expected #{Symbol}, got #{syntax.class}"
+ end
+
+ @syntax = syntax
+ end
+
+ def str=(str)
+ str = String(str).freeze
+ raise 'Expected non-blank string' if str.strip.empty?
+
+ @str = str
+ end
+
+ def indent=(indent)
+ @indent = Integer indent
+ raise 'Expected non-negative number' if @indent.negative?
+ end
+
+ def highlighted_str
+ @highlighted_str ||= Highlight.call syntax, transformed_str
+ end
+
+ def transformed_str
+ @transformed_str ||=
+ str.lines.map { |line| "#{indentation}#{line}" }.join.freeze
+ end
+
+ def indentation
+ @indentation ||= (' ' * indent).freeze
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/code_inline.rb b/lib/repubmark/elems/code_inline.rb
new file mode 100644
index 0000000..5d6ba15
--- /dev/null
+++ b/lib/repubmark/elems/code_inline.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class CodeInline < Base
+ parents :Joint
+
+ def initialize(parent, str)
+ super parent
+ self.str = str
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html = "#{CGI.escape_html(@str)}
".freeze
+
+ def to_gemtext = "«#{@str}»".freeze
+
+ private
+
+ def str=(str)
+ str = String(str).freeze
+ if str.include?("\n") || str.include?('«') || str.include?('»')
+ raise 'Invalid str'
+ end
+
+ @str = str
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/figure.rb b/lib/repubmark/elems/figure.rb
new file mode 100644
index 0000000..0e8c2c2
--- /dev/null
+++ b/lib/repubmark/elems/figure.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Figure < Base
+ parents :Canvas, :Figures
+
+ def initialize(parent, name, alt)
+ super parent
+
+ alt = String(alt).strip.split.join(' ').freeze
+ raise 'Empty alt text' if alt.empty?
+
+ @name = String(name).strip.freeze
+ @alt = alt
+ @caption = Caption.new self
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html
+ [
+ "\n",
+ "
\n",
+ "
\n",
+ ].join.freeze
+ end
+
+ def to_gemtext
+ caption = @caption.to_gemtext
+ caption = @alt if caption.empty?
+ "=> #{src} #{caption}\n".freeze
+ end
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def respond_to_missing?(method_name, _include_private)
+ @caption.respond_to?(method_name) || super
+ end
+
+ def method_missing(method_name, ...)
+ if @caption.respond_to? method_name
+ @caption.public_send(method_name, ...)
+ else
+ super
+ end
+ end
+
+ private
+
+ def src
+ @src ||= own_url "/assets/images/blog/#@name"
+ end
+
+ def caption_html
+ if @caption.empty?
+ "#{CGI.escape_html(@alt)}\n"
+ else
+ @caption.to_html
+ end
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/figures.rb b/lib/repubmark/elems/figures.rb
new file mode 100644
index 0000000..b109e18
--- /dev/null
+++ b/lib/repubmark/elems/figures.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Figures < Base
+ parents :Canvas
+
+ def initialize(parent)
+ super parent
+ @figures = []
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html
+ raise 'Expected two figures' unless @figures.size == 2
+
+ [
+ "\n",
+ *@figures.flat_map do |figure|
+ [
+ "
\n",
+ figure.to_html,
+ "
\n",
+ ]
+ end,
+ "
\n",
+ ].join.freeze
+ end
+
+ def to_gemtext = @figures.map(&:to_gemtext).join.freeze
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def figure(name, alt)
+ figure = Figure.new self, name, alt
+ @figures << figure
+ yield figure if block_given?
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/fraction.rb b/lib/repubmark/elems/fraction.rb
new file mode 100644
index 0000000..6ab8fa9
--- /dev/null
+++ b/lib/repubmark/elems/fraction.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Fraction < Base
+ parents :Joint
+
+ def initialize(parent, top, bottom)
+ super parent
+
+ @top = Integer top
+ @bottom = Integer bottom
+
+ raise 'Expected top to be non-negative' if @top.negative?
+ raise 'Expected bottom to be positive' unless @bottom.positive?
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = 1
+
+ def to_html
+ "#@top⁄#@bottom".freeze
+ end
+
+ def to_gemtext = "#@top/#@bottom"
+ end
+ end
+end
diff --git a/lib/repubmark/elems/iframe.rb b/lib/repubmark/elems/iframe.rb
new file mode 100644
index 0000000..94c6513
--- /dev/null
+++ b/lib/repubmark/elems/iframe.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Iframe < Base
+ parents :Canvas
+
+ attr_reader :title, :src, :url
+
+ def initialize(parent, title, src, url = nil)
+ super parent
+
+ self.title = title
+ self.src = src
+ self.url = url || src
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html
+ [
+ "\n",
+ "\n",
+ "
\n",
+ ].join.freeze
+ end
+
+ def to_gemtext = "=> #{url} #{title}\n"
+
+ private
+
+ def title=(title)
+ title = String(title).strip.freeze
+ raise 'Empty title' if title.empty?
+
+ @title = title
+ end
+
+ def src=(src)
+ src = String(src).strip.freeze
+ raise 'Empty src' if src.empty?
+
+ @src = src
+ end
+
+ def url=(url)
+ url = String(url).strip.freeze
+ raise 'Empty url' if url.empty?
+
+ @url = url
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/joint.rb b/lib/repubmark/elems/joint.rb
new file mode 100644
index 0000000..93bfe88
--- /dev/null
+++ b/lib/repubmark/elems/joint.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Joint < Base
+ parents :Caption, :Quote
+
+ def initialize(parent)
+ super parent
+
+ @raw1 = nil
+ @base = nil
+ @raw2 = nil
+ @notes = []
+ @raw3 = nil
+
+ @context = nil
+ @context_note = false
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = components.sum(&:word_count)
+
+ def to_html = components.map(&:to_html).join.freeze
+
+ def to_gemtext = components.map(&:to_gemtext).join.freeze
+
+ ########################
+ # Builder methods: Raw #
+ ########################
+
+ def raw(str)
+ text = Text.new self, str
+ if @notes.any?
+ raise 'Joint raw text already exists' if @raw3
+
+ @raw3 = text
+ elsif @base
+ raise 'Joint raw text already exists' if @raw2
+
+ @raw2 = text
+ else
+ raise 'Joint raw text already exists' if @raw1
+
+ @raw1 = text
+ end
+ end
+
+ #########################
+ # Builder methods: Base #
+ #########################
+
+ def abbrev(abbrev, transcript) = base Abbrev.new self, abbrev, transcript
+
+ def bold(str) = base Text.new self, str, bold: true
+
+ def code_inline(str) = base CodeInline.new self, str
+
+ def ellipsis = base Special.new self, :ellipsis
+
+ def fraction(top, bottom) = base Fraction.new self, top, bottom
+
+ def italic(str) = base Text.new self, str, italic: true
+
+ def link(text, uri) = base Link.new self, text, uri
+
+ def link_italic(text, uri) = base Link.new self, text, uri, italic: true
+
+ def mdash = base Special.new self, :mdash
+
+ def quote(str = nil)
+ quote = Quote.new self
+ case [!!str, block_given?]
+ when [true, false] then quote.text str
+ when [false, true] then yield quote
+ else
+ raise 'Invalid args'
+ end
+ base quote
+ end
+
+ def quote_italic(str)
+ quote = Quote.new self
+ quote.italic str
+ base quote
+ end
+
+ def section(*args) = base Section.new self, *args
+
+ def text(str) = base Text.new self, str
+
+ ##########################
+ # Builder methods: Notes #
+ ##########################
+
+ def context(index, anchor)
+ raise 'Context already given' if @context
+
+ @context = [index, anchor]
+ nil
+ end
+
+ def context_note
+ raise 'No context given' if @context.nil?
+ raise 'Context already noted' if @context_note
+
+ @context_note = true
+ ref_note(*@context)
+ end
+
+ def ref_note(index, anchor)
+ note Note.new self, index, anchor
+ end
+
+ private
+
+ def components
+ raise 'No context note' if @context && !@context_note
+
+ [@raw1, @base, @raw2, *@notes, @raw3].compact
+ end
+
+ def base(elem)
+ raise 'Joint base already exists' if @base
+ raise 'Joint notes already exist' if @notes.any?
+
+ @base = elem
+ nil
+ end
+
+ def note(elem)
+ raise 'Joint base does not exists' if @base.nil?
+
+ @notes << elem
+ nil
+ end
+
+ module ForwardingBuilders
+ def joint = raise NotImplementedError, "#{self.class}#joint"
+
+ def abbrev(*args) = joint { |joint| joint.abbrev(*args) }
+
+ def bold(*args) = joint { |joint| joint.bold(*args) }
+
+ def code_inline(*args) = joint { |joint| joint.code_inline(*args) }
+
+ def ellipsis(*args) = joint { |joint| joint.ellipsis(*args) }
+
+ def fraction(*args) = joint { |joint| joint.fraction(*args) }
+
+ def italic(*args) = joint { |joint| joint.italic(*args) }
+
+ def link(*args) = joint { |joint| joint.link(*args) }
+
+ def link_italic(*args) = joint { |joint| joint.link_italic(*args) }
+
+ def mdash(*args) = joint { |joint| joint.mdash(*args) }
+
+ def quote_italic(*args) = joint { |joint| joint.quote_italic(*args) }
+
+ def section(*args) = joint { |joint| joint.section(*args) }
+
+ def text(*args) = joint { |joint| joint.text(*args) }
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/link.rb b/lib/repubmark/elems/link.rb
new file mode 100644
index 0000000..df306d2
--- /dev/null
+++ b/lib/repubmark/elems/link.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Link < Text
+ parents :Joint
+
+ SCHEMES = %w[http https].freeze
+
+ def initialize(parent, str, uri, **kwargs)
+ super parent, str, **kwargs
+
+ self.uri = uri
+ validate_uri!
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html
+ "#{str_to_html}".freeze
+ end
+
+ private
+
+ attr_reader :uri
+
+ def uri=(uri)
+ @uri = URI.parse(uri).freeze
+ end
+
+ def validate_uri!
+ raise 'Expected normalized URI' unless uri.normalize == uri
+ raise 'Expected no userinfo' unless uri.userinfo.nil?
+
+ validate_uri_http_absolute!
+ end
+
+ def validate_uri_http_absolute!
+ return unless uri.is_a?(URI::HTTP) && uri.absolute?
+
+ raise 'Invalid scheme' unless SCHEMES.include? uri.scheme
+ raise 'Expected hostname' unless uri.hostname
+ end
+
+ def build_uri(ext)
+ if uri.is_a?(URI::MailTo) || uri.absolute?
+ uri
+ else
+ own_url "#{uri}#{ext}"
+ end.to_s.freeze
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/list.rb b/lib/repubmark/elems/list.rb
new file mode 100644
index 0000000..9b42a45
--- /dev/null
+++ b/lib/repubmark/elems/list.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class List < Base
+ parents :Canvas, :ListItem
+
+ def initialize(parent, links:, ordered:)
+ super parent
+
+ @links = !!links
+ @ordered = !!ordered
+
+ @items = []
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = @items.sum(&:word_count)
+
+ def to_html
+ [
+ "<#{tag_name}>\n",
+ *@items.map(&:to_html),
+ "#{tag_name}>\n",
+ ].join.freeze
+ end
+
+ def to_gemtext = @items.map(&:to_gemtext).join
+
+ ##################
+ # Helper methods #
+ ##################
+
+ def level = parent.instance_of?(ListItem) ? parent.level + 1 : 0
+
+ def items_count = @items.count
+
+ def unicode_decor
+ return if level <= 1
+
+ "#{parent.unicode_decor_parent}#{parent.last? ? '⠀ ' : '│ '}"
+ end
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def item(...) = @links ? item_links(...) : item_nolinks(...)
+
+ private
+
+ def parent=(parent)
+ unless parent.instance_of?(Canvas) || parent.instance_of?(ListItem)
+ raise TypeError,
+ "Expected #{Canvas} or #{ListItem}, got #{parent.class}"
+ end
+
+ @parent = parent
+ end
+
+ def tag_name = @ordered ? 'ol' : 'ul'
+
+ def item_nolinks
+ list_item = ListItem.new self, @items.count
+ @items << list_item
+ yield list_item
+ nil
+ end
+
+ def item_links(ref_url, ref_title = nil)
+ titled_ref = TitledRef.new ref_url, ref_title
+ list_item = ListItem.new self, @items.count, titled_ref
+ @items << list_item
+ yield list_item if block_given?
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/list_item.rb b/lib/repubmark/elems/list_item.rb
new file mode 100644
index 0000000..9b73dd3
--- /dev/null
+++ b/lib/repubmark/elems/list_item.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class ListItem < Base
+ extend Forwardable
+
+ parents :List
+
+ attr_reader :index, :titled_ref
+
+ def initialize(parent, index, titled_ref = nil)
+ super parent
+
+ self.index = index
+ self.titled_ref = titled_ref
+
+ @caption = Caption.new self
+ @sublist = nil
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = @caption.word_count + (@sublist&.word_count || 0)
+
+ def to_html
+ [
+ "\n",
+ build_ref(:html),
+ @caption.to_html,
+ @sublist&.to_html,
+ "\n",
+ ].join.freeze
+ end
+
+ def to_gemtext
+ [
+ if titled_ref
+ "#{build_ref(:gemtext)}#{@caption.to_gemtext}".strip
+ else
+ "* #{unicode_decor_own}#{@caption.to_gemtext}".strip
+ end,
+ @sublist&.to_gemtext,
+ ].join("\n").freeze
+ end
+
+ ##################
+ # Helper methods #
+ ##################
+
+ def level = parent.level
+
+ def last? = index >= parent.items_count - 1
+
+ def unicode_decor_own
+ "#{parent.unicode_decor}#{last? ? '└' : '├'} " unless level.zero?
+ end
+
+ def unicode_decor_parent = parent.unicode_decor
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def subolist
+ raise 'No nested link lists' if titled_ref
+ raise 'Sublist already exists' if @sublist
+
+ @sublist = List.new self, links: false, ordered: true
+ yield @sublist
+ nil
+ end
+
+ def subulist
+ raise 'No nested link lists' if titled_ref
+ raise 'Sublist already exists' if @sublist
+
+ @sublist = List.new self, links: false, ordered: false
+ yield @sublist
+ nil
+ end
+
+ def respond_to_missing?(method_name, _include_private)
+ @caption.respond_to?(method_name) || super
+ end
+
+ def method_missing(method_name, ...)
+ if @caption.respond_to? method_name
+ raise 'Caption after sublist' if @sublist
+
+ @caption.public_send(method_name, ...)
+ else
+ super
+ end
+ end
+
+ private
+
+ def parent=(parent)
+ unless parent.instance_of? List
+ raise TypeError, "Expected #{List}, got #{parent.class}"
+ end
+
+ @parent = parent
+ end
+
+ def index=(index)
+ index = Integer index
+ raise 'Invalid index' if index.negative?
+
+ @index = index
+ end
+
+ def titled_ref=(titled_ref)
+ return @titled_ref = nil if titled_ref.nil?
+
+ unless titled_ref.instance_of? TitledRef
+ raise TypeError, "Expected #{TitledRef}, got #{titled_ref.class}"
+ end
+
+ @titled_ref = titled_ref
+ end
+
+ def build_ref(format)
+ return if titled_ref.nil?
+
+ case format
+ when :html
+ "#{titled_ref.title}\n".freeze
+ when :gemtext
+ "=> #{titled_ref.url} #{unicode_decor_own}#{titled_ref.title} ".freeze
+ else
+ raise 'Invalid format'
+ end
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/note.rb b/lib/repubmark/elems/note.rb
new file mode 100644
index 0000000..9d268db
--- /dev/null
+++ b/lib/repubmark/elems/note.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Note < Base
+ parents :Joint
+
+ UNICODE_SUPS = {
+ '1' => '¹',
+ '2' => '²',
+ '3' => '³',
+ '4' => '⁴',
+ '5' => '⁵',
+ '6' => '⁶',
+ '7' => '⁷',
+ '8' => '⁸',
+ '9' => '⁹',
+ '0' => '⁰',
+ }.freeze
+
+ def initialize(parent, index, anchor)
+ super parent
+ @index = index
+ @anchor = anchor
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html
+ [
+ '',
+ %(),
+ %([#{CGI.escape_html(@index.to_s)}]),
+ '',
+ '',
+ ].join.freeze
+ end
+
+ def to_gemtext = "⁽#{index_unicode_sup}⁾".freeze
+
+ private
+
+ def index_unicode_sup
+ @index_unicode_sup ||=
+ @index.to_s.each_char.map { |chr| UNICODE_SUPS.fetch chr }.join.freeze
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/paragraph.rb b/lib/repubmark/elems/paragraph.rb
new file mode 100644
index 0000000..9b46cd7
--- /dev/null
+++ b/lib/repubmark/elems/paragraph.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Paragraph < Base
+ parents :Canvas
+
+ def initialize(parent)
+ super parent
+ @caption = Caption.new self
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = @caption.word_count
+
+ def to_html = "\n#{@caption.to_html}
\n".freeze
+
+ def to_gemtext = "#{@caption.to_gemtext}\n".freeze
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def respond_to_missing?(method_name, _include_private)
+ @caption.respond_to?(method_name) || super
+ end
+
+ def method_missing(method_name, ...)
+ if @caption.respond_to?(method_name)
+ @caption.public_send(method_name, ...)
+ else
+ super
+ end
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/quote.rb b/lib/repubmark/elems/quote.rb
new file mode 100644
index 0000000..bb685d4
--- /dev/null
+++ b/lib/repubmark/elems/quote.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Quote < Base
+ include Joint::ForwardingBuilders
+
+ parents :Caption, :Joint, :Quote
+
+ def initialize(...)
+ super
+ @items = []
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = @items.sum(&:word_count)
+
+ def to_html = "«#{@items.map(&:to_html).join("\n")}»".freeze
+
+ def to_gemtext = "«#{@items.map(&:to_gemtext).join(' ')}»".freeze
+
+ ###################
+ # Builder methods #
+ ###################
+
+ def joint
+ joint = Joint.new self
+ yield joint
+ @items << joint
+ nil
+ end
+
+ def quote(str = nil)
+ quote = Quote.new self
+ case [!!str, block_given?]
+ when [true, false] then quote.text str
+ when [false, true] then yield quote
+ else
+ raise 'Invalid args'
+ end
+ @items << quote
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/section.rb b/lib/repubmark/elems/section.rb
new file mode 100644
index 0000000..02ac611
--- /dev/null
+++ b/lib/repubmark/elems/section.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Section < Base
+ parents :Joint
+
+ SECT_HTML = '§'
+ SECT_GEMTEXT = '§'
+
+ attr_reader :count, :text
+
+ def initialize(parent, *args)
+ super parent
+
+ case args.length
+ when 1
+ self.count = 1
+ self.text = args[0]
+ when 2
+ self.count, self.text = args
+ else
+ raise ArgumentError, 'Expected 1 or 2 arguments'
+ end
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = count_words @text
+
+ def to_html = "#{SECT_HTML * count}#{text}".freeze
+
+ def to_gemtext = "#{SECT_GEMTEXT * count}#{text}".freeze
+
+ private
+
+ def count=(count)
+ unless count.instance_of? Integer
+ raise TypeError, "Expected #{Integer}, got #{count.class}"
+ end
+ raise 'Expected positive count' unless count.positive?
+
+ @count = count
+ end
+
+ def text=(text)
+ text = String(text).strip.freeze
+ raise 'Expected non-empty text' if text.empty?
+
+ @text = text
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/elems/separator.rb b/lib/repubmark/elems/separator.rb
new file mode 100644
index 0000000..a686699
--- /dev/null
+++ b/lib/repubmark/elems/separator.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Separator < Base
+ parents :Canvas
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html = "
\n"
+
+ def to_gemtext = "---\n"
+ end
+ end
+end
diff --git a/lib/repubmark/elems/special.rb b/lib/repubmark/elems/special.rb
new file mode 100644
index 0000000..eb1c2e3
--- /dev/null
+++ b/lib/repubmark/elems/special.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ # TODO: maybe don't allow mdash everywhere
+ class Special < Base
+ parents :Joint
+
+ HTML = {
+ ellipsis: '…',
+ mdash: '—',
+ }.freeze
+
+ GEMTEXT = {
+ ellipsis: '…',
+ mdash: '—',
+ }.freeze
+
+ def initialize(parent, name)
+ super parent
+ name = String(name).to_sym.freeze
+ HTML.fetch name
+ @name = name
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def to_html = HTML.fetch @name
+
+ def to_gemtext = GEMTEXT.fetch @name
+ end
+ end
+end
diff --git a/lib/repubmark/elems/text.rb b/lib/repubmark/elems/text.rb
new file mode 100644
index 0000000..827971e
--- /dev/null
+++ b/lib/repubmark/elems/text.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Repubmark
+ module Elems
+ class Text < Base
+ parents :Joint
+
+ def initialize(parent, str, bold: false, italic: false)
+ super parent
+
+ self.str = str
+
+ @bold = !!bold
+ @italic = !!italic
+ end
+
+ #################
+ # Basic methods #
+ #################
+
+ def word_count = count_words @str
+
+ def to_html = str_to_html
+
+ def to_gemtext = @str
+
+ private
+
+ def str=(str)
+ @str = String(str).split.join(' ').freeze
+ end
+
+ def str_to_html
+ result = @str
+ result = "#{result}" if @italic
+ result = "#{result}" if @bold
+ result.freeze
+ end
+ end
+ end
+end
diff --git a/lib/repubmark/highlight.rb b/lib/repubmark/highlight.rb
new file mode 100644
index 0000000..f4bbb4d
--- /dev/null
+++ b/lib/repubmark/highlight.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Repubmark
+ class Highlight
+ NO_SYNTAX = 'txt'
+
+ NO_LINE_NUMBERS = %i[sh_hist].freeze
+
+ SYNTAXES = {
+ certbot: 'ini',
+ css: 'css',
+ hjson: nil,
+ html: 'html',
+ ini: 'ini',
+ knockd: nil,
+ nginx: 'nginx',
+ openssh: nil,
+ ruby: 'ruby',
+ sh_hist: nil,
+ sysctl: nil,
+ torrc: nil,
+ wireguard: 'ini',
+ yggdrasil: nil, # Hjson
+ }.freeze
+
+ private_class_method :new
+
+ def self.call(...) = new(...).call
+
+ attr_reader :syntax, :str
+
+ def initialize(syntax, str)
+ self.syntax = syntax
+ self.str = str
+ end
+
+ def call
+ Open3.popen2(*cmdline) do |stdin, stdout, wait_thr|
+ stdin.write str
+ stdin.close
+ result = stdout.read.freeze
+ raise 'Highlight error' unless wait_thr.value.success?
+
+ "#{result}
\n".freeze
+ end
+ end
+
+ private
+
+ def syntax=(syntax)
+ return @syntax = nil if syntax.nil?
+
+ unless syntax.instance_of? Symbol
+ raise TypeError, "Expected #{Symbol}, got #{syntax.class}"
+ end
+
+ SYNTAXES.fetch syntax
+
+ @syntax = syntax
+ end
+
+ def str=(str)
+ str = String(str).strip.freeze
+ raise 'Expected non-empty string' if str.empty?
+
+ @str = str
+ end
+
+ def cmdline
+ @cmdline ||= [
+ 'highlight',
+ '--stdout',
+ '--fragment',
+ '--out-format',
+ 'html',
+ '--syntax',
+ SYNTAXES[syntax] || NO_SYNTAX,
+ ].tap do |cmdline|
+ unless syntax.nil? || NO_LINE_NUMBERS.include?(syntax)
+ cmdline << '--line-numbers'
+ end
+ end.freeze
+ end
+ end
+end
diff --git a/lib/repubmark/setup.rb b/lib/repubmark/setup.rb
new file mode 100644
index 0000000..ac9af0e
--- /dev/null
+++ b/lib/repubmark/setup.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Repubmark
+ class Setup
+ def initialize(**kwargs)
+ kwargs.each { |key, value| public_send :"#{key}=", value }
+ yield self if block_given?
+ freeze
+ end
+
+ attr_reader :prologue, :epilogue
+
+ def prologue=(prologue)
+ prologue = String(prologue).strip.freeze
+ prologue = nil if prologue.empty?
+ @prologue = prologue
+ end
+
+ def epilogue=(epilogue)
+ epilogue = String(epilogue).strip.freeze
+ epilogue = nil if epilogue.empty?
+ @epilogue = epilogue
+ end
+
+ ##########
+ # Locale #
+ ##########
+
+ LOCALE_RE = /\A[a-z]{2,3}\z/
+ DEFAULT_LOCALE = :en
+
+ def locale = @locale || DEFAULT_LOCALE
+
+ def locale=(locale)
+ locale = String(locale).to_sym
+ raise 'Invalid locale' unless LOCALE_RE.match? locale
+ raise 'Locale has already been set' if @locale
+
+ @locale = locale
+ end
+ end
+end
diff --git a/lib/repubmark/titled_ref.rb b/lib/repubmark/titled_ref.rb
new file mode 100644
index 0000000..bda0e1b
--- /dev/null
+++ b/lib/repubmark/titled_ref.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Repubmark
+ class TitledRef
+ attr_reader :url, :title
+
+ def initialize(url, title)
+ self.url = url
+ self.title = title
+ end
+
+ private
+
+ attr_writer :url, :title
+ end
+end