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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Yaml
|
|
6
|
+
module Converter
|
|
7
|
+
# Simple state machine to transform {Parser::Token} sequences into
|
|
8
|
+
# Markdown lines. Produces:
|
|
9
|
+
# - Title lines as plain paragraphs
|
|
10
|
+
# - A validation status line (formatted with date)
|
|
11
|
+
# - Fenced YAML blocks for YAML content
|
|
12
|
+
# - Blockquoted notes (outside fences)
|
|
13
|
+
class StateMachine
|
|
14
|
+
START_YAML = "```yaml"
|
|
15
|
+
END_YAML = "```"
|
|
16
|
+
|
|
17
|
+
# @param options [Hash]
|
|
18
|
+
# @option options [Symbol] :validation_status (:ok) Injected validation result (:ok or :fail)
|
|
19
|
+
# @option options [Integer] :max_line_length (70)
|
|
20
|
+
# @option options [Boolean] :truncate (true)
|
|
21
|
+
# @option options [Symbol] :margin_notes (:auto)
|
|
22
|
+
# @option options [Date] :current_date (Date.today)
|
|
23
|
+
def initialize(options = {})
|
|
24
|
+
@options = options
|
|
25
|
+
@validate_status = options.fetch(:validation_status, :ok)
|
|
26
|
+
@max_len = options.fetch(:max_line_length, 70)
|
|
27
|
+
@truncate = options.fetch(:truncate, true)
|
|
28
|
+
@margin_notes = options.fetch(:margin_notes, :auto)
|
|
29
|
+
@current_date = options[:current_date] || Date.today
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Apply stateful transformations to tokens and emit Markdown lines.
|
|
33
|
+
#
|
|
34
|
+
# @param tokens [Array<Parser::Token>]
|
|
35
|
+
# @return [Array<String>] Output lines (without trailing newlines)
|
|
36
|
+
def apply(tokens)
|
|
37
|
+
out = []
|
|
38
|
+
state = :text
|
|
39
|
+
tokens.each do |t|
|
|
40
|
+
case t.type
|
|
41
|
+
when :blank
|
|
42
|
+
out << ""
|
|
43
|
+
when :title
|
|
44
|
+
close_yaml(out, state)
|
|
45
|
+
out << t.text
|
|
46
|
+
out << ""
|
|
47
|
+
state = :text
|
|
48
|
+
when :validation
|
|
49
|
+
close_yaml(out, state)
|
|
50
|
+
date = @current_date.strftime("%d/%m/%Y")
|
|
51
|
+
status_str = ((@validate_status == :ok) ? "OK" : "Fail")
|
|
52
|
+
out << "YAML validation:*#{status_str}* on #{date}"
|
|
53
|
+
out << ""
|
|
54
|
+
state = :text
|
|
55
|
+
when :separator
|
|
56
|
+
# ignore
|
|
57
|
+
when :dash_heading
|
|
58
|
+
close_yaml(out, state)
|
|
59
|
+
out << "# #{t.text}".strip
|
|
60
|
+
out << ""
|
|
61
|
+
state = :text
|
|
62
|
+
when :yaml_line
|
|
63
|
+
state = open_yaml(out, state)
|
|
64
|
+
line = t.text
|
|
65
|
+
if @truncate && line.length > @max_len
|
|
66
|
+
line = line[0...(@max_len - 2)] + ".."
|
|
67
|
+
end
|
|
68
|
+
out << line
|
|
69
|
+
when :note
|
|
70
|
+
if @margin_notes != :ignore
|
|
71
|
+
was_yaml = (state == :yaml)
|
|
72
|
+
close_yaml(out, state)
|
|
73
|
+
out << "" if out.last && out.last != ""
|
|
74
|
+
out << "> NOTE: #{t.text}"
|
|
75
|
+
out << ""
|
|
76
|
+
state = was_yaml ? open_yaml(out, :text) : :text
|
|
77
|
+
end
|
|
78
|
+
else
|
|
79
|
+
# unknown token type: ignore gracefully
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
close_yaml(out, state)
|
|
83
|
+
out
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Open a fenced YAML block if not already inside one.
|
|
89
|
+
# @param out [Array<String>]
|
|
90
|
+
# @param state [Symbol]
|
|
91
|
+
# @return [Symbol] new state
|
|
92
|
+
def open_yaml(out, state)
|
|
93
|
+
if state != :yaml
|
|
94
|
+
out << "" if out.last && out.last != ""
|
|
95
|
+
out << START_YAML
|
|
96
|
+
:yaml
|
|
97
|
+
else
|
|
98
|
+
state
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Close a fenced YAML block if currently inside one.
|
|
103
|
+
# @param out [Array<String>]
|
|
104
|
+
# @param state [Symbol]
|
|
105
|
+
# @return [void]
|
|
106
|
+
def close_yaml(out, state)
|
|
107
|
+
if state == :yaml
|
|
108
|
+
out << END_YAML
|
|
109
|
+
# removed trailing blank line to keep legacy expected output pattern
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "parser"
|
|
5
|
+
|
|
6
|
+
module Yaml
|
|
7
|
+
module Converter
|
|
8
|
+
# StreamingEmitter converts YAML to Markdown incrementally.
|
|
9
|
+
# It mirrors MarkdownEmitter + StateMachine behavior while writing to an IO target.
|
|
10
|
+
# Intended for very large inputs to avoid building all output in memory.
|
|
11
|
+
class StreamingEmitter
|
|
12
|
+
START_YAML = "```yaml"
|
|
13
|
+
END_YAML = "```"
|
|
14
|
+
|
|
15
|
+
# @param options [Hash] same normalized options as Config.resolve
|
|
16
|
+
# @param io [#<<] destination IO (e.g., File) that receives lines with "\n"
|
|
17
|
+
def initialize(options, io)
|
|
18
|
+
@options = options
|
|
19
|
+
@io = io
|
|
20
|
+
@validation_status = :ok
|
|
21
|
+
@state = :text
|
|
22
|
+
@_last_line_blank = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Set the validation status used when injecting the validation status line.
|
|
26
|
+
# @param status [Symbol] :ok or :fail
|
|
27
|
+
# @return [void]
|
|
28
|
+
def set_validation_status(status)
|
|
29
|
+
@validation_status = status
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Stream-convert an input file path to the configured IO.
|
|
33
|
+
# @param input_path [String]
|
|
34
|
+
# @return [void]
|
|
35
|
+
def emit_file(input_path)
|
|
36
|
+
File.open(input_path, "r") do |f|
|
|
37
|
+
f.each_line do |raw|
|
|
38
|
+
emit_line(raw)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
close_yaml
|
|
42
|
+
emit_footer if @options[:emit_footer]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def max_len
|
|
48
|
+
@options.fetch(:max_line_length)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def truncate?
|
|
52
|
+
@options.fetch(:truncate)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def margin_notes
|
|
56
|
+
@options.fetch(:margin_notes)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def current_date
|
|
60
|
+
@options[:current_date] || Date.today
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def emit_line(raw)
|
|
64
|
+
# Reuse Parser for correctness on a per-line basis
|
|
65
|
+
parser = (@_parser ||= Parser.new(@options))
|
|
66
|
+
tokens = parser.tokenize([raw])
|
|
67
|
+
tokens.each { |t| handle_token(t) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle_token(t)
|
|
71
|
+
case t.type
|
|
72
|
+
when :blank
|
|
73
|
+
write_line("")
|
|
74
|
+
when :title
|
|
75
|
+
close_yaml
|
|
76
|
+
write_line(t.text)
|
|
77
|
+
write_line("")
|
|
78
|
+
when :validation
|
|
79
|
+
close_yaml
|
|
80
|
+
date = current_date.strftime("%d/%m/%Y")
|
|
81
|
+
status_str = ((@validation_status == :ok) ? "OK" : "Fail")
|
|
82
|
+
write_line("YAML validation:*#{status_str}* on #{date}")
|
|
83
|
+
write_line("")
|
|
84
|
+
when :separator
|
|
85
|
+
# ignore
|
|
86
|
+
when :dash_heading
|
|
87
|
+
close_yaml
|
|
88
|
+
write_line("# #{t.text}".strip)
|
|
89
|
+
write_line("")
|
|
90
|
+
when :yaml_line
|
|
91
|
+
open_yaml
|
|
92
|
+
line = t.text
|
|
93
|
+
if truncate? && line.length > max_len
|
|
94
|
+
line = line[0...(
|
|
95
|
+
max_len - 2
|
|
96
|
+
)] + ".."
|
|
97
|
+
end
|
|
98
|
+
write_line(line)
|
|
99
|
+
when :note
|
|
100
|
+
return if margin_notes == :ignore
|
|
101
|
+
was_yaml = (@state == :yaml)
|
|
102
|
+
close_yaml
|
|
103
|
+
write_line("") if last_line_not_blank?
|
|
104
|
+
write_line("> NOTE: #{t.text}")
|
|
105
|
+
write_line("")
|
|
106
|
+
open_yaml if was_yaml
|
|
107
|
+
else
|
|
108
|
+
# ignore unknown types
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def open_yaml
|
|
113
|
+
return if @state == :yaml
|
|
114
|
+
write_line("") if last_line_not_blank?
|
|
115
|
+
write_line(START_YAML)
|
|
116
|
+
@state = :yaml
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def close_yaml
|
|
120
|
+
if @state == :yaml
|
|
121
|
+
write_line(END_YAML)
|
|
122
|
+
@state = :text
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def emit_footer
|
|
127
|
+
write_line("---- ")
|
|
128
|
+
write_line("")
|
|
129
|
+
write_line("Produced by [yaml-converter](https://github.com/kettle-rb/yaml-converter)")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def write_line(s)
|
|
133
|
+
if @wrote_any_line
|
|
134
|
+
@io << "\n"
|
|
135
|
+
end
|
|
136
|
+
@io << s
|
|
137
|
+
@wrote_any_line = true
|
|
138
|
+
@_last_line_blank = (s == "")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def last_line_not_blank?
|
|
142
|
+
@_last_line_blank == false
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Yaml
|
|
6
|
+
module Converter
|
|
7
|
+
# YAML validation helpers.
|
|
8
|
+
#
|
|
9
|
+
# This module provides simple validation by attempting to parse input using
|
|
10
|
+
# Psych.safe_load with a minimal, safe set of options. It does not raise on
|
|
11
|
+
# failure; instead it returns a structured Hash indicating status and error.
|
|
12
|
+
#
|
|
13
|
+
# @see .validate_string
|
|
14
|
+
# @see .validate_file
|
|
15
|
+
module Validation
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Validate a YAML string by attempting to load it safely via Psych.
|
|
19
|
+
#
|
|
20
|
+
# Uses Psych.safe_load with:
|
|
21
|
+
# - permitted_classes: []
|
|
22
|
+
# - permitted_symbols: []
|
|
23
|
+
# - aliases: true
|
|
24
|
+
#
|
|
25
|
+
# @param yaml_string [String] the YAML content to validate
|
|
26
|
+
# @return [Hash] a result hash
|
|
27
|
+
# @return [Hash] [
|
|
28
|
+
# { status: :ok, error: nil } when parsing succeeds,
|
|
29
|
+
# { status: :fail, error: Exception } when parsing fails
|
|
30
|
+
# ]
|
|
31
|
+
# @example Success
|
|
32
|
+
# Yaml::Converter::Validation.validate_string("foo: bar")
|
|
33
|
+
# #=> { status: :ok, error: nil }
|
|
34
|
+
# @example Failure
|
|
35
|
+
# Yaml::Converter::Validation.validate_string(": : :")
|
|
36
|
+
# #=> { status: :fail, error: #<Psych::SyntaxError ...> }
|
|
37
|
+
def validate_string(yaml_string)
|
|
38
|
+
# Psych.safe_load requires permitted classes for complex YAML; for our purposes,
|
|
39
|
+
# allow basic types and symbols off.
|
|
40
|
+
Psych.safe_load(
|
|
41
|
+
yaml_string,
|
|
42
|
+
permitted_classes: [],
|
|
43
|
+
permitted_symbols: [],
|
|
44
|
+
aliases: true,
|
|
45
|
+
)
|
|
46
|
+
{status: :ok, error: nil}
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
{status: :fail, error: e}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Validate a YAML file by reading it and delegating to {.validate_string}.
|
|
52
|
+
#
|
|
53
|
+
# @param path [String] filesystem path to a YAML file
|
|
54
|
+
# @return [Hash] see {.validate_string}
|
|
55
|
+
# @example
|
|
56
|
+
# Yaml::Converter::Validation.validate_file("config.yml")
|
|
57
|
+
# #=> { status: :ok, error: nil }
|
|
58
|
+
def validate_file(path)
|
|
59
|
+
content = File.read(path)
|
|
60
|
+
validate_string(content)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yaml
|
|
4
|
+
module Converter
|
|
5
|
+
# Namespace for version information.
|
|
6
|
+
module Version
|
|
7
|
+
# The gem version.
|
|
8
|
+
# @return [String]
|
|
9
|
+
VERSION = "0.1.0"
|
|
10
|
+
end
|
|
11
|
+
# The gem version, exposed at the root of the Yaml::Converter namespace.
|
|
12
|
+
# @return [String]
|
|
13
|
+
VERSION = Version::VERSION
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# External dependencies
|
|
4
|
+
require "version_gem"
|
|
5
|
+
|
|
6
|
+
# This library
|
|
7
|
+
require_relative "converter/version"
|
|
8
|
+
require_relative "converter/config"
|
|
9
|
+
require_relative "converter/validation"
|
|
10
|
+
require_relative "converter/markdown_emitter"
|
|
11
|
+
require_relative "converter/streaming_emitter"
|
|
12
|
+
|
|
13
|
+
module Yaml
|
|
14
|
+
module Converter
|
|
15
|
+
# Base error class for all yaml-converter specific exceptions.
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
# Raised when provided arguments (paths, extensions, etc.) are invalid.
|
|
18
|
+
class InvalidArgumentsError < Error; end
|
|
19
|
+
# Raised when a requested renderer is not available or implemented.
|
|
20
|
+
class RendererUnavailableError < Error; end
|
|
21
|
+
# Raised when pandoc rendering is requested but pandoc cannot be located.
|
|
22
|
+
class PandocNotFoundError < Error; end
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Convert a YAML string into Markdown with optional validation & notes extraction.
|
|
27
|
+
#
|
|
28
|
+
# @param yaml_string [String] Raw YAML content (with optional inline `#note:` annotations or validation marker line)
|
|
29
|
+
# @param options [Hash, Config::DEFAULTS] User overrides for configuration (see {Yaml::Converter::Config::DEFAULTS})
|
|
30
|
+
# @option options [Boolean] :validate (true) Whether to attempt YAML parsing and inject validation status.
|
|
31
|
+
# @option options [Integer] :max_line_length (70) Maximum line length before truncation.
|
|
32
|
+
# @option options [Boolean] :truncate (true) Whether to truncate overly long lines.
|
|
33
|
+
# @option options [Symbol] :margin_notes (:auto) How to handle notes (:auto, :inline, :ignore).
|
|
34
|
+
# @option options [Date] :current_date (Date.today) Deterministic date injection for specs.
|
|
35
|
+
# @return [String] Markdown document including fenced YAML and extracted notes.
|
|
36
|
+
# @example Simple conversion
|
|
37
|
+
# Yaml::Converter.to_markdown("foo: 1 #note: first")
|
|
38
|
+
def to_markdown(yaml_string, options: {})
|
|
39
|
+
opts = Config.resolve(options)
|
|
40
|
+
emitter = MarkdownEmitter.new(opts)
|
|
41
|
+
if opts[:validate]
|
|
42
|
+
validation = Validation.validate_string(yaml_string)
|
|
43
|
+
status = (validation && validation[:status] == :ok) ? :ok : :fail
|
|
44
|
+
emitter.set_validation_status(status)
|
|
45
|
+
end
|
|
46
|
+
emitter.emit(yaml_string.lines).join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Stream a YAML file to Markdown into an IO target.
|
|
50
|
+
# Automatically injects validation status if enabled.
|
|
51
|
+
# @param input_path [String]
|
|
52
|
+
# @param io [#<<]
|
|
53
|
+
# @param options [Hash]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def to_markdown_streaming(input_path, io, options: {})
|
|
56
|
+
opts = Config.resolve(options)
|
|
57
|
+
emitter = StreamingEmitter.new(opts, io)
|
|
58
|
+
if opts[:validate]
|
|
59
|
+
validation = Validation.validate_file(input_path)
|
|
60
|
+
status = (validation && validation[:status] == :ok) ? :ok : :fail
|
|
61
|
+
emitter.set_validation_status(status)
|
|
62
|
+
end
|
|
63
|
+
emitter.emit_file(input_path)
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Validate a YAML string returning a structured status.
|
|
68
|
+
#
|
|
69
|
+
# @param yaml_string [String]
|
|
70
|
+
# @return [Hash{Symbol=>Object}] Hash with :status (:ok|:fail) and :error (Exception or nil)
|
|
71
|
+
# @example
|
|
72
|
+
# Yaml::Converter.validate("foo: bar") #=> { status: :ok, error: nil }
|
|
73
|
+
def validate(yaml_string)
|
|
74
|
+
Validation.validate_string(yaml_string)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Convert a YAML file to a target format determined by the output extension.
|
|
78
|
+
#
|
|
79
|
+
# Supported extensions (Phase 1): .md, .html, .pdf (native), .docx (pandoc).
|
|
80
|
+
# Other formats may be produced via pandoc when :use_pandoc is true.
|
|
81
|
+
#
|
|
82
|
+
# @param input_path [String] Path to existing .yaml source file.
|
|
83
|
+
# @param output_path [String] Destination file (extension decides rendering strategy).
|
|
84
|
+
# @param options [Hash] See {#to_markdown} plus pandoc/pdf specific keys.
|
|
85
|
+
# @option options [Boolean] :use_pandoc (false) Enable pandoc for non-native conversions.
|
|
86
|
+
# @option options [Array<String>] :pandoc_args (["-N", "--toc"]) Extra pandoc CLI args.
|
|
87
|
+
# @option options [String,nil] :pandoc_path (nil) Explicit pandoc binary path (auto-detected if nil).
|
|
88
|
+
# @option options [Boolean] :pdf_two_column_notes (false) Layout notes beside YAML in PDF.
|
|
89
|
+
# @option options [Boolean] :streaming (false) Force streaming mode for markdown conversion.
|
|
90
|
+
# @option options [Integer] :streaming_threshold_bytes (5_000_000) Auto-enable streaming over this size when not forced.
|
|
91
|
+
# @return [Hash] { status: Symbol, output_path: String, validation: Hash }
|
|
92
|
+
# @raise [InvalidArgumentsError] if input is missing or an invalid extension is requested.
|
|
93
|
+
# @raise [PandocNotFoundError] when pandoc rendering requested & missing.
|
|
94
|
+
# @raise [RendererUnavailableError] for unsupported formats.
|
|
95
|
+
# @example HTML conversion
|
|
96
|
+
# Yaml::Converter.convert(input_path: "blueprint.yaml", output_path: "blueprint.html", options: {})
|
|
97
|
+
def convert(input_path:, output_path:, options: {})
|
|
98
|
+
raise InvalidArgumentsError, "input file not found: #{input_path}" unless File.exist?(input_path)
|
|
99
|
+
|
|
100
|
+
ext = File.extname(output_path)
|
|
101
|
+
if ext == ".yaml"
|
|
102
|
+
raise InvalidArgumentsError, "Output must not be .yaml"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
opts = Config.resolve(options)
|
|
106
|
+
yaml_string = nil
|
|
107
|
+
|
|
108
|
+
if File.exist?(output_path) && ENV["KETTLE_TEST_SILENT"] != "true"
|
|
109
|
+
warn("Overwriting existing file: #{output_path}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
auto_stream = !opts[:streaming] && File.size?(input_path) && File.size(input_path) >= opts[:streaming_threshold_bytes]
|
|
113
|
+
|
|
114
|
+
if ext == ".md" || ext == ""
|
|
115
|
+
# Direct markdown output path: stream to file for large inputs
|
|
116
|
+
File.open(output_path, "w") do |io|
|
|
117
|
+
if opts[:streaming] || auto_stream
|
|
118
|
+
to_markdown_streaming(input_path, io, options: opts)
|
|
119
|
+
else
|
|
120
|
+
yaml_string = File.read(input_path)
|
|
121
|
+
io.write(to_markdown(yaml_string, options: opts))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
validation_result = if opts[:validate]
|
|
125
|
+
yaml_string ? Validation.validate_string(yaml_string) : Validation.validate_file(input_path)
|
|
126
|
+
else
|
|
127
|
+
{status: :ok, error: nil}
|
|
128
|
+
end
|
|
129
|
+
return {status: :ok, output_path: output_path, validation: validation_result}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# For non-markdown outputs, we still produce an intermediate markdown string.
|
|
133
|
+
yaml_string = File.read(input_path) if yaml_string.nil?
|
|
134
|
+
markdown = to_markdown(yaml_string, options: opts)
|
|
135
|
+
|
|
136
|
+
# Helper lambda to build standard success response
|
|
137
|
+
success = lambda do
|
|
138
|
+
{status: :ok, output_path: output_path, validation: (opts[:validate] ? Validation.validate_string(yaml_string) : {status: :ok, error: nil})}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
case ext
|
|
142
|
+
when ".html"
|
|
143
|
+
require "kramdown"
|
|
144
|
+
require "kramdown-parser-gfm"
|
|
145
|
+
|
|
146
|
+
body_html = Kramdown::Document.new(markdown, input: "GFM").to_html
|
|
147
|
+
note_style = ""
|
|
148
|
+
if markdown.include?("> NOTE:")
|
|
149
|
+
note_style = "<style>.yaml-note{font-style:italic;color:#555;margin-left:1em;}</style>\n"
|
|
150
|
+
body_html = body_html.gsub("<blockquote>", '<blockquote class="yaml-note">')
|
|
151
|
+
end
|
|
152
|
+
html = <<~HTML
|
|
153
|
+
<!DOCTYPE html>
|
|
154
|
+
<html>
|
|
155
|
+
<head>
|
|
156
|
+
<meta charset="utf-8">
|
|
157
|
+
#{note_style}</head>
|
|
158
|
+
<body>
|
|
159
|
+
#{body_html}
|
|
160
|
+
</body>
|
|
161
|
+
</html>
|
|
162
|
+
HTML
|
|
163
|
+
File.write(output_path, html)
|
|
164
|
+
success.call
|
|
165
|
+
when ".pdf"
|
|
166
|
+
if opts[:use_pandoc]
|
|
167
|
+
tmp_md = output_path + ".md"
|
|
168
|
+
File.write(tmp_md, markdown)
|
|
169
|
+
require_relative "converter/renderer/pandoc_shell"
|
|
170
|
+
ok = Renderer::PandocShell.render(md_path: tmp_md, out_path: output_path, pandoc_path: opts[:pandoc_path], args: opts[:pandoc_args])
|
|
171
|
+
File.delete(tmp_md) if File.exist?(tmp_md)
|
|
172
|
+
raise PandocNotFoundError, "pandoc not found in PATH" unless ok
|
|
173
|
+
else
|
|
174
|
+
require_relative "converter/renderer/pdf_prawn"
|
|
175
|
+
ok = Renderer::PdfPrawn.render(markdown: markdown, out_path: output_path, options: opts)
|
|
176
|
+
raise RendererUnavailableError, "PDF rendering failed" unless ok
|
|
177
|
+
end
|
|
178
|
+
success.call
|
|
179
|
+
when ".docx"
|
|
180
|
+
tmp_md = output_path + ".md"
|
|
181
|
+
File.write(tmp_md, markdown)
|
|
182
|
+
require_relative "converter/renderer/pandoc_shell"
|
|
183
|
+
pandoc_path = Renderer::PandocShell.which("pandoc")
|
|
184
|
+
if pandoc_path
|
|
185
|
+
ok = Renderer::PandocShell.render(md_path: tmp_md, out_path: output_path, pandoc_path: pandoc_path, args: [])
|
|
186
|
+
File.delete(tmp_md) if File.exist?(tmp_md)
|
|
187
|
+
raise RendererUnavailableError, "pandoc failed to generate DOCX" unless ok
|
|
188
|
+
success.call
|
|
189
|
+
else
|
|
190
|
+
File.delete(tmp_md) if File.exist?(tmp_md)
|
|
191
|
+
raise RendererUnavailableError, "DOCX requires pandoc; install pandoc or use .md/.html/.pdf"
|
|
192
|
+
end
|
|
193
|
+
else
|
|
194
|
+
tmp_md = output_path + ".md"
|
|
195
|
+
File.write(tmp_md, markdown)
|
|
196
|
+
if opts[:use_pandoc]
|
|
197
|
+
require_relative "converter/renderer/pandoc_shell"
|
|
198
|
+
ok = Renderer::PandocShell.render(md_path: tmp_md, out_path: output_path, pandoc_path: opts[:pandoc_path], args: opts[:pandoc_args])
|
|
199
|
+
File.delete(tmp_md) if File.exist?(tmp_md)
|
|
200
|
+
raise PandocNotFoundError, "pandoc not found in PATH" unless ok
|
|
201
|
+
success.call
|
|
202
|
+
else
|
|
203
|
+
raise RendererUnavailableError, "Renderer for #{ext} not implemented. Pass use_pandoc: true or use .md/.html/.pdf."
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Extend the Version with VersionGem::Basic to provide semantic version helpers.
|
|
211
|
+
Yaml::Converter::Version.class_eval do
|
|
212
|
+
extend VersionGem::Basic
|
|
213
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Yaml
|
|
2
|
+
module Converter
|
|
3
|
+
module Config
|
|
4
|
+
DEFAULTS: { max_line_length: Integer, truncate: bool, margin_notes: Symbol, validate: bool, use_pandoc: bool, pandoc_args: Array[String], pandoc_path: String? , html_theme: Symbol, pdf_page_size: String, pdf_margin: Array[Integer], pdf_title_font_size: Integer, pdf_body_font_size: Integer, pdf_yaml_font_size: Integer, pdf_two_column_notes: bool, current_date: untyped, emit_footer: bool, streaming: bool, streaming_threshold_bytes: Integer }
|
|
5
|
+
ENV_MAP: { max_line_length: String, truncate: String, margin_notes: String, validate: String, use_pandoc: String, pdf_page_size: String, pdf_title_font_size: String, pdf_body_font_size: String, pdf_yaml_font_size: String, pdf_two_column_notes: String, emit_footer: String, streaming: String, streaming_threshold_bytes: String }
|
|
6
|
+
BOOLEAN_KEYS: Array[Symbol]
|
|
7
|
+
|
|
8
|
+
def self.resolve: (?Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
9
|
+
def self.normalize: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
10
|
+
def self.coerce_env_value: (Symbol, String) -> untyped
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Yaml
|
|
2
|
+
module Converter
|
|
3
|
+
class MarkdownEmitter
|
|
4
|
+
START_YAML: String
|
|
5
|
+
END_YAML: String
|
|
6
|
+
VALIDATED_STR: String
|
|
7
|
+
NOTE_STR: String
|
|
8
|
+
|
|
9
|
+
def initialize: (Yaml::Converter::options options) -> void
|
|
10
|
+
def set_validation_status: (Symbol) -> void
|
|
11
|
+
def emit: (Array[String]) -> Array[String]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Yaml
|
|
2
|
+
module Converter
|
|
3
|
+
class StreamingEmitter
|
|
4
|
+
START_YAML: String
|
|
5
|
+
END_YAML: String
|
|
6
|
+
|
|
7
|
+
def initialize: (Yaml::Converter::options options, untyped io) -> void
|
|
8
|
+
def set_validation_status: (Symbol) -> void
|
|
9
|
+
def emit_file: (String input_path) -> void
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Yaml
|
|
2
|
+
module Converter
|
|
3
|
+
VERSION: String
|
|
4
|
+
|
|
5
|
+
class Error < ::StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class InvalidArgumentsError < Error
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class RendererUnavailableError < Error
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class PandocNotFoundError < Error
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
type options = {
|
|
18
|
+
max_line_length: Integer,
|
|
19
|
+
truncate: bool,
|
|
20
|
+
margin_notes: Symbol,
|
|
21
|
+
validate: bool,
|
|
22
|
+
use_pandoc: bool,
|
|
23
|
+
pandoc_args: Array[String],
|
|
24
|
+
pandoc_path: String?,
|
|
25
|
+
html_theme: Symbol,
|
|
26
|
+
pdf_page_size: String,
|
|
27
|
+
pdf_margin: Array[Integer],
|
|
28
|
+
pdf_title_font_size: Integer,
|
|
29
|
+
pdf_body_font_size: Integer,
|
|
30
|
+
pdf_yaml_font_size: Integer,
|
|
31
|
+
pdf_two_column_notes: bool,
|
|
32
|
+
current_date: untyped,
|
|
33
|
+
emit_footer: bool,
|
|
34
|
+
streaming: bool,
|
|
35
|
+
streaming_threshold_bytes: Integer
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def self.to_markdown: (String yaml_string, ?options: options) -> String
|
|
39
|
+
def self.to_markdown_streaming: (String input_path, untyped io, ?options: options) -> void
|
|
40
|
+
def self.validate: (String yaml_string) -> { status: Symbol, error: Exception? }
|
|
41
|
+
def self.convert: (input_path: String, output_path: String, ?options: options) -> { status: Symbol, output_path: String, validation: { status: Symbol, error: Exception? } }
|
|
42
|
+
end
|
|
43
|
+
end
|
data.tar.gz.sig
ADDED
|
Binary file
|