llm-docs-builder 0.13.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,8 +14,7 @@ module LlmDocsBuilder
14
14
  full_html_document?(content)
15
15
  end
16
16
 
17
- # Prepare a snippet of content for HTML detection by removing leading whitespace
18
- # and build metadata comments.
17
+ # Prepare a snippet of content for HTML detection by removing leading whitespace and build metadata comments.
19
18
  #
20
19
  # @param content [String]
21
20
  # @return [String, nil]
@@ -3,9 +3,8 @@
3
3
  module LlmDocsBuilder
4
4
  # Provides HTML to Markdown conversion functionality
5
5
  #
6
- # This module contains specialized renderers for converting HTML elements
7
- # to Markdown format, with support for complex structures like tables,
8
- # figures, and syntax-highlighted code blocks.
6
+ # This module contains specialized renderers for converting HTML elements to Markdown format, with support for complex
7
+ # structures like tables, figures, and syntax-highlighted code blocks.
9
8
  #
10
9
  # @api private
11
10
  module HtmlToMarkdown
@@ -644,11 +644,9 @@ module LlmDocsBuilder
644
644
 
645
645
  # Compute effective heading level adjusted for section nesting
646
646
  #
647
- # When HTML uses nested <section> elements with same-level headings,
648
- # the inner headings should receive deeper markdown levels. The offset
649
- # is calculated as the difference between the actual section ancestor
650
- # count and the expected count for that heading tag (h1 expects 0
651
- # sections, h2 expects 1, etc.), capped at heading level 6.
647
+ # When HTML uses nested <section> elements with same-level headings, the inner headings should receive deeper
648
+ # markdown levels. The offset is calculated as the difference between the actual section ancestor count and the
649
+ # expected count for that heading tag (h1 expects 0 sections, h2 expects 1, etc.), capped at heading level 6.
652
650
  #
653
651
  # @param element [Nokogiri::XML::Element] heading element
654
652
  # @param base_level [Integer] HTML heading level (1-6)
@@ -3,8 +3,7 @@
3
3
  module LlmDocsBuilder
4
4
  # Formats output for CLI display
5
5
  #
6
- # Provides formatting utilities for displaying comparison results,
7
- # byte sizes, and numbers in a user-friendly way.
6
+ # Provides formatting utilities for displaying comparison results, byte sizes, and numbers in a user-friendly way.
8
7
  #
9
8
  # @api private
10
9
  class OutputFormatter
@@ -129,7 +128,7 @@ module LlmDocsBuilder
129
128
  puts ' llm-docs-builder bulk-transform --docs ./docs --config llm-docs-builder.yml'
130
129
  puts ''
131
130
  puts '2. Configure your web server to serve .md files to AI bots:'
132
- puts ' See: https://github.com/mensfeld/llm-docs-builder#serving-optimized-docs'
131
+ puts ' See: https://github.com/mensfeld/llm-docs-builder#serving-optimized-docs-to-ai-bots'
133
132
  puts ''
134
133
  puts '3. Measure your actual savings:'
135
134
  puts ' llm-docs-builder compare --url <your-url> --file <local-md>'
@@ -3,9 +3,8 @@
3
3
  module LlmDocsBuilder
4
4
  # Parses llms.txt files into structured data
5
5
  #
6
- # Reads and parses llms.txt files according to the llms.txt specification,
7
- # extracting the title, description, and structured sections (Documentation,
8
- # Examples, Optional) with their links.
6
+ # Reads and parses llms.txt files according to the llms.txt specification, extracting the title, description, and
7
+ # structured sections (Documentation, Examples, Optional) with their links.
9
8
  #
10
9
  # @example Parse an llms.txt file
11
10
  # parser = LlmDocsBuilder::Parser.new('llms.txt')
@@ -109,8 +108,7 @@ module LlmDocsBuilder
109
108
 
110
109
  # Represents parsed llms.txt content with structured access to sections
111
110
  #
112
- # Provides convenient access to parsed llms.txt sections including title,
113
- # description, and link collections.
111
+ # Provides convenient access to parsed llms.txt sections including title, description, and link collections.
114
112
  #
115
113
  # @example Access parsed content
116
114
  # parsed.title # => "My Project"
@@ -3,9 +3,8 @@
3
3
  module LlmDocsBuilder
4
4
  # Advanced text compression techniques for reducing token count
5
5
  #
6
- # Provides more aggressive text compression methods including stopword removal,
7
- # duplicate content detection, and sentence deduplication. These methods are more
8
- # aggressive than basic markdown cleanup and should be used carefully.
6
+ # Provides more aggressive text compression methods including stopword removal, duplicate content detection, and
7
+ # sentence deduplication. These methods are more aggressive than basic markdown cleanup and should be used carefully.
9
8
  #
10
9
  # @example Basic usage
11
10
  # compressor = LlmDocsBuilder::TextCompressor.new
@@ -3,9 +3,8 @@
3
3
  module LlmDocsBuilder
4
4
  # Estimates token count for text content using character-based approximation
5
5
  #
6
- # Provides token estimation without requiring external tokenizer dependencies.
7
- # Uses the common heuristic that ~4 characters equals 1 token for English text,
8
- # which works reasonably well for documentation and markdown content.
6
+ # Provides token estimation without requiring external tokenizer dependencies. Uses the common heuristic that ~4
7
+ # characters equals 1 token for English text, which works reasonably well for documentation and markdown content.
9
8
  #
10
9
  # @example Basic usage
11
10
  # estimator = LlmDocsBuilder::TokenEstimator.new
@@ -3,9 +3,8 @@
3
3
  module LlmDocsBuilder
4
4
  # Provides content transformation functionality
5
5
  #
6
- # This module contains specialized transformers for modifying markdown content,
7
- # including cleanup operations, link processing, heading normalization, and
8
- # content enhancement for AI consumption.
6
+ # This module contains specialized transformers for modifying markdown content, including cleanup operations, link
7
+ # processing, heading normalization, and content enhancement for AI consumption.
9
8
  #
10
9
  # @api private
11
10
  module Transformers
@@ -4,9 +4,8 @@ module LlmDocsBuilder
4
4
  module Transformers
5
5
  # Normalizes headings to include hierarchical context
6
6
  #
7
- # Transforms markdown headings to include parent context, making each section
8
- # self-contained for RAG systems. This is particularly useful when documents
9
- # are chunked and retrieved independently.
7
+ # Transforms markdown headings to include parent context, making each section self-contained for RAG systems. This
8
+ # is particularly useful when documents are chunked and retrieved independently.
10
9
  #
11
10
  # @example Basic heading normalization
12
11
  # # Configuration
@@ -4,8 +4,7 @@ module LlmDocsBuilder
4
4
  module Transformers
5
5
  # Transformer for link-related operations
6
6
  #
7
- # Handles expansion of relative links to absolute URLs and
8
- # conversion of HTML URLs to markdown format.
7
+ # Handles expansion of relative links to absolute URLs and conversion of HTML URLs to markdown format.
9
8
  #
10
9
  # @api public
11
10
  class LinkTransformer
@@ -16,48 +15,349 @@ module LlmDocsBuilder
16
15
  # @param content [String] markdown content
17
16
  # @param options [Hash] transformation options
18
17
  # @option options [String] :base_url base URL for expanding relative links
18
+ # @option options [String] :source_path path of the transformed file relative to the
19
+ # docs root, used to resolve relative links against the file's own directory
19
20
  # @option options [Boolean] :convert_urls convert HTML URLs to markdown format
20
21
  # @return [String] transformed content
22
+ # @note Code (fenced and indented code blocks, inline code spans) is left unchanged
21
23
  def transform(content, options = {})
22
- result = content.dup
23
-
24
- result = expand_relative_links(result, options[:base_url]) if options[:base_url]
25
- result = convert_html_urls(result) if options[:convert_urls]
26
- result = simplify_links(result) if options[:simplify_links]
24
+ outside_code(content) do |result|
25
+ if options[:base_url]
26
+ result = expand_relative_links(
27
+ result, options[:base_url], options[:source_path], convert_urls: options[:convert_urls]
28
+ )
29
+ end
30
+ result = convert_html_urls(result, convert_definitions: !options[:base_url]) if options[:convert_urls]
31
+ result = simplify_links(result) if options[:simplify_links]
27
32
 
28
- result
33
+ result
34
+ end
29
35
  end
30
36
 
31
37
  private
32
38
 
33
- # Expand relative links to absolute URLs
39
+ # A link target with a URI scheme is never a relative path
40
+ URI_SCHEME_PATTERN = /\A[a-z][a-z0-9+.-]*:/i
41
+ private_constant :URI_SCHEME_PATTERN
42
+
43
+ # Placeholder that stands in for a protected code fragment; a code block's ends with `b`
44
+ CODE_PLACEHOLDER = /\u0000(\d+)b?\u0000/
45
+
46
+ # Inline code span: a backtick run closed by a run of the same length, within one paragraph
47
+ INLINE_CODE = /(?<![`\\])(`+)(?!`)(?:(?!\n[ \t]*\n)[^\u0000])+?(?<!`)\1(?!`)/m
48
+
49
+ # Opening line of a fenced code block
50
+ FENCE_OPENING = /\A[ \t]*(`{3,}|~{3,})/
51
+
52
+ # Blockquote markers at the start of a line
53
+ BLOCKQUOTE_PREFIX = /\A(?: {0,3}> ?)*/
54
+
55
+ # Line that starts a list item
56
+ LIST_ITEM = /\A {0,3}(?:[-+*]|\d{1,9}[.)])(?:[ \t]|\s*\z)/
57
+
58
+ # Contents of a bracket pair in a link text: plain text, images and brackets that do not open a link
59
+ # (links do not nest)
60
+ BRACKET_CONTENT_PATTERN = /(?:[^\[\]]|!\[[^\[\]]*\]|\[[^\[\]]*\](?!\())*/
61
+
62
+ # Part of a link text: brackets in it may be nested up to two levels deep (e.g. a linked image)
63
+ LINK_TEXT_PART_PATTERN = /[^\[\]]|!\[#{BRACKET_CONTENT_PATTERN}\]|\[#{BRACKET_CONTENT_PATTERN}\](?!\()/
64
+
65
+ # Link destination; it never runs into protected code, only its title may contain it
66
+ LINK_DESTINATION_PATTERN = /\(([^)\u0000]+(?:\s+(?:"[^")]*"|'[^')]*'))?)\)/
67
+
68
+ # Inline link
69
+ LINK_PATTERN = /\[((?:#{LINK_TEXT_PART_PATTERN})+)\]#{LINK_DESTINATION_PATTERN}/
70
+
71
+ # Inline link or image inside a link text, where an image may have no alt text (`[![](badge.svg)](url)`)
72
+ NESTED_LINK_PATTERN = /\[((?:#{LINK_TEXT_PART_PATTERN})*)\]#{LINK_DESTINATION_PATTERN}/
73
+
74
+ # Link reference definition line (`[id]: target "title"`): its label, destination and the rest of the line.
75
+ # Footnote definitions (`[^1]: text`) are not link reference definitions.
76
+ REFERENCE_DEFINITION_PATTERN = /
77
+ ^((?:\ {0,3}>\ ?)*\ {0,3}\[(?!\^)(?:[^\[\]\\\n]|\\.)+\]:[ \t]*)
78
+ (<[^<>\n]*>|[^\s<\u0000][^\s\u0000]*)
79
+ ((?:[ \t]+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?[ \t\r]*)$
80
+ /x
81
+
82
+ # Consecutive link reference definition lines
83
+ REFERENCE_DEFINITIONS_PATTERN = /#{REFERENCE_DEFINITION_PATTERN}(?:\n#{REFERENCE_DEFINITION_PATTERN})*/
84
+
85
+ # Line that link reference definitions may follow: a blank line, an ATX heading, a thematic break or a code
86
+ # block. A definition cannot interrupt a paragraph.
87
+ REFERENCE_DEFINITIONS_PRECEDING_LINE_PATTERN = /
88
+ (?:\ {0,3}>\ ?)*
89
+ (?:
90
+ [ \t]* |
91
+ \ {0,3}\#{1,6}(?:[ \t][^\n]*)? |
92
+ \ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,}) |
93
+ \u0000\d+b\u0000[ \t]*
94
+ )\r?
95
+ /x
96
+
97
+ # Definitions on the first or second line of the content
98
+ LEADING_REFERENCE_DEFINITIONS_PATTERN =
99
+ /\A(?:#{REFERENCE_DEFINITIONS_PRECEDING_LINE_PATTERN}\n)?\K#{REFERENCE_DEFINITIONS_PATTERN}/
100
+
101
+ # Definitions from the third line on, so that they never overlap the leading ones. Kept apart from the leading
102
+ # pattern: an `\A` alternative here would make the regex try every position of the content.
103
+ FOLLOWING_REFERENCE_DEFINITIONS_PATTERN =
104
+ /\n#{REFERENCE_DEFINITIONS_PRECEDING_LINE_PATTERN}\n\K#{REFERENCE_DEFINITIONS_PATTERN}/
105
+
106
+ private_constant :CODE_PLACEHOLDER, :INLINE_CODE, :FENCE_OPENING, :BLOCKQUOTE_PREFIX, :LIST_ITEM,
107
+ :BRACKET_CONTENT_PATTERN, :LINK_TEXT_PART_PATTERN, :LINK_DESTINATION_PATTERN, :LINK_PATTERN,
108
+ :NESTED_LINK_PATTERN, :REFERENCE_DEFINITION_PATTERN, :REFERENCE_DEFINITIONS_PATTERN,
109
+ :REFERENCE_DEFINITIONS_PRECEDING_LINE_PATTERN, :LEADING_REFERENCE_DEFINITIONS_PATTERN,
110
+ :FOLLOWING_REFERENCE_DEFINITIONS_PATTERN
111
+
112
+ # Run a transformation on content with code replaced by placeholders, then restore the code
113
+ #
114
+ # @param content [String] markdown content
115
+ # @yieldparam text [String] content with code fragments replaced by placeholders
116
+ # @yieldreturn [String] transformed content
117
+ # @return [String] transformed content with the original code restored
118
+ def outside_code(content)
119
+ codes = []
120
+ protect = lambda do |code, block: false|
121
+ codes << code
122
+ "\u0000#{codes.size - 1}#{'b' if block}\u0000"
123
+ end
124
+
125
+ text = protect_code_blocks(content, protect)
126
+ text = text.gsub(INLINE_CODE) { protect.call(::Regexp.last_match(0)) }
127
+
128
+ yield(text).gsub(CODE_PLACEHOLDER) { codes[::Regexp.last_match(1).to_i] }
129
+ end
130
+
131
+ # Replace fenced and indented code blocks with placeholders
132
+ #
133
+ # Indented lines count as code only after a blank line and outside lists, where they would otherwise be
134
+ # paragraph or list item continuations. Code inside blockquotes is detected after the `>` markers.
135
+ #
136
+ # @param content [String] markdown content
137
+ # @param protect [Proc] stores a code fragment and returns its placeholder
138
+ # @return [String] content with code blocks replaced
139
+ def protect_code_blocks(content, protect)
140
+ out = +''
141
+ code = +''
142
+ fence = nil
143
+ fence_depth = 0
144
+ depth = 0
145
+ in_list = false
146
+ prev_blank = true
147
+
148
+ content.each_line do |line|
149
+ quote = line[BLOCKQUOTE_PREFIX]
150
+ body = line.delete_prefix(quote)
151
+ prev_blank ||= quote.count('>') > depth
152
+ depth = quote.count('>')
153
+ fence = nil if depth < fence_depth
154
+
155
+ if fence
156
+ code << line
157
+ closing = line.sub(/\A(?: {0,3}> ?){#{fence_depth}}/, '')
158
+ fence = nil if closing.match?(/\A[ \t]*#{Regexp.escape(fence)}#{Regexp.escape(fence[0])}*\s*\z/)
159
+ prev_blank = true
160
+ next
161
+ end
162
+
163
+ if !in_list && (prev_blank || !code.empty?) && indented_code?(body)
164
+ code << line
165
+ next
166
+ end
167
+
168
+ if (fence = fence_opening(body))
169
+ fence_depth = depth
170
+ code << line
171
+ next
172
+ end
173
+
174
+ out << protect_lines(code, protect) << line
175
+ code = +''
176
+ in_list = in_list_after?(body, in_list, prev_blank)
177
+ prev_blank = body.strip.empty?
178
+ end
179
+
180
+ out << protect_lines(code, protect)
181
+ end
182
+
183
+ # Fence of a line that opens a fenced code block
184
+ #
185
+ # @param line [String] markdown line
186
+ # @return [String, nil] the opening fence, or nil when the line does not open a block
187
+ def fence_opening(line)
188
+ opening = line.match(FENCE_OPENING)
189
+ return unless opening
190
+ return if opening[1].start_with?('`') && opening.post_match.include?('`')
191
+
192
+ opening[1]
193
+ end
194
+
195
+ # Check whether a line is indented enough to be code
196
+ #
197
+ # @param line [String] markdown line
198
+ # @return [Boolean] true for a non-blank line indented by four spaces or a tab
199
+ def indented_code?(line)
200
+ line.match?(/\A(?: {4}|\t)\s*\S/)
201
+ end
202
+
203
+ # Check whether content after a line is still inside a list
204
+ #
205
+ # @param line [String] markdown line
206
+ # @param in_list [Boolean] whether the previous line was inside a list
207
+ # @param prev_blank [Boolean] whether the previous line was blank
208
+ # @return [Boolean] true when the list continues after this line
209
+ def in_list_after?(line, in_list, prev_blank)
210
+ return true if line.match?(LIST_ITEM)
211
+ return in_list if line.strip.empty? || !prev_blank || line.match?(/\A[ \t]/)
212
+
213
+ false
214
+ end
215
+
216
+ # Replace collected code lines with a placeholder, keeping the final line break
217
+ #
218
+ # @param code [String] collected code lines
219
+ # @param protect [Proc] stores a code fragment and returns its placeholder
220
+ # @return [String] placeholder, or an empty string when there is no code
221
+ def protect_lines(code, protect)
222
+ return '' if code.empty?
223
+
224
+ body = code.chomp
225
+ "#{protect.call(body, block: true)}#{code.delete_prefix(body)}"
226
+ end
227
+
228
+ # Expand relative links and link reference definitions to absolute URLs
34
229
  #
35
230
  # @param content [String] markdown content
36
231
  # @param base_url [String] base URL for expansion
232
+ # @param source_path [String, nil] path of the file relative to the docs root
233
+ # @param convert_urls [Boolean] also convert HTML targets of link reference definitions to markdown, so the
234
+ # definitions are scanned only once
37
235
  # @return [String] content with expanded links
38
- def expand_relative_links(content, base_url)
39
- content.gsub(/\[([^\]]+)\]\(([^)]+)\)/) do |match|
40
- text = ::Regexp.last_match(1)
41
- url = ::Regexp.last_match(2)
236
+ def expand_relative_links(content, base_url, source_path = nil, convert_urls: false)
237
+ result = expand_links_matching(content, LINK_PATTERN, base_url, source_path)
238
+
239
+ rewrite_reference_definitions(result) do |target|
240
+ expanded = expand_target(target, base_url, source_path)
241
+ convert_urls ? convert_html_target(expanded) : expanded
242
+ end
243
+ end
42
244
 
43
- if url.start_with?('http://', 'https://', '//', '#')
44
- match
45
- else
46
- clean_url = url.gsub(%r{^\./}, '')
47
- expanded_url = File.join(base_url, clean_url)
48
- "[#{text}](#{expanded_url})"
245
+ # Rewrite the destinations of link reference definitions
246
+ #
247
+ # @param content [String] markdown content
248
+ # @yieldparam target [String] destination of a definition
249
+ # @yieldreturn [String] new destination
250
+ # @return [String] content with rewritten definitions
251
+ def rewrite_reference_definitions(content)
252
+ return content unless content.include?(']:')
253
+
254
+ rewrite = lambda do |definitions|
255
+ definitions.gsub(REFERENCE_DEFINITION_PATTERN) do
256
+ match = ::Regexp.last_match
257
+
258
+ "#{match[1]}#{yield(match[2])}#{match[3]}"
49
259
  end
50
260
  end
261
+
262
+ content
263
+ .sub(LEADING_REFERENCE_DEFINITIONS_PATTERN, &rewrite)
264
+ .gsub(FOLLOWING_REFERENCE_DEFINITIONS_PATTERN, &rewrite)
265
+ end
266
+
267
+ # Expand relative links that match a pattern, and the links and images in their link text
268
+ #
269
+ # @param content [String] markdown content
270
+ # @param pattern [Regexp] pattern of the links to expand
271
+ # @param base_url [String] base URL for expansion
272
+ # @param source_path [String, nil] path of the file relative to the docs root
273
+ # @return [String] content with expanded links
274
+ def expand_links_matching(content, pattern, base_url, source_path)
275
+ content.gsub(pattern) do
276
+ url = ::Regexp.last_match(2)
277
+ text = expand_links_matching(::Regexp.last_match(1), NESTED_LINK_PATTERN, base_url, source_path)
278
+
279
+ "[#{text}](#{expand_target(url, base_url, source_path)})"
280
+ end
281
+ end
282
+
283
+ # Expand a link target unless it is absolute, protocol-relative or an anchor
284
+ #
285
+ # @param target [String] link target, optionally followed by a link title
286
+ # @param base_url [String] base URL for expansion
287
+ # @param source_path [String, nil] path of the file relative to the docs root
288
+ # @return [String] expanded or unchanged target
289
+ def expand_target(target, base_url, source_path)
290
+ destination = target.delete_prefix('<')
291
+ return target if destination.start_with?('//', '#') || destination.match?(URI_SCHEME_PATTERN)
292
+
293
+ resolve_relative_url(base_url, target, source_path)
294
+ end
295
+
296
+ # Resolve a relative link target against the base URL
297
+ #
298
+ # Targets starting with `/` are resolved from the base URL itself, other targets from the source
299
+ # file's directory. `./` and `../` segments are normalized. Titles, queries and anchors are kept.
300
+ # Angle-bracket destinations (`<path with spaces>`) are unwrapped and their spaces percent-encoded.
301
+ # A query-only target (`?tab=2`) resolves to the source file itself, as in RFC 3986, with its
302
+ # name percent-encoded.
303
+ #
304
+ # @param base_url [String] base URL for expansion
305
+ # @param target [String] relative link target, optionally followed by a link title
306
+ # @param source_path [String, nil] path of the source file relative to the docs root
307
+ # @return [String] expanded URL
308
+ def resolve_relative_url(base_url, target, source_path)
309
+ url, title = target.match(/\A(.*?)(\s+(?:"[^"]*"|'[^']*'))?\z/m).captures
310
+ url = url[/\A<(.*)>\z/m, 1]&.gsub(' ', '%20') || url
311
+ path, suffix = url.match(/\A([^?#]*)(.*)\z/m).captures
312
+ query_only = source_path && path.empty? && suffix.start_with?('?')
313
+ path = ERB::Util.url_encode(File.basename(source_path)) if query_only
314
+ source_dir = source_path ? File.dirname(source_path) : '.'
315
+ origin = base_url[%r{\A[^:/?#]+://[^/?#]*}].to_s
316
+ base_path = base_url.delete_prefix(origin)
317
+ dir = path.start_with?('/') ? base_path : File.join(base_path, source_dir)
318
+
319
+ "#{origin}#{normalize_path("#{dir}/#{path}")}#{suffix}#{title}"
320
+ end
321
+
322
+ # Normalize `.`, `..` and empty segments of a path
323
+ #
324
+ # @param path [String] path to normalize
325
+ # @return [String] normalized path, with its trailing slash kept
326
+ def normalize_path(path)
327
+ normalized = Pathname.new(path).cleanpath.to_s
328
+ normalized += '/' if path.match?(%r{/\.{0,2}\z}) && !normalized.end_with?('/')
329
+ normalized
51
330
  end
52
331
 
53
332
  # Convert HTML URLs to markdown format
54
333
  #
334
+ # Converts absolute URLs anywhere in the content and relative link and link reference definition targets,
335
+ # keeping queries and anchors.
336
+ #
55
337
  # @param content [String] markdown content
338
+ # @param convert_definitions [Boolean] whether to convert link reference definition targets
56
339
  # @return [String] content with converted URLs
57
- def convert_html_urls(content)
58
- content.gsub(%r{https?://[^\s<>]+\.html?(?=[)\s]|$)}) do |url|
59
- url.sub(/\.html?$/, '.md')
340
+ def convert_html_urls(content, convert_definitions: true)
341
+ result = content.gsub(%r{https?://[^\s<>?#]+?\.html?(?=[?#)\s]|$)}) do |url|
342
+ url.sub(/\.html?\z/, '.md')
343
+ end
344
+
345
+ result = result.gsub(/\]\(([^)\s?#]+?)\.html?(?=[?#)\s])/) do |match|
346
+ path = ::Regexp.last_match(1)
347
+ path.match?(URI_SCHEME_PATTERN) ? match : "](#{path}.md"
60
348
  end
349
+
350
+ return result unless convert_definitions
351
+
352
+ rewrite_reference_definitions(result) { |target| convert_html_target(target) }
353
+ end
354
+
355
+ # Convert a relative HTML link reference definition target to markdown
356
+ #
357
+ # @param target [String] destination of a definition
358
+ # @return [String] converted or unchanged target
359
+ def convert_html_target(target)
360
+ target.match?(URI_SCHEME_PATTERN) ? target : target.sub(/\A([^?#]+?)\.html?(?=[?#]|\z)/, '\1.md')
61
361
  end
62
362
 
63
363
  # Simplify verbose link text
@@ -65,14 +365,14 @@ module LlmDocsBuilder
65
365
  # @param content [String] markdown content
66
366
  # @return [String] content with simplified links
67
367
  def simplify_links(content)
68
- content.gsub(/\[([^\]]+)\]\(([^)]+)\)/) do
368
+ content.gsub(LINK_PATTERN) do
69
369
  text = ::Regexp.last_match(1)
70
370
  url = ::Regexp.last_match(2)
71
371
 
72
372
  simplified_text = text
73
- .gsub(/^(click here to|see|read more about|check out|visit)\s+(the\s+)?/i, '')
373
+ .gsub(/^(?:(?:click here to|see|read more about|check out|visit)\s+(?:the\s+)?)+/i, '')
74
374
  .gsub(/\s+(here|documentation|docs)$/i, '')
75
- .strip
375
+ .gsub(/\A\s+|\s+\z/, '') # String#strip also removes the NUL of code placeholders
76
376
 
77
377
  simplified_text = text if simplified_text.empty?
78
378
 
@@ -4,8 +4,7 @@ module LlmDocsBuilder
4
4
  module Transformers
5
5
  # Transformer for whitespace normalization
6
6
  #
7
- # Reduces excessive blank lines and trailing whitespace to make
8
- # content more compact for LLM consumption.
7
+ # Reduces excessive blank lines and trailing whitespace to make content more compact for LLM consumption.
9
8
  #
10
9
  # @api public
11
10
  class WhitespaceTransformer
@@ -56,8 +56,7 @@ module LlmDocsBuilder
56
56
 
57
57
  # Validate content and return result
58
58
  #
59
- # Runs all validation checks, populates {#errors} array, and returns whether
60
- # the content is valid.
59
+ # Runs all validation checks, populates {#errors} array, and returns whether the content is valid.
61
60
  #
62
61
  # @return [Boolean] true if content is valid, false otherwise
63
62
  def validate!
@@ -2,5 +2,5 @@
2
2
 
3
3
  module LlmDocsBuilder
4
4
  # Current version of the LlmDocsBuilder gem
5
- VERSION = '0.13.0'
5
+ VERSION = '1.1.0'
6
6
  end
@@ -6,6 +6,7 @@ require 'find'
6
6
 
7
7
  autoload(:Nokogiri, 'nokogiri')
8
8
  autoload(:CGI, 'cgi')
9
+ autoload(:ERB, 'erb')
9
10
 
10
11
  loader = Zeitwerk::Loader.for_gem
11
12
  loader.inflector.inflect('cli' => 'CLI')
@@ -13,9 +14,8 @@ loader.setup
13
14
 
14
15
  # Build and optimize documentation for LLMs
15
16
  #
16
- # This gem provides tools for generating llms.txt files and transforming markdown
17
- # documentation to be AI-friendly. It can reduce token consumption by 67-95% while
18
- # preserving essential documentation content.
17
+ # This gem provides tools for generating llms.txt files and transforming markdown documentation to be AI-friendly. It
18
+ # can reduce token consumption by 67-95% while preserving essential documentation content.
19
19
  #
20
20
  # @api public
21
21
  module LlmDocsBuilder
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
27
27
  spec.metadata['source_code_uri'] = 'https://github.com/mensfeld/llm-docs-builder'
28
28
  spec.metadata['changelog_uri'] = 'https://github.com/mensfeld/llm-docs-builder/blob/master/CHANGELOG.md'
29
29
  spec.metadata['documentation_uri'] = 'https://github.com/mensfeld/llm-docs-builder'
30
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/mensfeld/llm-docs-builder/issues'
30
31
  spec.metadata['rubygems_mfa_required'] = 'true'
31
32
 
32
33
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec|test)/}) }
@@ -42,5 +43,5 @@ Gem::Specification.new do |spec|
42
43
  spec.add_development_dependency 'rake', '~> 13.0'
43
44
  spec.add_development_dependency 'rspec', '~> 3.0'
44
45
  spec.add_development_dependency 'rubocop', '~> 1.0'
45
- spec.add_development_dependency 'simplecov', '~> 0.21'
46
+ spec.add_development_dependency 'simplecov', '~> 1.0'
46
47
  end
@@ -1,5 +1,5 @@
1
- # Example llms-txt.yml configuration file
2
- # Copy to llms-txt.yml and customize for your project
1
+ # Example llm-docs-builder.yml configuration file
2
+ # Copy to llm-docs-builder.yml and customize for your project
3
3
 
4
4
  # Path to documentation directory or file
5
5
  docs: ./docs
data/package-lock.json CHANGED
@@ -103,9 +103,9 @@
103
103
  }
104
104
  },
105
105
  "node_modules/fastq": {
106
- "version": "1.20.1",
107
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
108
- "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
106
+ "version": "1.20.3",
107
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
108
+ "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
109
109
  "dev": true,
110
110
  "license": "ISC",
111
111
  "dependencies": {
@@ -286,9 +286,9 @@
286
286
  }
287
287
  },
288
288
  "node_modules/smol-toml": {
289
- "version": "1.6.1",
290
- "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
291
- "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
289
+ "version": "1.9.0",
290
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
291
+ "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
292
292
  "dev": true,
293
293
  "license": "BSD-3-Clause",
294
294
  "engines": {
@@ -312,9 +312,9 @@
312
312
  }
313
313
  },
314
314
  "node_modules/yaml": {
315
- "version": "2.8.4",
316
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
317
- "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
315
+ "version": "2.9.1",
316
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
317
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
318
318
  "dev": true,
319
319
  "license": "ISC",
320
320
  "bin": {