diff options
Diffstat (limited to 'services/nuldoc/lib')
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' }, "© #{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' => "© #{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]+);)/, '&') + .gsub('<', '<') + .gsub('>', '>') + .gsub("'", ''') + .gsub('"', '"') + 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]+);)/, '&') + .gsub('<', '<') + .gsub('>', '>') + .gsub("'", ''') + .gsub('"', '"') + 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 |
