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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module CoreModel
|
|
5
|
+
# Typed options for an include directive, parsed once at construction.
|
|
6
|
+
#
|
|
7
|
+
# Each asciidoctor selector (tags / lines / leveloffset / indent /
|
|
8
|
+
# encoding) gets one typed attribute. Selectors downstream operate on
|
|
9
|
+
# the typed form, never re-parsing the raw string (DRY).
|
|
10
|
+
#
|
|
11
|
+
# tags Array<String> ["body"], [] when unspecified
|
|
12
|
+
# tags_wildcard Boolean true for tags=*
|
|
13
|
+
# tags_inverted Boolean true for tags=**
|
|
14
|
+
# lines_spec String? raw "1..2;5;7..8" — parsed by Lines selector
|
|
15
|
+
# leveloffset IncludeLevelOffset?
|
|
16
|
+
# indent Integer? 0 = strip, N = re-indent, nil = passthrough
|
|
17
|
+
# file_encoding String? passed through to resolver for File.read
|
|
18
|
+
class IncludeOptions < Base
|
|
19
|
+
attribute :tags, :string, collection: true, default: -> { [] }
|
|
20
|
+
attribute :tags_wildcard, :boolean, default: -> { false }
|
|
21
|
+
attribute :tags_inverted, :boolean, default: -> { false }
|
|
22
|
+
attribute :lines_spec, :string
|
|
23
|
+
attribute :leveloffset, Coradoc::CoreModel::IncludeLevelOffset
|
|
24
|
+
attribute :indent, :integer
|
|
25
|
+
attribute :file_encoding, :string
|
|
26
|
+
|
|
27
|
+
# Whether the lines selector is in effect. Tags are ignored when
|
|
28
|
+
# lines is set — matches asciidoctor precedence (SPEC 3.5).
|
|
29
|
+
def lines?
|
|
30
|
+
!lines_spec.nil? && !lines_spec.strip.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Whether any tag selector is in effect.
|
|
34
|
+
def tags?
|
|
35
|
+
tags_wildcard || tags_inverted || !tags.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# True when both selectors were specified (lines wins).
|
|
39
|
+
def conflict_resolved_to_lines?
|
|
40
|
+
lines? && tags?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build from a flat hash of asciidoctor-style key/value strings.
|
|
44
|
+
# Whitespace around keys and values is trimmed (SPEC 6.3).
|
|
45
|
+
#
|
|
46
|
+
# @param attrs [Hash{String=>String}] e.g. {"tags"=>"a;b", "leveloffset"=>"+2"}
|
|
47
|
+
# @return [IncludeOptions]
|
|
48
|
+
def self.from_hash(attrs)
|
|
49
|
+
new(build_args(attrs))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_args(attrs)
|
|
56
|
+
cleaned = clean_keys(attrs)
|
|
57
|
+
{
|
|
58
|
+
tags: parse_tags(cleaned['tags']),
|
|
59
|
+
tags_wildcard: wildcard?(cleaned['tags']),
|
|
60
|
+
tags_inverted: inverted?(cleaned['tags']),
|
|
61
|
+
lines_spec: cleaned['lines'],
|
|
62
|
+
leveloffset: CoreModel::IncludeLevelOffset.parse(cleaned['leveloffset']),
|
|
63
|
+
indent: parse_integer(cleaned['indent']),
|
|
64
|
+
file_encoding: cleaned['encoding']
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def clean_keys(attrs)
|
|
69
|
+
attrs.each_with_object({}) do |(k, v), h|
|
|
70
|
+
h[k.to_s.strip] = v.to_s.strip
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_tags(raw)
|
|
75
|
+
return [] if raw.nil?
|
|
76
|
+
trimmed = raw.strip
|
|
77
|
+
return [] if trimmed.empty? || trimmed == '*' || trimmed == '**'
|
|
78
|
+
|
|
79
|
+
trimmed.split(';').map(&:strip).reject(&:empty?)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def wildcard?(raw)
|
|
83
|
+
!raw.nil? && raw.strip == '*'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inverted?(raw)
|
|
87
|
+
!raw.nil? && raw.strip == '**'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_integer(raw)
|
|
91
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
92
|
+
|
|
93
|
+
Integer(raw.strip)
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -24,6 +24,18 @@ module Coradoc
|
|
|
24
24
|
self.class.format_type || format_type
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Polymorphic classification used by LinkRewriter::Visitor. Returns
|
|
28
|
+
# :link / :xref when this node carries a rewrite-able target, nil
|
|
29
|
+
# otherwise. Generic InlineElement instances defer to their
|
|
30
|
+
# resolved format_type; typed subclasses override with a literal.
|
|
31
|
+
# Keeps the visitor free of class-keyed case/when (OCP).
|
|
32
|
+
def link_kind
|
|
33
|
+
case resolve_format_type
|
|
34
|
+
when 'link' then :link
|
|
35
|
+
when 'xref' then :xref
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
27
39
|
FORMAT_TYPES = %w[
|
|
28
40
|
bold italic monospace underline strikethrough
|
|
29
41
|
subscript superscript highlight
|
|
@@ -99,12 +111,20 @@ module Coradoc
|
|
|
99
111
|
def self.format_type
|
|
100
112
|
'link'
|
|
101
113
|
end
|
|
114
|
+
|
|
115
|
+
def link_kind
|
|
116
|
+
:link
|
|
117
|
+
end
|
|
102
118
|
end
|
|
103
119
|
|
|
104
120
|
class CrossReferenceElement < InlineElement
|
|
105
121
|
def self.format_type
|
|
106
122
|
'xref'
|
|
107
123
|
end
|
|
124
|
+
|
|
125
|
+
def link_kind
|
|
126
|
+
:xref
|
|
127
|
+
end
|
|
108
128
|
end
|
|
109
129
|
|
|
110
130
|
class StemElement < InlineElement
|
|
@@ -108,6 +108,23 @@ module Coradoc
|
|
|
108
108
|
# @return [Array<ListItem>] collection of list items
|
|
109
109
|
attribute :items, ListItem, collection: true
|
|
110
110
|
|
|
111
|
+
# -- Fluent construction helpers (paired with Base.build) --
|
|
112
|
+
|
|
113
|
+
# Append a new ListItem, built via ListItem.build. The block
|
|
114
|
+
# (if given) is yielded the new item so callers can chain
|
|
115
|
+
# +add_text+ / +add_link+ on it inline:
|
|
116
|
+
#
|
|
117
|
+
# ListBlock.build do |ul|
|
|
118
|
+
# children.each { |c| ul.add_item { |li| li.add_link(c[:slug], text: c[:title]) } }
|
|
119
|
+
# end
|
|
120
|
+
#
|
|
121
|
+
# Returns self for chaining at the list level.
|
|
122
|
+
def add_item(marker: self.marker_type == 'ordered' ? '.' : '*')
|
|
123
|
+
item = ListItem.build(marker: marker) { |li| yield li if block_given? }
|
|
124
|
+
self.items = Array(items) + [item]
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
111
128
|
private
|
|
112
129
|
|
|
113
130
|
# Attributes to compare for semantic equivalence
|
|
@@ -109,6 +109,27 @@ module Coradoc
|
|
|
109
109
|
true
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# -- Fluent construction helpers (paired with Base.build) --
|
|
113
|
+
|
|
114
|
+
# Append a plain-text inline to this item's children. Returns
|
|
115
|
+
# self for chaining. Pairs naturally with ListItem.build:
|
|
116
|
+
#
|
|
117
|
+
# ListItem.build do |li|
|
|
118
|
+
# li.add_text("See ")
|
|
119
|
+
# li.add_link("foo.adoc", text: "Foo")
|
|
120
|
+
# end
|
|
121
|
+
def add_text(text)
|
|
122
|
+
self.children = Array(children) + [TextContent.new(text: text)]
|
|
123
|
+
self
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Append a +link:+ inline to this item's children. +text+ becomes
|
|
127
|
+
# the link's visible label; +target+ is the URL/anchor.
|
|
128
|
+
def add_link(target, text: nil)
|
|
129
|
+
self.children = Array(children) + [LinkElement.new(target: target, content: text)]
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
112
133
|
private
|
|
113
134
|
|
|
114
135
|
# Compare two list blocks for equivalence
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module CoreModel
|
|
5
|
+
# Output-side state object for host-system emitters (VitePress, Hugo,
|
|
6
|
+
# Astro, plain ERB, etc.).
|
|
7
|
+
#
|
|
8
|
+
# coradoc's source side has +IncludeResolver::Filesystem+ to resolve
|
|
9
|
+
# include targets without coupling to a storage layer. The output side
|
|
10
|
+
# has no analogous state object — until now. +OutputArtifact+ captures
|
|
11
|
+
# the three pieces of state coradoc genuinely needs to hand to a
|
|
12
|
+
# downstream emitter:
|
|
13
|
+
#
|
|
14
|
+
# - +output_key+ — site-relative key (e.g. "author/iso/ref/foo")
|
|
15
|
+
# - +frontmatter_block+ — parsed YAML frontmatter (may be empty)
|
|
16
|
+
# - +core_document+ — the canonical CoreModel document
|
|
17
|
+
#
|
|
18
|
+
# The consumer takes these and renders whatever wrapper it needs in
|
|
19
|
+
# its host system's native template language. coradoc does not know
|
|
20
|
+
# about VitePress, ERB, or Liquid. Symmetric with the source side:
|
|
21
|
+
# minimal protocol object, not an engine.
|
|
22
|
+
#
|
|
23
|
+
# A mirror-tree document is deliberately NOT bundled here. coradoc
|
|
24
|
+
# core has no runtime dependency on coradoc-mirror; consumers that
|
|
25
|
+
# target the mirror JSON pipeline pair an +OutputArtifact+ with a
|
|
26
|
+
# separately-computed +Coradoc::Mirror.transform(core)+ result.
|
|
27
|
+
class OutputArtifact < Base
|
|
28
|
+
# @!attribute output_key
|
|
29
|
+
# @return [String, nil] site-relative key with no leading slash
|
|
30
|
+
# and no trailing extension. SSGs map this to their URL space.
|
|
31
|
+
attribute :output_key, :string
|
|
32
|
+
|
|
33
|
+
# @!attribute frontmatter_block
|
|
34
|
+
# @return [FrontmatterBlock, nil] parsed YAML frontmatter
|
|
35
|
+
attribute :frontmatter_block, FrontmatterBlock
|
|
36
|
+
|
|
37
|
+
# @!attribute core_document
|
|
38
|
+
# @return [DocumentElement, nil] canonical CoreModel document
|
|
39
|
+
attribute :core_document, DocumentElement
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def comparable_attributes
|
|
44
|
+
%i[output_key frontmatter_block core_document]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module CoreModel
|
|
5
|
+
# STEM block — mathematical/scientific content authored in LaTeX,
|
|
6
|
+
# AsciiMath, or another STEM markup. Carries a +language+ attribute so
|
|
7
|
+
# downstream renderers know which interpreter to invoke.
|
|
8
|
+
#
|
|
9
|
+
# AsciiDoc surface forms:
|
|
10
|
+
# [stem]\n++++\nx^2\n++++ # language: "latex" (default)
|
|
11
|
+
# [latexmath]\n++++\nx^2\n++++ # language: "latex"
|
|
12
|
+
# [asciimath]\n++++\nx^2\n++++ # language: "asciimath"
|
|
13
|
+
class StemBlock < Block
|
|
14
|
+
attribute :language, :string, default: -> { 'latex' }
|
|
15
|
+
|
|
16
|
+
def self.semantic_type
|
|
17
|
+
:stem
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -11,6 +11,11 @@ module Coradoc
|
|
|
11
11
|
# Structural elements can contain other elements (blocks, lists, etc.)
|
|
12
12
|
# and can be nested hierarchically to represent document structure.
|
|
13
13
|
class StructuralElement < Base
|
|
14
|
+
# StructuralElements carry typed block children (sections, paragraphs,
|
|
15
|
+
# etc.) rather than mixed inline content, so they don't include
|
|
16
|
+
# ChildrenContent. HasChildren marks the structural predicate that
|
|
17
|
+
# downstream traversal dispatches on (OCP).
|
|
18
|
+
include HasChildren
|
|
14
19
|
# @!attribute level
|
|
15
20
|
# @return [Integer, nil] hierarchical level (1-6 for sections)
|
|
16
21
|
attribute :level, :integer
|
|
@@ -47,6 +52,38 @@ module Coradoc
|
|
|
47
52
|
false
|
|
48
53
|
end
|
|
49
54
|
|
|
55
|
+
# Override in subclasses that carry document-title semantics.
|
|
56
|
+
# HeaderElement at level 0 represents the document title (`= Title`
|
|
57
|
+
# in AsciiDoc). Consumers that walk the body — TOC builders,
|
|
58
|
+
# section numbering — skip these so the title is not counted as
|
|
59
|
+
# "section 1". Polymorphic dispatch (vs. an is_a? guard at the
|
|
60
|
+
# call site) keeps the predicate open for future subclasses.
|
|
61
|
+
def document_title?
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Children that count as body content and aren't whitespace-only.
|
|
66
|
+
# Derived from per-node {#body_content?} and {#whitespace_only?}
|
|
67
|
+
# predicates — no central walker, no is_a? switch to maintain.
|
|
68
|
+
def visible_children
|
|
69
|
+
Array(children).select(&:body_content?).reject(&:whitespace_only?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# True when the body has no visible content anywhere in its subtree.
|
|
73
|
+
# A document with only frontmatter + comments returns true; a
|
|
74
|
+
# document with one non-whitespace paragraph returns false.
|
|
75
|
+
def empty_body?
|
|
76
|
+
return true if children.nil? || children.empty?
|
|
77
|
+
|
|
78
|
+
children.all? do |child|
|
|
79
|
+
next true unless child.body_content?
|
|
80
|
+
next true if child.whitespace_only?
|
|
81
|
+
next child.empty_body? if child.is_a?(StructuralElement)
|
|
82
|
+
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
50
87
|
# Derived element_type string for backward compatibility with
|
|
51
88
|
# templates and legacy consumers. Subclasses override this.
|
|
52
89
|
def element_type
|
|
@@ -103,6 +140,15 @@ module Coradoc
|
|
|
103
140
|
true
|
|
104
141
|
end
|
|
105
142
|
|
|
143
|
+
# A level-0 HeaderElement represents the document title (the `= Title`
|
|
144
|
+
# line in AsciiDoc, the `<h1>` in HTML). It is structurally part of
|
|
145
|
+
# the body but semantically the document's title, not a section —
|
|
146
|
+
# section numbering, TOC builders, and other section-aware logic
|
|
147
|
+
# skip these so the title is not counted as "section 1".
|
|
148
|
+
def document_title?
|
|
149
|
+
level.to_i.zero?
|
|
150
|
+
end
|
|
151
|
+
|
|
106
152
|
def self.element_type_name
|
|
107
153
|
'header'
|
|
108
154
|
end
|
data/lib/coradoc/core_model.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Coradoc
|
|
|
12
12
|
# Autoload submodules lazily using relative paths
|
|
13
13
|
autoload :Base, "#{__dir__}/core_model/base"
|
|
14
14
|
autoload :ChildrenContent, "#{__dir__}/core_model/children_content"
|
|
15
|
+
autoload :HasChildren, "#{__dir__}/core_model/has_children"
|
|
15
16
|
autoload :Callout, "#{__dir__}/core_model/callout"
|
|
16
17
|
autoload :CalloutText, "#{__dir__}/core_model/callout_text"
|
|
17
18
|
autoload :Block, "#{__dir__}/core_model/block"
|
|
@@ -69,6 +70,7 @@ module Coradoc
|
|
|
69
70
|
autoload :SidebarBlock, "#{__dir__}/core_model/sidebar_block"
|
|
70
71
|
autoload :LiteralBlock, "#{__dir__}/core_model/literal_block"
|
|
71
72
|
autoload :PassBlock, "#{__dir__}/core_model/pass_block"
|
|
73
|
+
autoload :StemBlock, "#{__dir__}/core_model/stem_block"
|
|
72
74
|
autoload :ListingBlock, "#{__dir__}/core_model/listing_block"
|
|
73
75
|
autoload :OpenBlock, "#{__dir__}/core_model/open_block"
|
|
74
76
|
autoload :VerseBlock, "#{__dir__}/core_model/verse_block"
|
|
@@ -78,5 +80,9 @@ module Coradoc
|
|
|
78
80
|
autoload :CommentLine, "#{__dir__}/core_model/comment_line"
|
|
79
81
|
autoload :HorizontalRuleBlock, "#{__dir__}/core_model/horizontal_rule_block"
|
|
80
82
|
autoload :IdGenerator, "#{__dir__}/core_model/id_generator"
|
|
83
|
+
autoload :Include, "#{__dir__}/core_model/include"
|
|
84
|
+
autoload :IncludeOptions, "#{__dir__}/core_model/include_options"
|
|
85
|
+
autoload :IncludeLevelOffset, "#{__dir__}/core_model/include_level_offset"
|
|
86
|
+
autoload :OutputArtifact, "#{__dir__}/core_model/output_artifact"
|
|
81
87
|
end
|
|
82
88
|
end
|
data/lib/coradoc/errors.rb
CHANGED
|
@@ -283,6 +283,62 @@ module Coradoc
|
|
|
283
283
|
end
|
|
284
284
|
end
|
|
285
285
|
|
|
286
|
+
# Error raised when an include directive's target cannot be located.
|
|
287
|
+
# Honors the +missing_include+ policy: +:error+ raises this; +:warn+,
|
|
288
|
+
# +:silent+, and +:passthrough+ swallow it.
|
|
289
|
+
class IncludeNotFoundError < Error
|
|
290
|
+
attr_reader :target
|
|
291
|
+
|
|
292
|
+
def initialize(target)
|
|
293
|
+
@target = target
|
|
294
|
+
super("Include target not found: #{target}")
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Error raised when an include chain exceeds the configured depth limit.
|
|
299
|
+
class IncludeDepthExceededError < Error
|
|
300
|
+
attr_reader :depth, :target
|
|
301
|
+
|
|
302
|
+
def initialize(target:, depth:, max:)
|
|
303
|
+
@target = target
|
|
304
|
+
@depth = depth
|
|
305
|
+
super("Include depth #{depth} exceeds max #{max} at #{target}")
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Error raised when a cycle is detected in the include graph.
|
|
310
|
+
# The chain is the list of files leading back to the repeated target.
|
|
311
|
+
class CircularIncludeError < Error
|
|
312
|
+
attr_reader :chain, :target
|
|
313
|
+
|
|
314
|
+
def initialize(target:, chain:)
|
|
315
|
+
@target = target
|
|
316
|
+
@chain = chain
|
|
317
|
+
super("Circular include detected: #{chain.join(' -> ')} -> #{target}")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Error raised when an include target escapes the resolver's safe base
|
|
322
|
+
# directory and +allow_unsafe_includes+ is not set.
|
|
323
|
+
class UnsafeIncludeError < Error
|
|
324
|
+
attr_reader :target
|
|
325
|
+
|
|
326
|
+
def initialize(target)
|
|
327
|
+
@target = target
|
|
328
|
+
super("Unsafe include path blocked: #{target}")
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Error raised when an include target exceeds the resolver's size limit.
|
|
333
|
+
class IncludeTooLargeError < Error
|
|
334
|
+
attr_reader :target
|
|
335
|
+
|
|
336
|
+
def initialize(target)
|
|
337
|
+
@target = target
|
|
338
|
+
super("Include target too large to read: #{target}")
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
286
342
|
# Error raised when a requested format is not supported
|
|
287
343
|
#
|
|
288
344
|
# @example
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Coradoc
|
|
6
|
+
class IncludeResolver
|
|
7
|
+
# Default include resolver: reads files from the local filesystem,
|
|
8
|
+
# rooted at +base_dir+. Path-traversal protection is ON by default
|
|
9
|
+
# to match asciidoctor's +:safe+ mode.
|
|
10
|
+
#
|
|
11
|
+
# Pass +allow_unsafe: true+ to opt out (matches +:unsafe+ mode).
|
|
12
|
+
class Filesystem < IncludeResolver
|
|
13
|
+
attr_reader :base_dir, :allow_unsafe, :max_bytes
|
|
14
|
+
|
|
15
|
+
# @param base_dir [String] absolute path to the directory includes
|
|
16
|
+
# are resolved against. Usually the directory of the including
|
|
17
|
+
# document. Relative paths inside the resolver are expanded
|
|
18
|
+
# against this.
|
|
19
|
+
# @param allow_unsafe [Boolean] when false (default), refuses any
|
|
20
|
+
# resolved path that escapes +base_dir+ via .. or that is an
|
|
21
|
+
# absolute path outside +base_dir+.
|
|
22
|
+
# @param max_bytes [Integer, nil] if set, refuses files larger
|
|
23
|
+
# than this. Defense against accidental megabyte-include loops.
|
|
24
|
+
def initialize(base_dir:, allow_unsafe: false, max_bytes: nil)
|
|
25
|
+
@base_dir = File.expand_path(base_dir)
|
|
26
|
+
@allow_unsafe = allow_unsafe
|
|
27
|
+
@max_bytes = max_bytes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(target:, base_dir:, options:, context:)
|
|
31
|
+
full = File.expand_path(target, base_dir)
|
|
32
|
+
enforce_safety!(full, base_dir) unless allow_unsafe
|
|
33
|
+
raise Coradoc::IncludeNotFoundError, target unless File.file?(full)
|
|
34
|
+
|
|
35
|
+
enforce_size!(full, target)
|
|
36
|
+
|
|
37
|
+
encoding = options&.file_encoding || 'utf-8'
|
|
38
|
+
read_with_encoding(full, encoding)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def enforce_safety!(full_path, base_dir)
|
|
44
|
+
base_expanded = File.expand_path(base_dir)
|
|
45
|
+
base_with_sep = "#{base_expanded}#{File::SEPARATOR}"
|
|
46
|
+
|
|
47
|
+
return if full_path == base_expanded || full_path.start_with?(base_with_sep)
|
|
48
|
+
|
|
49
|
+
raise Coradoc::UnsafeIncludeError, full_path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def enforce_size!(full_path, target)
|
|
53
|
+
return unless max_bytes
|
|
54
|
+
return unless File.exist?(full_path)
|
|
55
|
+
|
|
56
|
+
size = File.size(full_path)
|
|
57
|
+
return if size <= max_bytes
|
|
58
|
+
|
|
59
|
+
raise Coradoc::IncludeTooLargeError, target
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_with_encoding(full_path, encoding_name)
|
|
63
|
+
content = File.binread(full_path)
|
|
64
|
+
return content if encoding_name.to_s.downcase == 'binary'
|
|
65
|
+
|
|
66
|
+
content.force_encoding(clean_encoding_name(encoding_name))
|
|
67
|
+
encoded = content.encode('utf-8', invalid: :replace, undef: :replace)
|
|
68
|
+
normalize_line_endings(encoded)
|
|
69
|
+
rescue ArgumentError => e
|
|
70
|
+
raise Coradoc::Error, "Unsupported encoding #{encoding_name.inspect}: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# asciidoctor parity: normalize CRLF and lone CR to LF so the parser
|
|
74
|
+
# sees consistent line endings regardless of the source platform.
|
|
75
|
+
def normalize_line_endings(text)
|
|
76
|
+
text.gsub(/\r\n?/, "\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clean_encoding_name(name)
|
|
80
|
+
name.to_s.downcase.sub(/^utf-8$/, 'utf-8')
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
# Include resolver protocol.
|
|
5
|
+
#
|
|
6
|
+
# A resolver is anything that responds to +#call(target:, base_dir:,
|
|
7
|
+
# options:, context:)+ and returns the raw bytes of the included
|
|
8
|
+
# target, BEFORE tag/line/indent selectors are applied. Selectors
|
|
9
|
+
# live in the processor; the resolver only fetches bytes.
|
|
10
|
+
#
|
|
11
|
+
# The default resolver is {IncludeResolver::Filesystem}. Custom
|
|
12
|
+
# resolvers (HTTP, database, generated) plug in here without changes
|
|
13
|
+
# to the processor (OCP).
|
|
14
|
+
#
|
|
15
|
+
# Contract:
|
|
16
|
+
# call(target:, base_dir:, options:, context:) -> String
|
|
17
|
+
# target String path/URL as authored
|
|
18
|
+
# base_dir String absolute path to the including file's dir
|
|
19
|
+
# options CoreModel::IncludeOptions
|
|
20
|
+
# context Hash recursion state (depth, parent_chain, ...)
|
|
21
|
+
#
|
|
22
|
+
# Raises Coradoc::IncludeNotFoundError if the target cannot be located.
|
|
23
|
+
# The processor's missing-file policy decides what to do with that.
|
|
24
|
+
#
|
|
25
|
+
# This base class is provided for documentation and for is_a? checks.
|
|
26
|
+
# Custom resolvers do NOT need to inherit — duck typing on the call
|
|
27
|
+
# signature is sufficient. ( SPEC 13 uses a bare Object with
|
|
28
|
+
# define_singleton_method, which we support.)
|
|
29
|
+
class IncludeResolver
|
|
30
|
+
autoload :Filesystem, "#{__dir__}/include_resolver/filesystem"
|
|
31
|
+
|
|
32
|
+
def call(target:, base_dir:, options:, context:)
|
|
33
|
+
raise NotImplementedError,
|
|
34
|
+
"#{self.class} must implement #call(target:, base_dir:, options:, context:)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Coerce +value+ into something that quacks like an IncludeResolver.
|
|
39
|
+
# - Already-callable objects (respond to :call) are returned as-is.
|
|
40
|
+
# - Symbols are interpreted as built-in names: +:filesystem+,
|
|
41
|
+
# +:filesystem_strict+ (path-traversal protection on).
|
|
42
|
+
#
|
|
43
|
+
# @param value [Object, nil] the resolver or built-in name
|
|
44
|
+
# @param base_dir [String] required for built-in filesystem resolvers
|
|
45
|
+
# @param allow_unsafe [Boolean] opt-out of path-traversal protection
|
|
46
|
+
# @return [Object] something callable as a resolver
|
|
47
|
+
def coerce(value, base_dir:, allow_unsafe: false)
|
|
48
|
+
return Filesystem.new(base_dir: base_dir, allow_unsafe: allow_unsafe) if value.nil?
|
|
49
|
+
|
|
50
|
+
case value
|
|
51
|
+
when Symbol then coerce_symbol(value, base_dir: base_dir, allow_unsafe: allow_unsafe)
|
|
52
|
+
else value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def coerce_symbol(name, base_dir:, allow_unsafe:)
|
|
59
|
+
case name
|
|
60
|
+
when :filesystem then Filesystem.new(base_dir: base_dir, allow_unsafe: allow_unsafe)
|
|
61
|
+
else
|
|
62
|
+
raise ArgumentError, "Unknown include resolver: #{name.inspect}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Indent normalization. Two modes per asciidoctor:
|
|
6
|
+
#
|
|
7
|
+
# indent=0 strip all leading whitespace from every line
|
|
8
|
+
# indent=N normalize leading whitespace to exactly N spaces
|
|
9
|
+
# nil pass through unchanged
|
|
10
|
+
module Indent
|
|
11
|
+
# @param text [String]
|
|
12
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
13
|
+
# @return [String]
|
|
14
|
+
def self.call(text, options:)
|
|
15
|
+
return text if options.indent.nil?
|
|
16
|
+
|
|
17
|
+
if options.indent.zero?
|
|
18
|
+
strip_all(text)
|
|
19
|
+
else
|
|
20
|
+
reindent(text, options.indent)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def strip_all(text)
|
|
28
|
+
text.lines.map { |line| line.sub(/\A[[:space:]]+/, '') }.join
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reindent(text, target)
|
|
32
|
+
min_indent = text.lines
|
|
33
|
+
.reject { |l| l.strip.empty? }
|
|
34
|
+
.map { |l| l.length - l.lstrip.length }
|
|
35
|
+
.min || 0
|
|
36
|
+
|
|
37
|
+
pad = ' ' * target
|
|
38
|
+
text.lines.map do |line|
|
|
39
|
+
stripped = strip_common_prefix(line, min_indent)
|
|
40
|
+
if stripped.strip.empty?
|
|
41
|
+
stripped.strip + "\n"
|
|
42
|
+
else
|
|
43
|
+
pad + stripped
|
|
44
|
+
end
|
|
45
|
+
end.join
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def strip_common_prefix(line, count)
|
|
49
|
+
line.sub(/\A[[:space:]]{0,#{count}}/, '')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|