coradoc 2.0.21 → 2.0.23
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 +4 -4
- data/lib/coradoc/cli.rb +26 -1
- data/lib/coradoc/coradoc.rb +93 -8
- data/lib/coradoc/core_model/base.rb +39 -0
- data/lib/coradoc/core_model/children_content.rb +5 -0
- data/lib/coradoc/core_model/comment_block.rb +4 -0
- data/lib/coradoc/core_model/comment_line.rb +4 -0
- data/lib/coradoc/core_model/frontmatter/codec.rb +66 -19
- data/lib/coradoc/core_model/frontmatter/frontmatter_value.rb +61 -0
- data/lib/coradoc/core_model/frontmatter.rb +4 -0
- data/lib/coradoc/core_model/has_children.rb +23 -0
- data/lib/coradoc/core_model/include.rb +43 -0
- data/lib/coradoc/core_model/include_level_offset.rb +71 -0
- data/lib/coradoc/core_model/include_options.rb +100 -0
- data/lib/coradoc/core_model/inline_element.rb +20 -0
- data/lib/coradoc/core_model/list_block.rb +17 -0
- data/lib/coradoc/core_model/list_item.rb +21 -0
- data/lib/coradoc/core_model/output_artifact.rb +48 -0
- data/lib/coradoc/core_model/paragraph_block.rb +4 -0
- data/lib/coradoc/core_model/stem_block.rb +21 -0
- data/lib/coradoc/core_model/structural_element.rb +46 -0
- data/lib/coradoc/core_model/text_content.rb +4 -0
- data/lib/coradoc/core_model.rb +6 -0
- data/lib/coradoc/errors.rb +56 -0
- data/lib/coradoc/include_resolver/filesystem.rb +84 -0
- data/lib/coradoc/include_resolver.rb +67 -0
- data/lib/coradoc/include_selectors/indent.rb +54 -0
- data/lib/coradoc/include_selectors/level_offset.rb +86 -0
- data/lib/coradoc/include_selectors/lines.rb +60 -0
- data/lib/coradoc/include_selectors/tags.rb +138 -0
- data/lib/coradoc/include_selectors.rb +26 -0
- data/lib/coradoc/link_rewriter/identity.rb +13 -0
- data/lib/coradoc/link_rewriter/visitor.rb +157 -0
- data/lib/coradoc/link_rewriter.rb +37 -0
- data/lib/coradoc/relative_path.rb +32 -0
- data/lib/coradoc/resolve_includes.rb +202 -0
- data/lib/coradoc/version.rb +1 -1
- data/lib/coradoc.rb +2 -0
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7370b3dabd4535faf0592e455d0bfd13c582b37941c411a0c4c7b86528843896
|
|
4
|
+
data.tar.gz: c61e126d88a2dc69c507657201b9b3e7875a7d6beffac957d5b805a81ffcc6d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 548b69fbd08a23cabf10e60ed599bafb904c87e857678bd539f820fc824ff2d407761728c10a2c6be2359a40b8d6973211adaf7c41171e7928c83a8816f43360
|
|
7
|
+
data.tar.gz: 04e7b4f63d935208df375f2ecf979104db5b0b7de9e95b6c04d77939d1a293114f2c285ccea7cc7a7e0709e364d7fe1c58d2038e2b6adc923591421be4d2ade8
|
data/lib/coradoc/cli.rb
CHANGED
|
@@ -22,6 +22,16 @@ module Coradoc
|
|
|
22
22
|
option :section_numbers, desc: 'Enable section numbering', type: :boolean, default: false
|
|
23
23
|
option :section_number_levels, desc: 'Section numbering depth (1-6)', type: :numeric, default: 3
|
|
24
24
|
option :lang, desc: 'Document language code', type: :string, default: 'en'
|
|
25
|
+
option :resolve_includes, desc: 'Resolve include:: directives inline (default: leave as link nodes)',
|
|
26
|
+
type: :boolean, default: false
|
|
27
|
+
option :base_dir, desc: 'Base directory for include resolution (default: dirname of FILE)',
|
|
28
|
+
type: :string
|
|
29
|
+
option :missing_include, desc: 'Policy for missing includes: error, warn, silent, passthrough',
|
|
30
|
+
type: :string, default: 'error'
|
|
31
|
+
option :max_include_depth, desc: 'Maximum include nesting depth', type: :numeric,
|
|
32
|
+
default: 64
|
|
33
|
+
option :allow_unsafe_includes, desc: 'Disable path-traversal protection (asciidoctor :unsafe mode)',
|
|
34
|
+
type: :boolean, default: false
|
|
25
35
|
def convert(file)
|
|
26
36
|
source_format = resolve_format(file, :from)
|
|
27
37
|
target_format = options[:to] ? Coradoc.normalize_format(options[:to]) : Coradoc.resolve_output_format(options[:output])
|
|
@@ -38,8 +48,11 @@ module Coradoc
|
|
|
38
48
|
|
|
39
49
|
verbose_log "Converting #{file} (#{source_format}) to #{target_format}"
|
|
40
50
|
|
|
51
|
+
doc = Coradoc.parse_file(file, format: source_format)
|
|
52
|
+
doc = resolve_includes!(doc, file) if options[:resolve_includes]
|
|
53
|
+
|
|
41
54
|
opts = build_convert_options
|
|
42
|
-
result = Coradoc.
|
|
55
|
+
result = Coradoc.serialize(doc, to: target_format, **opts)
|
|
43
56
|
write_output(result, options[:output])
|
|
44
57
|
rescue Coradoc::Error => e
|
|
45
58
|
error "Error: #{e.message}"
|
|
@@ -215,5 +228,17 @@ module Coradoc
|
|
|
215
228
|
opts[key] = SYMBOL_OPTIONS.include?(key) ? value.to_sym : value
|
|
216
229
|
end
|
|
217
230
|
end
|
|
231
|
+
|
|
232
|
+
def resolve_includes!(doc, source_file)
|
|
233
|
+
base_dir = options[:base_dir] || File.expand_path(File.dirname(source_file))
|
|
234
|
+
verbose_log "Resolving includes against #{base_dir}"
|
|
235
|
+
Coradoc.resolve_includes(
|
|
236
|
+
doc,
|
|
237
|
+
base_dir: base_dir,
|
|
238
|
+
missing_include: options[:missing_include].to_sym,
|
|
239
|
+
max_depth: options[:max_include_depth],
|
|
240
|
+
allow_unsafe: options[:allow_unsafe_includes]
|
|
241
|
+
)
|
|
242
|
+
end
|
|
218
243
|
end
|
|
219
244
|
end
|
data/lib/coradoc/coradoc.rb
CHANGED
|
@@ -86,22 +86,27 @@ module Coradoc
|
|
|
86
86
|
registry.list
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
# Parse text to a document model
|
|
89
|
+
# Parse text to a document model.
|
|
90
90
|
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
91
|
+
# Graph mode is the only mode: +include::+ directives survive as
|
|
92
|
+
# +CoreModel::Include+ link nodes pointing at other files. NO file
|
|
93
|
+
# I/O happens during parse. The result is a single document that
|
|
94
|
+
# references other documents via Include edges — a text graph.
|
|
95
|
+
#
|
|
96
|
+
# To splice included content inline, call +Coradoc.resolve_includes+
|
|
97
|
+
# on the parsed document. This is an explicit, separate step so the
|
|
98
|
+
# caller controls when (and whether) file I/O happens.
|
|
93
99
|
#
|
|
94
100
|
# @param text [String] the document text to parse
|
|
95
101
|
# @param format [Symbol] the source format (:asciidoc, :html, :markdown)
|
|
96
102
|
# @return [Coradoc::CoreModel::Base, Object] the parsed document model
|
|
97
103
|
# @raise [UnsupportedFormatError] if the format is not registered
|
|
98
104
|
#
|
|
99
|
-
# @example Parse
|
|
100
|
-
# doc = Coradoc.parse(
|
|
101
|
-
# doc = Coradoc.parse(File.read("doc.adoc"), format: :asciidoc)
|
|
105
|
+
# @example Parse — Include directives stay as link nodes
|
|
106
|
+
# doc = Coradoc.parse(text, format: :asciidoc)
|
|
102
107
|
#
|
|
103
|
-
# @example
|
|
104
|
-
#
|
|
108
|
+
# @example Then flatten — splice included files inline
|
|
109
|
+
# flat = Coradoc.resolve_includes(doc, base_dir: Dir.pwd)
|
|
105
110
|
def parse(text, format:)
|
|
106
111
|
format_module = get_format(format)
|
|
107
112
|
unless format_module
|
|
@@ -115,6 +120,83 @@ module Coradoc
|
|
|
115
120
|
Hooks.invoke(:after_parse, result, format: format)
|
|
116
121
|
end
|
|
117
122
|
|
|
123
|
+
# Resolve +include::+ directives in a parsed document.
|
|
124
|
+
#
|
|
125
|
+
# Walks the document tree and replaces every +CoreModel::Include+
|
|
126
|
+
# link node with the parsed content of its target file, recursing
|
|
127
|
+
# into the result. The original document is left unchanged; a new
|
|
128
|
+
# subtree is constructed.
|
|
129
|
+
#
|
|
130
|
+
# This is the explicit "flatten" step that turns a text graph into
|
|
131
|
+
# a single spliced document. Callers control:
|
|
132
|
+
# - +base_dir+ — where to root relative include paths
|
|
133
|
+
# - +missing_include+ — what to do when a target is missing
|
|
134
|
+
# - +max_depth+ — recursion cap
|
|
135
|
+
# - +allow_unsafe+ — opt out of path-traversal protection
|
|
136
|
+
# - +resolver+ — custom resolution strategy (e.g. HTTP, in-memory)
|
|
137
|
+
#
|
|
138
|
+
# @param document [Coradoc::CoreModel::Base] parsed document
|
|
139
|
+
# @param base_dir [String] base directory for relative include paths
|
|
140
|
+
# @param missing_include [Symbol] :error (default), :warn, :silent, :passthrough
|
|
141
|
+
# @param max_depth [Integer] recursion cap (default 64)
|
|
142
|
+
# @param allow_unsafe [Boolean] disable path-traversal protection
|
|
143
|
+
# @param resolver [Object, nil] custom resolver. Defaults to
|
|
144
|
+
# +Coradoc::IncludeResolver::Filesystem+ rooted at +base_dir+.
|
|
145
|
+
# @return [Coradoc::CoreModel::Base] new document with includes expanded
|
|
146
|
+
# @raise [Coradoc::IncludeNotFoundError] when a target is missing
|
|
147
|
+
# and policy is :error
|
|
148
|
+
# @raise [Coradoc::IncludeDepthExceededError] when +max_depth+ is hit
|
|
149
|
+
# @raise [Coradoc::CircularIncludeError] when an include cycle is detected
|
|
150
|
+
#
|
|
151
|
+
# @example
|
|
152
|
+
# doc = Coradoc.parse(text, format: :asciidoc)
|
|
153
|
+
# flat = Coradoc.resolve_includes(doc, base_dir: Dir.pwd)
|
|
154
|
+
def resolve_includes(document, base_dir:,
|
|
155
|
+
missing_include: :error,
|
|
156
|
+
max_depth: Coradoc::ResolveIncludes::DEFAULT_MAX_DEPTH,
|
|
157
|
+
allow_unsafe: false,
|
|
158
|
+
resolver: nil)
|
|
159
|
+
resolver = Coradoc::IncludeResolver.coerce(
|
|
160
|
+
resolver,
|
|
161
|
+
base_dir: base_dir,
|
|
162
|
+
allow_unsafe: allow_unsafe
|
|
163
|
+
)
|
|
164
|
+
Coradoc::ResolveIncludes.call(
|
|
165
|
+
document,
|
|
166
|
+
resolver: resolver,
|
|
167
|
+
base_dir: base_dir,
|
|
168
|
+
missing_include: missing_include,
|
|
169
|
+
max_depth: max_depth
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Rewrite every link/xref target in a parsed document.
|
|
174
|
+
#
|
|
175
|
+
# Walks the document tree and invokes the supplied rewriter for each
|
|
176
|
+
# link and cross-reference target. The original document is never
|
|
177
|
+
# mutated — a NEW document is returned.
|
|
178
|
+
#
|
|
179
|
+
# Verbatim blocks (+SourceBlock+, +ListingBlock+, +LiteralBlock+,
|
|
180
|
+
# +PassBlock+, +StemBlock+) are skipped entirely so link-shaped text
|
|
181
|
+
# inside code/math bodies is never rewritten.
|
|
182
|
+
#
|
|
183
|
+
# The rewriter responds to +#call(target:, kind:, context:)+ and
|
|
184
|
+
# returns the new target String. +kind+ is +:link+ or +:xref+; the
|
|
185
|
+
# block form is supported for one-liners.
|
|
186
|
+
#
|
|
187
|
+
# @param document [Coradoc::CoreModel::Base] parsed document
|
|
188
|
+
# @param rewriter [#call, nil] callable rewriter; ignored when a block is given
|
|
189
|
+
# @return [Coradoc::CoreModel::Base] new document with rewritten targets
|
|
190
|
+
#
|
|
191
|
+
# @example Canonicalize snake_case targets to kebab-case
|
|
192
|
+
# doc = Coradoc.parse(adoc, format: :asciidoc)
|
|
193
|
+
# rewritten = Coradoc.rewrite_links(doc) do |target:, kind:, **|
|
|
194
|
+
# target.tr('_', '-')
|
|
195
|
+
# end
|
|
196
|
+
def rewrite_links(document, rewriter: nil, &block)
|
|
197
|
+
Coradoc::LinkRewriter.rewrite(document, rewriter: rewriter, &block)
|
|
198
|
+
end
|
|
199
|
+
|
|
118
200
|
# Convert document text from one format to another
|
|
119
201
|
#
|
|
120
202
|
# This is the main entry point for format conversion. It handles the
|
|
@@ -459,6 +541,9 @@ module Coradoc
|
|
|
459
541
|
autoload :DocumentManipulator, "#{__dir__}/document_manipulator"
|
|
460
542
|
autoload :Visitor, "#{__dir__}/visitor"
|
|
461
543
|
autoload :PerformanceRegression, "#{__dir__}/performance_regression"
|
|
544
|
+
autoload :IncludeResolver, "#{__dir__}/include_resolver"
|
|
545
|
+
autoload :IncludeSelectors, "#{__dir__}/include_selectors"
|
|
546
|
+
autoload :ResolveIncludes, "#{__dir__}/resolve_includes"
|
|
462
547
|
end
|
|
463
548
|
|
|
464
549
|
# Format gems self-register via Coradoc.register_format when they are required.
|
|
@@ -41,6 +41,28 @@ module Coradoc
|
|
|
41
41
|
# @return [Array<MetadataEntry>] additional metadata entries
|
|
42
42
|
attribute :metadata_entries, MetadataEntry, collection: true
|
|
43
43
|
|
|
44
|
+
# Construct an instance and yield it for in-place mutation.
|
|
45
|
+
#
|
|
46
|
+
# This is the programmatic-construction entry point for CoreModel
|
|
47
|
+
# nodes. It calls +new+ exactly as a caller would, then yields
|
|
48
|
+
# the resulting instance for append-style construction. No new
|
|
49
|
+
# class hierarchy, no +method_missing+ — the block operates on
|
|
50
|
+
# the real model object.
|
|
51
|
+
#
|
|
52
|
+
# Per-class fluent helpers (e.g., +ListBlock#add_item+,
|
|
53
|
+
# +ListItem#add_text+) compose naturally with +build+:
|
|
54
|
+
#
|
|
55
|
+
# list = ListBlock.build do |ul|
|
|
56
|
+
# children.each { |c| ul.add_item { |li| li.add_link(c[:slug], text: c[:title]) } }
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
# Without a block, +build(**attrs)+ is identical to +new(**attrs)+.
|
|
60
|
+
def self.build(**attrs)
|
|
61
|
+
instance = new(**attrs)
|
|
62
|
+
yield instance if block_given?
|
|
63
|
+
instance
|
|
64
|
+
end
|
|
65
|
+
|
|
44
66
|
# Get all metadata as a hash, or a specific metadata value by key
|
|
45
67
|
# @overload metadata
|
|
46
68
|
# @return [Hash] All metadata as key-value pairs
|
|
@@ -145,6 +167,23 @@ module Coradoc
|
|
|
145
167
|
visitor.visit(self)
|
|
146
168
|
end
|
|
147
169
|
|
|
170
|
+
# True when this node counts as "real body content" for the
|
|
171
|
+
# purposes of empty-document detection and similar structural
|
|
172
|
+
# queries. Default is true; metadata and ephemeral nodes
|
|
173
|
+
# (FrontmatterBlock, CommentBlock, CommentLine) override to
|
|
174
|
+
# false. Polymorphic dispatch keeps the predicate open for
|
|
175
|
+
# future "skip-me" types — no central walker to edit (OCP).
|
|
176
|
+
def body_content?
|
|
177
|
+
true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# True when this node is structurally present but carries no
|
|
181
|
+
# visible characters. Default is false; inline text and
|
|
182
|
+
# paragraph blocks override to inspect their text content.
|
|
183
|
+
def whitespace_only?
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
|
|
148
187
|
private
|
|
149
188
|
|
|
150
189
|
# List of attributes to compare for semantic equivalence
|
|
@@ -11,7 +11,12 @@ module Coradoc
|
|
|
11
11
|
# attribute :children, Base, collection: true
|
|
12
12
|
# on each including class. This module overrides the setter to
|
|
13
13
|
# auto-wrap raw strings as TextContent, keeping all callers simple.
|
|
14
|
+
#
|
|
15
|
+
# Includes HasChildren so all mixed-content classes also satisfy
|
|
16
|
+
# the structural predicate (OCP — no subclass enumeration needed
|
|
17
|
+
# for children-based dispatch).
|
|
14
18
|
module ChildrenContent
|
|
19
|
+
include HasChildren
|
|
15
20
|
# Override the children= setter to auto-wrap strings as TextContent.
|
|
16
21
|
# This is defined via define_method so it always overrides the
|
|
17
22
|
# lutaml-generated setter, regardless of include order.
|
|
@@ -10,42 +10,89 @@ module Coradoc
|
|
|
10
10
|
# No other code in any gem may call YAML directly for frontmatter.
|
|
11
11
|
# This isolates permitted-classes configuration and error handling
|
|
12
12
|
# in one MECE location (DRY).
|
|
13
|
+
#
|
|
14
|
+
# The Codec emits flat YAML — values rendered with their natural
|
|
15
|
+
# YAML type. This is what Jekyll, Hugo, VitePress, VuePress, 11ty
|
|
16
|
+
# and every SSG expects: +title: Foo+ / +date: 2024-01-01+.
|
|
17
|
+
# Round-trip fidelity for typed values (Date, Time, Symbol) is
|
|
18
|
+
# preserved by Psych's permitted-classes mechanism, not by a
|
|
19
|
+
# custom discriminator scheme.
|
|
20
|
+
#
|
|
21
|
+
# For the typed-tree representation used by the coradoc-mirror JSON
|
|
22
|
+
# pipeline, see +Coradoc::Mirror::Node::FrontmatterValue+ and
|
|
23
|
+
# +Coradoc::Mirror::Handlers::Frontmatter+. The typed-tree concern
|
|
24
|
+
# lives in the mirror gem; this Codec stays focused on YAML.
|
|
13
25
|
module Codec
|
|
14
26
|
PERMITTED_CLASSES = [Date, Time, DateTime, Symbol].freeze
|
|
15
27
|
|
|
16
28
|
class << self
|
|
17
|
-
# Parse
|
|
18
|
-
#
|
|
19
|
-
#
|
|
29
|
+
# Parse YAML text into a FrontmatterBlock. Returns an empty
|
|
30
|
+
# FrontmatterBlock on malformed YAML or non-Hash payload.
|
|
31
|
+
# Logs a warning so the conversion pipeline can surface the
|
|
32
|
+
# skip rather than silently dropping user-authored content.
|
|
20
33
|
def from_yaml(yaml_text)
|
|
21
34
|
return FrontmatterBlock.new if yaml_text.nil? || yaml_text.strip.empty?
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
aliases: true
|
|
27
|
-
)
|
|
28
|
-
return FrontmatterBlock.new unless parsed.is_a?(Hash)
|
|
29
|
-
|
|
30
|
-
schema = parsed['$schema']
|
|
31
|
-
data = parsed.except('$schema')
|
|
32
|
-
FrontmatterBlock.new(schema: schema&.to_s, data: data)
|
|
33
|
-
rescue YAML::SyntaxError, Psych::DisallowedClass
|
|
36
|
+
build_from_loaded(load_yaml(yaml_text))
|
|
37
|
+
rescue YAML::SyntaxError, Psych::DisallowedClass => e
|
|
38
|
+
Coradoc::Logger.warn("frontmatter parse failed: #{e.message}")
|
|
34
39
|
FrontmatterBlock.new
|
|
35
40
|
end
|
|
36
41
|
|
|
42
|
+
# Build a FrontmatterBlock from a Ruby hash with native-typed
|
|
43
|
+
# values (String, Integer, Date, …). Returns an empty block
|
|
44
|
+
# for non-Hash input.
|
|
45
|
+
def from_hash(hash)
|
|
46
|
+
return FrontmatterBlock.new unless hash.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
build_from_loaded(hash)
|
|
49
|
+
end
|
|
50
|
+
|
|
37
51
|
# Serialize a FrontmatterBlock to canonical YAML text.
|
|
38
|
-
# Does NOT include leading/trailing
|
|
39
|
-
# wraps the output.
|
|
52
|
+
# Does NOT include leading/trailing +---+ delimiters; the
|
|
53
|
+
# caller wraps the output. Returns +''+ for empty blocks.
|
|
40
54
|
def to_yaml(block)
|
|
41
55
|
return '' unless block.is_a?(FrontmatterBlock)
|
|
42
56
|
|
|
57
|
+
payload = flat_tree(block)
|
|
58
|
+
return '' if payload.empty?
|
|
59
|
+
|
|
60
|
+
YAML.dump(payload).delete_prefix("---\n").delete_suffix("\n...")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Return the frontmatter as a native-typed Ruby hash.
|
|
64
|
+
# +$schema+ is included when present.
|
|
65
|
+
def to_hash(block)
|
|
66
|
+
return {} unless block.is_a?(FrontmatterBlock)
|
|
67
|
+
|
|
68
|
+
flat_tree(block)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def load_yaml(yaml_text)
|
|
74
|
+
YAML.safe_load(
|
|
75
|
+
yaml_text,
|
|
76
|
+
permitted_classes: PERMITTED_CLASSES,
|
|
77
|
+
aliases: true
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_from_loaded(loaded)
|
|
82
|
+
return FrontmatterBlock.new unless loaded.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
schema = loaded['$schema']
|
|
85
|
+
FrontmatterBlock.new(
|
|
86
|
+
schema: schema&.to_s,
|
|
87
|
+
data: loaded.except('$schema')
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def flat_tree(block)
|
|
43
92
|
tree = {}
|
|
44
93
|
tree['$schema'] = block.schema if block.schema
|
|
45
94
|
tree.merge!(block.data || {})
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
YAML.dump(tree).delete_prefix("---\n").delete_suffix("\n...")
|
|
95
|
+
tree
|
|
49
96
|
end
|
|
50
97
|
end
|
|
51
98
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module CoreModel
|
|
5
|
+
class FrontmatterBlock
|
|
6
|
+
# Single typed value node in a FrontmatterBlock entry tree.
|
|
7
|
+
#
|
|
8
|
+
# Replaces the previous `attribute :data, :hash` representation.
|
|
9
|
+
# Each value carries a `value_type` discriminator plus one populated
|
|
10
|
+
# slot matching that type. Container types (`array`, `map`) hold
|
|
11
|
+
# nested FrontmatterValue / FrontmatterEntry children.
|
|
12
|
+
#
|
|
13
|
+
# Supported value types (mirror what YAML.safe_load returns given
|
|
14
|
+
# the Codec's PERMITTED_CLASSES):
|
|
15
|
+
#
|
|
16
|
+
# scalar -> string, integer, float, boolean, date, datetime, symbol, nil
|
|
17
|
+
# container -> array, map
|
|
18
|
+
#
|
|
19
|
+
# Adding a new scalar type is purely additive: declare a new typed
|
|
20
|
+
# slot and extend the case in Codec::ValueBridge (OCP).
|
|
21
|
+
class FrontmatterValue < Base
|
|
22
|
+
SCALAR_TYPES = %w[
|
|
23
|
+
string integer float boolean date datetime symbol nil
|
|
24
|
+
].freeze
|
|
25
|
+
CONTAINER_TYPES = %w[array map].freeze
|
|
26
|
+
ALL_TYPES = (SCALAR_TYPES + CONTAINER_TYPES).freeze
|
|
27
|
+
|
|
28
|
+
attribute :value_type, :string
|
|
29
|
+
|
|
30
|
+
# Scalar slots — exactly one populated, selected by value_type.
|
|
31
|
+
attribute :string_value, :string
|
|
32
|
+
attribute :integer_value, :integer
|
|
33
|
+
attribute :float_value, :float
|
|
34
|
+
attribute :boolean_value, :boolean
|
|
35
|
+
attribute :date_value, :date
|
|
36
|
+
attribute :datetime_value, :date_time
|
|
37
|
+
attribute :symbol_value, :symbol
|
|
38
|
+
|
|
39
|
+
# Container slots — populated when value_type is array/map.
|
|
40
|
+
attribute :items, FrontmatterValue, collection: true
|
|
41
|
+
attribute :entries, FrontmatterEntry, collection: true
|
|
42
|
+
|
|
43
|
+
# Convenience: return the Ruby-native scalar value for this node,
|
|
44
|
+
# or nil for containers / nil-typed values. Used by callers that
|
|
45
|
+
# don't care about the type discriminator.
|
|
46
|
+
def ruby_value
|
|
47
|
+
case value_type
|
|
48
|
+
when 'string' then string_value
|
|
49
|
+
when 'integer' then integer_value
|
|
50
|
+
when 'float' then float_value
|
|
51
|
+
when 'boolean' then boolean_value
|
|
52
|
+
when 'date' then date_value
|
|
53
|
+
when 'datetime' then datetime_value
|
|
54
|
+
when 'symbol' then symbol_value
|
|
55
|
+
when 'nil' then nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -50,6 +50,10 @@ module Coradoc
|
|
|
50
50
|
schema.nil? && (data.nil? || data.empty?)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def body_content?
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
53
57
|
# Sub-namespaces (Codec, SchemaResolver, FieldTransform, TextSplitter)
|
|
54
58
|
# live under FrontmatterBlock and autoload lazily.
|
|
55
59
|
autoload :Codec, "#{__dir__}/frontmatter/codec"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module CoreModel
|
|
5
|
+
# Marker module for "this model exposes a +children+ collection".
|
|
6
|
+
#
|
|
7
|
+
# Including this is the canonical way to opt a CoreModel class into
|
|
8
|
+
# children-based traversal. Downstream code (e.g. mirror's
|
|
9
|
+
# CoreModelToMirror#element_children) dispatches on +is_a?(HasChildren)+
|
|
10
|
+
# rather than enumerating subclasses, so adding a new children-bearing
|
|
11
|
+
# class is purely additive (OCP).
|
|
12
|
+
#
|
|
13
|
+
# ChildrenContent (the mixed-content auto-wrap behavior) includes
|
|
14
|
+
# HasChildren, so every class that mixes in ChildrenContent also
|
|
15
|
+
# satisfies HasChildren. Classes that carry typed block children
|
|
16
|
+
# only (StructuralElement, etc.) include HasChildren directly.
|
|
17
|
+
module HasChildren
|
|
18
|
+
def has_children?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module CoreModel
|
|
5
|
+
# First-class include directive node in the canonical document model.
|
|
6
|
+
#
|
|
7
|
+
# An include directive is a LINK from one document to another file.
|
|
8
|
+
# Parsing preserves these nodes verbatim — no file I/O happens during
|
|
9
|
+
# parse. The result is a text graph: a document referencing other
|
|
10
|
+
# documents via Include edges.
|
|
11
|
+
#
|
|
12
|
+
# Splicing the linked content inline is an explicit, separate step:
|
|
13
|
+
# +Coradoc.resolve_includes(doc, base_dir:)+ walks the tree and
|
|
14
|
+
# replaces each Include node with the parsed content of its target,
|
|
15
|
+
# recursing into the result.
|
|
16
|
+
#
|
|
17
|
+
# This separation lets callers:
|
|
18
|
+
# - inspect the graph before deciding to flatten
|
|
19
|
+
# - resolve with different base dirs / resolvers without re-parsing
|
|
20
|
+
# - treat includes as external links (e.g. when parsing a site)
|
|
21
|
+
#
|
|
22
|
+
# Attributes:
|
|
23
|
+
# target String path or URL as authored
|
|
24
|
+
# options IncludeOptions parsed selectors (tags/lines/leveloffset/indent/encoding)
|
|
25
|
+
# raw_options String original bracket body, preserved for verbatim round-trip
|
|
26
|
+
# line_break String trailing line break, default "\n"
|
|
27
|
+
#
|
|
28
|
+
# The node is block-level: it appears in the +content+ / +children+
|
|
29
|
+
# array of any block container (Document, Section, Paragraph, List
|
|
30
|
+
# item, Table cell, etc.) alongside other block-level nodes.
|
|
31
|
+
class Include < Base
|
|
32
|
+
attribute :target, :string
|
|
33
|
+
attribute :options, Coradoc::CoreModel::IncludeOptions,
|
|
34
|
+
default: -> { Coradoc::CoreModel::IncludeOptions.new }
|
|
35
|
+
attribute :raw_options, :string, default: -> { '' }
|
|
36
|
+
attribute :line_break, :string, default: -> { "\n" }
|
|
37
|
+
|
|
38
|
+
def self.semantic_type
|
|
39
|
+
:include
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module CoreModel
|
|
5
|
+
# A leveloffset value parsed from an include directive.
|
|
6
|
+
#
|
|
7
|
+
# asciidoctor supports two forms:
|
|
8
|
+
# leveloffset=+N → relative shift (heading.level += N)
|
|
9
|
+
# leveloffset=-N → relative shift (heading.level -= N)
|
|
10
|
+
# leveloffset=N → absolute set (heading.level = N)
|
|
11
|
+
#
|
|
12
|
+
# The parsed form keeps the mode and delta separate so that the
|
|
13
|
+
# selector that applies the offset does not need to re-parse the
|
|
14
|
+
# string each time it walks a section (DRY).
|
|
15
|
+
class IncludeLevelOffset < Base
|
|
16
|
+
# "relative" (+N/-N) or "absolute" (bare N).
|
|
17
|
+
attribute :mode, :string
|
|
18
|
+
|
|
19
|
+
# Signed integer for relative shifts; the absolute target level
|
|
20
|
+
# for absolute mode.
|
|
21
|
+
attribute :delta, :integer
|
|
22
|
+
|
|
23
|
+
# Construct from a raw asciidoctor-style string ("+2", "-1", "3").
|
|
24
|
+
# Returns nil if the input is nil or unparsable.
|
|
25
|
+
#
|
|
26
|
+
# @param raw [String, nil]
|
|
27
|
+
# @return [IncludeLevelOffset, nil]
|
|
28
|
+
def self.parse(raw)
|
|
29
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
30
|
+
|
|
31
|
+
matched_offset(raw.strip)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Apply this offset to a heading level (1-indexed asciidoctor level).
|
|
35
|
+
#
|
|
36
|
+
# @param level [Integer] original section level
|
|
37
|
+
# @return [Integer] new section level, clamped to >= 0
|
|
38
|
+
def apply(level)
|
|
39
|
+
case mode
|
|
40
|
+
when 'relative' then [level + delta, 0].max
|
|
41
|
+
when 'absolute' then [delta, 0].max
|
|
42
|
+
else level
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Render back to the asciidoctor wire form ("+2", "-1", "3").
|
|
47
|
+
#
|
|
48
|
+
# @return [String]
|
|
49
|
+
def to_s
|
|
50
|
+
case mode
|
|
51
|
+
when 'relative' then format('%+d', delta)
|
|
52
|
+
when 'absolute' then delta.to_s
|
|
53
|
+
else ''
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def matched_offset(trimmed)
|
|
61
|
+
%r{\A(?<sign>[+-]?)(?<digits>\d+)\z}.match(trimmed) do |m|
|
|
62
|
+
digits = m[:digits].to_i
|
|
63
|
+
signed = m[:sign] == '-' ? -digits : digits
|
|
64
|
+
mode = m[:sign].empty? ? 'absolute' : 'relative'
|
|
65
|
+
new(mode: mode, delta: signed)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|