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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +13 -7
- data/.github/workflows/docker.yml +7 -7
- data/.github/workflows/push.yml +3 -3
- data/.ruby-version +1 -1
- data/.yard-lint.yml +35 -1
- data/CHANGELOG.md +36 -4
- data/Gemfile +4 -0
- data/Gemfile.lock +24 -28
- data/README.md +23 -11
- data/lib/llm_docs_builder/bulk_transformer.rb +8 -3
- data/lib/llm_docs_builder/cli.rb +1 -2
- data/lib/llm_docs_builder/generator.rb +75 -7
- data/lib/llm_docs_builder/html_detector.rb +1 -2
- data/lib/llm_docs_builder/html_to_markdown/figure_code_block_renderer.rb +2 -3
- data/lib/llm_docs_builder/html_to_markdown_converter.rb +3 -5
- data/lib/llm_docs_builder/output_formatter.rb +2 -3
- data/lib/llm_docs_builder/parser.rb +3 -5
- data/lib/llm_docs_builder/text_compressor.rb +2 -3
- data/lib/llm_docs_builder/token_estimator.rb +2 -3
- data/lib/llm_docs_builder/transformers/base_transformer.rb +2 -3
- data/lib/llm_docs_builder/transformers/heading_transformer.rb +2 -3
- data/lib/llm_docs_builder/transformers/link_transformer.rb +325 -25
- data/lib/llm_docs_builder/transformers/whitespace_transformer.rb +1 -2
- data/lib/llm_docs_builder/validator.rb +1 -2
- data/lib/llm_docs_builder/version.rb +1 -1
- data/lib/llm_docs_builder.rb +3 -3
- data/llm-docs-builder.gemspec +2 -1
- data/llm-docs-builder.yml.example +2 -2
- data/package-lock.json +9 -9
- data/renovate.json +13 -2
- metadata +5 -4
|
@@ -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
|
-
#
|
|
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
|
|
649
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
+
result
|
|
34
|
+
end
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
private
|
|
32
38
|
|
|
33
|
-
#
|
|
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 (`[](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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
59
|
-
url.sub(/\.html
|
|
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(
|
|
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+)
|
|
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!
|
data/lib/llm_docs_builder.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
data/llm-docs-builder.gemspec
CHANGED
|
@@ -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
|
|
46
|
+
spec.add_development_dependency 'simplecov', '~> 1.0'
|
|
46
47
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
# Example
|
|
2
|
-
# Copy to
|
|
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.
|
|
107
|
-
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.
|
|
108
|
-
"integrity": "sha512-
|
|
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.
|
|
290
|
-
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.
|
|
291
|
-
"integrity": "sha512-
|
|
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.
|
|
316
|
-
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.
|
|
317
|
-
"integrity": "sha512-
|
|
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": {
|