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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a55f2ba08f239388d3f4834df0c1caeef9c138278726b442f6533e000a3554c
4
- data.tar.gz: 173e4950404e3a4b83bd5213520a29b5b6f70b374dd18c58a8374c9b5bf55b21
3
+ metadata.gz: c9ce6fbe87a426d69139719ecd76489c5aed030517167758ea0b2df452f3d636
4
+ data.tar.gz: 9ce3bfab8d25b4c3a9f11b899f20038da89463eb82ffde1b69106ce4934377c8
5
5
  SHA512:
6
- metadata.gz: d8769c0a92e29025165900ff3a1c65b1acbd42b51b8f6402353c157dfd4755ddd84dce8fdb26c334d4cba456c04f83af322ceb9fb554aea4ff4ac3a5330f81c5
7
- data.tar.gz: 305ea0aa8cc44144079988e4faf80125fbf765331d3801ec8273f183c9b77cbc1430bf5b10eba5b09f0068ca526c4bc2b5d8a90a48179583270de1f62df12f77
6
+ metadata.gz: 9f98c868bfde76f265dd69854a43da9c67b48f5310234611105a6dec00a62236070461484b52c8e08c85cf9fd7bfcbffea0a85e92ce0943ac879b9dc532d3b77
7
+ data.tar.gz: 6a6340a373df54139338af837a7755b3148fdaef4d58810779cd88b5e2c5f1cca6edda231cee9e3cdace9cc60d465b14e0d78ecf41c3e338fb9fc40029d3f4c0
@@ -19,10 +19,12 @@ jobs:
19
19
  specs:
20
20
  timeout-minutes: 15
21
21
  runs-on: ubuntu-latest
22
+ continue-on-error: ${{ contains(matrix.ruby, 'preview') || contains(matrix.ruby, 'rc') }}
22
23
  strategy:
23
24
  fail-fast: false
24
25
  matrix:
25
26
  ruby:
27
+ - '4.1.0-preview1'
26
28
  - '4.0'
27
29
  - '3.4'
28
30
  - '3.3'
@@ -30,12 +32,16 @@ jobs:
30
32
  - ruby: '4.0'
31
33
  coverage: 'true'
32
34
  steps:
33
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
35
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
34
36
  with:
35
37
  fetch-depth: 0
36
38
 
39
+ - name: Remove Gemfile.lock for Ruby preview versions
40
+ if: contains(matrix.ruby, 'preview') || contains(matrix.ruby, 'rc')
41
+ run: rm -f Gemfile.lock
42
+
37
43
  - name: Set up Ruby
38
- uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
44
+ uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
39
45
  with:
40
46
  ruby-version: ${{ matrix.ruby }}
41
47
  bundler-cache: true
@@ -58,11 +64,11 @@ jobs:
58
64
  timeout-minutes: 5
59
65
  runs-on: ubuntu-latest
60
66
  steps:
61
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
67
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
62
68
  - name: Set up Ruby
63
- uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
69
+ uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
64
70
  with:
65
- ruby-version: '4.0.3'
71
+ ruby-version: '4.0.9'
66
72
  bundler-cache: true
67
73
  - name: Run yard-lint
68
74
  run: bundle exec yard-lint lib/
@@ -71,11 +77,11 @@ jobs:
71
77
  timeout-minutes: 5
72
78
  runs-on: ubuntu-latest
73
79
  steps:
74
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
80
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
75
81
  with:
76
82
  fetch-depth: 0
77
83
  - name: Set up Node.js
78
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
84
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
79
85
  with:
80
86
  node-version: '20'
81
87
  cache: 'npm'
@@ -25,13 +25,13 @@ jobs:
25
25
  id-token: write
26
26
  steps:
27
27
  - name: Checkout
28
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
28
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
29
29
  with:
30
30
  fetch-depth: 0
31
31
 
32
32
  - name: Docker meta
33
33
  id: meta
34
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
34
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
35
35
  with:
36
36
  images: |
37
37
  mensfeld/llm-docs-builder
@@ -45,28 +45,28 @@ jobs:
45
45
  type=raw,value=latest,enable={{is_default_branch}}
46
46
 
47
47
  - name: Set up QEMU
48
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
48
+ uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
49
49
 
50
50
  - name: Set up Docker Buildx
51
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
51
+ uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
52
52
 
53
53
  - name: Login to Docker Hub
54
54
  if: github.event_name != 'pull_request'
55
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
55
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
56
56
  with:
57
57
  username: ${{ secrets.DOCKERHUB_USERNAME }}
58
58
  password: ${{ secrets.DOCKERHUB_TOKEN }}
59
59
 
60
60
  - name: Login to GitHub Container Registry
61
61
  if: github.event_name != 'pull_request'
62
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
62
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
63
63
  with:
64
64
  registry: ghcr.io
65
65
  username: ${{ github.actor }}
66
66
  password: ${{ secrets.GITHUB_TOKEN }}
67
67
 
68
68
  - name: Build and push
69
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
69
+ uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
70
70
  with:
71
71
  context: .
72
72
  platforms: linux/amd64,linux/arm64
@@ -19,12 +19,12 @@ jobs:
19
19
  id-token: write
20
20
 
21
21
  steps:
22
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
22
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
23
23
  with:
24
24
  fetch-depth: 0
25
25
 
26
26
  - name: Set up Ruby
27
- uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
27
+ uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
28
28
  with:
29
29
  bundler-cache: false
30
30
 
@@ -32,4 +32,4 @@ jobs:
32
32
  run: |
33
33
  bundle install --jobs 4 --retry 3
34
34
 
35
- - uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0
35
+ - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.3
1
+ 4.0.9
data/.yard-lint.yml CHANGED
@@ -62,6 +62,34 @@ Documentation/BlankLineBeforeDefinition:
62
62
  SingleBlankLine: true
63
63
  OrphanedDocs: true
64
64
 
65
+ Documentation/DuplicateNamespaceComment:
66
+ Description: Detects namespaces documented with a YARD comment in more than one file.
67
+ Enabled: true
68
+ Severity: error
69
+
70
+ Documentation/UnderfilledLines:
71
+ Description: Detects documentation prose that wraps too early and wastes horizontal space.
72
+ Enabled: true
73
+ Severity: error
74
+ # Aligned with RuboCop's Layout/LineLength so documentation prose wraps to the same
75
+ # width as code.
76
+ MaxLength: 120
77
+
78
+ Documentation/LineLength:
79
+ Description: Detects documentation lines that exceed the maximum length.
80
+ Enabled: true
81
+ Severity: error
82
+ # Aligned with RuboCop's Layout/LineLength.
83
+ MaxLength: 120
84
+
85
+ Documentation/TextSubstitution:
86
+ Description: Detects em/en-dashes in documentation and replaces them with hyphens.
87
+ Enabled: true
88
+ Severity: error
89
+ Substitutions:
90
+ "—": "-" # em-dash (U+2014)
91
+ "–": "-" # en-dash (U+2013)
92
+
65
93
  # Tags validators
66
94
  Tags/Order:
67
95
  Description: Enforces consistent ordering of YARD tags.
@@ -70,9 +98,15 @@ Tags/Order:
70
98
  EnforcedOrder:
71
99
  - param
72
100
  - option
101
+ - yield
102
+ - yieldparam
103
+ - yieldreturn
73
104
  - return
74
105
  - raise
106
+ - see
75
107
  - example
108
+ - note
109
+ - todo
76
110
 
77
111
  Tags/InvalidTypes:
78
112
  Description: Validates type definitions in @param, @return, @option tags.
@@ -142,7 +176,7 @@ Tags/OptionTags:
142
176
  Tags/ExampleSyntax:
143
177
  Description: Validates Ruby syntax in @example tags.
144
178
  Enabled: true
145
- Severity: warning
179
+ Severity: error
146
180
 
147
181
  Tags/RedundantParamDescription:
148
182
  Description: Detects meaningless parameter descriptions that add no value.
data/CHANGELOG.md CHANGED
@@ -1,23 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 (2026-11-02)
4
+ - [Enhancement] **Relative Link Resolution** - `base_url` link expansion now resolves relative links like a browser would.
5
+ - `../` and `./` segments are normalized (`../other.md` → `https://example.com/other.md` instead of `https://example.com/docs/../other.md`).
6
+ - `bulk-transform` resolves each link from its file's own directory, so `./setup.md` in `guides/intro.md` expands to `<base_url>/guides/setup.md`.
7
+ - Links starting with `/` resolve from `base_url`; anchors, query strings, absolute URLs and `mailto:`/`tel:`/`ftp:` links are unchanged.
8
+ - [Fix] **llms.txt Paths Are Percent-Encoded** - `generate` now percent-encodes each path segment in `llms.txt` entries, with and without `base_url`.
9
+ - `docs/my docs/a b.md` gives `my%20docs/a%20b.md`, so names with spaces or parentheses no longer break the Markdown link.
10
+ - Non-ASCII names are encoded as UTF-8 (`zażółć.md` → `za%C5%BC%C3%B3%C5%82%C4%87.md`).
11
+ - [Fix] **llms.txt Descriptions** - A page description is now always a single line, and the 200-character limit no longer cuts a Markdown link or image in half; a link or image that does not fit is left out.
12
+ - [Fix] **Transformed Files Picked Up as Sources** - `bulk-transform` and `generate` now skip files ending with the transform suffix (`*.llm.md` by default, or `*<suffix>.md`).
13
+ - A second `bulk-transform` run no longer writes `*.llm.llm.md` files.
14
+ - `generate` lists each page once, from its source file, instead of listing both `README.md` and `README.llm.md`.
15
+ - Source files whose names end with the suffix (e.g. `notes.llm.md`) are skipped too. An empty suffix skips nothing.
16
+ - [Fix] **Angle-Bracket Link Destinations** - `base_url` link expansion now resolves CommonMark `[text](<path with spaces>)` links like other relative links: the brackets are dropped and spaces are percent-encoded (`<../my docs/a b.md>` → `<base_url>/my%20docs/a%20b.md`) instead of producing a broken URL with a stray `>`.
17
+ - [Fix] **Query-Only Links** - `base_url` link expansion now resolves a query-only link like `[q](?tab=2)` to the current page (`guides/sub/page.md` gives `<base_url>/guides/sub/page.md?tab=2`, as RFC 3986 does) instead of the page's directory (`<base_url>/guides/sub/?tab=2`). Anchor-only links like `#self` are unchanged.
18
+ - [Fix] **`simplify_links` Strips Only One Prefix** - repeated filler prefixes are now all removed, so `Click here to see the docs` becomes `docs` (as documented) instead of `see the`.
19
+ - [Fix] **Links Inside Code Are Rewritten** - link transformations (`base_url` expansion, `convert_urls`, `simplify_links`) no longer change links in fenced code blocks, indented code blocks and inline code spans.
20
+ - [Fix] **`convert_urls` Skips Anchors, Queries and Relative Links** - `.html`/`.htm` URLs followed by an anchor or a query are now converted (`doc.html#sec` → `doc.md#sec`, `doc.html?x=1` → `doc.md?x=1`).
21
+ - Relative link targets are converted too, also without `base_url` (`[Intro](../intro.html)` → `[Intro](../intro.md)`).
22
+ - `.html` inside a query or an anchor and targets with a non-HTTP scheme (`mailto:`, `ftp:`, `sftp:`, `file:`, ...) are left unchanged.
23
+ - [Fix] **Links With Brackets in Their Text** - `base_url` link expansion now expands links whose text holds brackets, nested up to two levels deep (`[a [b] c](../intro.md)`), and linked images like `[![alt](../img/a.png)](../intro.md)`, where both the image and the outer link are now expanded. Before, such links stayed relative, and a linked image got only its image expanded.
24
+ - [Fix] **Links With Any URI Scheme** - `base_url` link expansion now leaves every link with a URI scheme unchanged, in any case (`HTTPS://`, `Mailto:`, `sftp:`, `ssh:`, `git:`, `irc:`, `urn:`, `data:`, `javascript:`, ...). Before, only lowercase `http`, `https`, `mailto`, `ftp` and `tel` links were kept, and others became broken URLs like `<base_url>/guides/sftp:/host/x`.
25
+ - [Fix] **Link Reference Definitions** - `base_url` link expansion now expands reference-style link definitions (`[id]: ../intro.md "Title"`) like inline links, so `[text][id]`, `[id][]` and `[id]` links no longer stay relative while inline links in the same file are expanded. Titles are kept; definitions with a URI scheme, anchors, footnotes (`[^1]: ...`) and definitions in code are unchanged.
26
+ - `convert_urls` converts `.html`/`.htm` definition targets too (`[id]: ../intro.html` → `[id]: ../intro.md`).
27
+ - [Fix] **`compare` Web Server Link** - The "Configure your web server" link in the `compare` output now points at the README section "Serving Optimized Docs to AI Bots" (`#serving-optimized-docs-to-ai-bots`) instead of a missing `#serving-optimized-docs` anchor.
28
+
29
+ ## 1.0.0 (2026-08-24)
30
+ - [Fix] **Link Expansion Corrupts Non-HTTP URI Schemes** - `LinkTransformer#expand_relative_links` no longer rewrites `mailto:`, `tel:`, and `ftp:` links into dead `https://...` URLs when `base_url` is set (#165).
31
+ - Any link whose scheme is in the known absolute-scheme list (`http`, `https`, `mailto`, `ftp`, `tel`) is now left untouched, matching `HtmlToMarkdownConverter`'s `SAFE_URI_SCHEMES`.
32
+ - Relative links (e.g. `./guide.md`) continue to expand as before.
33
+ - [Enhancement] Add Ruby warning category opt-in to test helpers
34
+
3
35
  ## 0.13.0 (2026-05-11)
4
36
  - [Breaking] Dropped Ruby 3.2 support. Minimum required Ruby version is now 3.3.
5
- - [Fix] **Heading Nesting in Normalize Mode** — Fixed `normalize_headings` producing incorrect heading levels when same-level headings are nested under a parent.
37
+ - [Fix] **Heading Nesting in Normalize Mode** - Fixed `normalize_headings` producing incorrect heading levels when same-level headings are nested under a parent.
6
38
  - Consecutive same-level headings (e.g., two `##` headings) now correctly adjust the child's markdown level to be deeper than the parent's (e.g., `##` → `###`)
7
39
  - Effective level formula: `max(parent_effective_level + 1, original_level)`
8
40
  - ATX closing hashes (`## Title ##`) are now stripped from titles before building hierarchical paths
9
- - [Fix] **HTML-to-Markdown Section Nesting** — Headings inside nested `<section>` elements now receive adjusted markdown levels based on section nesting depth.
41
+ - [Fix] **HTML-to-Markdown Section Nesting** - Headings inside nested `<section>` elements now receive adjusted markdown levels based on section nesting depth.
10
42
  - `<h2>` inside a nested `<section>` under another `<section>` with its own `<h2>` is now correctly rendered as `###` instead of `##`
11
43
 
12
44
  ## 0.12.0 (2025-11-12)
13
- - [Feature] **HTML to Markdown Reverse Converter** — Added support for converting HTML content to markdown format.
45
+ - [Feature] **HTML to Markdown Reverse Converter** - Added support for converting HTML content to markdown format.
14
46
  - Enables processing of HTML documentation sources
15
47
  - Integrates seamlessly with the transformer pipeline
16
48
  - Useful for converting web-based docs to markdown for further processing
17
49
  - By @Eric-Guo in PR #32.
18
50
 
19
51
  ## 0.11.0 (2025-11-03)
20
- - [Feature] **Transform from URL** — The `transform` command now accepts a remote URL via `--url` and processes fetched content through the standard transformer pipeline.
52
+ - [Feature] **Transform from URL** - The `transform` command now accepts a remote URL via `--url` and processes fetched content through the standard transformer pipeline.
21
53
  - Example: `llm-docs-builder transform --url https://example.com/docs/page.html`
22
54
  - Applies all configured transformations and output options identically to local files
23
55
  - By @Eric-Guo and @codex in PR #28.
data/Gemfile CHANGED
@@ -8,3 +8,7 @@ group :development do
8
8
  gem 'pry'
9
9
  gem 'yard-lint'
10
10
  end
11
+
12
+ group :test do
13
+ gem 'warning'
14
+ end
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- llm-docs-builder (0.13.0)
4
+ llm-docs-builder (1.1.0)
5
5
  nokogiri (~> 1.17)
6
6
  zeitwerk (~> 2.6)
7
7
 
@@ -11,30 +11,29 @@ GEM
11
11
  ast (2.4.3)
12
12
  coderay (1.1.3)
13
13
  diff-lcs (1.6.2)
14
- docile (1.4.1)
15
- io-console (0.8.2)
16
- json (2.19.5)
17
- language_server-protocol (3.17.0.5)
14
+ io-console (0.9.4)
15
+ json (3.0.2)
16
+ language_server-protocol (3.17.0.6)
18
17
  lint_roller (1.1.0)
19
18
  method_source (1.1.0)
20
19
  mini_portile2 (2.8.9)
21
- nokogiri (1.19.3)
20
+ nokogiri (1.19.4)
22
21
  mini_portile2 (~> 2.8.2)
23
22
  racc (~> 1.4)
24
- parallel (2.1.0)
25
- parser (3.3.11.1)
23
+ parallel (2.2.0)
24
+ parser (3.3.12.0)
26
25
  ast (~> 2.4.1)
27
26
  racc
28
27
  prism (1.9.0)
29
- pry (0.16.0)
28
+ pry (0.16.1)
30
29
  coderay (~> 1.1)
31
30
  method_source (~> 1.0)
32
31
  reline (>= 0.6.0)
33
32
  racc (1.8.1)
34
33
  rainbow (3.1.1)
35
34
  rake (13.4.2)
36
- regexp_parser (2.12.0)
37
- reline (0.6.3)
35
+ regexp_parser (2.13.1)
36
+ reline (0.7.0)
38
37
  io-console (~> 0.5)
39
38
  rspec (3.13.2)
40
39
  rspec-core (~> 3.13.0)
@@ -49,8 +48,8 @@ GEM
49
48
  diff-lcs (>= 1.2.0, < 2.0)
50
49
  rspec-support (~> 3.13.0)
51
50
  rspec-support (3.13.7)
52
- rubocop (1.86.1)
53
- json (~> 2.3)
51
+ rubocop (1.91.0)
52
+ json (>= 2.3)
54
53
  language_server-protocol (~> 3.17.0.2)
55
54
  lint_roller (~> 1.1.0)
56
55
  parallel (>= 1.10)
@@ -60,24 +59,20 @@ GEM
60
59
  rubocop-ast (>= 1.49.0, < 2.0)
61
60
  ruby-progressbar (~> 1.7)
62
61
  unicode-display_width (>= 2.4.0, < 4.0)
63
- rubocop-ast (1.49.1)
62
+ rubocop-ast (1.50.0)
64
63
  parser (>= 3.3.7.2)
65
64
  prism (~> 1.7)
66
65
  ruby-progressbar (1.13.0)
67
- simplecov (0.22.0)
68
- docile (~> 1.1)
69
- simplecov-html (~> 0.11)
70
- simplecov_json_formatter (~> 0.1)
71
- simplecov-html (0.13.2)
72
- simplecov_json_formatter (0.1.4)
73
- unicode-display_width (3.2.0)
74
- unicode-emoji (~> 4.1)
75
- unicode-emoji (4.2.0)
76
- yard (0.9.43)
77
- yard-lint (1.5.1)
66
+ simplecov (1.3.2)
67
+ unicode-display_width (3.3.0)
68
+ unicode-emoji (~> 4.3)
69
+ unicode-emoji (4.3.0)
70
+ warning (1.6.0)
71
+ yard (0.9.45)
72
+ yard-lint (1.13.0)
78
73
  yard (~> 0.9)
79
74
  zeitwerk (~> 2.6)
80
- zeitwerk (2.7.5)
75
+ zeitwerk (2.8.3)
81
76
 
82
77
  PLATFORMS
83
78
  ruby
@@ -89,8 +84,9 @@ DEPENDENCIES
89
84
  rake (~> 13.0)
90
85
  rspec (~> 3.0)
91
86
  rubocop (~> 1.0)
92
- simplecov (~> 0.21)
87
+ simplecov (~> 1.0)
88
+ warning
93
89
  yard-lint
94
90
 
95
91
  BUNDLED WITH
96
- 2.6.9
92
+ 4.0.17
data/README.md CHANGED
@@ -125,7 +125,7 @@ llm-docs-builder generate --config llm-docs-builder.yml
125
125
  ```yaml
126
126
  # llm-docs-builder.yml
127
127
  docs: ./docs
128
- base_url: https://myproject.io
128
+ base_url: https://myproject.io # Base URL for expanding relative links
129
129
  title: My Project
130
130
  description: Brief description
131
131
  body: Optional body content between description and sections
@@ -165,6 +165,12 @@ excludes:
165
165
  - "**/drafts/**"
166
166
  ```
167
167
 
168
+ **Link expansion (`base_url`):** relative links become absolute URLs.
169
+ - Inline links, images (also linked images, `[![alt](a.png)](../intro.md)`) and reference-style definitions (`[a]: ../intro.md`) are expanded. `<path with spaces>` destinations are resolved and their spaces encoded as `%20`.
170
+ - `bulk-transform` resolves links from each file's directory under `docs`. Single-file `transform` (and `LlmDocsBuilder.transform_markdown`) resolves them from `base_url`: with `base_url: https://myproject.io/docs`, `../intro.md` becomes `https://myproject.io/intro.md`.
171
+ - Links starting with `/` resolve from `base_url`. In `bulk-transform`, a query-only link (`[q](?tab=2)`) resolves to the current page (`.../page.md?tab=2`).
172
+ - Left unchanged: links in fenced code, indented code and inline code; anchors (`#x`); protocol-relative URLs (`//cdn.io/a.md`); URLs with any scheme, in any case (`HTTPS:`, `sftp:`, `urn:`, `mailto:`); HTML `<a href>` and `<img src>`.
173
+
168
174
  ## CLI Commands
169
175
 
170
176
  ```bash
@@ -183,31 +189,34 @@ llm-docs-builder version # Show version
183
189
  -d, --docs PATH Documentation path
184
190
  -o, --output PATH Output file
185
191
  -u, --url URL URL for comparison
192
+ -f, --file PATH Local markdown file for comparison
186
193
  -v, --verbose Detailed output
187
194
  ```
188
195
 
189
196
  ## Serving Optimized Docs to AI Bots
190
197
 
191
- After using `bulk-transform` with `suffix: .llm`, configure your web server to serve optimized versions to AI bots:
198
+ After using `bulk-transform` with `suffix: .llm`, configure your web server to serve optimized versions to AI bots. Pages without an `.llm.md` version are served as is:
192
199
 
193
200
  **Apache (.htaccess):**
194
201
  ```apache
195
202
  SetEnvIf User-Agent "(?i)(openai|anthropic|claude|gpt)" IS_LLM_BOT
203
+ RewriteEngine On
196
204
  RewriteCond %{ENV:IS_LLM_BOT} !^$
205
+ RewriteCond %{REQUEST_URI} !\.llm\.md$
206
+ RewriteCond %{REQUEST_FILENAME} ^(.+)\.md$
207
+ RewriteCond %1.llm.md -f
197
208
  RewriteRule ^(.*)\.md$ $1.llm.md [L]
198
209
  ```
199
210
 
200
211
  **Nginx:**
201
212
  ```nginx
202
- map $http_user_agent $is_llm_bot {
203
- default 0;
204
- "~*(?i)(openai|anthropic|claude|gpt)" 1;
213
+ map $http_user_agent $llm_suffix {
214
+ default "";
215
+ "~*(openai|anthropic|claude|gpt)" ".llm";
205
216
  }
206
217
 
207
- location ~ ^/docs/(.*)\.md$ {
208
- if ($is_llm_bot) {
209
- rewrite ^(.*)\.md$ $1.llm.md last;
210
- }
218
+ location ~ ^/docs/(?<doc>.*)\.md$ {
219
+ try_files /docs/$doc$llm_suffix.md $uri =404;
211
220
  }
212
221
  ```
213
222
 
@@ -258,7 +267,10 @@ api = API.new
258
267
  ```markdown
259
268
  # API Documentation
260
269
 
261
- [complete API documentation](./api.md)
270
+
271
+ > Important: This is a note
272
+
273
+ [Click here to see the complete API documentation](./api.md)
262
274
 
263
275
  api = API.new
264
276
  ```
@@ -411,7 +423,7 @@ All compression features can be used individually for fine-grained control:
411
423
  - `generate_toc: true` - Generate table of contents from headings with anchor links
412
424
  - `custom_instruction: "text"` - Inject AI context message at document top
413
425
  - `simplify_links: true` - Simplify verbose link text (e.g., "Click here to see the docs" → "docs")
414
- - `convert_urls: true` - Convert `.html`/`.htm` URLs to `.md` format
426
+ - `convert_urls: true` - Convert `.html`/`.htm` URLs to `.md` format, keeping anchors and queries (`../intro.html#x` → `../intro.md#x`): absolute `http(s)` URLs anywhere in the text, and relative link and reference-style definition targets. URLs in code are unchanged
415
427
  - `normalize_whitespace: true` - Reduce excessive blank lines and remove trailing whitespace
416
428
 
417
429
  ## License
@@ -76,16 +76,19 @@ module LlmDocsBuilder
76
76
 
77
77
  # Recursively scans the docs directory for markdown files
78
78
  #
79
- # Skips hidden files (starting with dot) and returns sorted array of paths
79
+ # Skips hidden files (starting with dot) and already transformed files (ending with
80
+ # the suffix, e.g. README.llm.md) and returns sorted array of paths
80
81
  #
81
82
  # @return [Array<String>] paths to markdown files
82
83
  def find_markdown_files
83
84
  files = []
85
+ output_ending = "#{options[:suffix]}.md".downcase
84
86
 
85
87
  Find.find(docs_path) do |path|
86
88
  next unless File.file?(path)
87
89
  next unless path.match?(/\.md$/i)
88
90
  next if File.basename(path).start_with?('.')
91
+ next if output_ending != '.md' && path.downcase.end_with?(output_ending)
89
92
 
90
93
  files << path
91
94
  end
@@ -109,12 +112,14 @@ module LlmDocsBuilder
109
112
 
110
113
  # Applies markdown transformations to a single file
111
114
  #
112
- # Creates MarkdownTransformer instance and delegates transformation
115
+ # Creates MarkdownTransformer instance and delegates transformation. Passes the file's
116
+ # path relative to the docs directory so relative links resolve from its own location.
113
117
  #
114
118
  # @param file_path [String] path to markdown file
115
119
  # @return [String] transformed content
116
120
  def transform_file(file_path)
117
- transformer = MarkdownTransformer.new(file_path, options)
121
+ source_path = Pathname.new(file_path).relative_path_from(Pathname.new(docs_path)).to_s
122
+ transformer = MarkdownTransformer.new(file_path, options.merge(source_path: source_path))
118
123
  transformer.transform
119
124
  end
120
125
 
@@ -129,8 +129,7 @@ module LlmDocsBuilder
129
129
 
130
130
  # Generate llms.txt from documentation directory or file
131
131
  #
132
- # Loads configuration, merges with CLI options, generates llms.txt content,
133
- # and optionally validates the output.
132
+ # Loads configuration, merges with CLI options, generates llms.txt content, and optionally validates the output.
134
133
  #
135
134
  # @param options [Hash] command options from parse_options
136
135
  # @option options [String] :config path to config file
@@ -12,6 +12,10 @@ module LlmDocsBuilder
12
12
  #
13
13
  # @api public
14
14
  class Generator
15
+ # Maximum length of a file description in llms.txt, in characters
16
+ MAX_DESCRIPTION_LENGTH = 200
17
+ private_constant :MAX_DESCRIPTION_LENGTH
18
+
15
19
  # @return [String] path to documentation directory or file
16
20
  attr_reader :docs_path
17
21
 
@@ -27,6 +31,7 @@ module LlmDocsBuilder
27
31
  # @option options [String] :description project description (overrides auto-detection)
28
32
  # @option options [String] :output output file path for saving
29
33
  # @option options [Boolean] :verbose enable verbose output
34
+ # @option options [String] :suffix suffix of bulk-transformed files to skip (default: '.llm')
30
35
  def initialize(docs_path, options = {})
31
36
  @docs_path = docs_path
32
37
  @options = options
@@ -34,8 +39,7 @@ module LlmDocsBuilder
34
39
 
35
40
  # Generate llms.txt content from documentation
36
41
  #
37
- # Scans documentation files, extracts metadata, prioritizes them, and builds a formatted
38
- # llms.txt file.
42
+ # Scans documentation files, extracts metadata, prioritizes them, and builds a formatted llms.txt file.
39
43
  #
40
44
  # @return [String] generated llms.txt content
41
45
  def generate
@@ -69,11 +73,13 @@ module LlmDocsBuilder
69
73
 
70
74
  # Recursively finds and analyzes markdown files in directory
71
75
  #
72
- # Sorts by priority (README, guides, etc.) and skips hidden files
76
+ # Sorts by priority (README, guides, etc.) and skips hidden files and bulk-transformed
77
+ # files (ending with the suffix, e.g. README.llm.md)
73
78
  #
74
79
  # @return [Array<Hash>] sorted array of analyzed file metadata
75
80
  def find_markdown_files_in_directory
76
81
  files = []
82
+ output_ending = "#{options.fetch(:suffix, '.llm')}.md".downcase
77
83
 
78
84
  Find.find(docs_path) do |path|
79
85
  # Skip hidden directories unless explicitly enabled
@@ -86,6 +92,7 @@ module LlmDocsBuilder
86
92
  next unless File.file?(path)
87
93
  next unless path.match?(/\.md$/i)
88
94
  next if File.basename(path).start_with?('.')
95
+ next if output_ending != '.md' && path.downcase.end_with?(output_ending)
89
96
  next if should_exclude?(path)
90
97
 
91
98
  files << analyze_file(path)
@@ -158,7 +165,8 @@ module LlmDocsBuilder
158
165
 
159
166
  # Extracts description from file content
160
167
  #
161
- # Takes first paragraph after title, truncated to 200 characters
168
+ # Takes first paragraph after title as a single line, truncated to MAX_DESCRIPTION_LENGTH characters.
169
+ # A Markdown link or image that would be cut by the limit is dropped entirely.
162
170
  #
163
171
  # @param content [String] file content
164
172
  # @return [String] extracted description
@@ -171,7 +179,62 @@ module LlmDocsBuilder
171
179
  # Get first paragraph
172
180
  first_paragraph = description_lines.take_while { |line| !line.strip.empty? }
173
181
 
174
- first_paragraph.join(' ').strip.slice(0, 200)
182
+ description = first_paragraph.join(' ').gsub(/\s+/, ' ').strip
183
+ return description if description.length <= MAX_DESCRIPTION_LENGTH
184
+
185
+ cut_link = link_spans(description).find do |span|
186
+ span.begin < MAX_DESCRIPTION_LENGTH && span.end >= MAX_DESCRIPTION_LENGTH
187
+ end
188
+
189
+ return description.slice(0, MAX_DESCRIPTION_LENGTH) unless cut_link
190
+
191
+ description.slice(0, cut_link.begin).rstrip
192
+ end
193
+
194
+ # Finds the character ranges of Markdown links and images in text
195
+ #
196
+ # Brackets and parentheses are matched by nesting depth, so linked images, nested link text, URLs
197
+ # with parentheses and reference links are each one range. An unclosed bracket is not a link.
198
+ #
199
+ # @param text [String] single-line text
200
+ # @return [Array<Range>] inclusive index ranges
201
+ def link_spans(text)
202
+ closing = closing_indexes(text)
203
+ spans = []
204
+ index = 0
205
+
206
+ while (start = text.index('[', index))
207
+ stop = closing[start]
208
+ index = start + 1
209
+ next unless stop
210
+
211
+ stop = closing[stop + 1] while closing[stop + 1]
212
+ start -= 1 if start.positive? && text[start - 1] == '!'
213
+ spans << (start..stop)
214
+ index = stop + 1
215
+ end
216
+
217
+ spans
218
+ end
219
+
220
+ # Maps the index of each opening bracket or parenthesis to the index of the one that closes it
221
+ #
222
+ # @param text [String] text to search
223
+ # @return [Hash{Integer => Integer}] closing index by opening index, unclosed ones are absent
224
+ def closing_indexes(text)
225
+ closing = {}
226
+ open_indexes = { '[' => [], '(' => [] }
227
+ pairs = { ']' => '[', ')' => '(' }
228
+
229
+ text.each_char.with_index do |char, index|
230
+ if open_indexes.key?(char)
231
+ open_indexes[char] << index
232
+ elsif pairs.key?(char) && (opened = open_indexes[pairs[char]].pop)
233
+ closing[opened] = index
234
+ end
235
+ end
236
+
237
+ closing
175
238
  end
176
239
 
177
240
  # Assigns priority to file based on filename patterns
@@ -311,13 +374,18 @@ module LlmDocsBuilder
311
374
 
312
375
  # Constructs full URL from path using base_url option if provided
313
376
  #
377
+ # Each path segment is percent-encoded, so names with spaces, parentheses or
378
+ # non-ASCII characters give a valid Markdown link destination.
379
+ #
314
380
  # @param path [String] relative path to file
315
381
  # @return [String] full URL or relative path
316
382
  def build_url(path)
383
+ encoded_path = path.split('/').map { |segment| ERB::Util.url_encode(segment) }.join('/')
384
+
317
385
  if (base_url = options[:base_url])
318
- File.join(base_url, path)
386
+ File.join(base_url, encoded_path)
319
387
  else
320
- path
388
+ encoded_path
321
389
  end
322
390
  end
323
391