yaml-converter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +2 -0
- data/CHANGELOG.md +48 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +221 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +810 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/exe/yaml-convert +122 -0
- data/lib/yaml/converter/config.rb +98 -0
- data/lib/yaml/converter/markdown_emitter.rb +50 -0
- data/lib/yaml/converter/parser.rb +81 -0
- data/lib/yaml/converter/renderer/pandoc_shell.rb +54 -0
- data/lib/yaml/converter/renderer/pdf_prawn.rb +110 -0
- data/lib/yaml/converter/state_machine.rb +114 -0
- data/lib/yaml/converter/streaming_emitter.rb +146 -0
- data/lib/yaml/converter/validation.rb +64 -0
- data/lib/yaml/converter/version.rb +15 -0
- data/lib/yaml/converter.rb +213 -0
- data/sig/yaml/converter/config.rbs +13 -0
- data/sig/yaml/converter/markdown_emitter.rbs +14 -0
- data/sig/yaml/converter/streaming_emitter.rbs +13 -0
- data/sig/yaml/converter/validation.rbs +9 -0
- data/sig/yaml/converter.rbs +43 -0
- data.tar.gz.sig +0 -0
- metadata +352 -0
- metadata.gz.sig +3 -0
data/REEK
ADDED
|
File without changes
|
data/RUBOCOP.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# RuboCop Usage Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A tale of two RuboCop plugin gems.
|
|
6
|
+
|
|
7
|
+
### RuboCop Gradual
|
|
8
|
+
|
|
9
|
+
This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
|
|
10
|
+
|
|
11
|
+
### RuboCop LTS
|
|
12
|
+
|
|
13
|
+
This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
|
|
14
|
+
RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
|
|
15
|
+
|
|
16
|
+
## Checking RuboCop Violations
|
|
17
|
+
|
|
18
|
+
To check for RuboCop violations in this project, always use:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle exec rake rubocop_gradual:check
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Do not use** the standard RuboCop commands like:
|
|
25
|
+
- `bundle exec rubocop`
|
|
26
|
+
- `rubocop`
|
|
27
|
+
|
|
28
|
+
## Understanding the Lock File
|
|
29
|
+
|
|
30
|
+
The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
|
|
31
|
+
|
|
32
|
+
1. Prevent new violations while gradually fixing existing ones
|
|
33
|
+
2. Track progress on code style improvements
|
|
34
|
+
3. Ensure CI builds don't fail due to pre-existing violations
|
|
35
|
+
|
|
36
|
+
## Common Commands
|
|
37
|
+
|
|
38
|
+
- **Check violations**
|
|
39
|
+
- `bundle exec rake rubocop_gradual`
|
|
40
|
+
- `bundle exec rake rubocop_gradual:check`
|
|
41
|
+
- **(Safe) Autocorrect violations, and update lockfile if no new violations**
|
|
42
|
+
- `bundle exec rake rubocop_gradual:autocorrect`
|
|
43
|
+
- **Force update the lock file (w/o autocorrect) to match violations present in code**
|
|
44
|
+
- `bundle exec rake rubocop_gradual:force_update`
|
|
45
|
+
|
|
46
|
+
## Workflow
|
|
47
|
+
|
|
48
|
+
1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
|
|
49
|
+
a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
|
|
50
|
+
2. If there are new violations, either:
|
|
51
|
+
- Fix them in your code
|
|
52
|
+
- Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
|
|
53
|
+
3. Commit the updated `.rubocop_gradual.lock` file along with your changes
|
|
54
|
+
|
|
55
|
+
## Never add inline RuboCop disables
|
|
56
|
+
|
|
57
|
+
Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
|
|
58
|
+
|
|
59
|
+
- Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
|
|
60
|
+
- Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
|
|
61
|
+
- `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
|
|
62
|
+
- If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
|
|
63
|
+
|
|
64
|
+
In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
|
|
65
|
+
|
|
66
|
+
## Benefits of rubocop_gradual
|
|
67
|
+
|
|
68
|
+
- Allows incremental adoption of code style rules
|
|
69
|
+
- Prevents CI failures due to pre-existing violations
|
|
70
|
+
- Provides a clear record of code style debt
|
|
71
|
+
- Enables focused efforts on improving code quality over time
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
|----------|-----------|
|
|
7
|
+
| 1.latest | ✅ |
|
|
8
|
+
|
|
9
|
+
## Security contact information
|
|
10
|
+
|
|
11
|
+
To report a security vulnerability, please use the
|
|
12
|
+
[Tidelift security contact](https://tidelift.com/security).
|
|
13
|
+
Tidelift will coordinate the fix and disclosure.
|
|
14
|
+
|
|
15
|
+
## Additional Support
|
|
16
|
+
|
|
17
|
+
If you are interested in support for versions older than the latest release,
|
|
18
|
+
please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
|
|
19
|
+
or find other sponsorship links in the [README].
|
|
20
|
+
|
|
21
|
+
[README]: README.md
|
data/exe/yaml-convert
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "yaml/converter"
|
|
7
|
+
|
|
8
|
+
options = {
|
|
9
|
+
max_line_length: nil,
|
|
10
|
+
truncate: nil,
|
|
11
|
+
margin_notes: nil,
|
|
12
|
+
validate: nil,
|
|
13
|
+
use_pandoc: nil,
|
|
14
|
+
pandoc_args: nil,
|
|
15
|
+
glob: nil,
|
|
16
|
+
out_ext: nil,
|
|
17
|
+
out_dir: nil,
|
|
18
|
+
concurrency: nil,
|
|
19
|
+
streaming: nil,
|
|
20
|
+
streaming_threshold_bytes: nil,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
parser = OptionParser.new do |opts|
|
|
24
|
+
opts.banner = "Usage: yaml-convert INPUT.yaml OUTPUT.(md|html|pdf|docx) [options]\n yaml-convert --glob 'dir/**/*.yaml' --out-ext (md|html|pdf|docx) [--out-dir DIR] [options]"
|
|
25
|
+
|
|
26
|
+
opts.on("--max-line-length N", Integer, "Set maximum line length (default 70)") { |v| options[:max_line_length] = v }
|
|
27
|
+
opts.on("--no-truncate", "Disable line truncation") { options[:truncate] = false }
|
|
28
|
+
opts.on("--no-validate", "Disable YAML validation") { options[:validate] = false }
|
|
29
|
+
opts.on("--margin-notes MODE", "Set margin notes mode (auto|inline|ignore)") { |v| options[:margin_notes] = v.to_sym }
|
|
30
|
+
opts.on("--use-pandoc", "Use pandoc for non-md/html output formats") { options[:use_pandoc] = true }
|
|
31
|
+
opts.on("--pandoc-args STRING", "Extra args for pandoc (split on spaces)") { |v| options[:pandoc_args] = v.split(/\s+/) }
|
|
32
|
+
opts.on("--streaming", "Force streaming mode for markdown output") { options[:streaming] = true }
|
|
33
|
+
opts.on("--streaming-threshold BYTES", Integer, "Auto-enable streaming when input file >= BYTES") { |v| options[:streaming_threshold_bytes] = v }
|
|
34
|
+
|
|
35
|
+
# Batch mode
|
|
36
|
+
opts.on("--glob PATTERN", "Batch convert all YAML files matching the glob pattern") { |v| options[:glob] = v }
|
|
37
|
+
opts.on("--out-ext EXT", "Output extension for batch mode (md|html|pdf|docx)") { |v| options[:out_ext] = v }
|
|
38
|
+
opts.on("--out-dir DIR", "Output directory for batch mode (defaults next to input)") { |v| options[:out_dir] = v }
|
|
39
|
+
opts.on("--concurrency N", Integer, "Batch concurrency (currently not used; reserved)") { |v| options[:concurrency] = v }
|
|
40
|
+
|
|
41
|
+
opts.on("--version", "Show version") {
|
|
42
|
+
puts Yaml::Converter::VERSION
|
|
43
|
+
exit(0)
|
|
44
|
+
}
|
|
45
|
+
opts.on("--help", "Show help") {
|
|
46
|
+
puts opts
|
|
47
|
+
exit(0)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
parser.parse!(ARGV)
|
|
53
|
+
rescue OptionParser::InvalidOption => e
|
|
54
|
+
warn(e.message)
|
|
55
|
+
puts parser
|
|
56
|
+
exit(2)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Batch mode if --glob is provided
|
|
60
|
+
if options[:glob]
|
|
61
|
+
pattern = options[:glob]
|
|
62
|
+
out_ext = options[:out_ext]
|
|
63
|
+
unless out_ext&.match?(/^\w+$/)
|
|
64
|
+
warn "Error: --out-ext is required for --glob and must be like: md|html|pdf|docx"
|
|
65
|
+
puts parser
|
|
66
|
+
exit 2
|
|
67
|
+
end
|
|
68
|
+
files = Dir.glob(pattern).select { |p| File.file?(p) }
|
|
69
|
+
if files.empty?
|
|
70
|
+
warn "No files matched glob: #{pattern}"
|
|
71
|
+
exit 2
|
|
72
|
+
end
|
|
73
|
+
out_dir = options[:out_dir]
|
|
74
|
+
FileUtils.mkdir_p(out_dir) if out_dir
|
|
75
|
+
|
|
76
|
+
failures = 0
|
|
77
|
+
files.each do |input|
|
|
78
|
+
base = File.basename(input, File.extname(input))
|
|
79
|
+
dest_dir = out_dir || File.dirname(input)
|
|
80
|
+
FileUtils.mkdir_p(dest_dir)
|
|
81
|
+
output = File.join(dest_dir, base + "." + out_ext)
|
|
82
|
+
begin
|
|
83
|
+
result = Yaml::Converter.convert(input_path: input, output_path: output, options: options.compact)
|
|
84
|
+
puts "Converted: #{result[:output_path]}" unless ENV["KETTLE_TEST_SILENT"] == "true"
|
|
85
|
+
rescue Yaml::Converter::InvalidArgumentsError, Yaml::Converter::PandocNotFoundError, Yaml::Converter::RendererUnavailableError => e
|
|
86
|
+
failures += 1
|
|
87
|
+
warn("Failed: #{input}: #{e.message}")
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
failures += 1
|
|
90
|
+
warn("Unexpected error converting #{input}: #{e.class}: #{e.message}")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
puts "Batch complete: #{files.size - failures} succeeded, #{failures} failed" unless ENV["KETTLE_TEST_SILENT"] == "true"
|
|
94
|
+
exit(failures.zero? ? 0 : 5)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Single-file mode
|
|
98
|
+
if ARGV.length != 2
|
|
99
|
+
warn "Error: require INPUT.yaml and OUTPUT.<ext>"
|
|
100
|
+
puts parser
|
|
101
|
+
exit 2
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
input, output = ARGV
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
result = Yaml::Converter.convert(input_path: input, output_path: output, options: options.compact)
|
|
108
|
+
puts "Converted: #{result[:output_path]}" unless ENV["KETTLE_TEST_SILENT"] == "true"
|
|
109
|
+
exit(0)
|
|
110
|
+
rescue Yaml::Converter::InvalidArgumentsError => e
|
|
111
|
+
warn(e.message)
|
|
112
|
+
exit(2)
|
|
113
|
+
rescue Yaml::Converter::PandocNotFoundError => e
|
|
114
|
+
warn(e.message)
|
|
115
|
+
exit(5)
|
|
116
|
+
rescue Yaml::Converter::RendererUnavailableError => e
|
|
117
|
+
warn(e.message)
|
|
118
|
+
exit(5)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
warn("Unexpected error: #{e.class}: #{e.message}")
|
|
121
|
+
exit(9)
|
|
122
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Yaml
|
|
6
|
+
module Converter
|
|
7
|
+
# Central configuration handling: merges explicit options with ENV and defaults.
|
|
8
|
+
# Use {Yaml::Converter::Config.resolve} to obtain the finalized options hash.
|
|
9
|
+
module Config
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
max_line_length: 70,
|
|
12
|
+
truncate: true,
|
|
13
|
+
margin_notes: :auto, # :auto | :inline | :ignore
|
|
14
|
+
validate: true,
|
|
15
|
+
use_pandoc: false,
|
|
16
|
+
pandoc_args: ["-N", "--toc"],
|
|
17
|
+
pandoc_path: nil,
|
|
18
|
+
html_theme: :basic,
|
|
19
|
+
pdf_page_size: "LETTER",
|
|
20
|
+
pdf_margin: [36, 36, 36, 36], # top,right,bottom,left points (0.5")
|
|
21
|
+
pdf_title_font_size: 14,
|
|
22
|
+
pdf_body_font_size: 11,
|
|
23
|
+
pdf_yaml_font_size: 9,
|
|
24
|
+
pdf_two_column_notes: false,
|
|
25
|
+
current_date: Date.today, # allows injection for deterministic tests
|
|
26
|
+
emit_footer: true,
|
|
27
|
+
# Streaming options (Phase 3 feature now implemented):
|
|
28
|
+
streaming: false, # force streaming mode even for small files
|
|
29
|
+
streaming_threshold_bytes: 5_000_000, # auto-enable streaming for large files when not forced
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
ENV_MAP = {
|
|
33
|
+
max_line_length: "YAML_CONVERTER_MAX_LINE_LEN",
|
|
34
|
+
truncate: "YAML_CONVERTER_TRUNCATE",
|
|
35
|
+
margin_notes: "YAML_CONVERTER_MARGIN_NOTES",
|
|
36
|
+
validate: "YAML_CONVERTER_VALIDATE",
|
|
37
|
+
use_pandoc: "YAML_CONVERTER_USE_PANDOC",
|
|
38
|
+
pdf_page_size: "YAML_CONVERTER_PDF_PAGE_SIZE",
|
|
39
|
+
pdf_title_font_size: "YAML_CONVERTER_PDF_TITLE_FONT_SIZE",
|
|
40
|
+
pdf_body_font_size: "YAML_CONVERTER_PDF_BODY_FONT_SIZE",
|
|
41
|
+
pdf_yaml_font_size: "YAML_CONVERTER_PDF_YAML_FONT_SIZE",
|
|
42
|
+
pdf_two_column_notes: "YAML_CONVERTER_PDF_TWO_COLUMN_NOTES",
|
|
43
|
+
emit_footer: "YAML_CONVERTER_EMIT_FOOTER",
|
|
44
|
+
streaming: "YAML_CONVERTER_STREAMING",
|
|
45
|
+
streaming_threshold_bytes: "YAML_CONVERTER_STREAMING_THRESHOLD_BYTES",
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
BOOLEAN_KEYS = %i[truncate validate use_pandoc pdf_two_column_notes emit_footer streaming].freeze
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Merge caller options with environment overrides and defaults.
|
|
52
|
+
# Environment bools accept values: 1, true, yes, on (case-insensitive).
|
|
53
|
+
#
|
|
54
|
+
# @param options [Hash]
|
|
55
|
+
# @return [Hash] normalized options suitable for passing to emitters/renderers
|
|
56
|
+
def resolve(options = {})
|
|
57
|
+
opts = DEFAULTS.dup
|
|
58
|
+
ENV_MAP.each do |key, env_key|
|
|
59
|
+
val = ENV[env_key]
|
|
60
|
+
next if val.nil?
|
|
61
|
+
|
|
62
|
+
opts[key] = coerce_env_value(key, val)
|
|
63
|
+
end
|
|
64
|
+
options.each do |k, v|
|
|
65
|
+
opts[k] = v unless v.nil?
|
|
66
|
+
end
|
|
67
|
+
normalize(opts)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Normalize symbolic values loaded from ENV.
|
|
71
|
+
# @param opts [Hash]
|
|
72
|
+
# @return [Hash]
|
|
73
|
+
def normalize(opts)
|
|
74
|
+
opts[:margin_notes] = opts[:margin_notes].to_sym if opts[:margin_notes].is_a?(String)
|
|
75
|
+
opts[:html_theme] = opts[:html_theme].to_sym if opts[:html_theme].is_a?(String)
|
|
76
|
+
if opts[:streaming_threshold_bytes].is_a?(String)
|
|
77
|
+
opts[:streaming_threshold_bytes] = opts[:streaming_threshold_bytes].to_i
|
|
78
|
+
end
|
|
79
|
+
opts
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Coerce ENV string values into typed Ruby objects.
|
|
83
|
+
# @param key [Symbol]
|
|
84
|
+
# @param value [String]
|
|
85
|
+
# @return [Object]
|
|
86
|
+
def coerce_env_value(key, value)
|
|
87
|
+
if BOOLEAN_KEYS.include?(key)
|
|
88
|
+
%w[1 true yes on].include?(value.to_s.downcase)
|
|
89
|
+
elsif key == :max_line_length || key == :streaming_threshold_bytes
|
|
90
|
+
value.to_i
|
|
91
|
+
else
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "parser"
|
|
5
|
+
require_relative "state_machine"
|
|
6
|
+
|
|
7
|
+
module Yaml
|
|
8
|
+
module Converter
|
|
9
|
+
# High-level emitter orchestrates parsing tokens and applying the state machine,
|
|
10
|
+
# producing a Markdown document with fenced YAML blocks and extracted notes.
|
|
11
|
+
class MarkdownEmitter
|
|
12
|
+
START_YAML = "```yaml"
|
|
13
|
+
END_YAML = "```"
|
|
14
|
+
VALIDATED_STR = "YAML validation:"
|
|
15
|
+
NOTE_STR = "note:"
|
|
16
|
+
|
|
17
|
+
# @param options [Hash] Configuration values merged from {Config.resolve}
|
|
18
|
+
def initialize(options)
|
|
19
|
+
@options = options
|
|
20
|
+
@max_len = options.fetch(:max_line_length)
|
|
21
|
+
@truncate = options.fetch(:truncate)
|
|
22
|
+
@margin_notes = options.fetch(:margin_notes)
|
|
23
|
+
@validate = options.fetch(:validate)
|
|
24
|
+
@validation_status = :ok
|
|
25
|
+
@is_latex = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Set the validation status used when injecting the validation status line.
|
|
29
|
+
# @param status [Symbol] :ok or :fail
|
|
30
|
+
# @return [void]
|
|
31
|
+
def set_validation_status(status)
|
|
32
|
+
@validation_status = status
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Convert input lines to markdown using parser + state machine, then append a footer.
|
|
36
|
+
# @param lines [Array<String>] Raw YAML file lines
|
|
37
|
+
# @return [Array<String>] Final markdown lines
|
|
38
|
+
def emit(lines)
|
|
39
|
+
parser = Parser.new(@options)
|
|
40
|
+
tokens = parser.tokenize(lines)
|
|
41
|
+
sm = StateMachine.new(validation_status: @validation_status, max_line_length: @max_len, truncate: @truncate, margin_notes: @margin_notes, current_date: @options[:current_date])
|
|
42
|
+
body = sm.apply(tokens)
|
|
43
|
+
if @options[:emit_footer]
|
|
44
|
+
body << "---- \n\nProduced by [yaml-converter](https://github.com/kettle-rb/yaml-converter)"
|
|
45
|
+
end
|
|
46
|
+
body
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yaml
|
|
4
|
+
module Converter
|
|
5
|
+
# Tokenizes input YAML lines (with inline annotations) into structured tokens
|
|
6
|
+
# consumable by the {Yaml::Converter::StateMachine}.
|
|
7
|
+
#
|
|
8
|
+
# Input assumptions:
|
|
9
|
+
# - Comment titles: lines starting with `# ` become title tokens.
|
|
10
|
+
# - Validation marker: a comment line starting with `# YAML validation:` is recognized.
|
|
11
|
+
# - Separator lines (`---`) are recognized and currently ignored by the state machine.
|
|
12
|
+
# - Inline notes: fragments after `#note:` are captured as out-of-band NOTE tokens.
|
|
13
|
+
# - Other non-empty lines are treated as YAML content.
|
|
14
|
+
class Parser
|
|
15
|
+
# Lightweight token structure used by the parser/state machine pipeline.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [rw] type
|
|
18
|
+
# @return [Symbol] One of :blank, :title, :validation, :separator, :dash_heading, :yaml_line, :note
|
|
19
|
+
# @!attribute [rw] text
|
|
20
|
+
# @return [String] Payload string for this token
|
|
21
|
+
# @!attribute [rw] meta
|
|
22
|
+
# @return [Hash,nil] Optional metadata bag (currently unused)
|
|
23
|
+
Token = Struct.new(:type, :text, :meta, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
# Comment line prefix indicating a validation status line will be injected
|
|
26
|
+
VALIDATION_PREFIX = "# YAML validation:"
|
|
27
|
+
# Inline note marker captured from right side of a line
|
|
28
|
+
NOTE_MARK = "#note:"
|
|
29
|
+
|
|
30
|
+
# @param options [Hash] Reserved for future parsing options
|
|
31
|
+
def initialize(options = {})
|
|
32
|
+
@options = options
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Convert raw lines into token objects.
|
|
36
|
+
#
|
|
37
|
+
# @param lines [Array<String>] Input lines (including newlines)
|
|
38
|
+
# @return [Array<Token>] Sequence of tokens representing the document structure
|
|
39
|
+
def tokenize(lines)
|
|
40
|
+
tokens = []
|
|
41
|
+
lines.each do |raw|
|
|
42
|
+
line = raw.rstrip
|
|
43
|
+
if line.empty?
|
|
44
|
+
tokens << Token.new(type: :blank, text: "")
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if line.start_with?("# ") || line == "#"
|
|
49
|
+
if line.start_with?(VALIDATION_PREFIX)
|
|
50
|
+
tokens << Token.new(type: :validation, text: line[2..])
|
|
51
|
+
else
|
|
52
|
+
content = (line == "#") ? "" : line[2..]
|
|
53
|
+
tokens << Token.new(type: :title, text: content)
|
|
54
|
+
end
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if line == "---"
|
|
59
|
+
tokens << Token.new(type: :separator, text: line)
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if line.start_with?("-")
|
|
64
|
+
tokens << Token.new(type: :dash_heading, text: line[1..].strip)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
note_idx = line.index(NOTE_MARK)
|
|
68
|
+
if note_idx
|
|
69
|
+
base = line[0...note_idx].rstrip
|
|
70
|
+
note = line[(note_idx + NOTE_MARK.length)..].to_s.strip
|
|
71
|
+
tokens << Token.new(type: :yaml_line, text: base)
|
|
72
|
+
tokens << Token.new(type: :note, text: note)
|
|
73
|
+
else
|
|
74
|
+
tokens << Token.new(type: :yaml_line, text: line)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
tokens
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Yaml
|
|
6
|
+
module Converter
|
|
7
|
+
module Renderer
|
|
8
|
+
# Shells out to pandoc to convert markdown to target format.
|
|
9
|
+
# Provides lightweight detection of pandoc and execution with arbitrary arguments.
|
|
10
|
+
#
|
|
11
|
+
# @example Convert markdown to PDF
|
|
12
|
+
# Yaml::Converter::Renderer::PandocShell.render(
|
|
13
|
+
# md_path: 'doc.md', out_path: 'doc.pdf', args: ['-N', '--toc']
|
|
14
|
+
# )
|
|
15
|
+
module PandocShell
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Locate an executable in the current PATH.
|
|
19
|
+
# @param cmd [String] command name, e.g. 'pandoc'
|
|
20
|
+
# @return [String,nil] full path if found, otherwise nil
|
|
21
|
+
def which(cmd)
|
|
22
|
+
ENV["PATH"].split(File::PATH_SEPARATOR).each do |path|
|
|
23
|
+
exe = File.join(path, cmd)
|
|
24
|
+
return exe if File.executable?(exe)
|
|
25
|
+
end
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Invoke pandoc to convert markdown file to another format.
|
|
30
|
+
#
|
|
31
|
+
# @param md_path [String] path to markdown file
|
|
32
|
+
# @param out_path [String] desired output file path with extension
|
|
33
|
+
# @param pandoc_path [String,nil] override path to pandoc binary (auto-detected if nil)
|
|
34
|
+
# @param args [Array<String>] extra pandoc arguments (e.g. ['-N','--toc'])
|
|
35
|
+
# @return [Boolean] true if successful, false otherwise
|
|
36
|
+
# @example Basic HTML conversion
|
|
37
|
+
# Yaml::Converter::Renderer::PandocShell.render(
|
|
38
|
+
# md_path: 'in.md', out_path: 'out.html'
|
|
39
|
+
# )
|
|
40
|
+
def render(md_path:, out_path:, pandoc_path: nil, args: [])
|
|
41
|
+
bin = pandoc_path || which("pandoc")
|
|
42
|
+
return false unless bin
|
|
43
|
+
cmd = [bin] + args + ["-o", out_path, md_path]
|
|
44
|
+
_stdout, stderr, status = Open3.capture3(*cmd)
|
|
45
|
+
unless status.success?
|
|
46
|
+
warn("pandoc failed: #{stderr}")
|
|
47
|
+
return false
|
|
48
|
+
end
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yaml
|
|
4
|
+
module Converter
|
|
5
|
+
module Renderer
|
|
6
|
+
# Native PDF rendering using prawn.
|
|
7
|
+
# Basic layout: title lines, YAML block in monospace, notes as italic paragraphs.
|
|
8
|
+
#
|
|
9
|
+
# For PDF rendering via pandoc, see {Yaml::Converter::Renderer::PandocShell}.
|
|
10
|
+
module PdfPrawn
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Render a PDF document from the given markdown string.
|
|
14
|
+
#
|
|
15
|
+
# @param markdown [String] Markdown that includes fenced YAML and blockquote notes
|
|
16
|
+
# @param out_path [String] Destination PDF path
|
|
17
|
+
# @param options [Hash] PDF options (see Config defaults: page size, margins, font sizes, two-column notes)
|
|
18
|
+
# @option options [String] :pdf_page_size ("LETTER")
|
|
19
|
+
# @option options [Array<Integer>] :pdf_margin ([36,36,36,36])
|
|
20
|
+
# @option options [Integer] :pdf_title_font_size (14)
|
|
21
|
+
# @option options [Integer] :pdf_body_font_size (11)
|
|
22
|
+
# @option options [Integer] :pdf_yaml_font_size (9)
|
|
23
|
+
# @option options [Boolean] :pdf_two_column_notes (false)
|
|
24
|
+
# @return [Boolean] true if rendering succeeded
|
|
25
|
+
def render(markdown:, out_path:, options: {})
|
|
26
|
+
require "prawn"
|
|
27
|
+
|
|
28
|
+
notes = extract_notes(markdown)
|
|
29
|
+
yaml_section = fenced_yaml(markdown)
|
|
30
|
+
title_lines = header_lines(markdown)
|
|
31
|
+
|
|
32
|
+
page_size = options[:pdf_page_size] || "LETTER"
|
|
33
|
+
margin = options[:pdf_margin] || [36, 36, 36, 36]
|
|
34
|
+
title_fs = options[:pdf_title_font_size] || 14
|
|
35
|
+
body_fs = options[:pdf_body_font_size] || 11
|
|
36
|
+
yaml_fs = options[:pdf_yaml_font_size] || 9
|
|
37
|
+
two_col = !!options[:pdf_two_column_notes]
|
|
38
|
+
|
|
39
|
+
Prawn::Document.generate(out_path, page_size: page_size, margin: margin) do |pdf|
|
|
40
|
+
pdf.font_size(title_fs)
|
|
41
|
+
title_lines.each { |l| pdf.text(l, style: :bold) }
|
|
42
|
+
pdf.move_down(10) if title_lines.any?
|
|
43
|
+
|
|
44
|
+
if two_col && notes.any?
|
|
45
|
+
# Create two columns: left for YAML, right for notes
|
|
46
|
+
col_gap = 16
|
|
47
|
+
col_width = (pdf.bounds.width - col_gap) / 2.0
|
|
48
|
+
pdf.bounding_box([pdf.bounds.left, pdf.cursor], width: col_width, height: pdf.cursor) do
|
|
49
|
+
pdf.font_size(yaml_fs)
|
|
50
|
+
pdf.font("Courier") { yaml_section.each { |l| pdf.text(l) } }
|
|
51
|
+
end
|
|
52
|
+
pdf.bounding_box([pdf.bounds.left + col_width + col_gap, pdf.cursor], width: col_width, height: pdf.cursor) do
|
|
53
|
+
pdf.font_size(body_fs)
|
|
54
|
+
notes.each { |n| pdf.text("NOTE: #{n}", style: :italic) }
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
pdf.font_size(yaml_fs)
|
|
58
|
+
pdf.font("Courier") { yaml_section.each { |l| pdf.text(l) } }
|
|
59
|
+
pdf.move_down(10)
|
|
60
|
+
unless notes.empty?
|
|
61
|
+
pdf.font_size(body_fs)
|
|
62
|
+
notes.each { |n| pdf.text("NOTE: #{n}", style: :italic) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
pdf.number_pages("Page <page> of <total>", at: [pdf.bounds.right - 100, 0])
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
warn("prawn pdf failed: #{e.class}: #{e.message}")
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Extract leading `# Title lines`.
|
|
75
|
+
# @param markdown [String]
|
|
76
|
+
# @return [Array<String>]
|
|
77
|
+
def header_lines(markdown)
|
|
78
|
+
markdown.lines.take_while { |l| l.start_with?("# ") }.map { |l| l.sub(/^# /, "").strip }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Return the lines inside the first fenced ```yaml block.
|
|
82
|
+
# @param markdown [String]
|
|
83
|
+
# @return [Array<String>]
|
|
84
|
+
def fenced_yaml(markdown)
|
|
85
|
+
inside = false
|
|
86
|
+
lines = []
|
|
87
|
+
markdown.each_line do |l|
|
|
88
|
+
if l.start_with?("```yaml")
|
|
89
|
+
inside = true
|
|
90
|
+
next
|
|
91
|
+
elsif inside && l.strip == "```"
|
|
92
|
+
inside = false
|
|
93
|
+
break
|
|
94
|
+
elsif inside
|
|
95
|
+
lines << l.rstrip
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
lines
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Extract note strings from Markdown blockquote lines.
|
|
102
|
+
# @param markdown [String]
|
|
103
|
+
# @return [Array<String>]
|
|
104
|
+
def extract_notes(markdown)
|
|
105
|
+
markdown.lines.grep(/^> NOTE:/).map { |l| l.sub(/^> NOTE:\s*/, "").strip }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|