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", + "\n", + @canvas.to_html, + "\n", + caption_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", + "\"#{CGI.escape_html(@alt)}\"/\n", + "
\n", + caption_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), + "\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