From 376781b3592203de7d3ceb01b1d7a07dc5bd87a7 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 1 Feb 2026 11:35:02 +0900 Subject: refactor(nuldoc): DOM builder --- services/nuldoc/lib/nuldoc.rb | 5 +- .../nuldoc/lib/nuldoc/components/global_footer.rb | 11 +- .../nuldoc/lib/nuldoc/components/global_headers.rb | 50 +- .../nuldoc/lib/nuldoc/components/page_layout.rb | 43 +- .../nuldoc/lib/nuldoc/components/pagination.rb | 61 +- .../lib/nuldoc/components/post_page_entry.rb | 24 +- .../lib/nuldoc/components/slide_page_entry.rb | 24 +- .../nuldoc/lib/nuldoc/components/static_script.rb | 21 +- .../lib/nuldoc/components/static_stylesheet.rb | 15 +- .../lib/nuldoc/components/table_of_contents.rb | 17 +- services/nuldoc/lib/nuldoc/components/tag_list.rb | 14 +- services/nuldoc/lib/nuldoc/dom.rb | 92 +- services/nuldoc/lib/nuldoc/dom/atom_xml.rb | 26 - services/nuldoc/lib/nuldoc/dom/atom_xml_builder.rb | 16 + services/nuldoc/lib/nuldoc/dom/builder.rb | 67 ++ services/nuldoc/lib/nuldoc/dom/html.rb | 56 -- services/nuldoc/lib/nuldoc/dom/html_builder.rb | 46 + services/nuldoc/lib/nuldoc/generators/about.rb | 2 +- services/nuldoc/lib/nuldoc/generators/atom.rb | 2 +- services/nuldoc/lib/nuldoc/generators/home.rb | 2 +- services/nuldoc/lib/nuldoc/generators/not_found.rb | 2 +- services/nuldoc/lib/nuldoc/generators/post.rb | 2 +- services/nuldoc/lib/nuldoc/generators/post_list.rb | 4 +- services/nuldoc/lib/nuldoc/generators/slide.rb | 2 +- .../nuldoc/lib/nuldoc/generators/slide_list.rb | 2 +- services/nuldoc/lib/nuldoc/generators/tag.rb | 2 +- services/nuldoc/lib/nuldoc/generators/tag_list.rb | 2 +- .../lib/nuldoc/markdown/parser/block_parser.rb | 930 ++++++++++----------- .../lib/nuldoc/markdown/parser/inline_parser.rb | 596 ++++++------- services/nuldoc/lib/nuldoc/markdown/transform.rb | 86 +- services/nuldoc/lib/nuldoc/pages/about_page.rb | 113 +-- services/nuldoc/lib/nuldoc/pages/atom_page.rb | 39 +- services/nuldoc/lib/nuldoc/pages/home_page.rb | 53 +- services/nuldoc/lib/nuldoc/pages/not_found_page.rb | 25 +- services/nuldoc/lib/nuldoc/pages/post_list_page.rb | 39 +- services/nuldoc/lib/nuldoc/pages/post_page.rb | 63 +- .../nuldoc/lib/nuldoc/pages/slide_list_page.rb | 30 +- services/nuldoc/lib/nuldoc/pages/slide_page.rb | 98 ++- services/nuldoc/lib/nuldoc/pages/tag_list_page.rb | 46 +- services/nuldoc/lib/nuldoc/pages/tag_page.rb | 42 +- services/nuldoc/lib/nuldoc/renderers/html.rb | 2 +- services/nuldoc/lib/nuldoc/renderers/xml.rb | 2 +- 42 files changed, 1451 insertions(+), 1323 deletions(-) delete mode 100644 services/nuldoc/lib/nuldoc/dom/atom_xml.rb create mode 100644 services/nuldoc/lib/nuldoc/dom/atom_xml_builder.rb create mode 100644 services/nuldoc/lib/nuldoc/dom/builder.rb delete mode 100644 services/nuldoc/lib/nuldoc/dom/html.rb create mode 100644 services/nuldoc/lib/nuldoc/dom/html_builder.rb (limited to 'services/nuldoc/lib') diff --git a/services/nuldoc/lib/nuldoc.rb b/services/nuldoc/lib/nuldoc.rb index 83df42d6..cb94051d 100644 --- a/services/nuldoc/lib/nuldoc.rb +++ b/services/nuldoc/lib/nuldoc.rb @@ -11,8 +11,9 @@ require 'webrick' require_relative 'nuldoc/pipeline' require_relative 'nuldoc/dom' -require_relative 'nuldoc/dom/atom_xml' -require_relative 'nuldoc/dom/html' +require_relative 'nuldoc/dom/builder' +require_relative 'nuldoc/dom/html_builder' +require_relative 'nuldoc/dom/atom_xml_builder' require_relative 'nuldoc/revision' require_relative 'nuldoc/config' require_relative 'nuldoc/page' diff --git a/services/nuldoc/lib/nuldoc/components/global_footer.rb b/services/nuldoc/lib/nuldoc/components/global_footer.rb index 879aac55..8440d7d8 100644 --- a/services/nuldoc/lib/nuldoc/components/global_footer.rb +++ b/services/nuldoc/lib/nuldoc/components/global_footer.rb @@ -1,10 +1,13 @@ module Nuldoc module Components - class GlobalFooter - extend DOM::HTML + class GlobalFooter < DOM::HTMLBuilder + def initialize(config:) + super() + @config = config + end - def self.render(config:) - footer(class: 'footer') { text "© #{config.site.copyright_year} #{config.site.author}" } + def build + footer(class: 'footer') { text "© #{@config.site.copyright_year} #{@config.site.author}" } end end end diff --git a/services/nuldoc/lib/nuldoc/components/global_headers.rb b/services/nuldoc/lib/nuldoc/components/global_headers.rb index fb19096a..ab62a5f0 100644 --- a/services/nuldoc/lib/nuldoc/components/global_headers.rb +++ b/services/nuldoc/lib/nuldoc/components/global_headers.rb @@ -1,41 +1,50 @@ module Nuldoc module Components - class DefaultGlobalHeader - extend DOM::HTML + class DefaultGlobalHeader < DOM::HTMLBuilder + def initialize(config:) + super() + @config = config + end - def self.render(config:) + def build header(class: 'header') do div(class: 'site-logo') do - a(href: "https://#{config.sites.default.fqdn}/") { text 'nsfisis.dev' } + a(href: "https://#{@config.sites.default.fqdn}/") { text 'nsfisis.dev' } end end end end - class AboutGlobalHeader - extend DOM::HTML + class AboutGlobalHeader < DOM::HTMLBuilder + def initialize(config:) + super() + @config = config + end - def self.render(config:) + def build header(class: 'header') do div(class: 'site-logo') do - a(href: "https://#{config.sites.default.fqdn}/") { text 'nsfisis.dev' } + a(href: "https://#{@config.sites.default.fqdn}/") { text 'nsfisis.dev' } end end end end - class BlogGlobalHeader - extend DOM::HTML + class BlogGlobalHeader < DOM::HTMLBuilder + def initialize(config:) + super() + @config = config + end - def self.render(config:) + def build header(class: 'header') do div(class: 'site-logo') do - a(href: "https://#{config.sites.default.fqdn}/") { text 'nsfisis.dev' } + a(href: "https://#{@config.sites.default.fqdn}/") { text 'nsfisis.dev' } end - div(class: 'site-name') { text config.sites.blog.site_name } + div(class: 'site-name') { text @config.sites.blog.site_name } nav(class: 'nav') do ul do - li { a(href: "https://#{config.sites.about.fqdn}/") { text 'About' } } + li { a(href: "https://#{@config.sites.about.fqdn}/") { text 'About' } } li { a(href: '/posts/') { text 'Posts' } } li { a(href: '/tags/') { text 'Tags' } } end @@ -44,17 +53,20 @@ module Nuldoc end end - class SlidesGlobalHeader - extend DOM::HTML + class SlidesGlobalHeader < DOM::HTMLBuilder + def initialize(config:) + super() + @config = config + end - def self.render(config:) + def build header(class: 'header') do div(class: 'site-logo') do - a(href: "https://#{config.sites.default.fqdn}/") { text 'nsfisis.dev' } + a(href: "https://#{@config.sites.default.fqdn}/") { text 'nsfisis.dev' } end nav(class: 'nav') do ul do - li { a(href: "https://#{config.sites.about.fqdn}/") { text 'About' } } + li { a(href: "https://#{@config.sites.about.fqdn}/") { text 'About' } } li { a(href: '/slides/') { text 'Slides' } } li { a(href: '/tags/') { text 'Tags' } } end diff --git a/services/nuldoc/lib/nuldoc/components/page_layout.rb b/services/nuldoc/lib/nuldoc/components/page_layout.rb index 4ddd0968..d1b31744 100644 --- a/services/nuldoc/lib/nuldoc/components/page_layout.rb +++ b/services/nuldoc/lib/nuldoc/components/page_layout.rb @@ -1,32 +1,45 @@ module Nuldoc module Components - class PageLayout - extend DOM::HTML + class PageLayout < DOM::HTMLBuilder + def initialize(meta_copyright_year:, meta_description:, meta_title:, site:, config:, children:, + meta_keywords: nil, meta_atom_feed_href: nil) + super() + @meta_copyright_year = meta_copyright_year + @meta_description = meta_description + @meta_title = meta_title + @site = site + @config = config + @children = children + @meta_keywords = meta_keywords + @meta_atom_feed_href = meta_atom_feed_href + end - def self.render(meta_copyright_year:, meta_description:, meta_title:, site:, config:, children:, - meta_keywords: nil, meta_atom_feed_href: nil) - site_entry = config.site_entry(site) + def build + site_entry = @config.site_entry(@site) html(lang: 'ja-JP') do head do meta(charset: 'UTF-8') meta(name: 'viewport', content: 'width=device-width, initial-scale=1.0') - meta(name: 'author', content: config.site.author) - meta(name: 'copyright', content: "© #{meta_copyright_year} #{config.site.author}") - meta(name: 'description', content: meta_description) - meta(name: 'keywords', content: meta_keywords.join(',')) if meta_keywords && !meta_keywords.empty? + meta(name: 'author', content: @config.site.author) + meta(name: 'copyright', content: "© #{@meta_copyright_year} #{@config.site.author}") + meta(name: 'description', content: @meta_description) + if @meta_keywords && !@meta_keywords.empty? + meta(name: 'keywords', + content: @meta_keywords.join(',')) + end meta(property: 'og:type', content: 'article') - meta(property: 'og:title', content: meta_title) - meta(property: 'og:description', content: meta_description) + meta(property: 'og:title', content: @meta_title) + meta(property: 'og:description', content: @meta_description) meta(property: 'og:site_name', content: site_entry.site_name) meta(property: 'og:locale', content: 'ja_JP') meta(name: 'Hatena::Bookmark', content: 'nocomment') - link(rel: 'alternate', href: meta_atom_feed_href, type: 'application/atom+xml') if meta_atom_feed_href + link(rel: 'alternate', href: @meta_atom_feed_href, type: 'application/atom+xml') if @meta_atom_feed_href link(rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml') - title { text meta_title } - StaticStylesheet.render(file_name: '/style.css', config: config) + title { text @meta_title } + render(StaticStylesheet, file_name: '/style.css', config: @config) end - child children + child @children end end end diff --git a/services/nuldoc/lib/nuldoc/components/pagination.rb b/services/nuldoc/lib/nuldoc/components/pagination.rb index 61978cb1..3a6a5dc6 100644 --- a/services/nuldoc/lib/nuldoc/components/pagination.rb +++ b/services/nuldoc/lib/nuldoc/components/pagination.rb @@ -1,37 +1,46 @@ module Nuldoc module Components - class Pagination - extend DOM::HTML - - def self.render(current_page:, total_pages:, base_path:) - return div if total_pages <= 1 + class Pagination < DOM::HTMLBuilder + def initialize(current_page:, total_pages:, base_path:) + super() + @current_page = current_page + @total_pages = total_pages + @base_path = base_path + end - pages = generate_page_numbers(current_page, total_pages) + def build + if @total_pages <= 1 + div + else + pages = generate_page_numbers(@current_page, @total_pages) - nav(class: 'pagination') do - div(class: 'pagination-prev') do - a(href: page_url_at(base_path, current_page - 1)) { text '前へ' } if current_page > 1 - end - pages.each do |page| - if page == '...' - div(class: 'pagination-elipsis') { text "\u2026" } - elsif page == current_page - div(class: 'pagination-page pagination-page-current') do - span { text page.to_s } - end - else - div(class: 'pagination-page') do - a(href: page_url_at(base_path, page)) { text page.to_s } + nav(class: 'pagination') do + div(class: 'pagination-prev') do + a(href: page_url_at(@base_path, @current_page - 1)) { text '前へ' } if @current_page > 1 + end + pages.each do |page| + if page == '...' + div(class: 'pagination-elipsis') { text "\u2026" } + elsif page == @current_page + div(class: 'pagination-page pagination-page-current') do + span { text page.to_s } + end + else + div(class: 'pagination-page') do + a(href: page_url_at(@base_path, page)) { text page.to_s } + end end end - end - div(class: 'pagination-next') do - a(href: page_url_at(base_path, current_page + 1)) { text '次へ' } if current_page < total_pages + div(class: 'pagination-next') do + a(href: page_url_at(@base_path, @current_page + 1)) { text '次へ' } if @current_page < @total_pages + end end end end - def self.generate_page_numbers(current_page, total_pages) + private + + def generate_page_numbers(current_page, total_pages) pages = Set.new pages.add(1) pages.add([1, current_page - 1].max) @@ -57,11 +66,9 @@ module Nuldoc result end - def self.page_url_at(base_path, page) + def page_url_at(base_path, page) page == 1 ? base_path : "#{base_path}#{page}/" end - - private_class_method :generate_page_numbers, :page_url_at end end end diff --git a/services/nuldoc/lib/nuldoc/components/post_page_entry.rb b/services/nuldoc/lib/nuldoc/components/post_page_entry.rb index 623c2be6..4a9ab6c6 100644 --- a/services/nuldoc/lib/nuldoc/components/post_page_entry.rb +++ b/services/nuldoc/lib/nuldoc/components/post_page_entry.rb @@ -1,17 +1,21 @@ module Nuldoc module Components - class PostPageEntry - extend DOM::HTML + class PostPageEntry < DOM::HTMLBuilder + def initialize(post:, config:) + super() + @post = post + @config = config + end - def self.render(post:, config:) - published = Revision.date_to_string(GeneratorUtils.published_date(post)) - updated = Revision.date_to_string(GeneratorUtils.updated_date(post)) - has_updates = GeneratorUtils.any_updates?(post) + def build + published = Revision.date_to_string(GeneratorUtils.published_date(@post)) + updated = Revision.date_to_string(GeneratorUtils.updated_date(@post)) + has_updates = GeneratorUtils.any_updates?(@post) article(class: 'post-entry') do - a(href: post.href) do - header(class: 'entry-header') { h2 { text post.title } } - section(class: 'entry-content') { p { text post.description } } + a(href: @post.href) do + header(class: 'entry-header') { h2 { text @post.title } } + section(class: 'entry-content') { p { text @post.description } } footer(class: 'entry-footer') do time(datetime: published) { text published } text ' 投稿' @@ -20,7 +24,7 @@ module Nuldoc time(datetime: updated) { text updated } text ' 更新' end - TagList.render(tags: post.tags, config: config) if post.tags.length.positive? + render(TagList, tags: @post.tags, config: @config) if @post.tags.length.positive? end end end diff --git a/services/nuldoc/lib/nuldoc/components/slide_page_entry.rb b/services/nuldoc/lib/nuldoc/components/slide_page_entry.rb index c78d3d23..4aabc425 100644 --- a/services/nuldoc/lib/nuldoc/components/slide_page_entry.rb +++ b/services/nuldoc/lib/nuldoc/components/slide_page_entry.rb @@ -1,17 +1,21 @@ module Nuldoc module Components - class SlidePageEntry - extend DOM::HTML + class SlidePageEntry < DOM::HTMLBuilder + def initialize(slide:, config:) + super() + @slide = slide + @config = config + end - def self.render(slide:, config:) - published = Revision.date_to_string(GeneratorUtils.published_date(slide)) - updated = Revision.date_to_string(GeneratorUtils.updated_date(slide)) - has_updates = GeneratorUtils.any_updates?(slide) + def build + published = Revision.date_to_string(GeneratorUtils.published_date(@slide)) + updated = Revision.date_to_string(GeneratorUtils.updated_date(@slide)) + has_updates = GeneratorUtils.any_updates?(@slide) article(class: 'post-entry') do - a(href: slide.href) do - header(class: 'entry-header') { h2 { text slide.title } } - section(class: 'entry-content') { p { text slide.description } } + a(href: @slide.href) do + header(class: 'entry-header') { h2 { text @slide.title } } + section(class: 'entry-content') { p { text @slide.description } } footer(class: 'entry-footer') do time(datetime: published) { text published } text ' 登壇' @@ -20,7 +24,7 @@ module Nuldoc time(datetime: updated) { text updated } text ' 更新' end - TagList.render(tags: slide.tags, config: config) if slide.tags.length.positive? + render(TagList, tags: @slide.tags, config: @config) if @slide.tags.length.positive? end end end diff --git a/services/nuldoc/lib/nuldoc/components/static_script.rb b/services/nuldoc/lib/nuldoc/components/static_script.rb index 5c8af53d..fd6e1c3d 100644 --- a/services/nuldoc/lib/nuldoc/components/static_script.rb +++ b/services/nuldoc/lib/nuldoc/components/static_script.rb @@ -1,14 +1,21 @@ module Nuldoc module Components - class StaticScript - extend DOM::HTML + class StaticScript < DOM::HTMLBuilder + def initialize(file_name:, config:, site: nil, type: nil, defer: nil) + super() + @file_name = file_name + @config = config + @site = site + @type = type + @defer = defer + end - def self.render(file_name:, config:, site: nil, type: nil, defer: nil) - file_path = File.join(Dir.pwd, config.locations.static_dir, site || '_all', file_name) + def build + file_path = File.join(Dir.pwd, @config.locations.static_dir, @site || '_all', @file_name) hash = ComponentUtils.calculate_file_hash(file_path) - attrs = { src: "#{file_name}?h=#{hash}" } - attrs[:type] = type if type - attrs[:defer] = defer if defer + attrs = { src: "#{@file_name}?h=#{hash}" } + attrs[:type] = @type if @type + attrs[:defer] = @defer if @defer script(**attrs) end end diff --git a/services/nuldoc/lib/nuldoc/components/static_stylesheet.rb b/services/nuldoc/lib/nuldoc/components/static_stylesheet.rb index 5246ee1c..f0925e18 100644 --- a/services/nuldoc/lib/nuldoc/components/static_stylesheet.rb +++ b/services/nuldoc/lib/nuldoc/components/static_stylesheet.rb @@ -1,12 +1,17 @@ module Nuldoc module Components - class StaticStylesheet - extend DOM::HTML + class StaticStylesheet < DOM::HTMLBuilder + def initialize(file_name:, config:, site: nil) + super() + @file_name = file_name + @config = config + @site = site + end - def self.render(file_name:, config:, site: nil) - file_path = File.join(Dir.pwd, config.locations.static_dir, site || '_all', file_name) + def build + file_path = File.join(Dir.pwd, @config.locations.static_dir, @site || '_all', @file_name) hash = ComponentUtils.calculate_file_hash(file_path) - link(rel: 'stylesheet', href: "#{file_name}?h=#{hash}") + link(rel: 'stylesheet', href: "#{@file_name}?h=#{hash}") end end end diff --git a/services/nuldoc/lib/nuldoc/components/table_of_contents.rb b/services/nuldoc/lib/nuldoc/components/table_of_contents.rb index 0be95706..499ca681 100644 --- a/services/nuldoc/lib/nuldoc/components/table_of_contents.rb +++ b/services/nuldoc/lib/nuldoc/components/table_of_contents.rb @@ -1,23 +1,26 @@ module Nuldoc module Components - class TableOfContents - extend DOM::HTML + class TableOfContents < DOM::HTMLBuilder + def initialize(toc:) + super() + @toc = toc + end - def self.render(toc:) + def build nav(class: 'toc') do h2 { text '目次' } - ul { toc.items.each { |entry| toc_entry_component(entry) } } + ul { @toc.items.each { |entry| toc_entry_component(entry) } } end end - def self.toc_entry_component(entry) + private + + def toc_entry_component(entry) li do a(href: "##{entry.id}") { text entry.text } ul { entry.children.each { |c| toc_entry_component(c) } } if entry.children.length.positive? end end - - private_class_method :toc_entry_component end end end diff --git a/services/nuldoc/lib/nuldoc/components/tag_list.rb b/services/nuldoc/lib/nuldoc/components/tag_list.rb index 57d11ab0..acbcaaf3 100644 --- a/services/nuldoc/lib/nuldoc/components/tag_list.rb +++ b/services/nuldoc/lib/nuldoc/components/tag_list.rb @@ -1,13 +1,17 @@ module Nuldoc module Components - class TagList - extend DOM::HTML + class TagList < DOM::HTMLBuilder + def initialize(tags:, config:) + super() + @tags = tags + @config = config + end - def self.render(tags:, config:) + def build ul(class: 'entry-tags') do - tags.each do |slug| + @tags.each do |slug| li(class: 'tag') do - span(class: 'tag-inner') { text config.tag_label(slug) } + span(class: 'tag-inner') { text @config.tag_label(slug) } end end end diff --git a/services/nuldoc/lib/nuldoc/dom.rb b/services/nuldoc/lib/nuldoc/dom.rb index ec802fb6..abe625fe 100644 --- a/services/nuldoc/lib/nuldoc/dom.rb +++ b/services/nuldoc/lib/nuldoc/dom.rb @@ -1,63 +1,19 @@ module Nuldoc - Text = Struct.new(:content, keyword_init: true) do + Text = Data.define(:content) do def kind = :text end - RawHTML = Struct.new(:html, keyword_init: true) do + RawNode = Data.define(:content) do def kind = :raw end - Element = Struct.new(:name, :attributes, :children, keyword_init: true) do + Element = Data.define(:name, :attributes, :children) do def kind = :element end module DOM - CHILDREN_STACK_KEY = :__nuldoc_dom_children_stack - private_constant :CHILDREN_STACK_KEY - module_function - def text(content) - node = Text.new(content: content) - _auto_append(node) - node - end - - def raw_html(html) - node = RawHTML.new(html: html) - _auto_append(node) - node - end - - def child(*nodes) - stack = Thread.current[CHILDREN_STACK_KEY] - return unless stack && !stack.empty? - - nodes.each do |node| - case node - when nil, false - next - when String - stack.last.push(Text.new(content: node)) - when Array - node.each { |n| child(n) } - else - stack.last.push(node) - end - end - end - - def elem(name, **attrs, &) - children = _collect_children(&) - node = Element.new( - name: name, - attributes: attrs.transform_keys(&:to_s), - children: children - ) - _auto_append(node) - node - end - def add_class(element, klass) classes = element.attributes['class'] if classes.nil? @@ -104,6 +60,24 @@ module Nuldoc end end + def map_children_recursively(element, &) + element.children.map! do |c| + c = yield(c) + map_children_recursively(c, &) if c.kind == :element + c + end + end + + def map_element_of_type(root, element_name, &) + map_children_recursively(root) do |n| + if n.kind == :element && n.name == element_name + yield(n) + else + n + end + end + end + def process_text_nodes_in_element(element) new_children = [] element.children.each do |child| @@ -115,29 +89,5 @@ module Nuldoc end element.children.replace(new_children) end - - private - - def _collect_children(&block) - return [] unless block - - stack = Thread.current[CHILDREN_STACK_KEY] ||= [] - stack.push([]) - begin - yield - stack.last - ensure - stack.pop - end - end - - def _auto_append(node) - stack = Thread.current[CHILDREN_STACK_KEY] - return unless stack && !stack.empty? - - stack.last.push(node) - end - - module_function :_collect_children, :_auto_append end end diff --git a/services/nuldoc/lib/nuldoc/dom/atom_xml.rb b/services/nuldoc/lib/nuldoc/dom/atom_xml.rb deleted file mode 100644 index 9ab10822..00000000 --- a/services/nuldoc/lib/nuldoc/dom/atom_xml.rb +++ /dev/null @@ -1,26 +0,0 @@ -module Nuldoc - module DOM - module AtomXML - def self.extended(base) - base.extend(DOM) - end - - def self.included(base) - base.include(DOM) - end - - module_function - - def author(**attrs, &) = DOM.elem('author', **attrs, &) - def entry(**attrs, &) = DOM.elem('entry', **attrs, &) - def feed(**attrs, &) = DOM.elem('feed', **attrs, &) - def id(**attrs, &) = DOM.elem('id', **attrs, &) - def link(**attrs) = DOM.elem('link', **attrs) - def name(**attrs, &) = DOM.elem('name', **attrs, &) - def published(**attrs, &) = DOM.elem('published', **attrs, &) - def summary(**attrs, &) = DOM.elem('summary', **attrs, &) - def title(**attrs, &) = DOM.elem('title', **attrs, &) - def updated(**attrs, &) = DOM.elem('updated', **attrs, &) - end - end -end diff --git a/services/nuldoc/lib/nuldoc/dom/atom_xml_builder.rb b/services/nuldoc/lib/nuldoc/dom/atom_xml_builder.rb new file mode 100644 index 00000000..2d865b6e --- /dev/null +++ b/services/nuldoc/lib/nuldoc/dom/atom_xml_builder.rb @@ -0,0 +1,16 @@ +module Nuldoc + module DOM + class AtomXMLBuilder < Builder + def author(**attrs, &) = elem('author', **attrs, &) + def entry(**attrs, &) = elem('entry', **attrs, &) + def feed(**attrs, &) = elem('feed', **attrs, &) + def id(**attrs, &) = elem('id', **attrs, &) + def link(**attrs) = elem('link', **attrs) + def name(**attrs, &) = elem('name', **attrs, &) + def published(**attrs, &) = elem('published', **attrs, &) + def summary(**attrs, &) = elem('summary', **attrs, &) + def title(**attrs, &) = elem('title', **attrs, &) + def updated(**attrs, &) = elem('updated', **attrs, &) + end + end +end diff --git a/services/nuldoc/lib/nuldoc/dom/builder.rb b/services/nuldoc/lib/nuldoc/dom/builder.rb new file mode 100644 index 00000000..2d8751c5 --- /dev/null +++ b/services/nuldoc/lib/nuldoc/dom/builder.rb @@ -0,0 +1,67 @@ +module Nuldoc + module DOM + class Builder + def initialize + @stack = [] + end + + def build(&) + instance_eval(&) + end + + def text(content) + try_append(Text.new(content: content)) + end + + def raw(content) + try_append(RawNode.new(content: content)) + end + + def render(component, **) + try_append(component.new(**).build) + end + + def child(*nodes) + return if @stack.empty? + + nodes.each do |node| + case node + when nil, false + next + when String + try_append(Text.new(content: node)) + when Array + node.each { |n| child(n) } + else + try_append(node) + end + end + end + + def elem(name, **attrs, &) + try_append( + Element.new( + name: name, + attributes: attrs.transform_keys(&:to_s), + children: collect_children(&) + ) + ) + end + + private + + def collect_children + return [] unless block_given? + + @stack.push([]) + yield + @stack.pop + end + + def try_append(node) + @stack.last.push(node) unless @stack.empty? + node + end + end + end +end diff --git a/services/nuldoc/lib/nuldoc/dom/html.rb b/services/nuldoc/lib/nuldoc/dom/html.rb deleted file mode 100644 index 1d9b1cab..00000000 --- a/services/nuldoc/lib/nuldoc/dom/html.rb +++ /dev/null @@ -1,56 +0,0 @@ -module Nuldoc - module DOM - module HTML - def self.extended(base) - base.extend(DOM) - end - - def self.included(base) - base.include(DOM) - end - - module_function - - def a(**attrs, &) = DOM.elem('a', **attrs, &) - def article(**attrs, &) = DOM.elem('article', **attrs, &) - def blockquote(**attrs, &) = DOM.elem('blockquote', **attrs, &) - def body(**attrs, &) = DOM.elem('body', **attrs, &) - def button(**attrs, &) = DOM.elem('button', **attrs, &) - def canvas(**attrs, &) = DOM.elem('canvas', **attrs, &) - def code(**attrs, &) = DOM.elem('code', **attrs, &) - def del(**attrs, &) = DOM.elem('del', **attrs, &) - def div(**attrs, &) = DOM.elem('div', **attrs, &) - def em(**attrs, &) = DOM.elem('em', **attrs, &) - def footer(**attrs, &) = DOM.elem('footer', **attrs, &) - def h1(**attrs, &) = DOM.elem('h1', **attrs, &) - def h2(**attrs, &) = DOM.elem('h2', **attrs, &) - def h3(**attrs, &) = DOM.elem('h3', **attrs, &) - def h4(**attrs, &) = DOM.elem('h4', **attrs, &) - def h5(**attrs, &) = DOM.elem('h5', **attrs, &) - def h6(**attrs, &) = DOM.elem('h6', **attrs, &) - def head(**attrs, &) = DOM.elem('head', **attrs, &) - def header(**attrs, &) = DOM.elem('header', **attrs, &) - def hr(**attrs) = DOM.elem('hr', **attrs) - def html(**attrs, &) = DOM.elem('html', **attrs, &) - def img(**attrs) = DOM.elem('img', **attrs) - def li(**attrs, &) = DOM.elem('li', **attrs, &) - def link(**attrs) = DOM.elem('link', **attrs) - def main(**attrs, &) = DOM.elem('main', **attrs, &) - def meta(**attrs) = DOM.elem('meta', **attrs) - def nav(**attrs, &) = DOM.elem('nav', **attrs, &) - def ol(**attrs, &) = DOM.elem('ol', **attrs, &) - def p(**attrs, &) = DOM.elem('p', **attrs, &) - def script(**attrs, &) = DOM.elem('script', **attrs, &) - def section(**attrs, &) = DOM.elem('section', **attrs, &) - def span(**attrs, &) = DOM.elem('span', **attrs, &) - def strong(**attrs, &) = DOM.elem('strong', **attrs, &) - def table(**attrs, &) = DOM.elem('table', **attrs, &) - def tbody(**attrs, &) = DOM.elem('tbody', **attrs, &) - def thead(**attrs, &) = DOM.elem('thead', **attrs, &) - def time(**attrs, &) = DOM.elem('time', **attrs, &) - def title(**attrs, &) = DOM.elem('title', **attrs, &) - def tr(**attrs, &) = DOM.elem('tr', **attrs, &) - def ul(**attrs, &) = DOM.elem('ul', **attrs, &) - end - end -end diff --git a/services/nuldoc/lib/nuldoc/dom/html_builder.rb b/services/nuldoc/lib/nuldoc/dom/html_builder.rb new file mode 100644 index 00000000..2a506640 --- /dev/null +++ b/services/nuldoc/lib/nuldoc/dom/html_builder.rb @@ -0,0 +1,46 @@ +module Nuldoc + module DOM + class HTMLBuilder < Builder + def a(**attrs, &) = elem('a', **attrs, &) + def article(**attrs, &) = elem('article', **attrs, &) + def blockquote(**attrs, &) = elem('blockquote', **attrs, &) + def body(**attrs, &) = elem('body', **attrs, &) + def button(**attrs, &) = elem('button', **attrs, &) + def canvas(**attrs, &) = elem('canvas', **attrs, &) + def code(**attrs, &) = elem('code', **attrs, &) + def del(**attrs, &) = elem('del', **attrs, &) + def div(**attrs, &) = elem('div', **attrs, &) + def em(**attrs, &) = elem('em', **attrs, &) + def footer(**attrs, &) = elem('footer', **attrs, &) + def h1(**attrs, &) = elem('h1', **attrs, &) + def h2(**attrs, &) = elem('h2', **attrs, &) + def h3(**attrs, &) = elem('h3', **attrs, &) + def h4(**attrs, &) = elem('h4', **attrs, &) + def h5(**attrs, &) = elem('h5', **attrs, &) + def h6(**attrs, &) = elem('h6', **attrs, &) + def head(**attrs, &) = elem('head', **attrs, &) + def header(**attrs, &) = elem('header', **attrs, &) + def hr(**attrs) = elem('hr', **attrs) + def html(**attrs, &) = elem('html', **attrs, &) + def img(**attrs) = elem('img', **attrs) + def li(**attrs, &) = elem('li', **attrs, &) + def link(**attrs) = elem('link', **attrs) + def main(**attrs, &) = elem('main', **attrs, &) + def meta(**attrs) = elem('meta', **attrs) + def nav(**attrs, &) = elem('nav', **attrs, &) + def ol(**attrs, &) = elem('ol', **attrs, &) + def p(**attrs, &) = elem('p', **attrs, &) + def script(**attrs, &) = elem('script', **attrs, &) + def section(**attrs, &) = elem('section', **attrs, &) + def span(**attrs, &) = elem('span', **attrs, &) + def strong(**attrs, &) = elem('strong', **attrs, &) + def table(**attrs, &) = elem('table', **attrs, &) + def tbody(**attrs, &) = elem('tbody', **attrs, &) + def thead(**attrs, &) = elem('thead', **attrs, &) + def time(**attrs, &) = elem('time', **attrs, &) + def title(**attrs, &) = elem('title', **attrs, &) + def tr(**attrs, &) = elem('tr', **attrs, &) + def ul(**attrs, &) = elem('ul', **attrs, &) + end + end +end diff --git a/services/nuldoc/lib/nuldoc/generators/about.rb b/services/nuldoc/lib/nuldoc/generators/about.rb index e64f0d1d..d5b9dec4 100644 --- a/services/nuldoc/lib/nuldoc/generators/about.rb +++ b/services/nuldoc/lib/nuldoc/generators/about.rb @@ -7,7 +7,7 @@ module Nuldoc end def generate - html = Pages::AboutPage.render(slides: @slides, config: @config) + html = Pages::AboutPage.new(slides: @slides, config: @config).render Page.new( root: html, diff --git a/services/nuldoc/lib/nuldoc/generators/atom.rb b/services/nuldoc/lib/nuldoc/generators/atom.rb index 74750eb7..4b24bb2f 100644 --- a/services/nuldoc/lib/nuldoc/generators/atom.rb +++ b/services/nuldoc/lib/nuldoc/generators/atom.rb @@ -46,7 +46,7 @@ module Nuldoc ) Page.new( - root: Pages::AtomPage.render(feed: feed), + root: Pages::AtomPage.new(feed: feed).render, renderer: :xml, site: @site, dest_file_path: feed_path, diff --git a/services/nuldoc/lib/nuldoc/generators/home.rb b/services/nuldoc/lib/nuldoc/generators/home.rb index 54f8753f..1e9b36a3 100644 --- a/services/nuldoc/lib/nuldoc/generators/home.rb +++ b/services/nuldoc/lib/nuldoc/generators/home.rb @@ -6,7 +6,7 @@ module Nuldoc end def generate - html = Pages::HomePage.render(config: @config) + html = Pages::HomePage.new(config: @config).render Page.new( root: html, diff --git a/services/nuldoc/lib/nuldoc/generators/not_found.rb b/services/nuldoc/lib/nuldoc/generators/not_found.rb index cffe1df8..bd139d72 100644 --- a/services/nuldoc/lib/nuldoc/generators/not_found.rb +++ b/services/nuldoc/lib/nuldoc/generators/not_found.rb @@ -7,7 +7,7 @@ module Nuldoc end def generate - html = Pages::NotFoundPage.render(site: @site, config: @config) + html = Pages::NotFoundPage.new(site: @site, config: @config).render Page.new( root: html, diff --git a/services/nuldoc/lib/nuldoc/generators/post.rb b/services/nuldoc/lib/nuldoc/generators/post.rb index 0d5a3afc..ca46a460 100644 --- a/services/nuldoc/lib/nuldoc/generators/post.rb +++ b/services/nuldoc/lib/nuldoc/generators/post.rb @@ -28,7 +28,7 @@ module Nuldoc end def generate - html = Pages::PostPage.render(doc: @doc, config: @config) + html = Pages::PostPage.new(doc: @doc, config: @config).render content_dir = File.join(Dir.pwd, @config.locations.content_dir) dest_file_path = File.join( diff --git a/services/nuldoc/lib/nuldoc/generators/post_list.rb b/services/nuldoc/lib/nuldoc/generators/post_list.rb index 680a0c32..344e9b01 100644 --- a/services/nuldoc/lib/nuldoc/generators/post_list.rb +++ b/services/nuldoc/lib/nuldoc/generators/post_list.rb @@ -15,12 +15,12 @@ module Nuldoc page_posts = @posts[page_index * posts_per_page, posts_per_page] current_page = page_index + 1 - html = Pages::PostListPage.render( + html = Pages::PostListPage.new( posts: page_posts, config: @config, current_page: current_page, total_pages: total_pages - ) + ).render dest_file_path = current_page == 1 ? '/posts/index.html' : "/posts/#{current_page}/index.html" href = current_page == 1 ? '/posts/' : "/posts/#{current_page}/" diff --git a/services/nuldoc/lib/nuldoc/generators/slide.rb b/services/nuldoc/lib/nuldoc/generators/slide.rb index 58fde56e..334fd55e 100644 --- a/services/nuldoc/lib/nuldoc/generators/slide.rb +++ b/services/nuldoc/lib/nuldoc/generators/slide.rb @@ -11,7 +11,7 @@ module Nuldoc end def generate - html = Pages::SlidePage.render(slide: @slide, config: @config) + html = Pages::SlidePage.new(slide: @slide, config: @config).render content_dir = File.join(Dir.pwd, @config.locations.content_dir) dest_file_path = File.join( diff --git a/services/nuldoc/lib/nuldoc/generators/slide_list.rb b/services/nuldoc/lib/nuldoc/generators/slide_list.rb index 8d23e4b4..c3a42e27 100644 --- a/services/nuldoc/lib/nuldoc/generators/slide_list.rb +++ b/services/nuldoc/lib/nuldoc/generators/slide_list.rb @@ -7,7 +7,7 @@ module Nuldoc end def generate - html = Pages::SlideListPage.render(slides: @slides, config: @config) + html = Pages::SlideListPage.new(slides: @slides, config: @config).render Page.new( root: html, diff --git a/services/nuldoc/lib/nuldoc/generators/tag.rb b/services/nuldoc/lib/nuldoc/generators/tag.rb index 7a5f7a7b..f114c0b5 100644 --- a/services/nuldoc/lib/nuldoc/generators/tag.rb +++ b/services/nuldoc/lib/nuldoc/generators/tag.rb @@ -12,7 +12,7 @@ module Nuldoc end def generate - html = Pages::TagPage.render(tag_slug: @tag_slug, pages: @pages, site: @site, config: @config) + html = Pages::TagPage.new(tag_slug: @tag_slug, pages: @pages, site: @site, config: @config).render TagPageData.new( root: html, diff --git a/services/nuldoc/lib/nuldoc/generators/tag_list.rb b/services/nuldoc/lib/nuldoc/generators/tag_list.rb index 089b6f0c..106687c8 100644 --- a/services/nuldoc/lib/nuldoc/generators/tag_list.rb +++ b/services/nuldoc/lib/nuldoc/generators/tag_list.rb @@ -8,7 +8,7 @@ module Nuldoc end def generate - html = Pages::TagListPage.render(tags: @tags, site: @site, config: @config) + html = Pages::TagListPage.new(tags: @tags, site: @site, config: @config).render Page.new( root: html, diff --git a/services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb b/services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb index 583d7201..b914882f 100644 --- a/services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb +++ b/services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb @@ -1,607 +1,607 @@ module Nuldoc module Parser - class BlockParser - extend DOM::HTML - + class BlockParser < DOM::HTMLBuilder HeaderBlock = Struct.new(:level, :id, :attributes, :heading_element, keyword_init: true) FootnoteBlock = Struct.new(:id, :children, keyword_init: true) - class << self - def parse(text) - scanner = LineScanner.new(text) - blocks = parse_blocks(scanner) - build_document(blocks) - end + def self.parse(text) + new.parse(text) + end - private + def parse(text) + scanner = LineScanner.new(text) + blocks = parse_blocks(scanner) + build_document(blocks) + end - # --- Block parsing --- + private - def parse_blocks(scanner) - blocks = [] - until scanner.eof? - block = parse_block(scanner) - blocks << block if block - end - blocks - end + # --- Block parsing --- - def parse_block(scanner) - return nil if scanner.eof? + def parse_blocks(scanner) + blocks = [] + until scanner.eof? + block = parse_block(scanner) + blocks << block if block + end + blocks + end - line = scanner.peek + def parse_block(scanner) + return nil if scanner.eof? - # 1. Blank line - if line.strip.empty? - scanner.advance - return nil - end + line = scanner.peek - # 2. HTML comment - if (result = try_html_comment(scanner)) - return result - end + # 1. Blank line + if line.strip.empty? + scanner.advance + return nil + end - # 3. Fenced code block - if (result = try_fenced_code(scanner)) - return result - end + # 2. HTML comment + if (result = try_html_comment(scanner)) + return result + end - # 4. Note/Edit block - if (result = try_note_block(scanner)) - return result - end + # 3. Fenced code block + if (result = try_fenced_code(scanner)) + return result + end - # 5. Heading - if (result = try_heading(scanner)) - return result - end + # 4. Note/Edit block + if (result = try_note_block(scanner)) + return result + end - # 6. Horizontal rule - if (result = try_hr(scanner)) - return result - end + # 5. Heading + if (result = try_heading(scanner)) + return result + end - # 7. Footnote definition - if (result = try_footnote_def(scanner)) - return result - end + # 6. Horizontal rule + if (result = try_hr(scanner)) + return result + end - # 8. Table - if (result = try_table(scanner)) - return result - end + # 7. Footnote definition + if (result = try_footnote_def(scanner)) + return result + end - # 9. Blockquote - if (result = try_blockquote(scanner)) - return result - end + # 8. Table + if (result = try_table(scanner)) + return result + end - # 10. Ordered list - if (result = try_ordered_list(scanner)) - return result - end + # 9. Blockquote + if (result = try_blockquote(scanner)) + return result + end - # 11. Unordered list - if (result = try_unordered_list(scanner)) - return result - end + # 10. Ordered list + if (result = try_ordered_list(scanner)) + return result + end - # 12. HTML block - if (result = try_html_block(scanner)) - return result - end + # 11. Unordered list + if (result = try_unordered_list(scanner)) + return result + end - # 13. Paragraph - parse_paragraph(scanner) + # 12. HTML block + if (result = try_html_block(scanner)) + return result end - def try_html_comment(scanner) - line = scanner.peek - return nil unless line.strip.start_with?('') - end - nil # skip comments + def try_html_comment(scanner) + line = scanner.peek + return nil unless line.strip.start_with?('') end + nil # skip comments + end - def try_fenced_code(scanner) - match = scanner.match(/^```(\S*)(.*)$/) - return nil unless match + def try_fenced_code(scanner) + match = scanner.match(/^```(\S*)(.*)$/) + return nil unless match - scanner.advance - language = match[1].empty? ? nil : match[1] - meta_string = match[2].strip + scanner.advance + language = match[1].empty? ? nil : match[1] + meta_string = match[2].strip - attrs = {} - attrs[:language] = language if language + attrs = {} + attrs[:language] = language if language - if meta_string && !meta_string.empty? - filename_match = meta_string.match(/filename="([^"]+)"/) - attrs[:filename] = filename_match[1] if filename_match - attrs[:numbered] = 'true' if meta_string.include?('numbered') - end + if meta_string && !meta_string.empty? + filename_match = meta_string.match(/filename="([^"]+)"/) + attrs[:filename] = filename_match[1] if filename_match + attrs[:numbered] = 'true' if meta_string.include?('numbered') + end - code_lines = [] - until scanner.eof? - l = scanner.peek - if l.start_with?('```') - scanner.advance - break - end - code_lines << scanner.advance + code_lines = [] + until scanner.eof? + l = scanner.peek + if l.start_with?('```') + scanner.advance + break end - - code = code_lines.join("\n") - elem('codeblock', **attrs) { text code } + code_lines << scanner.advance end - def try_note_block(scanner) - match = scanner.match(/^:::(note|edit)(.*)$/) - return nil unless match - - scanner.advance - block_type = match[1] - attr_string = match[2].strip + code = code_lines.join("\n") + elem('codeblock', **attrs) { text code } + end - attrs = {} - if block_type == 'edit' - # Parse {editat="..." operation="..."} - editat_match = attr_string.match(/editat="([^"]+)"/) - operation_match = attr_string.match(/operation="([^"]+)"/) - attrs[:editat] = editat_match[1] if editat_match - attrs[:operation] = operation_match[1] if operation_match - end + def try_note_block(scanner) + match = scanner.match(/^:::(note|edit)(.*)$/) + return nil unless match + + scanner.advance + block_type = match[1] + attr_string = match[2].strip + + attrs = {} + if block_type == 'edit' + # Parse {editat="..." operation="..."} + editat_match = attr_string.match(/editat="([^"]+)"/) + operation_match = attr_string.match(/operation="([^"]+)"/) + attrs[:editat] = editat_match[1] if editat_match + attrs[:operation] = operation_match[1] if operation_match + end - # Collect content until ::: - content_lines = [] - until scanner.eof? - l = scanner.peek - if l.strip == ':::' - scanner.advance - break - end - content_lines << scanner.advance + # Collect content until ::: + content_lines = [] + until scanner.eof? + l = scanner.peek + if l.strip == ':::' + scanner.advance + break end - - inner_text = content_lines.join("\n") - inner_scanner = LineScanner.new(inner_text) - children = parse_blocks(inner_scanner) - - # Convert children - they are block elements already - child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawHTML) } - elem('note', **attrs) { child(*child_elements) } + content_lines << scanner.advance end - def try_heading(scanner) - match = scanner.match(/^(\#{1,5})\s+(.+)$/) - return nil unless match + inner_text = content_lines.join("\n") + inner_scanner = LineScanner.new(inner_text) + children = parse_blocks(inner_scanner) - scanner.advance - level = match[1].length - raw_text = match[2] + # Convert children - they are block elements already + child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawNode) } + elem('note', **attrs) { child(*child_elements) } + end - text_before, id, attributes = Attributes.parse_trailing_attributes(raw_text) + def try_heading(scanner) + match = scanner.match(/^(\#{1,5})\s+(.+)$/) + return nil unless match - inline_nodes = InlineParser.parse(text_before.strip) - heading_element = elem('h') { child(*inline_nodes) } + scanner.advance + level = match[1].length + raw_text = match[2] - HeaderBlock.new(level: level, id: id, attributes: attributes, heading_element: heading_element) - end + text_before, id, attributes = Attributes.parse_trailing_attributes(raw_text) - def try_hr(scanner) - match = scanner.match(/^---+\s*$/) - return nil unless match + inline_nodes = InlineParser.parse(text_before.strip) + heading_element = elem('h') { child(*inline_nodes) } - scanner.advance - hr - end + HeaderBlock.new(level: level, id: id, attributes: attributes, heading_element: heading_element) + end - def try_footnote_def(scanner) - match = scanner.match(/^\[\^([^\]]+)\]:\s*(.*)$/) - return nil unless match + def try_hr(scanner) + match = scanner.match(/^---+\s*$/) + return nil unless match - scanner.advance - id = match[1] - first_line = match[2] + scanner.advance + hr + end - content_lines = [first_line] - # Continuation lines: 4-space indent - until scanner.eof? - l = scanner.peek - break unless l.start_with?(' ') + def try_footnote_def(scanner) + match = scanner.match(/^\[\^([^\]]+)\]:\s*(.*)$/) + return nil unless match - content_lines << scanner.advance[4..] + scanner.advance + id = match[1] + first_line = match[2] - end + content_lines = [first_line] + # Continuation lines: 4-space indent + until scanner.eof? + l = scanner.peek + break unless l.start_with?(' ') - inner_text = content_lines.join("\n").strip - inner_scanner = LineScanner.new(inner_text) - children = parse_blocks(inner_scanner) + content_lines << scanner.advance[4..] - child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawHTML) } - FootnoteBlock.new(id: id, children: child_elements) end - def try_table(scanner) - # Check if this looks like a table - return nil unless scanner.peek.start_with?('|') + inner_text = content_lines.join("\n").strip + inner_scanner = LineScanner.new(inner_text) + children = parse_blocks(inner_scanner) - # Quick lookahead: second line must be a separator - return nil if scanner.pos + 1 >= scanner.lines.length - return nil unless scanner.lines[scanner.pos + 1].match?(/^\|[\s:|-]+\|$/) + child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawNode) } + FootnoteBlock.new(id: id, children: child_elements) + end - # Collect table lines - lines = [] - while !scanner.eof? && scanner.peek.start_with?('|') - lines << scanner.peek - scanner.advance - end + def try_table(scanner) + # Check if this looks like a table + return nil unless scanner.peek.start_with?('|') - header_line = lines[0] - separator_line = lines[1] - body_lines = lines[2..] || [] + # Quick lookahead: second line must be a separator + return nil if scanner.pos + 1 >= scanner.lines.length + return nil unless scanner.lines[scanner.pos + 1].match?(/^\|[\s:|-]+\|$/) - alignment = parse_table_alignment(separator_line) - header_cells = parse_table_row_cells(header_line) - header_row = build_table_row(header_cells, true, alignment) + # Collect table lines + lines = [] + while !scanner.eof? && scanner.peek.start_with?('|') + lines << scanner.peek + scanner.advance + end - body_rows = body_lines.map do |bl| - cells = parse_table_row_cells(bl) - build_table_row(cells, false, alignment) - end + header_line = lines[0] + separator_line = lines[1] + body_lines = lines[2..] || [] - table do - thead { child header_row } - tbody { child(*body_rows) } unless body_rows.empty? - end + alignment = parse_table_alignment(separator_line) + header_cells = parse_table_row_cells(header_line) + header_row = build_table_row(header_cells, true, alignment) + + body_rows = body_lines.map do |bl| + cells = parse_table_row_cells(bl) + build_table_row(cells, false, alignment) end - def parse_table_alignment(separator_line) - cells = separator_line.split('|').map(&:strip).reject(&:empty?) - cells.map do |cell| - left = cell.start_with?(':') - right = cell.end_with?(':') - if left && right - 'center' - elsif right - 'right' - elsif left - 'left' - end + table do + thead { child header_row } + tbody { child(*body_rows) } unless body_rows.empty? + end + end + + def parse_table_alignment(separator_line) + cells = separator_line.split('|').map(&:strip).reject(&:empty?) + cells.map do |cell| + left = cell.start_with?(':') + right = cell.end_with?(':') + if left && right + 'center' + elsif right + 'right' + elsif left + 'left' end end + end + + def parse_table_row_cells(line) + # Strip leading and trailing |, then split by | + stripped = line.strip + stripped = stripped[1..] if stripped.start_with?('|') + stripped = stripped[0...-1] if stripped.end_with?('|') + stripped.split('|').map(&:strip) + end + + def build_table_row(cells, is_header, alignment) + cell_elements = cells.each_with_index.map do |cell_text, i| + attrs = {} + align = alignment[i] + attrs[:align] = align if align && align != 'default' - def parse_table_row_cells(line) - # Strip leading and trailing |, then split by | - stripped = line.strip - stripped = stripped[1..] if stripped.start_with?('|') - stripped = stripped[0...-1] if stripped.end_with?('|') - stripped.split('|').map(&:strip) + tag = is_header ? 'th' : 'td' + inline_nodes = InlineParser.parse(cell_text) + elem(tag, **attrs) { child(*inline_nodes) } end + tr { child(*cell_elements) } + end - def build_table_row(cells, is_header, alignment) - cell_elements = cells.each_with_index.map do |cell_text, i| - attrs = {} - align = alignment[i] - attrs[:align] = align if align && align != 'default' + def try_blockquote(scanner) + return nil unless scanner.peek.start_with?('> ') || scanner.peek == '>' - tag = is_header ? 'th' : 'td' - inline_nodes = InlineParser.parse(cell_text) - elem(tag, **attrs) { child(*inline_nodes) } - end - tr { child(*cell_elements) } + lines = [] + while !scanner.eof? && (scanner.peek.start_with?('> ') || scanner.peek == '>') + line = scanner.advance + lines << (line == '>' ? '' : line[2..]) end - def try_blockquote(scanner) - return nil unless scanner.peek.start_with?('> ') || scanner.peek == '>' + inner_text = lines.join("\n") + inner_scanner = LineScanner.new(inner_text) + children = parse_blocks(inner_scanner) + child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawNode) } - lines = [] - while !scanner.eof? && (scanner.peek.start_with?('> ') || scanner.peek == '>') - line = scanner.advance - lines << (line == '>' ? '' : line[2..]) - end + blockquote { child(*child_elements) } + end - inner_text = lines.join("\n") - inner_scanner = LineScanner.new(inner_text) - children = parse_blocks(inner_scanner) - child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawHTML) } + def try_ordered_list(scanner) + match = scanner.match(/^(\d+)\.\s+(.*)$/) + return nil unless match - blockquote { child(*child_elements) } - end + items = parse_list_items(scanner, :ordered) + return nil if items.empty? - def try_ordered_list(scanner) - match = scanner.match(/^(\d+)\.\s+(.*)$/) - return nil unless match + build_list(:ordered, items) + end - items = parse_list_items(scanner, :ordered) - return nil if items.empty? + def try_unordered_list(scanner) + match = scanner.match(/^\*\s+(.*)$/) + return nil unless match - build_list(:ordered, items) - end + items = parse_list_items(scanner, :unordered) + return nil if items.empty? - def try_unordered_list(scanner) - match = scanner.match(/^\*\s+(.*)$/) - return nil unless match + build_list(:unordered, items) + end - items = parse_list_items(scanner, :unordered) - return nil if items.empty? + def parse_list_items(scanner, type) + items = [] + marker_re = type == :ordered ? /^(\d+)\.\s+(.*)$/ : /^\*\s+(.*)$/ + indent_size = 4 - build_list(:unordered, items) - end + while !scanner.eof? && (m = scanner.match(marker_re)) + scanner.advance + first_line = type == :ordered ? m[2] : m[1] - def parse_list_items(scanner, type) - items = [] - marker_re = type == :ordered ? /^(\d+)\.\s+(.*)$/ : /^\*\s+(.*)$/ - indent_size = 4 + content_lines = [first_line] + has_blank = false - while !scanner.eof? && (m = scanner.match(marker_re)) - scanner.advance - first_line = type == :ordered ? m[2] : m[1] - - content_lines = [first_line] - has_blank = false - - # Collect continuation lines and sub-items - until scanner.eof? - l = scanner.peek - - # Blank line might be part of loose list - if l.strip.empty? - # Check if next non-blank line is still part of the list - next_pos = scanner.pos + 1 - next_pos += 1 while next_pos < scanner.lines.length && scanner.lines[next_pos].strip.empty? - - if next_pos < scanner.lines.length - next_line = scanner.lines[next_pos] - if next_line.start_with?(' ' * indent_size) || next_line.match?(marker_re) - has_blank = true - content_lines << '' - scanner.advance - next - end - end - break - end + # Collect continuation lines and sub-items + until scanner.eof? + l = scanner.peek - # Indented continuation (sub-items or content) - if l.start_with?(' ' * indent_size) - content_lines << scanner.advance[indent_size..] - next + # Blank line might be part of loose list + if l.strip.empty? + # Check if next non-blank line is still part of the list + next_pos = scanner.pos + 1 + next_pos += 1 while next_pos < scanner.lines.length && scanner.lines[next_pos].strip.empty? + + if next_pos < scanner.lines.length + next_line = scanner.lines[next_pos] + if next_line.start_with?(' ' * indent_size) || next_line.match?(marker_re) + has_blank = true + content_lines << '' + scanner.advance + next + end end + break + end - # New list item at same level - break if l.match?(marker_re) + # Indented continuation (sub-items or content) + if l.start_with?(' ' * indent_size) + content_lines << scanner.advance[indent_size..] + next + end - # Non-indented, non-marker line - might be continuation of tight paragraph - break if l.strip.empty? - break if l.match?(/^[#>*\d]/) && !l.match?(/^\d+\.\s/) # another block element + # New list item at same level + break if l.match?(marker_re) - # Paragraph continuation - content_lines << scanner.advance - end + # Non-indented, non-marker line - might be continuation of tight paragraph + break if l.strip.empty? + break if l.match?(/^[#>*\d]/) && !l.match?(/^\d+\.\s/) # another block element - items << { lines: content_lines, has_blank: has_blank } + # Paragraph continuation + content_lines << scanner.advance end - items + items << { lines: content_lines, has_blank: has_blank } end - def build_list(type, items) - # Determine tight/loose - is_tight = items.none? { |item| item[:has_blank] } + items + end - attrs = {} - attrs[:__tight] = is_tight ? 'true' : 'false' + def build_list(type, items) + # Determine tight/loose + is_tight = items.none? { |item| item[:has_blank] } - # Check for task list items - is_task_list = false - if type == :unordered - is_task_list = items.any? { |item| item[:lines].first&.match?(/^\[[ xX]\]\s/) } - attrs[:type] = 'task' if is_task_list - end + attrs = {} + attrs[:__tight] = is_tight ? 'true' : 'false' - list_items = items.map do |item| - build_list_item(item, is_task_list) - end - - if type == :ordered - ol(**attrs) { child(*list_items) } - else - ul(**attrs) { child(*list_items) } - end + # Check for task list items + is_task_list = false + if type == :unordered + is_task_list = items.any? { |item| item[:lines].first&.match?(/^\[[ xX]\]\s/) } + attrs[:type] = 'task' if is_task_list end - def build_list_item(item, is_task_list) - attrs = {} - content = item[:lines].join("\n") + list_items = items.map do |item| + build_list_item(item, is_task_list) + end - if is_task_list - task_match = content.match(/^\[( |[xX])\]\s(.*)$/m) - if task_match - attrs[:checked] = task_match[1] == ' ' ? 'false' : 'true' - content = task_match[2] - end - end + if type == :ordered + ol(**attrs) { child(*list_items) } + else + ul(**attrs) { child(*list_items) } + end + end - # Parse inner content as blocks - inner_scanner = LineScanner.new(content) - children = parse_blocks(inner_scanner) + def build_list_item(item, is_task_list) + attrs = {} + content = item[:lines].join("\n") - # If no block-level elements were created, wrap in paragraph - child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawHTML) } - if child_elements.empty? - inline_nodes = InlineParser.parse(content) - child_elements = [p { child(*inline_nodes) }] + if is_task_list + task_match = content.match(/^\[( |[xX])\]\s(.*)$/m) + if task_match + attrs[:checked] = task_match[1] == ' ' ? 'false' : 'true' + content = task_match[2] end + end + + # Parse inner content as blocks + inner_scanner = LineScanner.new(content) + children = parse_blocks(inner_scanner) - li(**attrs) { child(*child_elements) } + # If no block-level elements were created, wrap in paragraph + child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawNode) } + if child_elements.empty? + inline_nodes = InlineParser.parse(content) + child_elements = [p { child(*inline_nodes) }] end - def try_html_block(scanner) - line = scanner.peek - match = line.match(/^<(div|details|summary)(\s[^>]*)?>/) - return nil unless match + li(**attrs) { child(*child_elements) } + end - tag = match[1] - lines = [] - close_tag = "" + def try_html_block(scanner) + line = scanner.peek + match = line.match(/^<(div|details|summary)(\s[^>]*)?>/) + return nil unless match - until scanner.eof? - l = scanner.advance - lines << l - break if l.include?(close_tag) - end + tag = match[1] + lines = [] + close_tag = "" - html_content = lines.join("\n") + until scanner.eof? + l = scanner.advance + lines << l + break if l.include?(close_tag) + end - if tag == 'div' - # Parse inner content for div blocks - inner_match = html_content.match(%r{]*)>(.*)}m) - if inner_match - attr_str = inner_match[1] - inner_content = inner_match[2].strip + html_content = lines.join("\n") - attrs = {} - attr_str.scan(/([\w-]+)="([^"]*)"/) do |key, value| - attrs[key.to_sym] = value - end + if tag == 'div' + # Parse inner content for div blocks + inner_match = html_content.match(%r{]*)>(.*)}m) + if inner_match + attr_str = inner_match[1] + inner_content = inner_match[2].strip - if inner_content.empty? - div(**attrs) - else - inner_scanner = LineScanner.new(inner_content) - children = parse_blocks(inner_scanner) - child_elements = children.compact.select do |c| - c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawHTML) - end - div(**attrs) { child(*child_elements) } - end + attrs = {} + attr_str.scan(/([\w-]+)="([^"]*)"/) do |key, value| + attrs[key.to_sym] = value + end + + if inner_content.empty? + div(**attrs) else - div(class: 'raw-html') { raw_html html_content } + inner_scanner = LineScanner.new(inner_content) + children = parse_blocks(inner_scanner) + child_elements = children.compact.select do |c| + c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawNode) + end + div(**attrs) { child(*child_elements) } end else - div(class: 'raw-html') { raw_html html_content } + div(class: 'raw-html') { raw html_content } end + else + div(class: 'raw-html') { raw html_content } end + end - def parse_paragraph(scanner) - lines = [] - until scanner.eof? - l = scanner.peek - break if l.strip.empty? - break if l.match?(/^```/) - break if l.match?(/^\#{1,5}\s/) - break if l.match?(/^---+\s*$/) - break if l.match?(/^>\s/) - break if l.match?(/^\*\s/) - break if l.match?(/^\d+\.\s/) - if l.match?(/^\|/) && - scanner.pos + 1 < scanner.lines.length && - scanner.lines[scanner.pos + 1].match?(/^\|[\s:|-]+\|$/) - break - end - break if l.match?(/^:::/) - break if l.match?(/^