aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/nuldoc/lib
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-01 00:49:15 +0900
committernsfisis <nsfisis@gmail.com>2026-02-01 00:49:19 +0900
commit6dedddc545e2f1930bdc2256784eb1551bd4231d (patch)
tree75fcb5a6043dc0f2c31b098bf3cfd17a2b938599 /services/nuldoc/lib
parentd08e3edb65b215152aa26e3518fb2f2cd7071c4b (diff)
downloadnsfisis.dev-6dedddc545e2f1930bdc2256784eb1551bd4231d.tar.gz
nsfisis.dev-6dedddc545e2f1930bdc2256784eb1551bd4231d.tar.zst
nsfisis.dev-6dedddc545e2f1930bdc2256784eb1551bd4231d.zip
feat(nuldoc): rewrite nuldoc in Ruby
Diffstat (limited to 'services/nuldoc/lib')
-rw-r--r--services/nuldoc/lib/nuldoc.rb62
-rw-r--r--services/nuldoc/lib/nuldoc/cli.rb46
-rw-r--r--services/nuldoc/lib/nuldoc/commands/build.rb216
-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.rb54
-rw-r--r--services/nuldoc/lib/nuldoc/components/page_layout.rb35
-rw-r--r--services/nuldoc/lib/nuldoc/components/pagination.rb62
-rw-r--r--services/nuldoc/lib/nuldoc/components/post_page_entry.rb25
-rw-r--r--services/nuldoc/lib/nuldoc/components/slide_page_entry.rb25
-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.rb21
-rw-r--r--services/nuldoc/lib/nuldoc/components/tag_list.rb15
-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.rb136
-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.rb602
-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.rb418
-rw-r--r--services/nuldoc/lib/nuldoc/page.rb3
-rw-r--r--services/nuldoc/lib/nuldoc/pages/about_page.rb70
-rw-r--r--services/nuldoc/lib/nuldoc/pages/atom_page.rb26
-rw-r--r--services/nuldoc/lib/nuldoc/pages/home_page.rb36
-rw-r--r--services/nuldoc/lib/nuldoc/pages/not_found_page.rb31
-rw-r--r--services/nuldoc/lib/nuldoc/pages/post_list_page.rb34
-rw-r--r--services/nuldoc/lib/nuldoc/pages/post_page.rb46
-rw-r--r--services/nuldoc/lib/nuldoc/pages/slide_list_page.rb29
-rw-r--r--services/nuldoc/lib/nuldoc/pages/slide_page.rb66
-rw-r--r--services/nuldoc/lib/nuldoc/pages/tag_list_page.rb39
-rw-r--r--services/nuldoc/lib/nuldoc/pages/tag_page.rb37
-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
52 files changed, 3647 insertions, 0 deletions
diff --git a/services/nuldoc/lib/nuldoc.rb b/services/nuldoc/lib/nuldoc.rb
new file mode 100644
index 00000000..2cd2a032
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc.rb
@@ -0,0 +1,62 @@
+require 'date'
+require 'digest'
+require 'English'
+require 'fileutils'
+require 'securerandom'
+
+require 'dry/cli'
+require 'rouge'
+require 'toml-rb'
+require 'webrick'
+
+require_relative 'nuldoc/dom'
+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..f3a18da9
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/cli.rb
@@ -0,0 +1,46 @@
+module Nuldoc
+ module CLI
+ extend Dry::CLI::Registry
+
+ class BuildCommand < Dry::CLI::Command
+ desc 'Build the site'
+
+ def call(**)
+ config = ConfigLoader.load_config(ConfigLoader.default_config_path)
+ Commands::Build.run(config)
+ 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..868493c3
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/commands/build.rb
@@ -0,0 +1,216 @@
+module Nuldoc
+ module Commands
+ class Build
+ def self.run(config)
+ new(config).run
+ end
+
+ def initialize(config)
+ @config = config
+ end
+
+ def run
+ posts = build_post_pages
+ build_post_list_page(posts)
+ slides = build_slide_pages
+ build_slide_list_page(slides)
+ post_tags = build_tag_pages(posts, 'blog')
+ build_tag_list_page(post_tags, 'blog')
+ slides_tags = build_tag_pages(slides, 'slides')
+ build_tag_list_page(slides_tags, 'slides')
+ build_home_page
+ build_about_page(slides)
+ %w[default about blog slides].each { |site| build_not_found_page(site) }
+ copy_static_files
+ copy_slides_files(slides)
+ copy_blog_asset_files
+ copy_slides_asset_files
+ copy_post_source_files(posts)
+ 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..8d4143fb
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/global_footer.rb
@@ -0,0 +1,11 @@
+module Nuldoc
+ module Components
+ class GlobalFooter
+ extend Dom
+
+ def self.render(config:)
+ footer({ 'class' => 'footer' }, "&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..b06c6173
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/global_headers.rb
@@ -0,0 +1,54 @@
+module Nuldoc
+ module Components
+ class DefaultGlobalHeader
+ extend Dom
+
+ def self.render(config:)
+ header({ 'class' => 'header' },
+ div({ 'class' => 'site-logo' },
+ a({ 'href' => "https://#{config.sites.default.fqdn}/" }, 'nsfisis.dev')))
+ end
+ end
+
+ class AboutGlobalHeader
+ extend Dom
+
+ def self.render(config:)
+ header({ 'class' => 'header' },
+ div({ 'class' => 'site-logo' },
+ a({ 'href' => "https://#{config.sites.default.fqdn}/" }, 'nsfisis.dev')))
+ end
+ end
+
+ class BlogGlobalHeader
+ extend Dom
+
+ def self.render(config:)
+ header({ 'class' => 'header' },
+ div({ 'class' => 'site-logo' },
+ a({ 'href' => "https://#{config.sites.default.fqdn}/" }, 'nsfisis.dev')),
+ div({ 'class' => 'site-name' }, config.sites.blog.site_name),
+ nav({ 'class' => 'nav' },
+ ul({},
+ li({}, a({ 'href' => "https://#{config.sites.about.fqdn}/" }, 'About')),
+ li({}, a({ 'href' => '/posts/' }, 'Posts')),
+ li({}, a({ 'href' => '/tags/' }, 'Tags')))))
+ end
+ end
+
+ class SlidesGlobalHeader
+ extend Dom
+
+ def self.render(config:)
+ header({ 'class' => 'header' },
+ div({ 'class' => 'site-logo' },
+ a({ 'href' => "https://#{config.sites.default.fqdn}/" }, 'nsfisis.dev')),
+ nav({ 'class' => 'nav' },
+ ul({},
+ li({}, a({ 'href' => "https://#{config.sites.about.fqdn}/" }, 'About')),
+ li({}, a({ 'href' => '/slides/' }, 'Slides')),
+ li({}, a({ 'href' => '/tags/' }, 'Tags')))))
+ 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..5d14ec0d
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/page_layout.rb
@@ -0,0 +1,35 @@
+module Nuldoc
+ module Components
+ class PageLayout
+ extend Dom
+
+ 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)
+
+ elem('html', { 'lang' => 'ja-JP' },
+ elem('head', {},
+ 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_keywords && !meta_keywords.empty? ? meta({ 'name' => 'keywords',
+ 'content' => meta_keywords.join(',') }) : nil,
+ 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' }),
+ meta_atom_feed_href ? link({ 'rel' => 'alternate', 'href' => meta_atom_feed_href,
+ 'type' => 'application/atom+xml' }) : nil,
+ link({ 'rel' => 'icon', 'href' => '/favicon.svg', 'type' => 'image/svg+xml' }),
+ elem('title', {}, meta_title),
+ StaticStylesheet.render(file_name: '/style.css', config: config)),
+ children)
+ 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..500b81f9
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/pagination.rb
@@ -0,0 +1,62 @@
+module Nuldoc
+ module Components
+ class Pagination
+ extend Dom
+
+ 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' },
+ div({ 'class' => 'pagination-prev' },
+ current_page > 1 ? a({ 'href' => page_url_at(base_path, current_page - 1) }, '前へ') : nil),
+ *pages.map do |page|
+ if page == '...'
+ div({ 'class' => 'pagination-elipsis' }, "\u2026")
+ elsif page == current_page
+ div({ 'class' => 'pagination-page pagination-page-current' },
+ span({}, page.to_s))
+ else
+ div({ 'class' => 'pagination-page' },
+ a({ 'href' => page_url_at(base_path, page) }, page.to_s))
+ end
+ end,
+ div({ 'class' => 'pagination-next' },
+ current_page < total_pages ? a({ 'href' => page_url_at(base_path, current_page + 1) }, '次へ') : nil))
+ 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..5232bc6b
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/post_page_entry.rb
@@ -0,0 +1,25 @@
+module Nuldoc
+ module Components
+ class PostPageEntry
+ extend Dom
+
+ 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' },
+ a({ 'href' => post.href },
+ header({ 'class' => 'entry-header' }, h2({}, post.title)),
+ section({ 'class' => 'entry-content' }, p({}, post.description)),
+ footer({ 'class' => 'entry-footer' },
+ elem('time', { 'datetime' => published }, published),
+ ' 投稿',
+ has_updates ? '、' : nil,
+ has_updates ? elem('time', { 'datetime' => updated }, updated) : nil,
+ has_updates ? ' 更新' : nil,
+ post.tags.length.positive? ? TagList.render(tags: post.tags, config: config) : nil)))
+ 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..b80f52c8
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/slide_page_entry.rb
@@ -0,0 +1,25 @@
+module Nuldoc
+ module Components
+ class SlidePageEntry
+ extend Dom
+
+ 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' },
+ a({ 'href' => slide.href },
+ header({ 'class' => 'entry-header' }, h2({}, slide.title)),
+ section({ 'class' => 'entry-content' }, p({}, slide.description)),
+ footer({ 'class' => 'entry-footer' },
+ elem('time', { 'datetime' => published }, published),
+ ' 登壇',
+ has_updates ? '、' : nil,
+ has_updates ? elem('time', { 'datetime' => updated }, updated) : nil,
+ has_updates ? ' 更新' : nil,
+ slide.tags.length.positive? ? TagList.render(tags: slide.tags, config: config) : nil)))
+ 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..755dc3fc
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/static_script.rb
@@ -0,0 +1,16 @@
+module Nuldoc
+ module Components
+ class StaticScript
+ extend Dom
+
+ 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..3127286d
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/static_stylesheet.rb
@@ -0,0 +1,13 @@
+module Nuldoc
+ module Components
+ class StaticStylesheet
+ extend Dom
+
+ 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..b3a5b531
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/table_of_contents.rb
@@ -0,0 +1,21 @@
+module Nuldoc
+ module Components
+ class TableOfContents
+ extend Dom
+
+ def self.render(toc:)
+ nav({ 'class' => 'toc' },
+ h2({}, '目次'),
+ ul({}, *toc.items.map { |entry| toc_entry_component(entry) }))
+ end
+
+ def self.toc_entry_component(entry)
+ li({},
+ a({ 'href' => "##{entry.id}" }, entry.text),
+ entry.children.length.positive? ? ul({}, *entry.children.map { |child| toc_entry_component(child) }) : nil)
+ 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..0c566f32
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/components/tag_list.rb
@@ -0,0 +1,15 @@
+module Nuldoc
+ module Components
+ class TagList
+ extend Dom
+
+ def self.render(tags:, config:)
+ ul({ 'class' => 'entry-tags' },
+ *tags.map do |slug|
+ li({ 'class' => 'tag' },
+ span({ 'class' => 'tag-inner' }, text(config.tag_label(slug))))
+ 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..7e28ac06
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/dom.rb
@@ -0,0 +1,136 @@
+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
+ module_function
+
+ def text(content)
+ Text.new(content: content)
+ end
+
+ def raw_html(html)
+ RawHTML.new(html: html)
+ end
+
+ def elem(name, attributes = {}, *children)
+ Element.new(
+ name: name,
+ attributes: attributes || {},
+ children: flatten_children(children)
+ )
+ end
+
+ def a(attributes = {}, *children) = elem('a', attributes, *children)
+ def article(attributes = {}, *children) = elem('article', attributes, *children)
+ def button(attributes = {}, *children) = elem('button', attributes, *children)
+ def div(attributes = {}, *children) = elem('div', attributes, *children)
+ def footer(attributes = {}, *children) = elem('footer', attributes, *children)
+ def h1(attributes = {}, *children) = elem('h1', attributes, *children)
+ def h2(attributes = {}, *children) = elem('h2', attributes, *children)
+ def h3(attributes = {}, *children) = elem('h3', attributes, *children)
+ def h4(attributes = {}, *children) = elem('h4', attributes, *children)
+ def h5(attributes = {}, *children) = elem('h5', attributes, *children)
+ def h6(attributes = {}, *children) = elem('h6', attributes, *children)
+ def header(attributes = {}, *children) = elem('header', attributes, *children)
+ def img(attributes = {}) = elem('img', attributes)
+ def li(attributes = {}, *children) = elem('li', attributes, *children)
+ def link(attributes = {}) = elem('link', attributes)
+ def meta(attributes = {}) = elem('meta', attributes)
+ def nav(attributes = {}, *children) = elem('nav', attributes, *children)
+ def ol(attributes = {}, *children) = elem('ol', attributes, *children)
+ def p(attributes = {}, *children) = elem('p', attributes, *children)
+ def script(attributes = {}, *children) = elem('script', attributes, *children)
+ def section(attributes = {}, *children) = elem('section', attributes, *children)
+ def span(attributes = {}, *children) = elem('span', attributes, *children)
+ def ul(attributes = {}, *children) = elem('ul', attributes, *children)
+
+ 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 flatten_children(children)
+ result = []
+ children.each do |child|
+ case child
+ when nil, false
+ next
+ when String
+ result.push(text(child))
+ when Array
+ result.concat(flatten_children(child))
+ else
+ result.push(child)
+ end
+ end
+ result
+ end
+
+ module_function :flatten_children
+ 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..6a135b5b
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb
@@ -0,0 +1,602 @@
+module Nuldoc
+ module Parser
+ class BlockParser
+ extend Dom
+
+ 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
+
+ attributes = {}
+ attributes['language'] = language if language
+
+ if meta_string && !meta_string.empty?
+ filename_match = meta_string.match(/filename="([^"]+)"/)
+ attributes['filename'] = filename_match[1] if filename_match
+ attributes['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', attributes, 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
+
+ attributes = {}
+ if block_type == 'edit'
+ # Parse {editat="..." operation="..."}
+ editat_match = attr_string.match(/editat="([^"]+)"/)
+ operation_match = attr_string.match(/operation="([^"]+)"/)
+ attributes['editat'] = editat_match[1] if editat_match
+ attributes['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', attributes, *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', {}, *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
+ elem('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_children = []
+ table_children << elem('thead', {}, header_row)
+ table_children << elem('tbody', {}, *body_rows) unless body_rows.empty?
+
+ elem('table', {}, *table_children)
+ 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|
+ attributes = {}
+ align = alignment[i]
+ attributes['align'] = align if align && align != 'default'
+
+ tag = is_header ? 'th' : 'td'
+ inline_nodes = InlineParser.parse(cell_text)
+ elem(tag, attributes, *inline_nodes)
+ end
+ elem('tr', {}, *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) }
+
+ elem('blockquote', {}, *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] }
+
+ attributes = {}
+ attributes['__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/) }
+ attributes['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(attributes, *list_items)
+ else
+ ul(attributes, *list_items)
+ end
+ end
+
+ def build_list_item(item, is_task_list)
+ attributes = {}
+ content = item[:lines].join("\n")
+
+ if is_task_list
+ task_match = content.match(/^\[( |[xX])\]\s(.*)$/m)
+ if task_match
+ attributes['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) }
+ child_elements = [p({}, *InlineParser.parse(content))] if child_elements.empty?
+
+ li(attributes, *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
+
+ attributes = {}
+ attr_str.scan(/([\w-]+)="([^"]*)"/) do |key, value|
+ attributes[key] = value
+ end
+
+ if inner_content.empty?
+ div(attributes)
+ else
+ inner_scanner = LineScanner.new(inner_content)
+ children = parse_blocks(inner_scanner)
+ child_elements = children.compact.select { |c| c.is_a?(Element) || c.is_a?(Text) || c.is_a?(RawHTML) }
+ div(attributes, *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({}, *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 }, *fb.children)
+ end
+ footnote_section = section({ 'class' => 'footnotes' }, *footnote_elements)
+ article_content.push(footnote_section)
+ end
+
+ elem('__root__', {}, article({}, *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, section_info[:heading], *section_info[:children])
+ 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..3d6f9ac7
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/parser/inline_parser.rb
@@ -0,0 +1,383 @@
+module Nuldoc
+ module Parser
+ class InlineParser
+ extend Dom
+
+ 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 << elem('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, {}, *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)
+
+ attributes = {}
+ attributes['src'] = url if url
+ attributes['alt'] = alt unless alt.empty?
+ attributes['title'] = title if title
+
+ nodes << img(attributes)
+ 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)
+
+ attributes = {}
+ attributes['href'] = url if url
+ attributes['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
+ attributes['class'] = 'url' if is_autolink
+
+ nodes << a(attributes, *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 << elem('strong', {}, *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 << elem('em', {}, *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 << elem('del', {}, *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..45be7ddd
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/markdown/transform.rb
@@ -0,0 +1,418 @@
+module Nuldoc
+ class Transform
+ include Dom
+
+ 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({}, *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' },
+ text(is_edit_block ? "#{editat_attr} #{operation_attr}" : 'NOTE'))
+ content_element = div({ 'class' => 'admonition-content' }, *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}"
+ },
+ text("[#{footnote_number}]")
+ )
+ ])
+ 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..7dfe7e6a
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/about_page.rb
@@ -0,0 +1,70 @@
+module Nuldoc
+ module Pages
+ class AboutPage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'single' },
+ Components::AboutGlobalHeader.render(config: config),
+ elem('main', { 'class' => 'main' },
+ article({ 'class' => 'post-single' },
+ header({ 'class' => 'post-header' },
+ h1({ 'class' => 'post-title' }, 'nsfisis'),
+ div({ 'class' => 'my-icon' },
+ div({ 'id' => 'myIcon' },
+ img({ 'src' => '/favicon.svg' })),
+ Components::StaticScript.render(
+ site: 'about',
+ file_name: '/my-icon.js',
+ defer: 'true',
+ config: config
+ ))),
+ div({ 'class' => 'post-content' },
+ section({},
+ h2({}, '読み方'),
+ p({}, '読み方は決めていません。音にする必要があるときは本名である「いまむら」をお使いください。')),
+ section({},
+ h2({}, 'アカウント'),
+ ul({},
+ li({}, a({ 'href' => 'https://twitter.com/nsfisis',
+ 'target' => '_blank',
+ 'rel' => 'noreferrer' },
+ 'Twitter (現 𝕏): @nsfisis')),
+ li({}, a({ 'href' => 'https://github.com/nsfisis',
+ 'target' => '_blank',
+ 'rel' => 'noreferrer' },
+ 'GitHub: @nsfisis')))),
+ section({},
+ h2({}, '仕事'),
+ ul({},
+ li({}, '2021-01~現在: ',
+ a({ 'href' => 'https://www.dgcircus.com/',
+ 'target' => '_blank',
+ 'rel' => 'noreferrer' },
+ 'デジタルサーカス株式会社')))),
+ section({},
+ h2({}, '登壇'),
+ ul({},
+ *sorted_slides.map do |slide|
+ slide_url = "https://#{config.sites.slides.fqdn}#{slide.href}"
+ slide_date = Revision.date_to_string(
+ GeneratorUtils.published_date(slide)
+ )
+ li({},
+ a({ 'href' => slide_url },
+ "#{slide_date}: #{slide.event} (#{slide.talk_type})"))
+ end))))),
+ Components::GlobalFooter.render(config: config))
+ )
+ 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..68c21193
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/atom_page.rb
@@ -0,0 +1,26 @@
+module Nuldoc
+ module Pages
+ class AtomPage
+ extend Dom
+
+ def self.render(feed:)
+ elem('feed', { 'xmlns' => 'http://www.w3.org/2005/Atom' },
+ elem('id', {}, feed.id),
+ elem('title', {}, feed.title),
+ link({ 'rel' => 'alternate', 'href' => feed.link_to_alternate }),
+ link({ 'rel' => 'self', 'href' => feed.link_to_self }),
+ elem('author', {}, elem('name', {}, feed.author)),
+ elem('updated', {}, feed.updated),
+ *feed.entries.map do |entry|
+ elem('entry', {},
+ elem('id', {}, entry.id),
+ link({ 'rel' => 'alternate', 'href' => entry.link_to_alternate }),
+ elem('title', {}, entry.title),
+ elem('summary', {}, entry.summary),
+ elem('published', {}, entry.published),
+ elem('updated', {}, entry.updated))
+ 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..3a7df7cc
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/home_page.rb
@@ -0,0 +1,36 @@
+module Nuldoc
+ module Pages
+ class HomePage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'single' },
+ Components::DefaultGlobalHeader.render(config: config),
+ elem('main', { 'class' => 'main' },
+ article({ 'class' => 'post-single' },
+ article({ 'class' => 'post-entry' },
+ a({ 'href' => "https://#{config.sites.about.fqdn}/" },
+ header({ 'class' => 'entry-header' }, h2({}, 'About')))),
+ article({ 'class' => 'post-entry' },
+ a({ 'href' => "https://#{config.sites.blog.fqdn}/posts/" },
+ header({ 'class' => 'entry-header' }, h2({}, 'Blog')))),
+ article({ 'class' => 'post-entry' },
+ a({ 'href' => "https://#{config.sites.slides.fqdn}/slides/" },
+ header({ 'class' => 'entry-header' }, h2({}, 'Slides')))),
+ article({ 'class' => 'post-entry' },
+ a({ 'href' => "https://repos.#{config.sites.default.fqdn}/" },
+ header({ 'class' => 'entry-header' },
+ h2({}, 'Repositories')))))),
+ Components::GlobalFooter.render(config: config))
+ )
+ 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..53023e47
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/not_found_page.rb
@@ -0,0 +1,31 @@
+module Nuldoc
+ module Pages
+ class NotFoundPage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'single' },
+ global_header.render(config: config),
+ elem('main', { 'class' => 'main' },
+ article({}, div({ 'class' => 'not-found' }, '404'))),
+ Components::GlobalFooter.render(config: config))
+ )
+ 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..c6978735
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/post_list_page.rb
@@ -0,0 +1,34 @@
+module Nuldoc
+ module Pages
+ class PostListPage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'list' },
+ Components::BlogGlobalHeader.render(config: config),
+ elem('main', { 'class' => 'main' },
+ header({ 'class' => 'page-header' },
+ h1({}, "#{page_title}#{page_info_suffix}")),
+ Components::Pagination.render(current_page: current_page, total_pages: total_pages,
+ base_path: '/posts/'),
+ *posts.map { |post| Components::PostPageEntry.render(post: post, config: config) },
+ Components::Pagination.render(current_page: current_page, total_pages: total_pages,
+ base_path: '/posts/')),
+ Components::GlobalFooter.render(config: config))
+ )
+ 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..a8ccda17
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/post_page.rb
@@ -0,0 +1,46 @@
+module Nuldoc
+ module Pages
+ class PostPage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'single' },
+ Components::BlogGlobalHeader.render(config: config),
+ elem('main', { 'class' => 'main' },
+ article({ 'class' => 'post-single' },
+ header({ 'class' => 'post-header' },
+ h1({ 'class' => 'post-title' }, doc.title),
+ doc.tags.length.positive? ? ul({ 'class' => 'post-tags' },
+ *doc.tags.map do |slug|
+ li({ 'class' => 'tag' },
+ a({ 'class' => 'tag-inner',
+ 'href' => "/tags/#{slug}/" },
+ config.tag_label(slug)))
+ end) : nil),
+ if doc.toc && doc.toc.items.length.positive?
+ Components::TableOfContents.render(toc: doc.toc)
+ end,
+ div({ 'class' => 'post-content' },
+ section({ 'id' => 'changelog' },
+ h2({}, a({ 'href' => '#changelog' }, '更新履歴')),
+ ol({},
+ *doc.revisions.map do |rev|
+ ds = Revision.date_to_string(rev.date)
+ li({ 'class' => 'revision' },
+ elem('time', { 'datetime' => ds }, ds),
+ ": #{rev.remark}")
+ end)),
+ *doc.root.children[0].children))),
+ Components::GlobalFooter.render(config: config))
+ )
+ 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..9dc25d30
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/slide_list_page.rb
@@ -0,0 +1,29 @@
+module Nuldoc
+ module Pages
+ class SlideListPage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'list' },
+ Components::SlidesGlobalHeader.render(config: config),
+ elem('main', { 'class' => 'main' },
+ header({ 'class' => 'page-header' }, h1({}, page_title)),
+ *sorted.map do |slide|
+ Components::SlidePageEntry.render(slide: slide, config: config)
+ end),
+ Components::GlobalFooter.render(config: config))
+ )
+ 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..ac54bb85
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/slide_page.rb
@@ -0,0 +1,66 @@
+module Nuldoc
+ module Pages
+ class SlidePage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'single' },
+ Components::StaticStylesheet.render(site: 'slides', file_name: '/slides.css',
+ config: config),
+ Components::SlidesGlobalHeader.render(config: config),
+ elem('main', { 'class' => 'main' },
+ article({ 'class' => 'post-single' },
+ header({ 'class' => 'post-header' },
+ h1({ 'class' => 'post-title' }, slide.title),
+ slide.tags.length.positive? ? ul({ 'class' => 'post-tags' },
+ *slide.tags.map do |slug|
+ li({ 'class' => 'tag' },
+ a({ 'class' => 'tag-inner',
+ 'href' => "/tags/#{slug}/" },
+ config.tag_label(slug)))
+ end) : nil),
+ div({ 'class' => 'post-content' },
+ section({ 'id' => 'changelog' },
+ h2({}, a({ 'href' => '#changelog' }, '更新履歴')),
+ ol({},
+ *slide.revisions.map do |rev|
+ ds = Revision.date_to_string(rev.date)
+ li({ 'class' => 'revision' },
+ elem('time', { 'datetime' => ds }, ds),
+ ": #{rev.remark}")
+ end)),
+ elem('canvas',
+ { 'id' => 'slide', 'data-slide-link' => slide.slide_link }),
+ div({ 'class' => 'controllers' },
+ div({ 'class' => 'controllers-buttons' },
+ button({ 'id' => 'prev', 'type' => 'button' },
+ elem('svg', { 'width' => '20', 'height' => '20',
+ 'viewBox' => '0 0 24 24', 'fill' => 'none',
+ 'stroke' => 'currentColor',
+ 'stroke-width' => '2' },
+ elem('path', { 'd' => 'M15 18l-6-6 6-6' }))),
+ button({ 'id' => 'next', 'type' => 'button' },
+ elem('svg', { 'width' => '20', 'height' => '20',
+ 'viewBox' => '0 0 24 24', 'fill' => 'none',
+ 'stroke' => 'currentColor',
+ 'stroke-width' => '2' },
+ elem('path', { 'd' => 'M9 18l6-6-6-6' }))))),
+ Components::StaticScript.render(
+ site: 'slides',
+ file_name: '/slide.js',
+ type: 'module',
+ config: config
+ )))),
+ Components::GlobalFooter.render(config: config))
+ )
+ 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..612a50b5
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/tag_list_page.rb
@@ -0,0 +1,39 @@
+module Nuldoc
+ module Pages
+ class TagListPage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'list' },
+ global_header.render(config: config),
+ elem('main', { 'class' => 'main' },
+ header({ 'class' => 'page-header' }, h1({}, page_title)),
+ *sorted_tags.map 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' },
+ a({ 'href' => tag.href },
+ header({ 'class' => 'entry-header' }, h2({}, tag.tag_label)),
+ footer({ 'class' => 'entry-footer' }, footer_text)))
+ end),
+ Components::GlobalFooter.render(config: config))
+ )
+ 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..38c55652
--- /dev/null
+++ b/services/nuldoc/lib/nuldoc/pages/tag_page.rb
@@ -0,0 +1,37 @@
+module Nuldoc
+ module Pages
+ class TagPage
+ extend Dom
+
+ 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: elem('body', { 'class' => 'list' },
+ global_header.render(config: config),
+ elem('main', { 'class' => 'main' },
+ header({ 'class' => 'page-header' }, h1({}, page_title)),
+ *pages.map do |page|
+ if page.respond_to?(:event)
+ Components::SlidePageEntry.render(slide: page, config: config)
+ else
+ Components::PostPageEntry.render(post: page, config: config)
+ end
+ end),
+ Components::GlobalFooter.render(config: config))
+ )
+ end
+ 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..facd670b
--- /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..956750c5
--- /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..c27fe256
--- /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