diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4456d20 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + branches: + - '**' + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.2' + - '3.3' + - '3.4' + - '4.0' + + name: Ruby ${{ matrix.ruby-version }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Run test suite + run: bundle exec rake test diff --git a/.gitignore b/.gitignore index 272bec0..5451bce 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ Gemfile.lock # .rubocop-https?--* .copilot-instructions.md .vscode +notes.txt + diff --git a/Gemfile b/Gemfile index 01983ac..9f3b83e 100644 --- a/Gemfile +++ b/Gemfile @@ -10,4 +10,6 @@ gem 'erb' group :test do gem 'minitest' + gem 'rake' + gem 'simplecov', require: false end diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..a6e57a3 --- /dev/null +++ b/Rakefile @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rake/testtask' + +def apply_test_options(task) + opts = ENV['TESTOPTS'].to_s.strip + task.options = opts.split(/\s+/) unless opts.empty? +end + +desc 'Run all tests' +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.pattern = 'test/**/*_test.rb' + apply_test_options(t) +end + +namespace :test do + desc 'Run CLI integration tests only' + Rake::TestTask.new(:cli) do |t| + t.libs << 'test' + t.pattern = 'test/cli_test.rb' + apply_test_options(t) + end + + desc 'Run all tests with coverage' + task :coverage do + original_coverage = ENV['COVERAGE'] + begin + ENV['COVERAGE'] = '1' + Rake::Task[:test].reenable + Rake::Task[:test].invoke + + require 'simplecov' + SimpleCov.collate Dir['coverage/.resultset*.json'] do + track_files 'lib/**/*.rb' + end + ensure + ENV['COVERAGE'] = original_coverage + end + end +end + +task default: :test diff --git a/lib/documark/parser.rb b/lib/documark/parser.rb index 0dcc8e6..ad11fde 100644 --- a/lib/documark/parser.rb +++ b/lib/documark/parser.rb @@ -51,6 +51,8 @@ def read_document(input_path) def parse_data_section(content) return nil if content.nil? || content.strip.empty? data = Psych.safe_load(content, aliases: false) + return data unless data.is_a?(Hash) + # lang is an alias of language data['language'] ||= data['lang'] if data.key?('lang') data diff --git a/lib/documark/render_html.rb b/lib/documark/render_html.rb index a52b4f3..8a3490c 100644 --- a/lib/documark/render_html.rb +++ b/lib/documark/render_html.rb @@ -15,7 +15,7 @@ def compose_html(options, layout, data, html) end def cleanup_markdown_output(content) - text = content.to_s + text = content.to_s.dup # Remove common kramdown attribute list syntax from block and inline usage. text.gsub!(/^[ \t]*\{:[^}\n]*\}[ \t]*\n/, '') text.gsub!(/[ \t]*\{:[^}\n]*\}/, '') diff --git a/test/cli_test.rb b/test/cli_test.rb new file mode 100644 index 0000000..38dd5a0 --- /dev/null +++ b/test/cli_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'open3' +require 'tmpdir' +require 'rbconfig' +require_relative 'test_helper' + +class DocumarkCliTest < Minitest::Test + def run_cli(*args, stdin_data: nil) + exe = File.expand_path('../bin/documark', __dir__) + preload = File.expand_path('support/simplecov_subprocess.rb', __dir__) + + env = {} + if ENV['COVERAGE'] == '1' + existing_rubyopt = ENV['RUBYOPT'].to_s + env['RUBYOPT'] = [existing_rubyopt, "-r#{preload}"].reject(&:empty?).join(' ') + env['COVERAGE'] = '1' + end + + Open3.capture3(env, RbConfig.ruby, exe, *args, stdin_data: stdin_data) + end + + def fixture_path(name) + File.expand_path("fixtures/#{name}", __dir__) + end + + def test_exits_with_error_when_action_is_missing + _stdout, stderr, status = run_cli + + refute status.success? + assert_includes stderr, 'Missing action. Usage: documark @switches.' + end + + def test_exits_with_error_when_required_process_options_are_missing + _stdout, stderr, status = run_cli('process') + + refute status.success? + assert_includes stderr, 'Missing required option for process action: --input' + end + + def test_exits_with_error_for_invalid_target + Dir.mktmpdir('documark-cli-test') do |tmpdir| + output_path = File.join(tmpdir, 'out.invalid') + + _stdout, stderr, status = run_cli( + 'process', + '--input', fixture_path('fmerrormissing.dm'), + '--output', output_path, + '--target', 'invalid' + ) + + refute status.success? + assert_includes stderr, "Invalid target 'invalid'. Supported targets:" + end + end + + def test_html_target_writes_html_output + Dir.mktmpdir('documark-cli-test') do |tmpdir| + output_path = File.join(tmpdir, 'out.html') + + _stdout, stderr, status = run_cli( + 'process', + '--input', File.expand_path('../doc/examples/simple.dm', __dir__), + '--output', output_path, + '--target', 'html' + ) + + assert status.success?, stderr + html = File.read(output_path) + assert_includes html, '' + assert_includes html, '

DocuMark Makes Word Processors Obsolete!

' + assert_includes html, 'A Simple DocuMark Document' + end + end + + def test_markdown_target_strips_kramdown_attribute_lists + Dir.mktmpdir('documark-cli-test') do |tmpdir| + input_path = File.join(tmpdir, 'with-attrs.dm') + output_path = File.join(tmpdir, 'out.md') + + File.write(input_path, <<~DOC) + !! documark document + !! data + title: "Attr Test" + !! end + + # Heading + {: .big} + + Paragraph with an inline attribute{: .highlighted} + DOC + + _stdout, stderr, status = run_cli( + 'process', + '--input', input_path, + '--output', output_path, + '--target', 'markdown' + ) + + assert status.success?, stderr + output = File.read(output_path) + refute_includes output, '{: .big}' + refute_includes output, '{: .highlighted}' + assert_includes output, '# Heading' + assert_includes output, 'Paragraph with an inline attribute' + end + end + + def test_fails_when_frontmatter_data_section_is_missing + _stdout, stderr, status = run_cli( + 'process', + '--input', fixture_path('fmerrormissing.dm'), + '--output', '/tmp/out.html', + '--target', 'html' + ) + + refute status.success? + assert_includes stderr, 'missing frontmatter section' + end + + def test_fails_when_data_section_is_unterminated + _stdout, stderr, status = run_cli( + 'process', + '--input', fixture_path('fmerrorsimple.dm'), + '--output', '/tmp/out.html', + '--target', 'html' + ) + + refute status.success? + assert_includes stderr, 'Unterminated data block' + end +end diff --git a/test/config_test.rb b/test/config_test.rb new file mode 100644 index 0000000..f39be5b --- /dev/null +++ b/test/config_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'tmpdir' +require_relative 'test_helper' +require_relative '../lib/documark/config' + +class DocumarkConfigTest < Minitest::Test + def setup + @previous_debug = Documark::Config.send(:debug?) + Documark::Config.send(:debug=, true) + end + + def teardown + Documark::Config.send(:debug=, @previous_debug) + end + + def test_read_config_parses_mapping_and_expands_default_layout + Dir.mktmpdir('documark-config-test') do |tmpdir| + config_path = File.join(tmpdir, 'documark.conf') + File.write(config_path, <<~CONF) + --- + default_layout: ./layouts/default.dml + template_folder: ~/Templates/ + --- + CONF + + parsed = Documark::Config.read_config(config_path) + + assert_equal File.expand_path('./layouts/default.dml'), parsed['default_layout'] + assert_equal '~/Templates/', parsed['template_folder'] + end + end + + def test_read_config_requires_mapping + Dir.mktmpdir('documark-config-test') do |tmpdir| + config_path = File.join(tmpdir, 'bad.conf') + File.write(config_path, <<~CONF) + --- + - item + - item + --- + CONF + + error = assert_raises(StandardError) do + Documark::Config.read_config(config_path) + end + + assert_equal 'Config file must be a mapping', error.message + end + end + + def test_executable_on_path_detects_ruby + assert_equal true, Documark::Config.executable_on_path?('ruby') + end +end diff --git a/test/parser_error_test.rb b/test/parser_error_test.rb new file mode 100644 index 0000000..156fe96 --- /dev/null +++ b/test/parser_error_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require_relative '../lib/documark/parser' + +class DocumarkParserErrorTest < Minitest::Test + def test_split_documark_sections_rejects_empty_file + error = assert_raises(StandardError) do + Documark::Parser.split_documark_sections('') + end + + assert_equal 'File must not be empty', error.message + end + + def test_split_documark_sections_raises_for_unterminated_data_block + content = <<~DOC + !! documark document + !! data + title: "Broken" + DOC + + error = assert_raises(StandardError) do + Documark::Parser.split_documark_sections(content) + end + + assert_equal 'Unterminated data block', error.message + end + + def test_split_documark_sections_raises_for_unterminated_layout_block + content = <<~DOC + !! documark document + !! data + title: "Example" + !! end + !! layout + --- + container_class: container + --- + @media print {} + DOC + + error = assert_raises(StandardError) do + Documark::Parser.split_documark_sections(content) + end + + assert_equal 'Unterminated layout block', error.message + end + + def test_parse_layout_section_requires_front_matter_start_delimiter + error = assert_raises(StandardError) do + Documark::Parser.parse_layout_section("container_class: container\n---\n") + end + + assert_equal 'Layout section must start with ---', error.message + end + + def test_parse_layout_section_requires_mapping_front_matter + layout = <<~LAYOUT + --- + - one + - two + --- + @media print {} + LAYOUT + + error = assert_raises(StandardError) do + Documark::Parser.parse_layout_section(layout) + end + + assert_equal 'Layout front matter must be a mapping', error.message + end +end diff --git a/test/parser_test.rb b/test/parser_test.rb new file mode 100644 index 0000000..6fd6a61 --- /dev/null +++ b/test/parser_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'tmpdir' +require_relative 'test_helper' +require_relative '../lib/documark/parser' + +class DocumarkParserTest < Minitest::Test + def test_parse_header_parses_context_type_and_options + parsed = Documark::Parser.parse_header('!! documark document option=baz mode=fast') + + assert_equal 'documark', parsed['context'] + assert_equal 'document', parsed['type'] + assert_equal 'baz', parsed['option'] + assert_equal 'fast', parsed['mode'] + end + + def test_read_header_rejects_non_documark_context + error = assert_raises(StandardError) do + Documark::Parser.read_header("!! other thing\n") + end + + assert_equal 'File must begin with !! documark', error.message + end + + def test_parse_data_section_applies_lang_alias + data = Documark::Parser.parse_data_section("lang: ja\ntitle: test\n") + + assert_equal 'ja', data['lang'] + assert_equal 'ja', data['language'] + assert_equal 'test', data['title'] + end + + def test_parse_layout_section_extracts_frontmatter_and_style + layout = <<~LAYOUT + --- + stylesheets: + - https://example.com/base.css + container_class: container + --- + @media print { + @page { size: letter; } + } + LAYOUT + + parsed = Documark::Parser.parse_layout_section(layout) + + assert_equal ['https://example.com/base.css'], parsed['stylesheets'] + assert_equal 'container', parsed['container_class'] + assert_includes parsed['style'], '@media print' + end + + def test_split_documark_sections_extracts_data_layout_and_body + content = <<~DOC + !! documark document + !! data + title: "Example" + !! end + !! layout + --- + container_class: container + --- + @media print {} + !! end + + # Body + hello + DOC + + parsed = Documark::Parser.split_documark_sections(content) + + assert_includes parsed['data'], 'title: "Example"' + assert_includes parsed['layout'], 'container_class: container' + assert_includes parsed['body'], '# Body' + end + + def test_read_layout_uses_explicit_default_layout_path + Dir.mktmpdir('documark-parser-test') do |tmpdir| + dml_path = File.join(tmpdir, 'custom.dml') + File.write(dml_path, <<~DML) + !! documark layout + !! layout + --- + container_class: wrapper + --- + @media print {} + !! end + DML + + parsed = Documark::Parser.read_layout({}, { 'default_layout' => dml_path }) + + assert_equal 'wrapper', parsed['container_class'] + assert_includes parsed['style'], '@media print' + end + end +end diff --git a/test/render_html_test.rb b/test/render_html_test.rb new file mode 100644 index 0000000..93c775d --- /dev/null +++ b/test/render_html_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'tmpdir' +require_relative 'test_helper' +require_relative '../lib/documark/render_html' + +class DocumarkRenderHtmlTest < Minitest::Test + def test_cleanup_markdown_output_removes_attribute_list_syntax + source = <<~DOC + # Heading + {: .lead} + + Paragraph{: .tight} + DOC + + cleaned = Documark::RenderHtml.cleanup_markdown_output(source) + + refute_includes cleaned, '{: .lead}' + refute_includes cleaned, '{: .tight}' + assert_includes cleaned, '# Heading' + assert_includes cleaned, 'Paragraph' + end + + def test_render_page_builds_full_html_document + layout = { + 'stylesheets' => ['https://example.com/site.css'], + 'container_class' => 'container', + 'style' => '@media print {}' + } + data = { + 'title' => 'Render Test', + 'language' => 'en' + } + + page = Documark::RenderHtml.render_page({}, layout, data, "# Hello\n") + + assert_includes page, '' + assert_includes page, 'Render Test' + assert_includes page, '' + assert_includes page, '' + assert_includes page, '

Hello

' + end + + def test_write_markdown_output_writes_cleaned_content + Dir.mktmpdir('documark-render-test') do |tmpdir| + output_path = File.join(tmpdir, 'out.md') + + Documark::RenderHtml.write_markdown_output(output_path, "Line{: .trim}\n") + + assert_equal "Line\n", File.read(output_path) + end + end +end diff --git a/test/support/simplecov_subprocess.rb b/test/support/simplecov_subprocess.rb new file mode 100644 index 0000000..49674f4 --- /dev/null +++ b/test/support/simplecov_subprocess.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +return unless ENV['COVERAGE'] == '1' + +require 'simplecov' + +# Distinguish each spawned CLI process in resultset output. +SimpleCov.command_name "cli:#{Process.pid}" + +SimpleCov.start do + track_files 'lib/**/*.rb' +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 92fef10..bfcf839 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,11 @@ # frozen_string_literal: true +if ENV['COVERAGE'] == '1' + require 'simplecov' + SimpleCov.command_name "tests:#{Process.pid}" + SimpleCov.start do + track_files 'lib/**/*.rb' + end +end + require 'minitest/autorun'