aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/nuldoc/lib
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-01 02:28:10 +0900
committernsfisis <nsfisis@gmail.com>2026-02-01 02:28:10 +0900
commitcd16ed5d6b46d91ae9ac7b2237d6405ad6715a4a (patch)
tree0e00d7caf3031fa86decaa0cbc226cc1e521b914 /services/nuldoc/lib
parentd08e3edb65b215152aa26e3518fb2f2cd7071c4b (diff)
parent1964f77d03eb647dcf46d63dde68d7ae7301604f (diff)
downloadnsfisis.dev-cd16ed5d6b46d91ae9ac7b2237d6405ad6715a4a.tar.gz
nsfisis.dev-cd16ed5d6b46d91ae9ac7b2237d6405ad6715a4a.tar.zst
nsfisis.dev-cd16ed5d6b46d91ae9ac7b2237d6405ad6715a4a.zip
Merge branch 'feat/ruby-rewrite'
Diffstat (limited to 'services/nuldoc/lib')
-rw-r--r--services/nuldoc/lib/nuldoc.rb65
-rw-r--r--services/nuldoc/lib/nuldoc/cli.rb48
-rw-r--r--services/nuldoc/lib/nuldoc/commands/build.rb227
-rw-r--r--services/nuldoc/lib/nuldoc/commands/new.rb81
-rw-r--r--services/nuldoc/lib/nuldoc/commands/serve.rb63
-rw-r--r--services/nuldoc/lib/nuldoc/components/global_footer.rb11
-rw-r--r--services/nuldoc/lib/nuldoc/components/global_headers.rb66
-rw-r--r--services/nuldoc/lib/nuldoc/components/page_layout.rb34
-rw-r--r--services/nuldoc/lib/nuldoc/components/pagination.rb67
-rw-r--r--services/nuldoc/lib/nuldoc/components/post_page_entry.rb30
-rw-r--r--services/nuldoc/lib/nuldoc/components/slide_page_entry.rb30
-rw-r--r--services/nuldoc/lib/nuldoc/components/static_script.rb16
-rw-r--r--services/nuldoc/lib/nuldoc/components/static_stylesheet.rb13
-rw-r--r--services/nuldoc/lib/nuldoc/components/table_of_contents.rb23
-rw-r--r--services/nuldoc/lib/nuldoc/components/tag_list.rb17
-rw-r--r--services/nuldoc/lib/nuldoc/components/utils.rb8
-rw-r--r--services/nuldoc/lib/nuldoc/config.rb67
-rw-r--r--services/nuldoc/lib/nuldoc/dom.rb143
-rw-r--r--services/nuldoc/lib/nuldoc/dom/atom_xml.rb26
-rw-r--r--services/nuldoc/lib/nuldoc/dom/html.rb56
-rw-r--r--services/nuldoc/lib/nuldoc/generators/about.rb22
-rw-r--r--services/nuldoc/lib/nuldoc/generators/atom.rb58
-rw-r--r--services/nuldoc/lib/nuldoc/generators/home.rb21
-rw-r--r--services/nuldoc/lib/nuldoc/generators/not_found.rb22
-rw-r--r--services/nuldoc/lib/nuldoc/generators/post.rb57
-rw-r--r--services/nuldoc/lib/nuldoc/generators/post_list.rb41
-rw-r--r--services/nuldoc/lib/nuldoc/generators/slide.rb42
-rw-r--r--services/nuldoc/lib/nuldoc/generators/slide_list.rb22
-rw-r--r--services/nuldoc/lib/nuldoc/generators/tag.rb31
-rw-r--r--services/nuldoc/lib/nuldoc/generators/tag_list.rb23
-rw-r--r--services/nuldoc/lib/nuldoc/markdown/document.rb19
-rw-r--r--services/nuldoc/lib/nuldoc/markdown/parse.rb51
-rw-r--r--services/nuldoc/lib/nuldoc/markdown/parser/attributes.rb25
-rw-r--r--services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb609
-rw-r--r--services/nuldoc/lib/nuldoc/markdown/parser/inline_parser.rb383
-rw-r--r--services/nuldoc/lib/nuldoc/markdown/parser/line_scanner.rb38
-rw-r--r--services/nuldoc/lib/nuldoc/markdown/transform.rb415
-rw-r--r--services/nuldoc/lib/nuldoc/page.rb3
-rw-r--r--services/nuldoc/lib/nuldoc/pages/about_page.rb85
-rw-r--r--services/nuldoc/lib/nuldoc/pages/atom_page.rb28
-rw-r--r--services/nuldoc/lib/nuldoc/pages/home_page.rb46
-rw-r--r--services/nuldoc/lib/nuldoc/pages/not_found_page.rb33
-rw-r--r--services/nuldoc/lib/nuldoc/pages/post_list_page.rb35
-rw-r--r--services/nuldoc/lib/nuldoc/pages/post_page.rb54
-rw-r--r--services/nuldoc/lib/nuldoc/pages/slide_list_page.rb31
-rw-r--r--services/nuldoc/lib/nuldoc/pages/slide_page.rb76
-rw-r--r--services/nuldoc/lib/nuldoc/pages/tag_list_page.rb43
-rw-r--r--services/nuldoc/lib/nuldoc/pages/tag_page.rb39
-rw-r--r--services/nuldoc/lib/nuldoc/pipeline.rb47
-rw-r--r--services/nuldoc/lib/nuldoc/render.rb14
-rw-r--r--services/nuldoc/lib/nuldoc/renderers/html.rb210
-rw-r--r--services/nuldoc/lib/nuldoc/renderers/xml.rb97
-rw-r--r--services/nuldoc/lib/nuldoc/revision.rb18
-rw-r--r--services/nuldoc/lib/nuldoc/slide/parse.rb16
-rw-r--r--services/nuldoc/lib/nuldoc/slide/slide.rb44
55 files changed, 3889 insertions, 0 deletions
diff --git a/services/nuldoc/lib/nuldoc.rb b/services/nuldoc/lib/nuldoc.rb
new file mode 100644
index 00000000..83df42d6
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc.rb
@@ -0,0 +1,65 @@
+require 'date'
+require 'digest'
+require 'English'
+require 'fileutils'
+require 'securerandom'
+
+require 'dry/cli'
+require 'rouge'
+require 'toml-rb'
+require 'webrick'
+
+require_relative 'nuldoc/pipeline'
+require_relative 'nuldoc/dom'
+require_relative 'nuldoc/dom/atom_xml'
+require_relative 'nuldoc/dom/html'
+require_relative 'nuldoc/revision'
+require_relative 'nuldoc/config'
+require_relative 'nuldoc/page'
+require_relative 'nuldoc/render'
+require_relative 'nuldoc/renderers/html'
+require_relative 'nuldoc/renderers/xml'
+require_relative 'nuldoc/markdown/document'
+require_relative 'nuldoc/markdown/parser/line_scanner'
+require_relative 'nuldoc/markdown/parser/attributes'
+require_relative 'nuldoc/markdown/parser/inline_parser'
+require_relative 'nuldoc/markdown/parser/block_parser'
+require_relative 'nuldoc/markdown/parse'
+require_relative 'nuldoc/markdown/transform'
+require_relative 'nuldoc/slide/slide'
+require_relative 'nuldoc/slide/parse'
+require_relative 'nuldoc/components/utils'
+require_relative 'nuldoc/components/page_layout'
+require_relative 'nuldoc/components/global_footer'
+require_relative 'nuldoc/components/global_headers'
+require_relative 'nuldoc/components/post_page_entry'
+require_relative 'nuldoc/components/slide_page_entry'
+require_relative 'nuldoc/components/pagination'
+require_relative 'nuldoc/components/table_of_contents'
+require_relative 'nuldoc/components/tag_list'
+require_relative 'nuldoc/components/static_stylesheet'
+require_relative 'nuldoc/components/static_script'
+require_relative 'nuldoc/pages/home_page'
+require_relative 'nuldoc/pages/about_page'
+require_relative 'nuldoc/pages/post_page'
+require_relative 'nuldoc/pages/post_list_page'
+require_relative 'nuldoc/pages/slide_page'
+require_relative 'nuldoc/pages/slide_list_page'
+require_relative 'nuldoc/pages/tag_page'
+require_relative 'nuldoc/pages/tag_list_page'
+require_relative 'nuldoc/pages/atom_page'
+require_relative 'nuldoc/pages/not_found_page'
+require_relative 'nuldoc/generators/home'
+require_relative 'nuldoc/generators/about'
+require_relative 'nuldoc/generators/post'
+require_relative 'nuldoc/generators/post_list'
+require_relative 'nuldoc/generators/slide'
+require_relative 'nuldoc/generators/slide_list'
+require_relative 'nuldoc/generators/tag'
+require_relative 'nuldoc/generators/tag_list'
+require_relative 'nuldoc/generators/atom'
+require_relative 'nuldoc/generators/not_found'
+require_relative 'nuldoc/commands/build'
+require_relative 'nuldoc/commands/serve'
+require_relative 'nuldoc/commands/new'
+require_relative 'nuldoc/cli'
diff --git a/services/nuldoc/lib/nuldoc/cli.rb b/services/nuldoc/lib/nuldoc/cli.rb
new file mode 100644
index 00000000..0face71c
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/cli.rb
@@ -0,0 +1,48 @@
+module Nuldoc
+ module CLI
+ extend Dry::CLI::Registry
+
+ class BuildCommand < Dry::CLI::Command
+ desc 'Build the site'
+
+ option :profile, type: :boolean, default: false, desc: 'Profile each build step'
+
+ def call(**options)
+ config = ConfigLoader.load_config(ConfigLoader.default_config_path)
+ Commands::Build.run(config, profile: options[:profile])
+ end
+ end
+
+ class ServeCommand < Dry::CLI::Command
+ desc 'Start development server'
+
+ argument :site, required: true, desc: 'Site to serve (default, about, blog, slides)'
+ option :no_rebuild, type: :boolean, default: false, desc: 'Skip rebuilding on each request'
+
+ def call(site:, **options)
+ config = ConfigLoader.load_config(ConfigLoader.default_config_path)
+ Commands::Serve.run(config, site_name: site, no_rebuild: options[:no_rebuild])
+ end
+ end
+
+ class NewCommand < Dry::CLI::Command
+ desc 'Create new content'
+
+ argument :type, required: true, desc: 'Content type (post or slide)'
+ option :date, desc: 'Date (YYYY-MM-DD)'
+
+ def call(type:, **options)
+ config = ConfigLoader.load_config(ConfigLoader.default_config_path)
+ Commands::New.run(config, type: type, date: options[:date])
+ end
+ end
+
+ register 'build', BuildCommand
+ register 'serve', ServeCommand
+ register 'new', NewCommand
+
+ def self.call
+ Dry::CLI.new(self).call
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/commands/build.rb b/services/nuldoc/lib/nuldoc/commands/build.rb
new file mode 100644
index 00000000..bec741d9
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/commands/build.rb
@@ -0,0 +1,227 @@
+module Nuldoc
+ module Commands
+ class Build
+ def self.run(config, profile: false)
+ new(config, profile: profile).run
+ end
+
+ def initialize(config, profile: false)
+ @config = config
+ @profile = profile
+ end
+
+ def run
+ pipeline = Pipeline.new
+
+ pipeline.step(:build_posts) { build_post_pages }
+ pipeline.step(:build_post_list, deps: [:build_posts]) { |r| build_post_list_page(r[:build_posts]) }
+ pipeline.step(:build_blog_tags, deps: [:build_posts]) { |r| build_tag_pages(r[:build_posts], 'blog') }
+ pipeline.step(:build_blog_tag_list, deps: [:build_blog_tags]) do |r|
+ build_tag_list_page(r[:build_blog_tags], 'blog')
+ end
+ pipeline.step(:copy_post_sources, deps: [:build_posts]) { |r| copy_post_source_files(r[:build_posts]) }
+
+ pipeline.step(:build_slides) { build_slide_pages }
+ pipeline.step(:build_slide_list, deps: [:build_slides]) { |r| build_slide_list_page(r[:build_slides]) }
+ pipeline.step(:build_slide_tags, deps: [:build_slides]) { |r| build_tag_pages(r[:build_slides], 'slides') }
+ pipeline.step(:build_slide_tag_list, deps: [:build_slide_tags]) do |r|
+ build_tag_list_page(r[:build_slide_tags], 'slides')
+ end
+ pipeline.step(:build_about, deps: [:build_slides]) { |r| build_about_page(r[:build_slides]) }
+ pipeline.step(:copy_slides_files, deps: [:build_slides]) { |r| copy_slides_files(r[:build_slides]) }
+
+ pipeline.step(:build_home) { build_home_page }
+ pipeline.step(:build_not_found) { %w[default about blog slides].each { |site| build_not_found_page(site) } }
+ pipeline.step(:copy_static) { copy_static_files }
+ pipeline.step(:copy_blog_assets) { copy_blog_asset_files }
+ pipeline.step(:copy_slides_assets) { copy_slides_asset_files }
+
+ pipeline.execute(profile: @profile)
+ end
+
+ private
+
+ def build_post_pages
+ source_dir = File.join(Dir.pwd, @config.locations.content_dir, 'posts')
+ post_files = Dir.glob(File.join(source_dir, '**', '*.md'))
+ posts = post_files.map do |file|
+ doc = MarkdownParser.new(file, @config).parse
+ Generators::Post.new(doc, @config).generate
+ end
+ posts.each { |post| write_page(post) }
+ posts
+ end
+
+ def build_post_list_page(posts)
+ sorted_posts = posts.sort do |a, b|
+ [GeneratorUtils.published_date(b), a.href] <=> [GeneratorUtils.published_date(a), b.href]
+ end
+
+ post_list_pages = Generators::PostList.new(sorted_posts, @config).generate
+ post_list_pages.each { |page| write_page(page) }
+
+ post_feed = Generators::Atom.new(
+ '/posts/', 'posts',
+ "投稿一覧|#{@config.sites.blog.site_name}",
+ posts, 'blog', @config
+ ).generate
+ write_page(post_feed)
+ end
+
+ def build_slide_pages
+ source_dir = File.join(Dir.pwd, @config.locations.content_dir, 'slides')
+ slide_files = Dir.glob(File.join(source_dir, '**', '*.toml'))
+ slides = slide_files.map do |file|
+ slide = SlideParser.new(file).parse
+ Generators::Slide.new(slide, @config).generate
+ end
+ slides.each { |slide| write_page(slide) }
+ slides
+ end
+
+ def build_slide_list_page(slides)
+ slide_list_page = Generators::SlideList.new(slides, @config).generate
+ write_page(slide_list_page)
+
+ slide_feed = Generators::Atom.new(
+ slide_list_page.href, 'slides',
+ "スライド一覧|#{@config.sites.slides.site_name}",
+ slides, 'slides', @config
+ ).generate
+ write_page(slide_feed)
+ end
+
+ def build_home_page
+ write_page(Generators::Home.new(@config).generate)
+ end
+
+ def build_about_page(slides)
+ write_page(Generators::About.new(slides, @config).generate)
+ end
+
+ def build_not_found_page(site)
+ write_page(Generators::NotFound.new(site, @config).generate)
+ end
+
+ def build_tag_pages(pages, site)
+ tags_and_pages = collect_tags(pages)
+ tags = []
+ tags_and_pages.each do |tag, tag_pages|
+ tag_page = Generators::Tag.new(tag, tag_pages, site, @config).generate
+ write_page(tag_page)
+
+ tag_feed = Generators::Atom.new(
+ tag_page.href, "tag-#{tag}",
+ "タグ「#{@config.tag_label(tag)}」一覧|#{@config.site_entry(site).site_name}",
+ tag_pages, site, @config
+ ).generate
+ write_page(tag_feed)
+ tags.push(tag_page)
+ end
+ tags
+ end
+
+ def build_tag_list_page(tags, site)
+ write_page(Generators::TagList.new(tags, site, @config).generate)
+ end
+
+ def collect_tags(tagged_pages)
+ tags_and_pages = {}
+ tagged_pages.each do |page|
+ page.tags.each do |tag|
+ tags_and_pages[tag] ||= []
+ tags_and_pages[tag].push(page)
+ end
+ end
+
+ tags_and_pages.sort_by { |tag, _| tag }.map do |tag, pages|
+ sorted_pages = pages.sort do |a, b|
+ [GeneratorUtils.published_date(b), a.href] <=> [GeneratorUtils.published_date(a), b.href]
+ end
+ [tag, sorted_pages]
+ end
+ end
+
+ def copy_static_files
+ static_dir = File.join(Dir.pwd, @config.locations.static_dir)
+
+ %w[default about blog slides].each do |site|
+ dest_dir = File.join(Dir.pwd, @config.locations.dest_dir, site)
+
+ Dir.glob(File.join(static_dir, '_all', '*')).each do |entry|
+ next unless File.file?(entry)
+
+ FileUtils.cp(entry, File.join(dest_dir, File.basename(entry)))
+ end
+
+ Dir.glob(File.join(static_dir, site, '*')).each do |entry|
+ next unless File.file?(entry)
+
+ FileUtils.cp(entry, File.join(dest_dir, File.basename(entry)))
+ end
+ end
+ end
+
+ def copy_slides_files(slides)
+ content_dir = File.join(Dir.pwd, @config.locations.content_dir)
+ dest_dir = File.join(Dir.pwd, @config.locations.dest_dir)
+
+ slides.each do |slide|
+ src = File.join(content_dir, slide.slide_link)
+ dst = File.join(dest_dir, 'slides', slide.slide_link)
+ FileUtils.mkdir_p(File.dirname(dst))
+ FileUtils.cp(src, dst)
+ end
+ end
+
+ def copy_blog_asset_files
+ content_dir = File.join(Dir.pwd, @config.locations.content_dir, 'posts')
+ dest_dir = File.join(Dir.pwd, @config.locations.dest_dir, 'blog')
+
+ Dir.glob(File.join(content_dir, '**', '*')).each do |path|
+ next unless File.file?(path)
+ next if path.end_with?('.md', '.toml', '.pdf')
+
+ relative = path.sub("#{content_dir}/", '')
+ dst = File.join(dest_dir, 'posts', relative)
+ FileUtils.mkdir_p(File.dirname(dst))
+ FileUtils.cp(path, dst)
+ end
+ end
+
+ def copy_slides_asset_files
+ content_dir = File.join(Dir.pwd, @config.locations.content_dir, 'slides')
+ dest_dir = File.join(Dir.pwd, @config.locations.dest_dir, 'slides')
+
+ Dir.glob(File.join(content_dir, '**', '*')).each do |path|
+ next unless File.file?(path)
+ next if path.end_with?('.md', '.toml', '.pdf')
+
+ relative = path.sub("#{content_dir}/", '')
+ dst = File.join(dest_dir, 'slides', relative)
+ FileUtils.mkdir_p(File.dirname(dst))
+ FileUtils.cp(path, dst)
+ end
+ end
+
+ def write_page(page)
+ dest_file_path = File.join(Dir.pwd, @config.locations.dest_dir, page.site, page.dest_file_path)
+ FileUtils.mkdir_p(File.dirname(dest_file_path))
+ File.write(dest_file_path, Renderer.new.render(page.root, page.renderer))
+ end
+
+ def copy_post_source_files(posts)
+ content_dir = File.join(Dir.pwd, @config.locations.content_dir)
+ dest_dir = File.join(Dir.pwd, @config.locations.dest_dir, 'blog')
+
+ posts.each do |post|
+ src = post.source_file_path
+ relative = src.sub("#{content_dir}/", '')
+ dst = File.join(dest_dir, relative)
+ FileUtils.mkdir_p(File.dirname(dst))
+ FileUtils.cp(src, dst)
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/commands/new.rb b/services/nuldoc/lib/nuldoc/commands/new.rb
new file mode 100644
index 00000000..13919a75
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/commands/new.rb
@@ -0,0 +1,81 @@
+module Nuldoc
+ module Commands
+ class New
+ def self.run(config, type:, date: nil)
+ new(config).run(type: type, date: date)
+ end
+
+ def initialize(config)
+ @config = config
+ end
+
+ def run(type:, date: nil)
+ unless %w[post slide].include?(type)
+ warn <<~USAGE
+ Usage: nuldoc new <type>
+
+ <type> must be either "post" or "slide".
+
+ OPTIONS:
+ --date <DATE>
+ USAGE
+ exit 1
+ end
+
+ ymd = date || Time.now.strftime('%Y-%m-%d')
+
+ dir_path = type == 'post' ? 'posts' : 'slides'
+ filename = type == 'post' ? 'TODO.md' : 'TODO.toml'
+
+ dest_file_path = File.join(Dir.pwd, @config.locations.content_dir, dir_path, ymd, filename)
+ FileUtils.mkdir_p(File.dirname(dest_file_path))
+ File.write(dest_file_path, template(type, ymd))
+
+ relative = dest_file_path.sub(Dir.pwd, '')
+ puts "New file #{relative} was successfully created."
+ end
+
+ private
+
+ def template(type, date)
+ uuid = SecureRandom.uuid
+ if type == 'post'
+ <<~TEMPLATE
+ ---
+ [article]
+ uuid = "#{uuid}"
+ title = "TODO"
+ description = "TODO"
+ tags = [
+ "TODO",
+ ]
+
+ [[article.revisions]]
+ date = "#{date}"
+ remark = "公開"
+ ---
+ # はじめに {#intro}
+
+ TODO
+ TEMPLATE
+ else
+ <<~TEMPLATE
+ [slide]
+ uuid = "#{uuid}"
+ title = "TODO"
+ event = "TODO"
+ talkType = "TODO"
+ link = "TODO"
+ tags = [
+ "TODO",
+ ]
+
+ [[slide.revisions]]
+ date = "#{date}"
+ remark = "登壇"
+ TEMPLATE
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/commands/serve.rb b/services/nuldoc/lib/nuldoc/commands/serve.rb
new file mode 100644
index 00000000..060e51b8
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/commands/serve.rb
@@ -0,0 +1,63 @@
+module Nuldoc
+ module Commands
+ class Serve
+ def self.run(config, site_name:, no_rebuild: false)
+ new(config).run(site_name: site_name, no_rebuild: no_rebuild)
+ end
+
+ def initialize(config)
+ @config = config
+ end
+
+ def run(site_name:, no_rebuild: false)
+ raise 'Usage: nuldoc serve <site>' if site_name.nil? || site_name.empty?
+
+ root_dir = File.join(Dir.pwd, @config.locations.dest_dir, site_name)
+
+ server = WEBrick::HTTPServer.new(
+ Port: 8000,
+ BindAddress: '127.0.0.1',
+ DocumentRoot: root_dir,
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO)
+ )
+
+ server.mount_proc '/' do |req, res|
+ pathname = req.path
+
+ unless resource_path?(pathname) || no_rebuild
+ Build.run(@config)
+ warn 'rebuild'
+ end
+
+ file_path = File.expand_path(File.join(root_dir, pathname))
+ unless file_path.start_with?(File.realpath(root_dir))
+ res.status = 403
+ res.body = '403 Forbidden'
+ next
+ end
+ file_path = File.join(file_path, 'index.html') if File.directory?(file_path)
+
+ if File.exist?(file_path)
+ res.body = File.read(file_path)
+ res['Content-Type'] = WEBrick::HTTPUtils.mime_type(file_path, WEBrick::HTTPUtils::DefaultMimeTypes)
+ else
+ not_found_path = File.join(root_dir, '404.html')
+ res.status = 404
+ res.body = File.exist?(not_found_path) ? File.read(not_found_path) : '404 Not Found'
+ res['Content-Type'] = 'text/html'
+ end
+ end
+
+ trap('INT') { server.shutdown }
+ server.start
+ end
+
+ private
+
+ def resource_path?(pathname)
+ extensions = %w[.css .gif .ico .jpeg .jpg .js .mjs .png .svg]
+ extensions.any? { |ext| pathname.end_with?(ext) }
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/components/global_footer.rb b/services/nuldoc/lib/nuldoc/components/global_footer.rb
new file mode 100644
index 00000000..879aac55
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/global_footer.rb
@@ -0,0 +1,11 @@
+module Nuldoc
+ module Components
+ class GlobalFooter
+ extend DOM::HTML
+
+ def self.render(config:)
+ footer(class: 'footer') { text "&copy; #{config.site.copyright_year} #{config.site.author}" }
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/components/global_headers.rb b/services/nuldoc/lib/nuldoc/components/global_headers.rb
new file mode 100644
index 00000000..fb19096a
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/global_headers.rb
@@ -0,0 +1,66 @@
+module Nuldoc
+ module Components
+ class DefaultGlobalHeader
+ extend DOM::HTML
+
+ def self.render(config:)
+ header(class: 'header') do
+ div(class: 'site-logo') do
+ a(href: "https://#{config.sites.default.fqdn}/") { text 'nsfisis.dev' }
+ end
+ end
+ end
+ end
+
+ class AboutGlobalHeader
+ extend DOM::HTML
+
+ def self.render(config:)
+ header(class: 'header') do
+ div(class: 'site-logo') do
+ a(href: "https://#{config.sites.default.fqdn}/") { text 'nsfisis.dev' }
+ end
+ end
+ end
+ end
+
+ class BlogGlobalHeader
+ extend DOM::HTML
+
+ def self.render(config:)
+ header(class: 'header') do
+ div(class: 'site-logo') do
+ a(href: "https://#{config.sites.default.fqdn}/") { text 'nsfisis.dev' }
+ end
+ 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: '/posts/') { text 'Posts' } }
+ li { a(href: '/tags/') { text 'Tags' } }
+ end
+ end
+ end
+ end
+ end
+
+ class SlidesGlobalHeader
+ extend DOM::HTML
+
+ def self.render(config:)
+ header(class: 'header') do
+ div(class: 'site-logo') do
+ 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: '/slides/') { text 'Slides' } }
+ li { a(href: '/tags/') { text 'Tags' } }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/components/page_layout.rb b/services/nuldoc/lib/nuldoc/components/page_layout.rb
new file mode 100644
index 00000000..4ddd0968
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/page_layout.rb
@@ -0,0 +1,34 @@
+module Nuldoc
+ module Components
+ class PageLayout
+ extend DOM::HTML
+
+ 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)
+
+ 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: "&copy; #{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(property: 'og:type', content: 'article')
+ 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: 'icon', href: '/favicon.svg', type: 'image/svg+xml')
+ title { text meta_title }
+ StaticStylesheet.render(file_name: '/style.css', config: config)
+ end
+ child children
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/components/pagination.rb b/services/nuldoc/lib/nuldoc/components/pagination.rb
new file mode 100644
index 00000000..61978cb1
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/pagination.rb
@@ -0,0 +1,67 @@
+module Nuldoc
+ module Components
+ class Pagination
+ extend DOM::HTML
+
+ def self.render(current_page:, total_pages:, base_path:)
+ return div if total_pages <= 1
+
+ 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 }
+ end
+ end
+ end
+ div(class: 'pagination-next') do
+ a(href: page_url_at(base_path, current_page + 1)) { text '次へ' } if current_page < total_pages
+ end
+ end
+ end
+
+ def self.generate_page_numbers(current_page, total_pages)
+ pages = Set.new
+ pages.add(1)
+ pages.add([1, current_page - 1].max)
+ pages.add(current_page)
+ pages.add([total_pages, current_page + 1].min)
+ pages.add(total_pages)
+
+ sorted = pages.sort
+
+ result = []
+ sorted.each_with_index do |page, i|
+ if i.positive?
+ gap = page - sorted[i - 1]
+ if gap == 2
+ result.push(sorted[i - 1] + 1)
+ elsif gap > 2
+ result.push('...')
+ end
+ end
+ result.push(page)
+ end
+
+ result
+ end
+
+ def self.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
new file mode 100644
index 00000000..623c2be6
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/post_page_entry.rb
@@ -0,0 +1,30 @@
+module Nuldoc
+ module Components
+ class PostPageEntry
+ extend DOM::HTML
+
+ 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)
+
+ 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 } }
+ footer(class: 'entry-footer') do
+ time(datetime: published) { text published }
+ text ' 投稿'
+ if has_updates
+ text '、'
+ time(datetime: updated) { text updated }
+ text ' 更新'
+ end
+ TagList.render(tags: post.tags, config: config) if post.tags.length.positive?
+ end
+ end
+ end
+ end
+ 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
new file mode 100644
index 00000000..c78d3d23
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/slide_page_entry.rb
@@ -0,0 +1,30 @@
+module Nuldoc
+ module Components
+ class SlidePageEntry
+ extend DOM::HTML
+
+ 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)
+
+ 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 } }
+ footer(class: 'entry-footer') do
+ time(datetime: published) { text published }
+ text ' 登壇'
+ if has_updates
+ text '、'
+ time(datetime: updated) { text updated }
+ text ' 更新'
+ end
+ TagList.render(tags: slide.tags, config: config) if slide.tags.length.positive?
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/components/static_script.rb b/services/nuldoc/lib/nuldoc/components/static_script.rb
new file mode 100644
index 00000000..5c8af53d
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/static_script.rb
@@ -0,0 +1,16 @@
+module Nuldoc
+ module Components
+ class StaticScript
+ extend DOM::HTML
+
+ 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)
+ hash = ComponentUtils.calculate_file_hash(file_path)
+ attrs = { src: "#{file_name}?h=#{hash}" }
+ attrs[:type] = type if type
+ attrs[:defer] = defer if defer
+ script(**attrs)
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/components/static_stylesheet.rb b/services/nuldoc/lib/nuldoc/components/static_stylesheet.rb
new file mode 100644
index 00000000..5246ee1c
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/static_stylesheet.rb
@@ -0,0 +1,13 @@
+module Nuldoc
+ module Components
+ class StaticStylesheet
+ extend DOM::HTML
+
+ def self.render(file_name:, config:, site: nil)
+ 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}")
+ end
+ 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
new file mode 100644
index 00000000..0be95706
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/table_of_contents.rb
@@ -0,0 +1,23 @@
+module Nuldoc
+ module Components
+ class TableOfContents
+ extend DOM::HTML
+
+ def self.render(toc:)
+ nav(class: 'toc') do
+ h2 { text '目次' }
+ ul { toc.items.each { |entry| toc_entry_component(entry) } }
+ end
+ end
+
+ def self.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
new file mode 100644
index 00000000..57d11ab0
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/tag_list.rb
@@ -0,0 +1,17 @@
+module Nuldoc
+ module Components
+ class TagList
+ extend DOM::HTML
+
+ def self.render(tags:, config:)
+ ul(class: 'entry-tags') do
+ tags.each do |slug|
+ li(class: 'tag') do
+ span(class: 'tag-inner') { text config.tag_label(slug) }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/components/utils.rb b/services/nuldoc/lib/nuldoc/components/utils.rb
new file mode 100644
index 00000000..18337c86
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/utils.rb
@@ -0,0 +1,8 @@
+module Nuldoc
+ class ComponentUtils
+ def self.calculate_file_hash(file_path)
+ content = File.binread(file_path)
+ Digest::MD5.hexdigest(content)
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/config.rb b/services/nuldoc/lib/nuldoc/config.rb
new file mode 100644
index 00000000..b4334bcd
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/config.rb
@@ -0,0 +1,67 @@
+module Nuldoc
+ Config = Data.define(:locations, :site, :sites, :tag_labels) do
+ def tag_label(slug)
+ label = tag_labels[slug]
+ raise "Unknown tag: #{slug}" if label.nil?
+
+ label
+ end
+
+ def site_entry(site_key)
+ sites.public_send(site_key)
+ end
+ end
+
+ LocationsConfig = Data.define(:content_dir, :dest_dir, :static_dir)
+
+ SiteConfig = Data.define(:author, :copyright_year)
+
+ SiteEntry = Data.define(:fqdn, :site_name, :posts_per_page)
+
+ SitesConfig = Data.define(:default, :about, :blog, :slides)
+
+ class ConfigLoader
+ def self.default_config_path
+ File.join(Dir.pwd, 'nuldoc.toml')
+ end
+
+ def self.load_config(file_path)
+ raw = TomlRB.load_file(file_path)
+
+ locations = LocationsConfig.new(
+ content_dir: raw.dig('locations', 'contentDir'),
+ dest_dir: raw.dig('locations', 'destDir'),
+ static_dir: raw.dig('locations', 'staticDir')
+ )
+
+ site = SiteConfig.new(
+ author: raw.dig('site', 'author'),
+ copyright_year: raw.dig('site', 'copyrightYear')
+ )
+
+ sites = SitesConfig.new(
+ default: build_site_entry(raw.dig('sites', 'default')),
+ about: build_site_entry(raw.dig('sites', 'about')),
+ blog: build_site_entry(raw.dig('sites', 'blog')),
+ slides: build_site_entry(raw.dig('sites', 'slides'))
+ )
+
+ Config.new(
+ locations: locations,
+ site: site,
+ sites: sites,
+ tag_labels: raw['tagLabels'] || {}
+ )
+ end
+
+ def self.build_site_entry(hash)
+ SiteEntry.new(
+ fqdn: hash['fqdn'],
+ site_name: hash['siteName'],
+ posts_per_page: hash['postsPerPage']
+ )
+ end
+
+ private_class_method :build_site_entry
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/dom.rb b/services/nuldoc/lib/nuldoc/dom.rb
new file mode 100644
index 00000000..ec802fb6
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/dom.rb
@@ -0,0 +1,143 @@
+module Nuldoc
+ Text = Struct.new(:content, keyword_init: true) do
+ def kind = :text
+ end
+
+ RawHTML = Struct.new(:html, keyword_init: true) do
+ def kind = :raw
+ end
+
+ Element = Struct.new(:name, :attributes, :children, keyword_init: true) 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?
+ element.attributes['class'] = klass
+ else
+ class_list = classes.split
+ class_list.push(klass)
+ class_list.sort!
+ element.attributes['class'] = class_list.join(' ')
+ end
+ end
+
+ def find_first_child_element(element, name)
+ element.children.find { |c| c.kind == :element && c.name == name }
+ end
+
+ def find_child_elements(element, name)
+ element.children.select { |c| c.kind == :element && c.name == name }
+ end
+
+ def inner_text(element)
+ t = +''
+ for_each_child(element) do |c|
+ t << c.content if c.kind == :text
+ end
+ t
+ end
+
+ def for_each_child(element, &)
+ element.children.each(&)
+ end
+
+ def for_each_child_recursively(element)
+ g = proc do |c|
+ yield(c)
+ for_each_child(c, &g) if c.kind == :element
+ end
+ for_each_child(element, &g)
+ end
+
+ def for_each_element_of_type(root, element_name)
+ for_each_child_recursively(root) do |n|
+ yield(n) if n.kind == :element && n.name == element_name
+ end
+ end
+
+ def process_text_nodes_in_element(element)
+ new_children = []
+ element.children.each do |child|
+ if child.kind == :text
+ new_children.concat(yield(child.content))
+ else
+ new_children.push(child)
+ end
+ 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
new file mode 100644
index 00000000..9ab10822
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/dom/atom_xml.rb
@@ -0,0 +1,26 @@
+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/html.rb b/services/nuldoc/lib/nuldoc/dom/html.rb
new file mode 100644
index 00000000..1d9b1cab
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/dom/html.rb
@@ -0,0 +1,56 @@
+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/generators/about.rb b/services/nuldoc/lib/nuldoc/generators/about.rb
new file mode 100644
index 00000000..e64f0d1d
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/about.rb
@@ -0,0 +1,22 @@
+module Nuldoc
+ module Generators
+ class About
+ def initialize(slides, config)
+ @slides = slides
+ @config = config
+ end
+
+ def generate
+ html = Pages::AboutPage.render(slides: @slides, config: @config)
+
+ Page.new(
+ root: html,
+ renderer: :html,
+ site: 'about',
+ dest_file_path: '/index.html',
+ href: '/'
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/atom.rb b/services/nuldoc/lib/nuldoc/generators/atom.rb
new file mode 100644
index 00000000..74750eb7
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/atom.rb
@@ -0,0 +1,58 @@
+module Nuldoc
+ Feed = Data.define(:author, :icon, :id, :link_to_self, :link_to_alternate, :title, :updated, :entries)
+
+ FeedEntry = Data.define(:id, :link_to_alternate, :published, :summary, :title, :updated)
+
+ module Generators
+ class Atom
+ BASE_NAME = 'atom.xml'.freeze
+
+ def initialize(alternate_link, feed_slug, feed_title, entries, site, config)
+ @alternate_link = alternate_link
+ @feed_slug = feed_slug
+ @feed_title = feed_title
+ @entries = entries
+ @site = site
+ @config = config
+ end
+
+ def generate
+ feed_entries = @entries.map do |entry|
+ fqdn = entry.respond_to?(:event) ? @config.sites.slides.fqdn : @config.sites.blog.fqdn
+ FeedEntry.new(
+ id: "urn:uuid:#{entry.uuid}",
+ link_to_alternate: "https://#{fqdn}#{entry.href}",
+ title: entry.title,
+ summary: entry.description,
+ published: Revision.date_to_rfc3339_string(entry.published),
+ updated: Revision.date_to_rfc3339_string(entry.updated)
+ )
+ end
+
+ feed_entries.sort! { |a, b| [b.published, a.link_to_alternate] <=> [a.published, b.link_to_alternate] }
+
+ site_entry = @config.site_entry(@site)
+ feed_path = "#{@alternate_link}#{BASE_NAME}"
+
+ feed = Feed.new(
+ author: @config.site.author,
+ icon: "https://#{site_entry.fqdn}/favicon.svg",
+ id: "tag:#{site_entry.fqdn},#{@config.site.copyright_year}:#{@feed_slug}",
+ link_to_self: "https://#{site_entry.fqdn}#{feed_path}",
+ link_to_alternate: "https://#{site_entry.fqdn}#{@alternate_link}",
+ title: @feed_title,
+ updated: feed_entries.map(&:updated).max || feed_entries.first&.updated || '',
+ entries: feed_entries
+ )
+
+ Page.new(
+ root: Pages::AtomPage.render(feed: feed),
+ renderer: :xml,
+ site: @site,
+ dest_file_path: feed_path,
+ href: feed_path
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/home.rb b/services/nuldoc/lib/nuldoc/generators/home.rb
new file mode 100644
index 00000000..54f8753f
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/home.rb
@@ -0,0 +1,21 @@
+module Nuldoc
+ module Generators
+ class Home
+ def initialize(config)
+ @config = config
+ end
+
+ def generate
+ html = Pages::HomePage.render(config: @config)
+
+ Page.new(
+ root: html,
+ renderer: :html,
+ site: 'default',
+ dest_file_path: '/index.html',
+ href: '/'
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/not_found.rb b/services/nuldoc/lib/nuldoc/generators/not_found.rb
new file mode 100644
index 00000000..cffe1df8
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/not_found.rb
@@ -0,0 +1,22 @@
+module Nuldoc
+ module Generators
+ class NotFound
+ def initialize(site, config)
+ @site = site
+ @config = config
+ end
+
+ def generate
+ html = Pages::NotFoundPage.render(site: @site, config: @config)
+
+ Page.new(
+ root: html,
+ renderer: :html,
+ site: @site,
+ dest_file_path: '/404.html',
+ href: '/404.html'
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/post.rb b/services/nuldoc/lib/nuldoc/generators/post.rb
new file mode 100644
index 00000000..0d5a3afc
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/post.rb
@@ -0,0 +1,57 @@
+module Nuldoc
+ PostPage = Data.define(:root, :renderer, :site, :dest_file_path, :href,
+ :title, :description, :tags, :revisions, :published, :updated,
+ :uuid, :source_file_path)
+
+ class GeneratorUtils
+ def self.published_date(page)
+ page.revisions.each do |rev|
+ return rev.date unless rev.is_internal
+ end
+ page.revisions[0].date
+ end
+
+ def self.updated_date(page)
+ page.revisions.last.date
+ end
+
+ def self.any_updates?(page)
+ page.revisions.count { |rev| !rev.is_internal } >= 2
+ end
+ end
+
+ module Generators
+ class Post
+ def initialize(doc, config)
+ @doc = doc
+ @config = config
+ end
+
+ def generate
+ html = Pages::PostPage.render(doc: @doc, config: @config)
+
+ content_dir = File.join(Dir.pwd, @config.locations.content_dir)
+ dest_file_path = File.join(
+ @doc.source_file_path.sub(content_dir, '').sub('.md', ''),
+ 'index.html'
+ )
+
+ PostPage.new(
+ root: html,
+ renderer: :html,
+ site: 'blog',
+ dest_file_path: dest_file_path,
+ href: dest_file_path.sub('index.html', ''),
+ title: @doc.title,
+ description: @doc.description,
+ tags: @doc.tags,
+ revisions: @doc.revisions,
+ published: GeneratorUtils.published_date(@doc),
+ updated: GeneratorUtils.updated_date(@doc),
+ uuid: @doc.uuid,
+ source_file_path: @doc.source_file_path
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/post_list.rb b/services/nuldoc/lib/nuldoc/generators/post_list.rb
new file mode 100644
index 00000000..680a0c32
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/post_list.rb
@@ -0,0 +1,41 @@
+module Nuldoc
+ module Generators
+ class PostList
+ def initialize(posts, config)
+ @posts = posts
+ @config = config
+ end
+
+ def generate
+ posts_per_page = @config.sites.blog.posts_per_page
+ total_pages = (@posts.length.to_f / posts_per_page).ceil
+ pages = []
+
+ (0...total_pages).each do |page_index|
+ page_posts = @posts[page_index * posts_per_page, posts_per_page]
+ current_page = page_index + 1
+
+ html = Pages::PostListPage.render(
+ posts: page_posts,
+ config: @config,
+ current_page: current_page,
+ total_pages: total_pages
+ )
+
+ dest_file_path = current_page == 1 ? '/posts/index.html' : "/posts/#{current_page}/index.html"
+ href = current_page == 1 ? '/posts/' : "/posts/#{current_page}/"
+
+ pages.push(Page.new(
+ root: html,
+ renderer: :html,
+ site: 'blog',
+ dest_file_path: dest_file_path,
+ href: href
+ ))
+ end
+
+ pages
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/slide.rb b/services/nuldoc/lib/nuldoc/generators/slide.rb
new file mode 100644
index 00000000..58fde56e
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/slide.rb
@@ -0,0 +1,42 @@
+module Nuldoc
+ SlidePageData = Data.define(:root, :renderer, :site, :dest_file_path, :href,
+ :title, :description, :event, :talk_type, :slide_link,
+ :tags, :revisions, :published, :updated, :uuid)
+
+ module Generators
+ class Slide
+ def initialize(slide, config)
+ @slide = slide
+ @config = config
+ end
+
+ def generate
+ html = Pages::SlidePage.render(slide: @slide, config: @config)
+
+ content_dir = File.join(Dir.pwd, @config.locations.content_dir)
+ dest_file_path = File.join(
+ @slide.source_file_path.sub(content_dir, '').sub('.toml', ''),
+ 'index.html'
+ )
+
+ SlidePageData.new(
+ root: html,
+ renderer: :html,
+ site: 'slides',
+ dest_file_path: dest_file_path,
+ href: dest_file_path.sub('index.html', ''),
+ title: @slide.title,
+ description: "#{@slide.event} (#{@slide.talk_type})",
+ event: @slide.event,
+ talk_type: @slide.talk_type,
+ slide_link: @slide.slide_link,
+ tags: @slide.tags,
+ revisions: @slide.revisions,
+ published: GeneratorUtils.published_date(@slide),
+ updated: GeneratorUtils.updated_date(@slide),
+ uuid: @slide.uuid
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/slide_list.rb b/services/nuldoc/lib/nuldoc/generators/slide_list.rb
new file mode 100644
index 00000000..8d23e4b4
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/slide_list.rb
@@ -0,0 +1,22 @@
+module Nuldoc
+ module Generators
+ class SlideList
+ def initialize(slides, config)
+ @slides = slides
+ @config = config
+ end
+
+ def generate
+ html = Pages::SlideListPage.render(slides: @slides, config: @config)
+
+ Page.new(
+ root: html,
+ renderer: :html,
+ site: 'slides',
+ dest_file_path: '/slides/index.html',
+ href: '/slides/'
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/tag.rb b/services/nuldoc/lib/nuldoc/generators/tag.rb
new file mode 100644
index 00000000..7a5f7a7b
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/tag.rb
@@ -0,0 +1,31 @@
+module Nuldoc
+ TagPageData = Data.define(:root, :renderer, :site, :dest_file_path, :href,
+ :tag_slug, :tag_label, :num_of_posts, :num_of_slides)
+
+ module Generators
+ class Tag
+ def initialize(tag_slug, pages, site, config)
+ @tag_slug = tag_slug
+ @pages = pages
+ @site = site
+ @config = config
+ end
+
+ def generate
+ html = Pages::TagPage.render(tag_slug: @tag_slug, pages: @pages, site: @site, config: @config)
+
+ TagPageData.new(
+ root: html,
+ renderer: :html,
+ site: @site,
+ dest_file_path: "/tags/#{@tag_slug}/index.html",
+ href: "/tags/#{@tag_slug}/",
+ tag_slug: @tag_slug,
+ tag_label: @config.tag_label(@tag_slug),
+ num_of_posts: @pages.count { |p| !p.respond_to?(:event) },
+ num_of_slides: @pages.count { |p| p.respond_to?(:event) }
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/generators/tag_list.rb b/services/nuldoc/lib/nuldoc/generators/tag_list.rb
new file mode 100644
index 00000000..089b6f0c
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/generators/tag_list.rb
@@ -0,0 +1,23 @@
+module Nuldoc
+ module Generators
+ class TagList
+ def initialize(tags, site, config)
+ @tags = tags
+ @site = site
+ @config = config
+ end
+
+ def generate
+ html = Pages::TagListPage.render(tags: @tags, site: @site, config: @config)
+
+ Page.new(
+ root: html,
+ renderer: :html,
+ site: @site,
+ dest_file_path: '/tags/index.html',
+ href: '/tags/'
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/markdown/document.rb b/services/nuldoc/lib/nuldoc/markdown/document.rb
new file mode 100644
index 00000000..1268a7f8
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/document.rb
@@ -0,0 +1,19 @@
+module Nuldoc
+ TocEntry = Struct.new(:id, :text, :level, :children, keyword_init: true)
+
+ TocRoot = Struct.new(:items, keyword_init: true)
+
+ Document = Struct.new(
+ :root,
+ :source_file_path,
+ :uuid,
+ :link,
+ :title,
+ :description,
+ :tags,
+ :revisions,
+ :toc,
+ :is_toc_enabled,
+ keyword_init: true
+ )
+end
diff --git a/services/nuldoc/lib/nuldoc/markdown/parse.rb b/services/nuldoc/lib/nuldoc/markdown/parse.rb
new file mode 100644
index 00000000..fc96352d
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/parse.rb
@@ -0,0 +1,51 @@
+module Nuldoc
+ class MarkdownParser
+ def initialize(file_path, config)
+ @file_path = file_path
+ @config = config
+ end
+
+ def parse
+ file_content = File.read(@file_path)
+ _, frontmatter, *rest = file_content.split(/^---$/m)
+ meta = parse_metadata(frontmatter)
+ content = rest.join('---')
+
+ dom_root = Parser::BlockParser.parse(content)
+ content_dir = File.join(Dir.pwd, @config.locations.content_dir)
+ link_path = @file_path.sub(content_dir, '').sub('.md', '/')
+
+ revisions = meta['article']['revisions'].each_with_index.map do |r, i|
+ Revision.new(
+ number: i,
+ date: Revision.string_to_date(r['date']),
+ remark: r['remark'],
+ is_internal: !r['isInternal'].nil?
+ )
+ end
+
+ doc = Document.new(
+ root: dom_root,
+ source_file_path: @file_path,
+ uuid: meta['article']['uuid'],
+ link: link_path,
+ title: meta['article']['title'],
+ description: meta['article']['description'],
+ tags: meta['article']['tags'],
+ revisions: revisions,
+ toc: nil,
+ is_toc_enabled: meta['article']['toc'] != false
+ )
+
+ Transform.to_html(doc)
+ rescue StandardError => e
+ raise e.class, "#{e.message} in #{@file_path}"
+ end
+
+ private
+
+ def parse_metadata(s)
+ TomlRB.parse(s)
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/markdown/parser/attributes.rb b/services/nuldoc/lib/nuldoc/markdown/parser/attributes.rb
new file mode 100644
index 00000000..328c2446
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/parser/attributes.rb
@@ -0,0 +1,25 @@
+module Nuldoc
+ module Parser
+ class Attributes
+ def self.parse_trailing_attributes(text)
+ match = text.match(/\s*\{([^}]+)\}\s*$/)
+ return [text, nil, {}] unless match
+
+ attr_string = match[1]
+ text_before = text[0...match.begin(0)]
+
+ id = nil
+ attributes = {}
+
+ id_match = attr_string.match(/#([\w-]+)/)
+ id = id_match[1] if id_match
+
+ attr_string.scan(/([\w-]+)="([^"]*)"/) do |key, value|
+ attributes[key] = value
+ end
+
+ [text_before, id, attributes]
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb b/services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb
new file mode 100644
index 00000000..583d7201
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb
@@ -0,0 +1,609 @@
+module Nuldoc
+ module Parser
+ class BlockParser
+ extend DOM::HTML
+
+ 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
+
+ private
+
+ # --- Block parsing ---
+
+ def parse_blocks(scanner)
+ blocks = []
+ until scanner.eof?
+ block = parse_block(scanner)
+ blocks << block if block
+ end
+ blocks
+ end
+
+ def parse_block(scanner)
+ return nil if scanner.eof?
+
+ line = scanner.peek
+
+ # 1. Blank line
+ if line.strip.empty?
+ scanner.advance
+ return nil
+ end
+
+ # 2. HTML comment
+ if (result = try_html_comment(scanner))
+ return result
+ end
+
+ # 3. Fenced code block
+ if (result = try_fenced_code(scanner))
+ return result
+ end
+
+ # 4. Note/Edit block
+ if (result = try_note_block(scanner))
+ return result
+ end
+
+ # 5. Heading
+ if (result = try_heading(scanner))
+ return result
+ end
+
+ # 6. Horizontal rule
+ if (result = try_hr(scanner))
+ return result
+ end
+
+ # 7. Footnote definition
+ if (result = try_footnote_def(scanner))
+ return result
+ end
+
+ # 8. Table
+ if (result = try_table(scanner))
+ return result
+ end
+
+ # 9. Blockquote
+ if (result = try_blockquote(scanner))
+ return result
+ end
+
+ # 10. Ordered list
+ if (result = try_ordered_list(scanner))
+ return result
+ end
+
+ # 11. Unordered list
+ if (result = try_unordered_list(scanner))
+ return result
+ end
+
+ # 12. HTML block
+ if (result = try_html_block(scanner))
+ return result
+ end
+
+ # 13. Paragraph
+ parse_paragraph(scanner)
+ end
+
+ def try_html_comment(scanner)
+ line = scanner.peek
+ return nil unless line.strip.start_with?('<!--')
+
+ content = +''
+ until scanner.eof?
+ l = scanner.advance
+ content << l << "\n"
+ break if l.include?('-->')
+ end
+ nil # skip comments
+ end
+
+ 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
+
+ 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
+
+ code_lines = []
+ until scanner.eof?
+ l = scanner.peek
+ if l.start_with?('```')
+ scanner.advance
+ break
+ end
+ code_lines << scanner.advance
+ end
+
+ code = code_lines.join("\n")
+ elem('codeblock', **attrs) { text code }
+ 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
+ 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) }
+ end
+
+ def try_heading(scanner)
+ match = scanner.match(/^(\#{1,5})\s+(.+)$/)
+ return nil unless match
+
+ scanner.advance
+ level = match[1].length
+ raw_text = match[2]
+
+ text_before, id, attributes = Attributes.parse_trailing_attributes(raw_text)
+
+ inline_nodes = InlineParser.parse(text_before.strip)
+ heading_element = elem('h') { child(*inline_nodes) }
+
+ HeaderBlock.new(level: level, id: id, attributes: attributes, heading_element: heading_element)
+ end
+
+ def try_hr(scanner)
+ match = scanner.match(/^---+\s*$/)
+ return nil unless match
+
+ scanner.advance
+ hr
+ end
+
+ def try_footnote_def(scanner)
+ match = scanner.match(/^\[\^([^\]]+)\]:\s*(.*)$/)
+ return nil unless match
+
+ scanner.advance
+ id = match[1]
+ first_line = match[2]
+
+ content_lines = [first_line]
+ # Continuation lines: 4-space indent
+ until scanner.eof?
+ l = scanner.peek
+ break unless l.start_with?(' ')
+
+ content_lines << scanner.advance[4..]
+
+ end
+
+ inner_text = content_lines.join("\n").strip
+ 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) }
+ 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?('|')
+
+ # 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:|-]+\|$/)
+
+ # Collect table lines
+ lines = []
+ while !scanner.eof? && scanner.peek.start_with?('|')
+ lines << scanner.peek
+ scanner.advance
+ end
+
+ header_line = lines[0]
+ separator_line = lines[1]
+ body_lines = lines[2..] || []
+
+ 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
+
+ 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'
+
+ tag = is_header ? 'th' : 'td'
+ inline_nodes = InlineParser.parse(cell_text)
+ elem(tag, **attrs) { child(*inline_nodes) }
+ end
+ tr { child(*cell_elements) }
+ end
+
+ def try_blockquote(scanner)
+ return nil unless scanner.peek.start_with?('> ') || scanner.peek == '>'
+
+ lines = []
+ while !scanner.eof? && (scanner.peek.start_with?('> ') || scanner.peek == '>')
+ line = scanner.advance
+ lines << (line == '>' ? '' : line[2..])
+ 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) }
+
+ blockquote { child(*child_elements) }
+ end
+
+ def try_ordered_list(scanner)
+ match = scanner.match(/^(\d+)\.\s+(.*)$/)
+ return nil unless match
+
+ items = parse_list_items(scanner, :ordered)
+ return nil if items.empty?
+
+ build_list(:ordered, items)
+ end
+
+ def try_unordered_list(scanner)
+ match = scanner.match(/^\*\s+(.*)$/)
+ return nil unless match
+
+ items = parse_list_items(scanner, :unordered)
+ return nil if items.empty?
+
+ build_list(:unordered, items)
+ end
+
+ def parse_list_items(scanner, type)
+ items = []
+ marker_re = type == :ordered ? /^(\d+)\.\s+(.*)$/ : /^\*\s+(.*)$/
+ indent_size = 4
+
+ 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
+
+ # Indented continuation (sub-items or content)
+ if l.start_with?(' ' * indent_size)
+ content_lines << scanner.advance[indent_size..]
+ next
+ end
+
+ # New list item at same level
+ break if l.match?(marker_re)
+
+ # 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
+
+ # Paragraph continuation
+ content_lines << scanner.advance
+ end
+
+ items << { lines: content_lines, has_blank: has_blank }
+ end
+
+ items
+ end
+
+ def build_list(type, items)
+ # Determine tight/loose
+ is_tight = items.none? { |item| item[:has_blank] }
+
+ attrs = {}
+ attrs[:__tight] = is_tight ? 'true' : 'false'
+
+ # 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
+
+ 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
+ end
+
+ def build_list_item(item, is_task_list)
+ attrs = {}
+ content = item[:lines].join("\n")
+
+ 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)
+
+ # 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) }]
+ end
+
+ li(**attrs) { child(*child_elements) }
+ end
+
+ def try_html_block(scanner)
+ line = scanner.peek
+ match = line.match(/^<(div|details|summary)(\s[^>]*)?>/)
+ return nil unless match
+
+ tag = match[1]
+ lines = []
+ close_tag = "</#{tag}>"
+
+ until scanner.eof?
+ l = scanner.advance
+ lines << l
+ break if l.include?(close_tag)
+ end
+
+ html_content = lines.join("\n")
+
+ if tag == 'div'
+ # Parse inner content for div blocks
+ inner_match = html_content.match(%r{<div([^>]*)>(.*)</div>}m)
+ if inner_match
+ attr_str = inner_match[1]
+ inner_content = inner_match[2].strip
+
+ attrs = {}
+ attr_str.scan(/([\w-]+)="([^"]*)"/) do |key, value|
+ attrs[key.to_sym] = value
+ end
+
+ 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
+ else
+ div(class: 'raw-html') { raw_html html_content }
+ end
+ else
+ div(class: 'raw-html') { raw_html 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?(/^<!--/)
+ break if l.match?(/^<(div|details|summary)/)
+ break if l.match?(/^\[\^[^\]]+\]:/)
+
+ lines << scanner.advance
+ end
+
+ return nil if lines.empty?
+
+ text_content = lines.join("\n")
+ inline_nodes = InlineParser.parse(text_content)
+ p { child(*inline_nodes) }
+ end
+
+ # --- Section hierarchy ---
+
+ def build_document(blocks)
+ footnote_blocks = blocks.select { |b| b.is_a?(FootnoteBlock) }
+ non_footnote_blocks = blocks.reject { |b| b.is_a?(FootnoteBlock) }
+
+ article_content = build_section_hierarchy(non_footnote_blocks)
+
+ unless footnote_blocks.empty?
+ footnote_elements = footnote_blocks.map do |fb|
+ elem('footnote', id: fb.id) { child(*fb.children) }
+ end
+ footnote_section = section(class: 'footnotes') { child(*footnote_elements) }
+ article_content.push(footnote_section)
+ end
+
+ elem('__root__') { article { child(*article_content) } }
+ end
+
+ def build_section_hierarchy(blocks)
+ result = []
+ section_stack = []
+
+ blocks.each do |block|
+ if block.is_a?(HeaderBlock)
+ level = block.level
+
+ while !section_stack.empty? && section_stack.last[:level] >= level
+ closed = section_stack.pop
+ section_el = create_section_element(closed)
+ if section_stack.empty?
+ result.push(section_el)
+ else
+ section_stack.last[:children].push(section_el)
+ end
+ end
+
+ section_stack.push({
+ id: block.id,
+ attributes: block.attributes,
+ level: level,
+ heading: block.heading_element,
+ children: []
+ })
+ else
+ next if block.nil?
+
+ targets = if section_stack.empty?
+ result
+ else
+ section_stack.last[:children]
+ end
+
+ if block.is_a?(Array)
+ targets.concat(block)
+ else
+ targets.push(block)
+ end
+ end
+ end
+
+ until section_stack.empty?
+ closed = section_stack.pop
+ section_el = create_section_element(closed)
+ if section_stack.empty?
+ result.push(section_el)
+ else
+ section_stack.last[:children].push(section_el)
+ end
+ end
+
+ result
+ end
+
+ def create_section_element(section_info)
+ attributes = section_info[:attributes].dup
+ attributes['id'] = section_info[:id] if section_info[:id]
+
+ section(**attributes.transform_keys(&:to_sym)) do
+ child section_info[:heading]
+ child(*section_info[:children])
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/markdown/parser/inline_parser.rb b/services/nuldoc/lib/nuldoc/markdown/parser/inline_parser.rb
new file mode 100644
index 00000000..c1715904
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/parser/inline_parser.rb
@@ -0,0 +1,383 @@
+module Nuldoc
+ module Parser
+ class InlineParser
+ extend DOM::HTML
+
+ class << self
+ INLINE_HTML_TAGS = %w[del mark sub sup ins br].freeze
+ SELF_CLOSING_TAGS = %w[br].freeze
+
+ def parse(text)
+ parse_inline(text, 0, text.length)
+ end
+
+ private
+
+ def parse_inline(text, start, stop)
+ nodes = []
+ pos = start
+
+ while pos < stop
+ # Try each inline pattern
+ matched = try_escape(text, pos, stop, nodes) ||
+ try_code_span(text, pos, stop, nodes) ||
+ try_autolink(text, pos, stop, nodes) ||
+ try_inline_html(text, pos, stop, nodes) ||
+ try_image(text, pos, stop, nodes) ||
+ try_link(text, pos, stop, nodes) ||
+ try_footnote_ref(text, pos, stop, nodes) ||
+ try_bold(text, pos, stop, nodes) ||
+ try_italic(text, pos, stop, nodes) ||
+ try_strikethrough(text, pos, stop, nodes) ||
+ try_typographic(text, pos, stop, nodes)
+
+ if matched
+ pos = matched
+ else
+ # Plain character
+ char_end = pos + 1
+ # Collect consecutive plain characters
+ while char_end < stop
+ break if special_char?(text[char_end])
+
+ char_end += 1
+ end
+ nodes << text(text[pos...char_end])
+ pos = char_end
+ end
+ end
+
+ merge_text_nodes(nodes)
+ end
+
+ def special_char?(ch)
+ case ch
+ when '\\', '`', '<', '!', '[', '*', '~', '.', '-', "'", '"'
+ true
+ else
+ false
+ end
+ end
+
+ def try_escape(text, pos, _stop, nodes)
+ return nil unless text[pos] == '\\'
+ return nil if pos + 1 >= text.length
+
+ next_char = text[pos + 1]
+ return unless '\\`*_{}[]()#+-.!~|>:'.include?(next_char)
+
+ nodes << text(next_char)
+ pos + 2
+ end
+
+ def try_autolink(text, pos, stop, nodes)
+ return nil unless text[pos] == '<'
+
+ # Match <URL> where URL starts with http:// or https://
+ close = text.index('>', pos + 1)
+ return nil unless close && close < stop
+
+ url = text[(pos + 1)...close]
+ return nil unless url.match?(%r{^https?://\S+$})
+
+ nodes << a(href: url, class: 'url') { text url }
+ close + 1
+ end
+
+ def try_code_span(text, pos, stop, nodes)
+ return nil unless text[pos] == '`'
+
+ # Count opening backticks
+ tick_count = 0
+ i = pos
+ while i < stop && text[i] == '`'
+ tick_count += 1
+ i += 1
+ end
+
+ # Find matching closing backticks
+ close_pos = text.index('`' * tick_count, i)
+ return nil unless close_pos && close_pos < stop
+
+ content = text[i...close_pos]
+ # Strip one leading and one trailing space if both present
+ content = content[1...-1] if content.length >= 2 && content[0] == ' ' && content[-1] == ' '
+
+ nodes << code { text content }
+ close_pos + tick_count
+ end
+
+ def try_inline_html(text, pos, stop, nodes)
+ return nil unless text[pos] == '<'
+
+ # Self-closing tags
+ SELF_CLOSING_TAGS.each do |tag|
+ pattern = "<#{tag}>"
+ len = pattern.length
+ if pos + len <= stop && text[pos, len].downcase == pattern
+ nodes << elem(tag)
+ return pos + len
+ end
+ pattern_sc = "<#{tag} />"
+ len_sc = pattern_sc.length
+ if pos + len_sc <= stop && text[pos, len_sc].downcase == pattern_sc
+ nodes << elem(tag)
+ return pos + len_sc
+ end
+ pattern_sc2 = "<#{tag}/>"
+ len_sc2 = pattern_sc2.length
+ if pos + len_sc2 <= stop && text[pos, len_sc2].downcase == pattern_sc2
+ nodes << elem(tag)
+ return pos + len_sc2
+ end
+ end
+
+ # Opening tags with content
+ (INLINE_HTML_TAGS - SELF_CLOSING_TAGS).each do |tag|
+ open_tag = "<#{tag}>"
+ close_tag = "</#{tag}>"
+ next unless pos + open_tag.length <= stop && text[pos, open_tag.length].downcase == open_tag
+
+ close_pos = text.index(close_tag, pos + open_tag.length)
+ next unless close_pos && close_pos + close_tag.length <= stop
+
+ inner = text[(pos + open_tag.length)...close_pos]
+ children = parse_inline(inner, 0, inner.length)
+ nodes << elem(tag) { child(*children) }
+ return close_pos + close_tag.length
+ end
+
+ nil
+ end
+
+ def try_image(text, pos, stop, nodes)
+ return nil unless text[pos] == '!' && pos + 1 < stop && text[pos + 1] == '['
+
+ # Find ]
+ bracket_close = find_matching_bracket(text, pos + 1, stop)
+ return nil unless bracket_close
+
+ alt = text[(pos + 2)...bracket_close]
+
+ # Expect (
+ return nil unless bracket_close + 1 < stop && text[bracket_close + 1] == '('
+
+ paren_close = find_matching_paren(text, bracket_close + 1, stop)
+ return nil unless paren_close
+
+ inner = text[(bracket_close + 2)...paren_close].strip
+ url, title = parse_url_title(inner)
+
+ attrs = {}
+ attrs[:src] = url if url
+ attrs[:alt] = alt unless alt.empty?
+ attrs[:title] = title if title
+
+ nodes << img(**attrs)
+ paren_close + 1
+ end
+
+ def try_link(text, pos, stop, nodes)
+ return nil unless text[pos] == '['
+
+ bracket_close = find_matching_bracket(text, pos, stop)
+ return nil unless bracket_close
+
+ link_text = text[(pos + 1)...bracket_close]
+
+ # Expect (
+ return nil unless bracket_close + 1 < stop && text[bracket_close + 1] == '('
+
+ paren_close = find_matching_paren(text, bracket_close + 1, stop)
+ return nil unless paren_close
+
+ inner = text[(bracket_close + 2)...paren_close].strip
+ url, title = parse_url_title(inner)
+
+ attrs = {}
+ attrs[:href] = url if url
+ attrs[:title] = title if title
+
+ children = parse_inline(link_text, 0, link_text.length)
+
+ # Check if autolink
+ is_autolink = children.length == 1 &&
+ children[0].kind == :text &&
+ children[0].content == url
+ attrs[:class] = 'url' if is_autolink
+
+ nodes << a(**attrs) { child(*children) }
+ paren_close + 1
+ end
+
+ def try_footnote_ref(text, pos, stop, nodes)
+ return nil unless text[pos] == '[' && pos + 1 < stop && text[pos + 1] == '^'
+
+ close = text.index(']', pos + 2)
+ return nil unless close && close < stop
+
+ # Make sure there's no nested [ or ( between
+ inner = text[(pos + 2)...close]
+ return nil if inner.include?('[') || inner.include?(']')
+ return nil if inner.empty?
+
+ nodes << elem('footnoteref', reference: inner)
+ close + 1
+ end
+
+ def try_bold(text, pos, stop, nodes)
+ return nil unless text[pos] == '*' && pos + 1 < stop && text[pos + 1] == '*'
+
+ close = text.index('**', pos + 2)
+ return nil unless close && close + 2 <= stop
+
+ inner = text[(pos + 2)...close]
+ children = parse_inline(inner, 0, inner.length)
+ nodes << strong { child(*children) }
+ close + 2
+ end
+
+ def try_italic(text, pos, stop, nodes)
+ return nil unless text[pos] == '*'
+ return nil if pos + 1 < stop && text[pos + 1] == '*'
+
+ # Find closing * that is not **
+ i = pos + 1
+ while i < stop
+ if text[i] == '*'
+ # Make sure it's not **
+ if i + 1 < stop && text[i + 1] == '*'
+ i += 2
+ else
+ inner = text[(pos + 1)...i]
+ return nil if inner.empty?
+
+ children = parse_inline(inner, 0, inner.length)
+ nodes << em { child(*children) }
+ return i + 1
+ end
+ else
+ i += 1
+ end
+ end
+ nil
+ end
+
+ def try_strikethrough(text, pos, stop, nodes)
+ return nil unless text[pos] == '~' && pos + 1 < stop && text[pos + 1] == '~'
+
+ close = text.index('~~', pos + 2)
+ return nil unless close && close + 2 <= stop
+
+ inner = text[(pos + 2)...close]
+ children = parse_inline(inner, 0, inner.length)
+ nodes << del { child(*children) }
+ close + 2
+ end
+
+ def try_typographic(text, pos, stop, nodes)
+ # Ellipsis
+ if text[pos] == '.' && pos + 2 < stop && text[pos + 1] == '.' && text[pos + 2] == '.'
+ nodes << text("\u2026")
+ return pos + 3
+ end
+
+ # Em dash (must check before en dash)
+ if text[pos] == '-' && pos + 2 < stop && text[pos + 1] == '-' && text[pos + 2] == '-'
+ nodes << text("\u2014")
+ return pos + 3
+ end
+
+ # En dash
+ # Make sure it's not ---
+ if text[pos] == '-' && pos + 1 < stop && text[pos + 1] == '-' && (pos + 2 >= stop || text[pos + 2] != '-')
+ nodes << text("\u2013")
+ return pos + 2
+ end
+
+ # Smart quotes
+ try_smart_quotes(text, pos, stop, nodes)
+ end
+
+ def try_smart_quotes(text, pos, _stop, nodes)
+ ch = text[pos]
+ return nil unless ["'", '"'].include?(ch)
+
+ prev_char = pos.positive? ? text[pos - 1] : nil
+ is_opening = prev_char.nil? || prev_char == ' ' || prev_char == "\n" || prev_char == '(' || prev_char == '['
+
+ nodes << if ch == "'"
+ text(is_opening ? "\u2018" : "\u2019")
+ else
+ text(is_opening ? "\u201C" : "\u201D")
+ end
+ pos + 1
+ end
+
+ def find_matching_bracket(text, pos, stop)
+ return nil unless text[pos] == '['
+
+ depth = 0
+ i = pos
+ while i < stop
+ case text[i]
+ when '\\'
+ i += 2
+ next
+ when '['
+ depth += 1
+ when ']'
+ depth -= 1
+ return i if depth.zero?
+ end
+ i += 1
+ end
+ nil
+ end
+
+ def find_matching_paren(text, pos, stop)
+ return nil unless text[pos] == '('
+
+ depth = 0
+ i = pos
+ while i < stop
+ case text[i]
+ when '\\'
+ i += 2
+ next
+ when '('
+ depth += 1
+ when ')'
+ depth -= 1
+ return i if depth.zero?
+ end
+ i += 1
+ end
+ nil
+ end
+
+ def parse_url_title(inner)
+ # URL might be followed by "title"
+ match = inner.match(/^(\S+)\s+"([^"]*)"$/)
+ if match
+ [match[1], match[2]]
+ else
+ [inner, nil]
+ end
+ end
+
+ def merge_text_nodes(nodes)
+ result = []
+ nodes.each do |node|
+ if node.kind == :text && !result.empty? && result.last.kind == :text
+ result[-1] = text(result.last.content + node.content)
+ else
+ result << node
+ end
+ end
+ result
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/markdown/parser/line_scanner.rb b/services/nuldoc/lib/nuldoc/markdown/parser/line_scanner.rb
new file mode 100644
index 00000000..18a66158
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/parser/line_scanner.rb
@@ -0,0 +1,38 @@
+module Nuldoc
+ module Parser
+ class LineScanner
+ attr_reader :lines, :pos
+
+ def initialize(text)
+ @lines = text.lines(chomp: true)
+ @pos = 0
+ end
+
+ def eof?
+ @pos >= @lines.length
+ end
+
+ def peek
+ return nil if eof?
+
+ @lines[@pos]
+ end
+
+ def advance
+ line = peek
+ @pos += 1
+ line
+ end
+
+ def match(pattern)
+ return false if eof?
+
+ peek.match(pattern)
+ end
+
+ def skip_blank_lines
+ advance while !eof? && peek.strip.empty?
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/markdown/transform.rb b/services/nuldoc/lib/nuldoc/markdown/transform.rb
new file mode 100644
index 00000000..76968fb8
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/transform.rb
@@ -0,0 +1,415 @@
+module Nuldoc
+ class Transform
+ include DOM::HTML
+
+ def self.to_html(doc)
+ new(doc).to_html
+ end
+
+ def initialize(doc)
+ @doc = doc
+ end
+
+ def to_html
+ merge_consecutive_text_nodes
+ remove_unnecessary_text_node
+ transform_link_like_to_anchor_element
+ transform_section_id_attribute
+ assign_section_title_anchor
+ transform_section_title_element
+ transform_note_element
+ add_attributes_to_external_link_element
+ traverse_footnotes
+ remove_unnecessary_paragraph_node
+ transform_and_highlight_code_block_element
+ merge_consecutive_text_nodes
+ generate_table_of_contents
+ remove_toc_attributes
+ @doc
+ end
+
+ private
+
+ def merge_consecutive_text_nodes
+ for_each_child_recursively(@doc.root) do |n|
+ next unless n.kind == :element
+
+ new_children = []
+ current_text = +''
+
+ n.children.each do |child|
+ if child.kind == :text
+ current_text << child.content
+ else
+ unless current_text.empty?
+ new_children.push(text(current_text))
+ current_text = +''
+ end
+ new_children.push(child)
+ end
+ end
+
+ new_children.push(text(current_text)) unless current_text.empty?
+
+ n.children.replace(new_children)
+ end
+ end
+
+ def remove_unnecessary_text_node
+ for_each_child_recursively(@doc.root) do |n|
+ next unless n.kind == :element
+
+ loop do
+ changed = false
+ if !n.children.empty? && n.children.first.kind == :text && n.children.first.content.strip.empty?
+ n.children.shift
+ changed = true
+ end
+ if !n.children.empty? && n.children.last.kind == :text && n.children.last.content.strip.empty?
+ n.children.pop
+ changed = true
+ end
+ break unless changed
+ end
+ end
+ end
+
+ def transform_link_like_to_anchor_element
+ for_each_child_recursively(@doc.root) do |n|
+ next unless n.kind == :element
+ next if %w[a code codeblock].include?(n.name)
+
+ process_text_nodes_in_element(n) do |content|
+ nodes = []
+ rest = content
+ until rest.empty?
+ match = %r{^(.*?)(https?://[^ \n]+)(.*)$}m.match(rest)
+ unless match
+ nodes.push(text(rest))
+ break
+ end
+ nodes.push(text(match[1])) unless match[1].empty?
+ nodes.push(a(href: match[2], class: 'url') { text match[2] })
+ rest = match[3]
+ end
+ nodes
+ end
+ end
+ end
+
+ def transform_section_id_attribute
+ section_stack = []
+ used_ids = Set.new
+
+ process_node = proc do |n|
+ next unless n.kind == :element
+
+ if n.name == 'section'
+ id_attr = n.attributes['id']
+ if id_attr
+ new_id = if section_stack.empty?
+ "section--#{id_attr}"
+ else
+ "section--#{section_stack.join('--')}--#{id_attr}"
+ end
+
+ raise "[nuldoc.tohtml] Duplicate section ID: #{new_id}" if used_ids.include?(new_id)
+
+ used_ids.add(new_id)
+ n.attributes['id'] = new_id
+ section_stack.push(id_attr)
+
+ for_each_child(n, &process_node)
+
+ section_stack.pop
+ else
+ for_each_child(n, &process_node)
+ end
+ else
+ for_each_child(n, &process_node)
+ end
+ end
+
+ for_each_child(@doc.root, &process_node)
+ end
+
+ def assign_section_title_anchor
+ section_stack = []
+
+ g = proc do |c|
+ next unless c.kind == :element
+
+ section_stack.push(c) if c.name == 'section'
+ for_each_child(c, &g)
+ section_stack.pop if c.name == 'section'
+
+ if c.name == 'h'
+ current_section = section_stack.last
+ raise '[nuldoc.tohtml] <h> element must be inside <section>' unless current_section
+
+ section_id = current_section.attributes['id']
+ a_element = a { child(*c.children) }
+ a_element.attributes['href'] = "##{section_id}"
+ c.children.replace([a_element])
+ end
+ end
+
+ for_each_child(@doc.root, &g)
+ end
+
+ def transform_section_title_element
+ section_level = 1
+
+ g = proc do |c|
+ next unless c.kind == :element
+
+ if c.name == 'section'
+ section_level += 1
+ c.attributes['__sectionLevel'] = section_level.to_s
+ end
+ for_each_child(c, &g)
+ section_level -= 1 if c.name == 'section'
+ c.name = "h#{section_level}" if c.name == 'h'
+ end
+
+ for_each_child(@doc.root, &g)
+ end
+
+ def transform_note_element
+ for_each_element_of_type(@doc.root, 'note') do |n|
+ editat_attr = n.attributes['editat']
+ operation_attr = n.attributes['operation']
+ is_edit_block = editat_attr && operation_attr
+
+ label_element = div(class: 'admonition-label') do
+ text(is_edit_block ? "#{editat_attr} #{operation_attr}" : 'NOTE')
+ end
+ content_element = div(class: 'admonition-content') { child(*n.children.dup) }
+ n.name = 'div'
+ add_class(n, 'admonition')
+ n.children.replace([label_element, content_element])
+ end
+ end
+
+ def add_attributes_to_external_link_element
+ for_each_element_of_type(@doc.root, 'a') do |n|
+ href = n.attributes['href'] || ''
+ next unless href.start_with?('http')
+
+ n.attributes['target'] = '_blank'
+ n.attributes['rel'] = 'noreferrer'
+ end
+ end
+
+ def traverse_footnotes
+ footnote_counter = 0
+ footnote_map = {}
+
+ for_each_element_of_type(@doc.root, 'footnoteref') do |n|
+ reference = n.attributes['reference']
+ next unless reference
+
+ unless footnote_map.key?(reference)
+ footnote_counter += 1
+ footnote_map[reference] = footnote_counter
+ end
+ footnote_number = footnote_map[reference]
+
+ n.name = 'sup'
+ n.attributes.delete('reference')
+ n.attributes['class'] = 'footnote'
+ n.children.replace([
+ a(id: "footnoteref--#{reference}", class: 'footnote',
+ href: "#footnote--#{reference}") do
+ text "[#{footnote_number}]"
+ end
+ ])
+ end
+
+ for_each_element_of_type(@doc.root, 'footnote') do |n|
+ id = n.attributes['id']
+ unless id && footnote_map.key?(id)
+ n.name = 'span'
+ n.children.replace([])
+ next
+ end
+
+ footnote_number = footnote_map[id]
+
+ n.name = 'div'
+ n.attributes.delete('id')
+ n.attributes['class'] = 'footnote'
+ n.attributes['id'] = "footnote--#{id}"
+
+ old_children = n.children.dup
+ n.children.replace([
+ a(href: "#footnoteref--#{id}") { text "#{footnote_number}. " },
+ *old_children
+ ])
+ end
+ end
+
+ def remove_unnecessary_paragraph_node
+ for_each_child_recursively(@doc.root) do |n|
+ next unless n.kind == :element
+ next unless %w[ul ol].include?(n.name)
+
+ is_tight = n.attributes['__tight'] == 'true'
+ next unless is_tight
+
+ n.children.each do |child|
+ next unless child.kind == :element && child.name == 'li'
+
+ new_grand_children = []
+ child.children.each do |grand_child|
+ if grand_child.kind == :element && grand_child.name == 'p'
+ new_grand_children.concat(grand_child.children)
+ else
+ new_grand_children.push(grand_child)
+ end
+ end
+ child.children.replace(new_grand_children)
+ end
+ end
+ end
+
+ def transform_and_highlight_code_block_element
+ for_each_child_recursively(@doc.root) do |n|
+ next unless n.kind == :element && n.name == 'codeblock'
+
+ language = n.attributes['language'] || 'text'
+ filename = n.attributes['filename']
+ numbered = n.attributes['numbered']
+ source_code_node = n.children[0]
+ source_code = if source_code_node.kind == :text
+ source_code_node.content.rstrip
+ else
+ source_code_node.html.rstrip
+ end
+
+ highlighted = highlight_code(source_code, language)
+
+ n.name = 'div'
+ n.attributes['class'] = 'codeblock'
+ n.attributes.delete('language')
+
+ if numbered == 'true'
+ n.attributes.delete('numbered')
+ add_class(n, 'numbered')
+ end
+
+ if filename
+ n.attributes.delete('filename')
+ n.children.replace([
+ div(class: 'filename') { text filename },
+ raw_html(highlighted)
+ ])
+ else
+ n.children.replace([raw_html(highlighted)])
+ end
+ end
+ end
+
+ def highlight_code(source, language)
+ lexer = Rouge::Lexer.find(language) || Rouge::Lexers::PlainText.new
+ lexer = lexer.new if lexer.is_a?(Class)
+ formatter = Rouge::Formatters::HTMLInline.new('github.light')
+ tokens = lexer.lex(source)
+ inner_html = formatter.format(tokens)
+ "<pre class=\"highlight\" style=\"background-color:#f5f5f5\"><code>#{inner_html}\n</code></pre>"
+ end
+
+ def generate_table_of_contents
+ return unless @doc.is_toc_enabled
+
+ toc_entries = []
+ stack = []
+ excluded_levels = []
+
+ process_node = proc do |node|
+ next unless node.kind == :element
+
+ match = node.name.match(/^h(\d+)$/)
+ if match
+ level = match[1].to_i
+
+ parent_section = find_parent_section(@doc.root, node)
+ next unless parent_section
+
+ if parent_section.attributes['toc'] == 'false'
+ excluded_levels.clear
+ excluded_levels.push(level)
+ next
+ end
+
+ should_exclude = excluded_levels.any? { |el| level > el }
+ next if should_exclude
+
+ excluded_levels.pop while !excluded_levels.empty? && excluded_levels.last >= level
+
+ section_id = parent_section.attributes['id']
+ next unless section_id
+
+ heading_text = ''
+ node.children.each do |child|
+ heading_text = inner_text(child) if child.kind == :element && child.name == 'a'
+ end
+
+ entry = { id: section_id, text: heading_text, level: level, children: [] }
+
+ stack.pop while !stack.empty? && stack.last[:level] >= level
+
+ if stack.empty?
+ toc_entries.push(entry)
+ else
+ stack.last[:children].push(entry)
+ end
+
+ stack.push(entry)
+ end
+
+ for_each_child(node, &process_node)
+ end
+
+ for_each_child(@doc.root, &process_node)
+
+ return if toc_entries.length == 1 && toc_entries[0][:children].empty?
+
+ toc = TocRoot.new(items: build_toc_entries(toc_entries))
+ @doc.toc = toc
+ end
+
+ def build_toc_entries(raw_entries)
+ raw_entries.map do |e|
+ TocEntry.new(
+ id: e[:id],
+ text: e[:text],
+ level: e[:level],
+ children: build_toc_entries(e[:children])
+ )
+ end
+ end
+
+ def find_parent_section(root, target)
+ return root if root.kind == :element && root.name == 'section' && root.children.include?(target)
+
+ if root.kind == :element
+ root.children.each do |child|
+ next unless child.kind == :element
+
+ return child if child.name == 'section' && child.children.include?(target)
+
+ result = find_parent_section(child, target)
+ return result if result
+ end
+ end
+ nil
+ end
+
+ def remove_toc_attributes
+ for_each_child_recursively(@doc.root) do |node|
+ node.attributes.delete('toc') if node.kind == :element && node.name == 'section'
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/page.rb b/services/nuldoc/lib/nuldoc/page.rb
new file mode 100644
index 00000000..88795859
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/page.rb
@@ -0,0 +1,3 @@
+module Nuldoc
+ Page = Data.define(:root, :renderer, :site, :dest_file_path, :href)
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/about_page.rb b/services/nuldoc/lib/nuldoc/pages/about_page.rb
new file mode 100644
index 00000000..755ec233
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/about_page.rb
@@ -0,0 +1,85 @@
+module Nuldoc
+ module Pages
+ class AboutPage
+ extend DOM::HTML
+
+ def self.render(slides:, config:)
+ sorted_slides = slides.sort_by { |s| GeneratorUtils.published_date(s) }.reverse
+
+ Components::PageLayout.render(
+ meta_copyright_year: config.site.copyright_year,
+ meta_description: 'このサイトの著者について',
+ meta_title: "About|#{config.sites.about.site_name}",
+ site: 'about',
+ config: config,
+ children: body(class: 'single') do
+ Components::AboutGlobalHeader.render(config: config)
+ main(class: 'main') do
+ article(class: 'post-single') do
+ header(class: 'post-header') do
+ h1(class: 'post-title') { text 'nsfisis' }
+ div(class: 'my-icon') do
+ div(id: 'myIcon') { img(src: '/favicon.svg') }
+ Components::StaticScript.render(
+ site: 'about',
+ file_name: '/my-icon.js',
+ defer: 'true',
+ config: config
+ )
+ end
+ end
+ div(class: 'post-content') do
+ section do
+ h2 { text '読み方' }
+ p { text '読み方は決めていません。音にする必要があるときは本名である「いまむら」をお使いください。' }
+ end
+ section do
+ h2 { text 'アカウント' }
+ ul do
+ li do
+ a(href: 'https://twitter.com/nsfisis', target: '_blank', rel: 'noreferrer') do
+ text 'Twitter (現 𝕏): @nsfisis'
+ end
+ end
+ li do
+ a(href: 'https://github.com/nsfisis', target: '_blank', rel: 'noreferrer') do
+ text 'GitHub: @nsfisis'
+ end
+ end
+ end
+ end
+ section do
+ h2 { text '仕事' }
+ ul do
+ li do
+ text '2021-01~現在: '
+ a(href: 'https://www.dgcircus.com/', target: '_blank', rel: 'noreferrer') do
+ text 'デジタルサーカス株式会社'
+ end
+ end
+ end
+ end
+ section do
+ h2 { text '登壇' }
+ ul do
+ sorted_slides.each do |slide|
+ slide_url = "https://#{config.sites.slides.fqdn}#{slide.href}"
+ slide_date = Revision.date_to_string(GeneratorUtils.published_date(slide))
+ li do
+ a(href: slide_url) do
+ text "#{slide_date}: #{slide.event} (#{slide.talk_type})"
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/atom_page.rb b/services/nuldoc/lib/nuldoc/pages/atom_page.rb
new file mode 100644
index 00000000..e9f9541c
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/atom_page.rb
@@ -0,0 +1,28 @@
+module Nuldoc
+ module Pages
+ class AtomPage
+ extend DOM::AtomXML
+
+ def self.render(feed:)
+ feed(xmlns: 'http://www.w3.org/2005/Atom') do
+ id { text feed.id }
+ title { text feed.title }
+ link(rel: 'alternate', href: feed.link_to_alternate)
+ link(rel: 'self', href: feed.link_to_self)
+ author { name { text feed.author } }
+ updated { text feed.updated }
+ feed.entries.each do |entry|
+ entry do
+ id { text entry.id }
+ link(rel: 'alternate', href: entry.link_to_alternate)
+ title { text entry.title }
+ summary { text entry.summary }
+ published { text entry.published }
+ updated { text entry.updated }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/home_page.rb b/services/nuldoc/lib/nuldoc/pages/home_page.rb
new file mode 100644
index 00000000..405197d1
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/home_page.rb
@@ -0,0 +1,46 @@
+module Nuldoc
+ module Pages
+ class HomePage
+ extend DOM::HTML
+
+ def self.render(config:)
+ Components::PageLayout.render(
+ meta_copyright_year: config.site.copyright_year,
+ meta_description: 'nsfisis のサイト',
+ meta_title: config.sites.default.site_name,
+ meta_atom_feed_href: "https://#{config.sites.default.fqdn}/atom.xml",
+ site: 'default',
+ config: config,
+ children: body(class: 'single') do
+ Components::DefaultGlobalHeader.render(config: config)
+ main(class: 'main') do
+ article(class: 'post-single') do
+ article(class: 'post-entry') do
+ a(href: "https://#{config.sites.about.fqdn}/") do
+ header(class: 'entry-header') { h2 { text 'About' } }
+ end
+ end
+ article(class: 'post-entry') do
+ a(href: "https://#{config.sites.blog.fqdn}/posts/") do
+ header(class: 'entry-header') { h2 { text 'Blog' } }
+ end
+ end
+ article(class: 'post-entry') do
+ a(href: "https://#{config.sites.slides.fqdn}/slides/") do
+ header(class: 'entry-header') { h2 { text 'Slides' } }
+ end
+ end
+ article(class: 'post-entry') do
+ a(href: "https://repos.#{config.sites.default.fqdn}/") do
+ header(class: 'entry-header') { h2 { text 'Repositories' } }
+ end
+ end
+ end
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/not_found_page.rb b/services/nuldoc/lib/nuldoc/pages/not_found_page.rb
new file mode 100644
index 00000000..08a9f2f5
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/not_found_page.rb
@@ -0,0 +1,33 @@
+module Nuldoc
+ module Pages
+ class NotFoundPage
+ extend DOM::HTML
+
+ def self.render(site:, config:)
+ global_header = case site
+ when 'about' then Components::AboutGlobalHeader
+ when 'blog' then Components::BlogGlobalHeader
+ when 'slides' then Components::SlidesGlobalHeader
+ else Components::DefaultGlobalHeader
+ end
+
+ site_entry = config.site_entry(site)
+
+ Components::PageLayout.render(
+ meta_copyright_year: config.site.copyright_year,
+ meta_description: 'リクエストされたページが見つかりません',
+ meta_title: "Page Not Found|#{site_entry.site_name}",
+ site: site,
+ config: config,
+ children: body(class: 'single') do
+ global_header.render(config: config)
+ main(class: 'main') do
+ article { div(class: 'not-found') { text '404' } }
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/post_list_page.rb b/services/nuldoc/lib/nuldoc/pages/post_list_page.rb
new file mode 100644
index 00000000..dfa77b6f
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/post_list_page.rb
@@ -0,0 +1,35 @@
+module Nuldoc
+ module Pages
+ class PostListPage
+ extend DOM::HTML
+
+ def self.render(posts:, config:, current_page:, total_pages:)
+ page_title = '投稿一覧'
+ page_info_suffix = " (#{current_page}ページ目)"
+ meta_title = "#{page_title}#{page_info_suffix}|#{config.sites.blog.site_name}"
+ meta_description = "投稿した記事の一覧#{page_info_suffix}"
+
+ Components::PageLayout.render(
+ meta_copyright_year: config.site.copyright_year,
+ meta_description: meta_description,
+ meta_title: meta_title,
+ meta_atom_feed_href: "https://#{config.sites.blog.fqdn}/posts/atom.xml",
+ site: 'blog',
+ config: config,
+ children: body(class: 'list') do
+ Components::BlogGlobalHeader.render(config: config)
+ main(class: 'main') do
+ header(class: 'page-header') { h1 { text "#{page_title}#{page_info_suffix}" } }
+ Components::Pagination.render(current_page: current_page, total_pages: total_pages,
+ base_path: '/posts/')
+ posts.each { |post| Components::PostPageEntry.render(post: post, config: config) }
+ Components::Pagination.render(current_page: current_page, total_pages: total_pages,
+ base_path: '/posts/')
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/post_page.rb b/services/nuldoc/lib/nuldoc/pages/post_page.rb
new file mode 100644
index 00000000..d98dcd5d
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/post_page.rb
@@ -0,0 +1,54 @@
+module Nuldoc
+ module Pages
+ class PostPage
+ extend DOM::HTML
+
+ def self.render(doc:, config:)
+ Components::PageLayout.render(
+ meta_copyright_year: GeneratorUtils.published_date(doc).year,
+ meta_description: doc.description,
+ meta_keywords: doc.tags.map { |slug| config.tag_label(slug) },
+ meta_title: "#{doc.title}|#{config.sites.blog.site_name}",
+ site: 'blog',
+ config: config,
+ children: body(class: 'single') do
+ Components::BlogGlobalHeader.render(config: config)
+ main(class: 'main') do
+ article(class: 'post-single') do
+ header(class: 'post-header') do
+ h1(class: 'post-title') { text doc.title }
+ if doc.tags.length.positive?
+ ul(class: 'post-tags') do
+ doc.tags.each do |slug|
+ li(class: 'tag') do
+ a(class: 'tag-inner', href: "/tags/#{slug}/") { text config.tag_label(slug) }
+ end
+ end
+ end
+ end
+ end
+ Components::TableOfContents.render(toc: doc.toc) if doc.toc && doc.toc.items.length.positive?
+ div(class: 'post-content') do
+ section(id: 'changelog') do
+ h2 { a(href: '#changelog') { text '更新履歴' } }
+ ol do
+ doc.revisions.each do |rev|
+ ds = Revision.date_to_string(rev.date)
+ li(class: 'revision') do
+ time(datetime: ds) { text ds }
+ text ": #{rev.remark}"
+ end
+ end
+ end
+ end
+ child(*doc.root.children[0].children)
+ end
+ end
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/slide_list_page.rb b/services/nuldoc/lib/nuldoc/pages/slide_list_page.rb
new file mode 100644
index 00000000..86a5fb40
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/slide_list_page.rb
@@ -0,0 +1,31 @@
+module Nuldoc
+ module Pages
+ class SlideListPage
+ extend DOM::HTML
+
+ def self.render(slides:, config:)
+ page_title = 'スライド一覧'
+ sorted = slides.sort_by { |s| GeneratorUtils.published_date(s) }.reverse
+
+ Components::PageLayout.render(
+ meta_copyright_year: config.site.copyright_year,
+ meta_description: '登壇したイベントで使用したスライドの一覧',
+ meta_title: "#{page_title}|#{config.sites.slides.site_name}",
+ meta_atom_feed_href: "https://#{config.sites.slides.fqdn}/slides/atom.xml",
+ site: 'slides',
+ config: config,
+ children: body(class: 'list') do
+ Components::SlidesGlobalHeader.render(config: config)
+ main(class: 'main') do
+ header(class: 'page-header') { h1 { text page_title } }
+ sorted.each do |slide|
+ Components::SlidePageEntry.render(slide: slide, config: config)
+ end
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/slide_page.rb b/services/nuldoc/lib/nuldoc/pages/slide_page.rb
new file mode 100644
index 00000000..0259c1e4
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/slide_page.rb
@@ -0,0 +1,76 @@
+module Nuldoc
+ module Pages
+ class SlidePage
+ extend DOM::HTML
+
+ def self.render(slide:, config:)
+ Components::PageLayout.render(
+ meta_copyright_year: GeneratorUtils.published_date(slide).year,
+ meta_description: "「#{slide.title}」(#{slide.event} で登壇)",
+ meta_keywords: slide.tags.map { |slug| config.tag_label(slug) },
+ meta_title: "#{slide.title} (#{slide.event})|#{config.sites.slides.site_name}",
+ site: 'slides',
+ config: config,
+ children: body(class: 'single') do
+ Components::StaticStylesheet.render(site: 'slides', file_name: '/slides.css', config: config)
+ Components::SlidesGlobalHeader.render(config: config)
+ main(class: 'main') do
+ article(class: 'post-single') do
+ header(class: 'post-header') do
+ h1(class: 'post-title') { text slide.title }
+ if slide.tags.length.positive?
+ ul(class: 'post-tags') do
+ slide.tags.each do |slug|
+ li(class: 'tag') do
+ a(class: 'tag-inner', href: "/tags/#{slug}/") { text config.tag_label(slug) }
+ end
+ end
+ end
+ end
+ end
+ div(class: 'post-content') do
+ section(id: 'changelog') do
+ h2 { a(href: '#changelog') { text '更新履歴' } }
+ ol do
+ slide.revisions.each do |rev|
+ ds = Revision.date_to_string(rev.date)
+ li(class: 'revision') do
+ time(datetime: ds) { text ds }
+ text ": #{rev.remark}"
+ end
+ end
+ end
+ end
+ canvas(id: 'slide', 'data-slide-link': slide.slide_link)
+ div(class: 'controllers') do
+ div(class: 'controllers-buttons') do
+ button(id: 'prev', type: 'button') do
+ elem('svg', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none',
+ stroke: 'currentColor', 'stroke-width': '2') do
+ elem('path', d: 'M15 18l-6-6 6-6')
+ end
+ end
+ button(id: 'next', type: 'button') do
+ elem('svg', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none',
+ stroke: 'currentColor', 'stroke-width': '2') do
+ elem('path', d: 'M9 18l6-6-6-6')
+ end
+ end
+ end
+ end
+ Components::StaticScript.render(
+ site: 'slides',
+ file_name: '/slide.js',
+ type: 'module',
+ config: config
+ )
+ end
+ end
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/tag_list_page.rb b/services/nuldoc/lib/nuldoc/pages/tag_list_page.rb
new file mode 100644
index 00000000..16b3df05
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/tag_list_page.rb
@@ -0,0 +1,43 @@
+module Nuldoc
+ module Pages
+ class TagListPage
+ extend DOM::HTML
+
+ def self.render(tags:, site:, config:)
+ page_title = 'タグ一覧'
+ global_header = site == 'blog' ? Components::BlogGlobalHeader : Components::SlidesGlobalHeader
+ site_entry = config.site_entry(site)
+
+ sorted_tags = tags.sort_by(&:tag_slug)
+
+ Components::PageLayout.render(
+ meta_copyright_year: config.site.copyright_year,
+ meta_description: 'タグの一覧',
+ meta_title: "#{page_title}|#{site_entry.site_name}",
+ site: site,
+ config: config,
+ children: body(class: 'list') do
+ global_header.render(config: config)
+ main(class: 'main') do
+ header(class: 'page-header') { h1 { text page_title } }
+ sorted_tags.each do |tag|
+ posts_text = tag.num_of_posts.zero? ? '' : "#{tag.num_of_posts}件の記事"
+ slides_text = tag.num_of_slides.zero? ? '' : "#{tag.num_of_slides}件のスライド"
+ separator = !posts_text.empty? && !slides_text.empty? ? '、' : ''
+ footer_text = "#{posts_text}#{separator}#{slides_text}"
+
+ article(class: 'post-entry') do
+ a(href: tag.href) do
+ header(class: 'entry-header') { h2 { text tag.tag_label } }
+ footer(class: 'entry-footer') { text footer_text }
+ end
+ end
+ end
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pages/tag_page.rb b/services/nuldoc/lib/nuldoc/pages/tag_page.rb
new file mode 100644
index 00000000..4bc08a8c
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/tag_page.rb
@@ -0,0 +1,39 @@
+module Nuldoc
+ module Pages
+ class TagPage
+ extend DOM::HTML
+
+ def self.render(tag_slug:, pages:, site:, config:)
+ tag_label = config.tag_label(tag_slug)
+ page_title = "タグ「#{tag_label}」一覧"
+
+ global_header = site == 'blog' ? Components::BlogGlobalHeader : Components::SlidesGlobalHeader
+ site_entry = config.site_entry(site)
+
+ Components::PageLayout.render(
+ meta_copyright_year: GeneratorUtils.published_date(pages.last).year,
+ meta_description: "タグ「#{tag_label}」のついた記事またはスライドの一覧",
+ meta_keywords: [tag_label],
+ meta_title: "#{page_title}|#{site_entry.site_name}",
+ meta_atom_feed_href: "https://#{site_entry.fqdn}/tags/#{tag_slug}/atom.xml",
+ site: site,
+ config: config,
+ children: body(class: 'list') do
+ global_header.render(config: config)
+ main(class: 'main') do
+ header(class: 'page-header') { h1 { text page_title } }
+ pages.each do |page|
+ if page.respond_to?(:event)
+ Components::SlidePageEntry.render(slide: page, config: config)
+ else
+ Components::PostPageEntry.render(post: page, config: config)
+ end
+ end
+ end
+ Components::GlobalFooter.render(config: config)
+ end
+ )
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/pipeline.rb b/services/nuldoc/lib/nuldoc/pipeline.rb
new file mode 100644
index 00000000..802bba75
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pipeline.rb
@@ -0,0 +1,47 @@
+require 'tsort'
+
+module Nuldoc
+ Step = Data.define(:name, :deps, :block)
+
+ class Pipeline
+ include TSort
+
+ def initialize
+ @steps = {}
+ end
+
+ def step(name, deps: [], &block)
+ @steps[name] = Step.new(name: name, deps: deps, block: block)
+ end
+
+ def execute(profile: false)
+ results = {}
+ total_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
+ tsort_each do |name|
+ if profile
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ results[name] = @steps[name].block.call(results)
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
+ warn format('[profile] %-30<name>s %8.3<ms>f ms', name: name, ms: elapsed * 1000)
+ else
+ results[name] = @steps[name].block.call(results)
+ end
+ end
+ if profile
+ total_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - total_start
+ warn format('[profile] %-30<name>s %8.3<ms>f ms', name: 'TOTAL', ms: total_elapsed * 1000)
+ end
+ results
+ end
+
+ private
+
+ def tsort_each_node(&)
+ @steps.each_key(&)
+ end
+
+ def tsort_each_child(name, &)
+ @steps[name].deps.each(&)
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/render.rb b/services/nuldoc/lib/nuldoc/render.rb
new file mode 100644
index 00000000..39cd25bf
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/render.rb
@@ -0,0 +1,14 @@
+module Nuldoc
+ class Renderer
+ def render(root, renderer_type)
+ case renderer_type
+ when :html
+ HTMLRenderer.new.render(root)
+ when :xml
+ XMLRenderer.new.render(root)
+ else
+ raise "Unknown renderer: #{renderer_type}"
+ end
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/renderers/html.rb b/services/nuldoc/lib/nuldoc/renderers/html.rb
new file mode 100644
index 00000000..89cd0ede
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/renderers/html.rb
@@ -0,0 +1,210 @@
+module Nuldoc
+ class HTMLRenderer
+ DTD = {
+ 'a' => { type: :inline },
+ 'article' => { type: :block },
+ 'blockquote' => { type: :block },
+ 'body' => { type: :block },
+ 'br' => { type: :block, self_closing: true },
+ 'button' => { type: :block },
+ 'canvas' => { type: :block },
+ 'caption' => { type: :block },
+ 'code' => { type: :inline },
+ 'del' => { type: :block },
+ 'div' => { type: :block },
+ 'em' => { type: :inline },
+ 'footer' => { type: :block },
+ 'h1' => { type: :inline },
+ 'h2' => { type: :inline },
+ 'h3' => { type: :inline },
+ 'h4' => { type: :inline },
+ 'h5' => { type: :inline },
+ 'h6' => { type: :inline },
+ 'head' => { type: :block },
+ 'header' => { type: :block },
+ 'hr' => { type: :block, self_closing: true },
+ 'html' => { type: :block },
+ 'i' => { type: :inline },
+ 'li' => { type: :block },
+ 'link' => { type: :block, self_closing: true },
+ 'img' => { type: :inline, self_closing: true },
+ 'ins' => { type: :inline },
+ 'main' => { type: :block },
+ 'mark' => { type: :inline },
+ 'meta' => { type: :block, self_closing: true },
+ 'nav' => { type: :block },
+ 'noscript' => { type: :block },
+ 'ol' => { type: :block },
+ 'p' => { type: :block },
+ 'pre' => { type: :block },
+ 'script' => { type: :block },
+ 'section' => { type: :block },
+ 'span' => { type: :inline },
+ 'strong' => { type: :inline },
+ 'sub' => { type: :inline },
+ 'sup' => { type: :inline },
+ 'table' => { type: :block },
+ 'tbody' => { type: :block },
+ 'td' => { type: :block },
+ 'tfoot' => { type: :block },
+ 'th' => { type: :block },
+ 'thead' => { type: :block },
+ 'time' => { type: :inline },
+ 'title' => { type: :inline },
+ 'tr' => { type: :block },
+ 'ul' => { type: :block },
+ 'svg' => { type: :block },
+ 'path' => { type: :block }
+ }.freeze
+
+ def render(root)
+ "<!DOCTYPE html>\n#{node_to_html(root, indent_level: 0, is_in_pre: false)}"
+ end
+
+ private
+
+ def get_dtd(name)
+ dtd = DTD[name]
+ raise "[html.write] Unknown element name: #{name}" if dtd.nil?
+
+ dtd
+ end
+
+ def inline_node?(node)
+ return true if %i[text raw].include?(node.kind)
+
+ return get_dtd(node.name)[:type] == :inline if node.name != 'a'
+
+ node.children.all? { |c| inline_node?(c) }
+ end
+
+ def block_node?(node)
+ !inline_node?(node)
+ end
+
+ def node_to_html(node, indent_level:, is_in_pre:)
+ case node.kind
+ when :text
+ text_node_to_html(node, _indent_level: indent_level, is_in_pre: is_in_pre)
+ when :raw
+ node.html
+ when :element
+ element_node_to_html(node, indent_level: indent_level, is_in_pre: is_in_pre)
+ end
+ end
+
+ def text_node_to_html(node, _indent_level:, is_in_pre:)
+ s = encode_special_characters(node.content)
+ return s if is_in_pre
+
+ s.gsub(/\n */) do |_match|
+ offset = $LAST_MATCH_INFO.begin(0)
+ last_char = offset.positive? ? s[offset - 1] : nil
+ if ['。', '、'].include?(last_char)
+ ''
+ else
+ ' '
+ end
+ end
+ end
+
+ def encode_special_characters(s)
+ s.gsub(/&(?!(?:\w+|#\d+|#x[\da-fA-F]+);)/, '&amp;')
+ .gsub('<', '&lt;')
+ .gsub('>', '&gt;')
+ .gsub("'", '&apos;')
+ .gsub('"', '&quot;')
+ end
+
+ def element_node_to_html(element, indent_level:, is_in_pre:)
+ dtd = get_dtd(element.name)
+ s = +''
+
+ s << indent(indent_level) if block_node?(element)
+ s << "<#{element.name}"
+
+ attributes = get_element_attributes(element)
+ unless attributes.empty?
+ s << ' '
+ attributes.each_with_index do |(name, value), i|
+ if name == 'defer' && value == 'true'
+ s << 'defer'
+ else
+ attr_name = name == 'className' ? 'class' : name
+ s << "#{attr_name}=\"#{encode_special_characters(value)}\""
+ end
+ s << ' ' if i != attributes.length - 1
+ end
+ end
+ s << '>'
+ s << "\n" if block_node?(element) && element.name != 'pre'
+
+ child_indent = indent_level + 1
+ prev_child = nil
+ child_is_in_pre = is_in_pre || element.name == 'pre'
+
+ element.children.each do |child|
+ if block_node?(element) && !child_is_in_pre
+ if inline_node?(child)
+ s << indent(child_indent) if needs_indent?(prev_child)
+ elsif needs_line_break?(prev_child)
+ s << "\n"
+ end
+ end
+ s << node_to_html(child, indent_level: child_indent, is_in_pre: child_is_in_pre)
+ prev_child = child
+ end
+
+ unless dtd[:self_closing]
+ if (element.name != 'pre') && block_node?(element)
+ s << "\n" if needs_line_break?(prev_child)
+ s << indent(indent_level)
+ end
+ s << "</#{element.name}>"
+ s << "\n" if block_node?(element)
+ end
+
+ s
+ end
+
+ def indent(level)
+ ' ' * level
+ end
+
+ def get_element_attributes(element)
+ element.attributes
+ .reject { |k, _| k.start_with?('__') }
+ .compact
+ .sort { |a, b| compare_attributes(element.name, a, b) }
+ end
+
+ def compare_attributes(element_name, a, b)
+ ak, = a
+ bk, = b
+
+ if element_name == 'meta'
+ return 1 if ak == 'content' && bk == 'name'
+ return -1 if ak == 'name' && bk == 'content'
+ return 1 if ak == 'content' && bk == 'property'
+ return -1 if ak == 'property' && bk == 'content'
+ end
+
+ if element_name == 'link'
+ return 1 if ak == 'href' && bk == 'rel'
+ return -1 if ak == 'rel' && bk == 'href'
+ return 1 if ak == 'href' && bk == 'type'
+ return -1 if ak == 'type' && bk == 'href'
+ end
+
+ ak <=> bk
+ end
+
+ def needs_indent?(prev_child)
+ prev_child.nil? || block_node?(prev_child)
+ end
+
+ def needs_line_break?(prev_child)
+ !needs_indent?(prev_child)
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/renderers/xml.rb b/services/nuldoc/lib/nuldoc/renderers/xml.rb
new file mode 100644
index 00000000..a1494003
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/renderers/xml.rb
@@ -0,0 +1,97 @@
+module Nuldoc
+ class XMLRenderer
+ BLOCK_ELEMENTS = %w[feed entry author].freeze
+
+ def render(root)
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n#{node_to_xml(root, indent_level: 0)}"
+ end
+
+ private
+
+ def inline_node?(node)
+ return true if %i[text raw].include?(node.kind)
+
+ !BLOCK_ELEMENTS.include?(node.name)
+ end
+
+ def block_node?(node)
+ !inline_node?(node)
+ end
+
+ def node_to_xml(node, indent_level:)
+ case node.kind
+ when :text
+ text_node_to_xml(node)
+ when :raw
+ node.html
+ when :element
+ element_node_to_xml(node, indent_level: indent_level)
+ end
+ end
+
+ def text_node_to_xml(node)
+ encode_special_characters(node.content).gsub(/\n */, ' ')
+ end
+
+ def encode_special_characters(s)
+ s.gsub(/&(?!(?:\w+|#\d+|#x[\da-fA-F]+);)/, '&amp;')
+ .gsub('<', '&lt;')
+ .gsub('>', '&gt;')
+ .gsub("'", '&apos;')
+ .gsub('"', '&quot;')
+ end
+
+ def element_node_to_xml(element, indent_level:)
+ s = +''
+
+ s << indent(indent_level)
+ s << "<#{element.name}"
+
+ attributes = get_element_attributes(element)
+ unless attributes.empty?
+ s << ' '
+ attributes.each_with_index do |(name, value), i|
+ s << "#{name}=\"#{encode_special_characters(value)}\""
+ s << ' ' if i != attributes.length - 1
+ end
+ end
+ s << '>'
+ s << "\n" if block_node?(element)
+
+ child_indent = indent_level + 1
+ element.children.each do |child|
+ s << node_to_xml(child, indent_level: child_indent)
+ end
+
+ s << indent(indent_level) if block_node?(element)
+ s << "</#{element.name}>"
+ s << "\n"
+
+ s
+ end
+
+ def indent(level)
+ ' ' * level
+ end
+
+ def get_element_attributes(element)
+ element.attributes
+ .reject { |k, _| k.start_with?('__') }
+ .sort { |a, b| compare_attributes(element.name, a, b) }
+ end
+
+ def compare_attributes(element_name, a, b)
+ ak, = a
+ bk, = b
+
+ if element_name == 'link'
+ return 1 if ak == 'href' && bk == 'rel'
+ return -1 if ak == 'rel' && bk == 'href'
+ return 1 if ak == 'href' && bk == 'type'
+ return -1 if ak == 'type' && bk == 'href'
+ end
+
+ ak <=> bk
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/revision.rb b/services/nuldoc/lib/nuldoc/revision.rb
new file mode 100644
index 00000000..f672af8e
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/revision.rb
@@ -0,0 +1,18 @@
+module Nuldoc
+ Revision = Data.define(:number, :date, :remark, :is_internal) do
+ def self.string_to_date(s)
+ match = s.match(/\A(\d{4})-(\d{2})-(\d{2})\z/)
+ raise "Invalid date string: #{s}" if match.nil?
+
+ Date.new(match[1].to_i, match[2].to_i, match[3].to_i)
+ end
+
+ def self.date_to_string(date)
+ format('%<year>04d-%<month>02d-%<day>02d', year: date.year, month: date.month, day: date.day)
+ end
+
+ def self.date_to_rfc3339_string(date)
+ format('%<year>04d-%<month>02d-%<day>02dT00:00:00+09:00', year: date.year, month: date.month, day: date.day)
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/slide/parse.rb b/services/nuldoc/lib/nuldoc/slide/parse.rb
new file mode 100644
index 00000000..32046f73
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/slide/parse.rb
@@ -0,0 +1,16 @@
+require 'toml-rb'
+
+module Nuldoc
+ class SlideParser
+ def initialize(file_path)
+ @file_path = file_path
+ end
+
+ def parse
+ metadata = TomlRB.load_file(@file_path)
+ SlideFactory.new(metadata, @file_path).create
+ rescue StandardError => e
+ raise e.class, "#{e.message} in #{@file_path}"
+ end
+ end
+end
diff --git a/services/nuldoc/lib/nuldoc/slide/slide.rb b/services/nuldoc/lib/nuldoc/slide/slide.rb
new file mode 100644
index 00000000..cb4cadf2
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/slide/slide.rb
@@ -0,0 +1,44 @@
+module Nuldoc
+ Slide = Data.define(
+ :source_file_path,
+ :uuid,
+ :title,
+ :event,
+ :talk_type,
+ :slide_link,
+ :tags,
+ :revisions
+ )
+
+ class SlideFactory
+ def initialize(metadata, source_file_path)
+ @metadata = metadata
+ @source_file_path = source_file_path
+ end
+
+ def create
+ slide_data = @metadata['slide']
+ revisions = slide_data['revisions'].each_with_index.map do |rev, i|
+ Revision.new(
+ number: i + 1,
+ date: Revision.string_to_date(rev['date']),
+ remark: rev['remark'],
+ is_internal: !rev['isInternal'].nil?
+ )
+ end
+
+ raise "[slide.new] 'slide.revisions' field is empty" if revisions.empty?
+
+ Slide.new(
+ source_file_path: @source_file_path,
+ uuid: slide_data['uuid'],
+ title: slide_data['title'],
+ event: slide_data['event'],
+ talk_type: slide_data['talkType'],
+ slide_link: slide_data['link'],
+ tags: slide_data['tags'],
+ revisions: revisions
+ )
+ end
+ end
+end