coradoc 2.0.22 → 2.0.24

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/coradoc.rb +35 -402
  3. data/lib/coradoc/core_model/base.rb +39 -0
  4. data/lib/coradoc/core_model/comment_block.rb +4 -0
  5. data/lib/coradoc/core_model/comment_line.rb +4 -0
  6. data/lib/coradoc/core_model/frontmatter/codec.rb +66 -19
  7. data/lib/coradoc/core_model/frontmatter.rb +4 -0
  8. data/lib/coradoc/core_model/inline_content.rb +77 -0
  9. data/lib/coradoc/core_model/inline_element.rb +52 -26
  10. data/lib/coradoc/core_model/list_block.rb +17 -0
  11. data/lib/coradoc/core_model/list_item.rb +21 -0
  12. data/lib/coradoc/core_model/output_artifact.rb +48 -0
  13. data/lib/coradoc/core_model/paragraph_block.rb +4 -0
  14. data/lib/coradoc/core_model/stem_block.rb +21 -0
  15. data/lib/coradoc/core_model/structural_element.rb +41 -0
  16. data/lib/coradoc/core_model/text_content.rb +4 -0
  17. data/lib/coradoc/core_model.rb +3 -0
  18. data/lib/coradoc/dispatch.rb +95 -0
  19. data/lib/coradoc/format_catalog.rb +83 -0
  20. data/lib/coradoc/introspection/element_counter.rb +48 -0
  21. data/lib/coradoc/introspection.rb +72 -0
  22. data/lib/coradoc/link_rewriter/identity.rb +13 -0
  23. data/lib/coradoc/link_rewriter/visitor.rb +157 -0
  24. data/lib/coradoc/link_rewriter.rb +37 -0
  25. data/lib/coradoc/pipeline.rb +108 -0
  26. data/lib/coradoc/relative_path.rb +32 -0
  27. data/lib/coradoc/version.rb +1 -1
  28. data/lib/coradoc.rb +7 -13
  29. metadata +13 -9
  30. data/lib/coradoc/document_builder.rb +0 -184
  31. data/lib/coradoc/document_manipulator.rb +0 -203
  32. data/lib/coradoc/input.rb +0 -22
  33. data/lib/coradoc/output.rb +0 -22
  34. data/lib/coradoc/processor_registry.rb +0 -50
  35. data/lib/coradoc/serializer/registry.rb +0 -150
  36. data/lib/coradoc/transform/base.rb +0 -21
  37. data/lib/coradoc/transform.rb +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a33595a0aa6205f5a5d1adabd8ede0dc8f3d4237c2376a910800822400af2f59
4
- data.tar.gz: ecb898a27a6cb1540b553fdb3aa97bef73077a85a13175099738fefe3307faf2
3
+ metadata.gz: bcce76c634ae51a857397a301fc2638950cb25e1a97048589ee5510f8a7aee40
4
+ data.tar.gz: 6cd3b391ae83e360ed282b40f778dad1c4290202b386b457b881e264ddca464e
5
5
  SHA512:
6
- metadata.gz: db01c4272054b9f2907ba0af6444e741c6d3151bf916f22aeb11dc25380ea7b0a9d63f88ba2fe2eeacd07bfcf08e50a30b8eeac914605ac394d4bccc9d3d282a
7
- data.tar.gz: c40699fd199f0f74a8f657ff7ee26036703e0865862919f2787acd52f532e40c825687cd52c4ef4c4a01337f3819121813f6f291dcd76d264c27bd916f1bc823
6
+ metadata.gz: f424dfb3267d2497dc46b9e764fab868083add0ef5cd82163aa89a35b884e75b81c7426a47d9c0b81abd15659dcc15b49f6f309b786848cba7c0aed42557cf3a
7
+ data.tar.gz: 07fe25f772cf02b7673f25f4bb41e9abb65e25631898af071407eb68961bba9c2a74c19c7d20d85544c722a4ad07404c4846d389278fb989d2a186caab36bdbb
@@ -52,402 +52,67 @@ module Coradoc
52
52
  # @see Coradoc::UnsupportedFormatError Unsupported format errors
53
53
 
54
54
  class << self
55
- # Get the format registry
56
- #
57
- # @return [Registry] the format registry
58
- def registry
59
- @registry ||= Registry.new
60
- end
55
+ # ---- Format registry (delegates to FormatCatalog) ----
56
+
57
+ def registry = FormatCatalog.registry
61
58
 
62
- # Register a format gem
63
- #
64
- # @param format_name [Symbol] the format name (e.g., :asciidoc, :html, :markdown)
65
- # @param format_module [Module] the format module
66
- # @param options [Hash] optional configuration (e.g., extensions: [])
67
- # @return [void]
68
59
  def register_format(format_name, format_module, **options)
69
- format_module.extend(FormatModule::Interface) unless format_module.is_a?(FormatModule::Interface)
70
- registry.register(format_name, format_module, options)
71
- FormatModule.validate!(format_module, format_name)
60
+ FormatCatalog.register_format(format_name, format_module, **options)
72
61
  end
73
62
 
74
- # Get a registered format
75
- #
76
- # @param format_name [Symbol] the format name
77
- # @return [Module, nil] the format module or nil if not found
78
- def get_format(format_name)
79
- registry.get(format_name)
80
- end
63
+ def get_format(format_name) = FormatCatalog.get_format(format_name)
81
64
 
82
- # List all registered formats
83
- #
84
- # @return [Array<Symbol>] list of registered format names
85
- def registered_formats
86
- registry.list
87
- end
65
+ def registered_formats = FormatCatalog.registered_formats
88
66
 
89
- # Parse text to a document model.
90
- #
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.
99
- #
100
- # @param text [String] the document text to parse
101
- # @param format [Symbol] the source format (:asciidoc, :html, :markdown)
102
- # @return [Coradoc::CoreModel::Base, Object] the parsed document model
103
- # @raise [UnsupportedFormatError] if the format is not registered
104
- #
105
- # @example Parse — Include directives stay as link nodes
106
- # doc = Coradoc.parse(text, format: :asciidoc)
107
- #
108
- # @example Then flatten — splice included files inline
109
- # flat = Coradoc.resolve_includes(doc, base_dir: Dir.pwd)
110
- def parse(text, format:)
111
- format_module = get_format(format)
112
- unless format_module
113
- raise UnsupportedFormatError,
114
- "Format '#{format}' is not registered. " \
115
- "Available formats: #{registered_formats.join(', ')}"
116
- end
67
+ # ---- Pipeline (delegates to Pipeline) ----
117
68
 
118
- text = Hooks.invoke(:before_parse, text, format: format)
119
- result = format_module.parse_to_core(text)
120
- Hooks.invoke(:after_parse, result, format: format)
121
- end
69
+ def parse(text, format:) = Pipeline.parse(text, format: format)
122
70
 
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
71
+ def resolve_includes(document, **) = Pipeline.resolve_includes(document, **)
172
72
 
173
- # Convert document text from one format to another
174
- #
175
- # This is the main entry point for format conversion. It handles the
176
- # complete pipeline: parse -> transform to CoreModel -> transform to target -> serialize
177
- #
178
- # @param text [String] the source document text
179
- # @param from [Symbol] the source format (:asciidoc, :html, :markdown)
180
- # @param to [Symbol] the target format (:asciidoc, :html, :markdown)
181
- # @param options [Hash] additional options for the conversion
182
- # @return [String] the converted document text
183
- # @raise [UnsupportedFormatError] if a format is not registered
184
- #
185
- # @example Convert AsciiDoc to HTML
186
- # html = Coradoc.convert(adoc_text, from: :asciidoc, to: :html)
187
- #
188
- # @example Convert HTML to AsciiDoc
189
- # adoc = Coradoc.convert(html_text, from: :html, to: :asciidoc)
190
- def convert(text, from:, to:, **)
191
- # Parse to CoreModel
192
- core = parse(text, format: from)
193
-
194
- # Convert to target format
195
- serialize(core, to: to, **)
196
- end
73
+ def rewrite_links(...) = Pipeline.rewrite_links(...)
197
74
 
198
- # Transform a model to CoreModel
199
- #
200
- # @param model [Object] a format-specific model
201
- # @return [Coradoc::CoreModel::Base] the CoreModel representation
202
- def to_core(model)
203
- return model if model.is_a?(CoreModel::Base)
75
+ def convert(text, **) = Pipeline.convert(text, **)
204
76
 
205
- registry.each_value do |format_module|
206
- next unless format_module.handles_model?(model)
77
+ def to_core(model) = Pipeline.to_core(model)
207
78
 
208
- return format_module.to_core(model)
209
- end
79
+ def serialize(model, **) = Pipeline.serialize(model, **)
210
80
 
211
- raise TransformationError, "No transformer found for #{model.class}"
212
- end
81
+ def build(...) = Pipeline.build(...)
213
82
 
214
- # Serialize a CoreModel to a specific format
215
- #
216
- # @param model [Coradoc::CoreModel::Base] the CoreModel to serialize
217
- # @param to [Symbol] the target format
218
- # @param options [Hash] additional options
219
- # @return [String] the serialized document
220
- def serialize(model, to:, **)
221
- format_module = get_format(to)
222
- raise UnsupportedFormatError, "Format '#{to}' is not registered" unless format_module
223
-
224
- model = Hooks.invoke(:before_serialize, model, format: to)
225
- result = format_module.serialize(model, **)
226
- Hooks.invoke(:after_serialize, result, format: to)
227
- end
83
+ def parse_file(path, **) = Pipeline.parse_file(path, **)
228
84
 
229
- # Create a DocumentManipulator for chainable operations
230
- #
231
- # @param document [Coradoc::CoreModel::Base] the document to manipulate
232
- # @return [DocumentManipulator] a new manipulator instance
233
- #
234
- # @example Chainable document manipulation
235
- # html = Coradoc.manipulate(doc)
236
- # .transform_text(&:upcase)
237
- # .add_toc
238
- # .to_html
239
- def manipulate(document)
240
- DocumentManipulator.new(document)
241
- end
85
+ def convert_file(path, **) = Pipeline.convert_file(path, **)
242
86
 
243
- # Detect format from a file extension
244
- #
245
- # @param filename [String] Filename or extension to detect
246
- # @return [Symbol, nil] the detected format symbol
247
- #
248
- # @example
249
- # Coradoc.detect_format("document.adoc") # => :asciidoc
250
- # Coradoc.detect_format("file.md") # => :markdown
251
- def detect_format(filename)
252
- ext = File.extname(filename).downcase
253
- registry.each_key do |name|
254
- opts = registry.options_for(name)
255
- return name if opts[:extensions]&.include?(ext)
256
- end
257
- nil
258
- end
87
+ # ---- Format detection (delegates to FormatCatalog) ----
259
88
 
260
- # Parse a document from a file path
261
- #
262
- # Handles both text formats (reads file content) and binary formats
263
- # (passes file path directly to the format module).
264
- #
265
- # @param path [String] path to the document file
266
- # @param format [Symbol, nil] source format (auto-detected if nil)
267
- # @return [Coradoc::CoreModel::Base] the parsed CoreModel document
268
- # @raise [UnsupportedFormatError] if format is not detected or registered
269
- #
270
- # @example
271
- # doc = Coradoc.parse_file("document.adoc")
272
- # doc = Coradoc.parse_file("report.docx", format: :docx)
273
- def parse_file(path, format: nil)
274
- raise FileNotFoundError, path unless File.exist?(path)
89
+ def detect_format(filename) = FormatCatalog.detect_format(filename)
275
90
 
276
- source_format = format || detect_format(path)
277
- raise UnsupportedFormatError, "Could not detect format for: #{path}" unless source_format
91
+ def binary_format?(format) = FormatCatalog.binary_format?(format)
278
92
 
279
- format_module = get_format(source_format)
280
- raise UnsupportedFormatError, "Format '#{source_format}' is not registered" unless format_module
93
+ def normalize_format(name) = FormatCatalog.normalize_format(name)
281
94
 
282
- if binary_format?(source_format)
283
- format_module.parse_to_core(path)
284
- else
285
- content = File.read(path)
286
- content = Hooks.invoke(:before_parse, content, format: source_format)
287
- result = format_module.parse_file_to_core(path, content)
288
- Hooks.invoke(:after_parse, result, format: source_format)
289
- end
290
- end
95
+ def serialize_format?(format) = FormatCatalog.serialize_format?(format)
291
96
 
292
- # Convert a file from one format to another
293
- #
294
- # @param path [String] path to the source document file
295
- # @param from [Symbol, nil] source format (auto-detected if nil)
296
- # @param to [Symbol] target format
297
- # @param options [Hash] additional options
298
- # @return [String] the converted document text
299
- #
300
- # @example
301
- # html = Coradoc.convert_file("document.adoc", to: :html)
302
- # adoc = Coradoc.convert_file("report.docx", to: :asciidoc)
303
- def convert_file(path, to:, from: nil, **)
304
- source_format = from || detect_format(path)
305
- raise UnsupportedFormatError, "Could not detect format for: #{path}" unless source_format
306
-
307
- core = parse_file(path, format: source_format)
308
- serialize(core, to: to, **)
309
- end
97
+ def parse_format?(format) = FormatCatalog.parse_format?(format)
310
98
 
311
- # Check if a format requires binary (file path) input
312
- #
313
- # @param format [Symbol] the format to check
314
- # @return [Boolean] true if the format is binary
315
- def binary_format?(format)
316
- opts = registry.options_for(format)
317
- opts&.fetch(:binary, false) == true
318
- end
99
+ def format_capabilities = FormatCatalog.capabilities
319
100
 
320
- # Normalize a format name string to a symbol
321
- #
322
- # Handles common aliases like "adoc" → :asciidoc, "md" → :markdown.
323
- #
324
- # @param name [String, Symbol, nil] the format name to normalize
325
- # @return [Symbol, nil] the normalized format symbol, or nil
326
- def normalize_format(name)
327
- return nil unless name
328
-
329
- key = name.to_s.downcase
330
- registry.each_key do |fmt_name|
331
- opts = registry.options_for(fmt_name)
332
- return fmt_name if opts[:aliases]&.include?(key)
333
- end
334
- key.to_sym
335
- end
101
+ def resolve_output_format(output_file, **) = FormatCatalog.resolve_output_format(output_file, **)
336
102
 
337
- # Check if a format supports serialization (writing output)
338
- #
339
- # @param format [Symbol] the format to check
340
- # @return [Boolean] true if the format can serialize
341
- def serialize_format?(format)
342
- mod = get_format(format)
343
- return false unless mod
103
+ # ---- Introspection (delegates to Introspection) ----
344
104
 
345
- mod.serialize?
346
- end
105
+ def file_info(path) = Introspection.file_info(path)
347
106
 
348
- # Check if a format supports parsing (reading input)
349
- #
350
- # @param format [Symbol] the format to check
351
- # @return [Boolean] true if the format can parse
352
- def parse_format?(format)
353
- mod = get_format(format)
354
- return false unless mod
107
+ def validate_file(path, **) = Introspection.validate_file(path, **)
355
108
 
356
- mod.public_methods.include?(:parse_to_core) || mod.public_methods.include?(:parse)
357
- end
109
+ def document_stats(doc) = Introspection.document_stats(doc)
358
110
 
359
- # Get capability summary for all registered formats
360
- #
361
- # Returns a hash mapping each format name to its capabilities
362
- # (parse: bool, serialize: bool). Useful for CLI display and introspection.
363
- #
364
- # @return [Hash<Symbol, Hash<Symbol, Boolean>>]
365
- def format_capabilities
366
- registered_formats.each_with_object({}) do |name, caps|
367
- caps[name] = {
368
- parse: parse_format?(name),
369
- serialize: serialize_format?(name)
370
- }
371
- end
372
- end
373
-
374
- # Resolve the output format from a filename, with a default
375
- #
376
- # @param output_file [String, nil] output filename to detect from
377
- # @param default [Symbol] default format when detection fails (default: :html)
378
- # @return [Symbol] the resolved format
379
- def resolve_output_format(output_file, default: :html)
380
- return default unless output_file
111
+ def describe_element(elem) = Introspection.describe_element(elem)
381
112
 
382
- detect_format(output_file) || default
383
- end
113
+ # ---- Utilities that stay on the top-level façade ----
384
114
 
385
- # Get file metadata for display
386
- #
387
- # @param path [String] path to the file
388
- # @return [Hash] metadata including :size, :format, and :lines (for text formats)
389
- def file_info(path)
390
- fmt = detect_format(path)
391
- info = { size: File.size(path), format: fmt }
392
- info[:lines] = File.foreach(path).count unless binary_format?(fmt)
393
- info
394
- end
395
-
396
- # Validate a document file
397
- #
398
- # Parses the file and validates against auto-generated schema.
399
- # Returns a Coradoc::Validation::Result.
400
- #
401
- # @param path [String] path to the document file
402
- # @param format [Symbol, nil] source format (auto-detected if nil)
403
- # @return [Coradoc::Validation::Result] validation result
404
- # @raise [UnsupportedFormatError] if format is not detected or registered
405
- def validate_file(path, format: nil)
406
- doc = parse_file(path, format: format)
407
-
408
- schema = Validation::SchemaGenerator.generate(doc.class)
409
- return schema.validate(doc) if schema
410
-
411
- Validation::Result.new
412
- end
413
-
414
- # Gather statistics about a parsed document
415
- #
416
- # @param doc [CoreModel::Base] parsed document
417
- # @return [Hash] statistics including element counts, title, etc.
418
- def document_stats(doc)
419
- stats = {}
420
-
421
- stats[:title] = doc.title if doc.title
422
-
423
- if doc.is_a?(CoreModel::StructuralElement)
424
- stats[:child_count] = count_elements(doc)
425
- stats[:element_counts] = count_element_types(doc)
426
- end
427
-
428
- stats
429
- end
430
-
431
- # Describe an element for display
432
- #
433
- # @param elem [Object] element to describe
434
- # @return [String] human-readable description
435
- def describe_element(elem)
436
- return elem.to_s unless elem.is_a?(CoreModel::Base)
437
-
438
- type = elem.class.name.split('::').last
439
- if elem.title
440
- "#{type}: #{elem.title}"
441
- elsif elem.is_a?(CoreModel::Block) && elem.content
442
- preview = elem.content.to_s[0..50]
443
- preview += '...' if elem.content.to_s.length > 50
444
- "#{type}: #{preview}"
445
- else
446
- type
447
- end
448
- end
449
-
450
- # Strip unicode whitespace from a string
115
+ # Strip unicode whitespace from a string.
451
116
  #
452
117
  # @param string [String] the string to strip
453
118
  # @param only [Symbol, nil] what to strip: :begin, :end, or nil for both
@@ -464,38 +129,6 @@ module Coradoc
464
129
  string.sub(/^\p{Zs}+/, '').sub(/\p{Zs}+$/, '')
465
130
  end
466
131
  end
467
-
468
- private
469
-
470
- def count_elements(doc)
471
- return 0 unless doc.is_a?(CoreModel::StructuralElement)
472
-
473
- doc.children.sum do |child|
474
- 1 + (child.is_a?(CoreModel::StructuralElement) ? count_elements(child) : 0)
475
- end
476
- end
477
-
478
- def count_element_types(doc)
479
- counts = Hash.new(0)
480
- visitor = Class.new(Visitor::Base) do
481
- define_method(:visit) do |element|
482
- if element.is_a?(CoreModel::Base)
483
- has_element_type = element.is_a?(CoreModel::StructuralElement) || element.is_a?(CoreModel::Block)
484
- type_key = if has_element_type && element.element_type
485
- element.element_type
486
- else
487
- element.class.name.split('::').last
488
- .gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, '')
489
- end
490
- counts[type_key] += 1
491
- end
492
- super(element)
493
- end
494
- end.new
495
- visitor.visit(doc)
496
- counts.reject! { |_, v| v.zero? }
497
- counts
498
- end
499
132
  end
500
133
 
501
134
  autoload :Error, "#{__dir__}/errors"
@@ -508,15 +141,15 @@ module Coradoc
508
141
  autoload :FormatModule, "#{__dir__}/format_module"
509
142
  autoload :CoreModel, "#{__dir__}/core_model"
510
143
  autoload :Registry, "#{__dir__}/registry"
511
- autoload :Transform, "#{__dir__}/transform"
512
- autoload :Input, "#{__dir__}/input"
513
- autoload :Output, "#{__dir__}/output"
514
- autoload :DocumentManipulator, "#{__dir__}/document_manipulator"
515
144
  autoload :Visitor, "#{__dir__}/visitor"
516
145
  autoload :PerformanceRegression, "#{__dir__}/performance_regression"
517
146
  autoload :IncludeResolver, "#{__dir__}/include_resolver"
518
147
  autoload :IncludeSelectors, "#{__dir__}/include_selectors"
519
148
  autoload :ResolveIncludes, "#{__dir__}/resolve_includes"
149
+ autoload :Pipeline, "#{__dir__}/pipeline"
150
+ autoload :FormatCatalog, "#{__dir__}/format_catalog"
151
+ autoload :Introspection, "#{__dir__}/introspection"
152
+ autoload :Dispatch, "#{__dir__}/dispatch"
520
153
  end
521
154
 
522
155
  # 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
@@ -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