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