From 6dedddc545e2f1930bdc2256784eb1551bd4231d Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 1 Feb 2026 00:49:15 +0900 Subject: feat(nuldoc): rewrite nuldoc in Ruby --- services/nuldoc/lib/nuldoc.rb | 62 +++ services/nuldoc/lib/nuldoc/cli.rb | 46 ++ services/nuldoc/lib/nuldoc/commands/build.rb | 216 ++++++++ services/nuldoc/lib/nuldoc/commands/new.rb | 81 +++ services/nuldoc/lib/nuldoc/commands/serve.rb | 63 +++ .../nuldoc/lib/nuldoc/components/global_footer.rb | 11 + .../nuldoc/lib/nuldoc/components/global_headers.rb | 54 ++ .../nuldoc/lib/nuldoc/components/page_layout.rb | 35 ++ .../nuldoc/lib/nuldoc/components/pagination.rb | 62 +++ .../lib/nuldoc/components/post_page_entry.rb | 25 + .../lib/nuldoc/components/slide_page_entry.rb | 25 + .../nuldoc/lib/nuldoc/components/static_script.rb | 16 + .../lib/nuldoc/components/static_stylesheet.rb | 13 + .../lib/nuldoc/components/table_of_contents.rb | 21 + services/nuldoc/lib/nuldoc/components/tag_list.rb | 15 + services/nuldoc/lib/nuldoc/components/utils.rb | 8 + services/nuldoc/lib/nuldoc/config.rb | 67 +++ services/nuldoc/lib/nuldoc/dom.rb | 136 +++++ services/nuldoc/lib/nuldoc/generators/about.rb | 22 + services/nuldoc/lib/nuldoc/generators/atom.rb | 58 ++ services/nuldoc/lib/nuldoc/generators/home.rb | 21 + services/nuldoc/lib/nuldoc/generators/not_found.rb | 22 + services/nuldoc/lib/nuldoc/generators/post.rb | 57 ++ services/nuldoc/lib/nuldoc/generators/post_list.rb | 41 ++ services/nuldoc/lib/nuldoc/generators/slide.rb | 42 ++ .../nuldoc/lib/nuldoc/generators/slide_list.rb | 22 + services/nuldoc/lib/nuldoc/generators/tag.rb | 31 ++ services/nuldoc/lib/nuldoc/generators/tag_list.rb | 23 + services/nuldoc/lib/nuldoc/markdown/document.rb | 19 + services/nuldoc/lib/nuldoc/markdown/parse.rb | 51 ++ .../lib/nuldoc/markdown/parser/attributes.rb | 25 + .../lib/nuldoc/markdown/parser/block_parser.rb | 602 +++++++++++++++++++++ .../lib/nuldoc/markdown/parser/inline_parser.rb | 383 +++++++++++++ .../lib/nuldoc/markdown/parser/line_scanner.rb | 38 ++ services/nuldoc/lib/nuldoc/markdown/transform.rb | 418 ++++++++++++++ services/nuldoc/lib/nuldoc/page.rb | 3 + services/nuldoc/lib/nuldoc/pages/about_page.rb | 70 +++ services/nuldoc/lib/nuldoc/pages/atom_page.rb | 26 + services/nuldoc/lib/nuldoc/pages/home_page.rb | 36 ++ services/nuldoc/lib/nuldoc/pages/not_found_page.rb | 31 ++ services/nuldoc/lib/nuldoc/pages/post_list_page.rb | 34 ++ services/nuldoc/lib/nuldoc/pages/post_page.rb | 46 ++ .../nuldoc/lib/nuldoc/pages/slide_list_page.rb | 29 + services/nuldoc/lib/nuldoc/pages/slide_page.rb | 66 +++ services/nuldoc/lib/nuldoc/pages/tag_list_page.rb | 39 ++ services/nuldoc/lib/nuldoc/pages/tag_page.rb | 37 ++ services/nuldoc/lib/nuldoc/render.rb | 14 + services/nuldoc/lib/nuldoc/renderers/html.rb | 210 +++++++ services/nuldoc/lib/nuldoc/renderers/xml.rb | 97 ++++ services/nuldoc/lib/nuldoc/revision.rb | 18 + services/nuldoc/lib/nuldoc/slide/parse.rb | 16 + services/nuldoc/lib/nuldoc/slide/slide.rb | 44 ++ 52 files changed, 3647 insertions(+) create mode 100644 services/nuldoc/lib/nuldoc.rb create mode 100644 services/nuldoc/lib/nuldoc/cli.rb create mode 100644 services/nuldoc/lib/nuldoc/commands/build.rb create mode 100644 services/nuldoc/lib/nuldoc/commands/new.rb create mode 100644 services/nuldoc/lib/nuldoc/commands/serve.rb create mode 100644 services/nuldoc/lib/nuldoc/components/global_footer.rb create mode 100644 services/nuldoc/lib/nuldoc/components/global_headers.rb create mode 100644 services/nuldoc/lib/nuldoc/components/page_layout.rb create mode 100644 services/nuldoc/lib/nuldoc/components/pagination.rb create mode 100644 services/nuldoc/lib/nuldoc/components/post_page_entry.rb create mode 100644 services/nuldoc/lib/nuldoc/components/slide_page_entry.rb create mode 100644 services/nuldoc/lib/nuldoc/components/static_script.rb create mode 100644 services/nuldoc/lib/nuldoc/components/static_stylesheet.rb create mode 100644 services/nuldoc/lib/nuldoc/components/table_of_contents.rb create mode 100644 services/nuldoc/lib/nuldoc/components/tag_list.rb create mode 100644 services/nuldoc/lib/nuldoc/components/utils.rb create mode 100644 services/nuldoc/lib/nuldoc/config.rb create mode 100644 services/nuldoc/lib/nuldoc/dom.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/about.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/atom.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/home.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/not_found.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/post.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/post_list.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/slide.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/slide_list.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/tag.rb create mode 100644 services/nuldoc/lib/nuldoc/generators/tag_list.rb create mode 100644 services/nuldoc/lib/nuldoc/markdown/document.rb create mode 100644 services/nuldoc/lib/nuldoc/markdown/parse.rb create mode 100644 services/nuldoc/lib/nuldoc/markdown/parser/attributes.rb create mode 100644 services/nuldoc/lib/nuldoc/markdown/parser/block_parser.rb create mode 100644 services/nuldoc/lib/nuldoc/markdown/parser/inline_parser.rb create mode 100644 services/nuldoc/lib/nuldoc/markdown/parser/line_scanner.rb create mode 100644 services/nuldoc/lib/nuldoc/markdown/transform.rb create mode 100644 services/nuldoc/lib/nuldoc/page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/about_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/atom_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/home_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/not_found_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/post_list_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/post_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/slide_list_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/slide_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/tag_list_page.rb create mode 100644 services/nuldoc/lib/nuldoc/pages/tag_page.rb create mode 100644 services/nuldoc/lib/nuldoc/render.rb create mode 100644 services/nuldoc/lib/nuldoc/renderers/html.rb create mode 100644 services/nuldoc/lib/nuldoc/renderers/xml.rb create mode 100644 services/nuldoc/lib/nuldoc/revision.rb create mode 100644 services/nuldoc/lib/nuldoc/slide/parse.rb create mode 100644 services/nuldoc/lib/nuldoc/slide/slide.rb (limited to 'services/nuldoc/lib') 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 + + must be either "post" or "slide". + + OPTIONS: + --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 ' 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?('') + 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 = "" + + 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{]*)>(.*)}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?(/^