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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/cli.rb +26 -1
  3. data/lib/coradoc/coradoc.rb +93 -8
  4. data/lib/coradoc/core_model/base.rb +39 -0
  5. data/lib/coradoc/core_model/children_content.rb +5 -0
  6. data/lib/coradoc/core_model/comment_block.rb +4 -0
  7. data/lib/coradoc/core_model/comment_line.rb +4 -0
  8. data/lib/coradoc/core_model/frontmatter/codec.rb +66 -19
  9. data/lib/coradoc/core_model/frontmatter/frontmatter_value.rb +61 -0
  10. data/lib/coradoc/core_model/frontmatter.rb +4 -0
  11. data/lib/coradoc/core_model/has_children.rb +23 -0
  12. data/lib/coradoc/core_model/include.rb +43 -0
  13. data/lib/coradoc/core_model/include_level_offset.rb +71 -0
  14. data/lib/coradoc/core_model/include_options.rb +100 -0
  15. data/lib/coradoc/core_model/inline_element.rb +20 -0
  16. data/lib/coradoc/core_model/list_block.rb +17 -0
  17. data/lib/coradoc/core_model/list_item.rb +21 -0
  18. data/lib/coradoc/core_model/output_artifact.rb +48 -0
  19. data/lib/coradoc/core_model/paragraph_block.rb +4 -0
  20. data/lib/coradoc/core_model/stem_block.rb +21 -0
  21. data/lib/coradoc/core_model/structural_element.rb +46 -0
  22. data/lib/coradoc/core_model/text_content.rb +4 -0
  23. data/lib/coradoc/core_model.rb +6 -0
  24. data/lib/coradoc/errors.rb +56 -0
  25. data/lib/coradoc/include_resolver/filesystem.rb +84 -0
  26. data/lib/coradoc/include_resolver.rb +67 -0
  27. data/lib/coradoc/include_selectors/indent.rb +54 -0
  28. data/lib/coradoc/include_selectors/level_offset.rb +86 -0
  29. data/lib/coradoc/include_selectors/lines.rb +60 -0
  30. data/lib/coradoc/include_selectors/tags.rb +138 -0
  31. data/lib/coradoc/include_selectors.rb +26 -0
  32. data/lib/coradoc/link_rewriter/identity.rb +13 -0
  33. data/lib/coradoc/link_rewriter/visitor.rb +157 -0
  34. data/lib/coradoc/link_rewriter.rb +37 -0
  35. data/lib/coradoc/relative_path.rb +32 -0
  36. data/lib/coradoc/resolve_includes.rb +202 -0
  37. data/lib/coradoc/version.rb +1 -1
  38. data/lib/coradoc.rb +2 -0
  39. metadata +20 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4ebd4f989cb1c3bc9c6adcede592e91c6013a29bf94c3d12319e03c5c42ab38
4
- data.tar.gz: d321f36297bb31aac76fe30a6195aa9cd1456c840dfb1888405c0e36f474c4b7
3
+ metadata.gz: 7370b3dabd4535faf0592e455d0bfd13c582b37941c411a0c4c7b86528843896
4
+ data.tar.gz: c61e126d88a2dc69c507657201b9b3e7875a7d6beffac957d5b805a81ffcc6d5
5
5
  SHA512:
6
- metadata.gz: 1bba8d765b1af164d1713f16013eddbdbc906340f37d1918777fd3ec1c6f1ec784e869155ccae100b6d4a4306bea003c4031fbf6276890608471a97d5a407bd2
7
- data.tar.gz: e76a4e152ebbc25ee03732c6870fbfbdf062d8a3e0af8423b4da9486d14483952a56a3af6b25c256a6350e60e475afa89112e25931b08b47209884ceb1e8089a
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.convert_file(file, from: source_format, to: target_format, **opts)
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
@@ -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
- # This is the main entry point for parsing documents. It automatically
92
- # selects the appropriate parser based on the format.
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 AsciiDoc
100
- # doc = Coradoc.parse("= Title\n\nContent", format: :asciidoc)
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 Parse and get CoreModel
104
- # core = Coradoc.parse(text, format: :asciidoc) # Returns CoreModel
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.
@@ -7,6 +7,10 @@ module Coradoc
7
7
  def self.semantic_type
8
8
  :comment
9
9
  end
10
+
11
+ def body_content?
12
+ false
13
+ end
10
14
  end
11
15
  end
12
16
  end
@@ -13,6 +13,10 @@ module Coradoc
13
13
  :comment_line
14
14
  end
15
15
 
16
+ def body_content?
17
+ false
18
+ end
19
+
16
20
  attribute :text, :string
17
21
  end
18
22
  end
@@ -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 a YAML string into a FrontmatterBlock.
18
- # Returns an empty FrontmatterBlock on malformed YAML (graceful
19
- # degradation body parsing continues).
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
- parsed = YAML.safe_load(
24
- yaml_text,
25
- permitted_classes: PERMITTED_CLASSES,
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 `---` delimiters; the caller
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
- return '' if tree.empty?
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