ruby_llm-browser 0.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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/CODE_OF_CONDUCT.md +11 -0
  4. data/CONTRIBUTING.md +42 -0
  5. data/LICENSE.txt +21 -0
  6. data/NOTICE.md +92 -0
  7. data/README.md +139 -0
  8. data/SECURITY.md +46 -0
  9. data/docs/API.md +177 -0
  10. data/docs/CNAME +1 -0
  11. data/docs/RAILS.md +167 -0
  12. data/docs/README.md +16 -0
  13. data/docs/RELEASE_ACTION_PLAN.md +81 -0
  14. data/docs/RELEASE_AUDIT.md +158 -0
  15. data/docs/RELEASING.md +131 -0
  16. data/docs/SECURITY_MODEL.md +117 -0
  17. data/docs/SPEC_BASELINE.md +107 -0
  18. data/docs/WORK.md +260 -0
  19. data/docs/_advanced/live-backend-qualification.md +74 -0
  20. data/docs/_advanced/rails-integration.md +56 -0
  21. data/docs/_config.yml +109 -0
  22. data/docs/_core_features/inspect-extract.md +91 -0
  23. data/docs/_core_features/offline-act.md +110 -0
  24. data/docs/_core_features/operation-lifecycle.md +63 -0
  25. data/docs/_core_features/source-policy.md +61 -0
  26. data/docs/_data/navigation.yml +12 -0
  27. data/docs/_data/sidebar.yml +8 -0
  28. data/docs/_data/social_links.yml +3 -0
  29. data/docs/_getting_started/configuration.md +68 -0
  30. data/docs/_getting_started/getting-started.md +77 -0
  31. data/docs/_getting_started/overview.md +52 -0
  32. data/docs/_plugins/versioned_docs.rb +46 -0
  33. data/docs/_reference/api-reference.md +29 -0
  34. data/docs/_reference/release-history.md +23 -0
  35. data/docs/_reference/upgrading.md +33 -0
  36. data/docs/bin/build-api.sh +23 -0
  37. data/docs/bin/build.sh +161 -0
  38. data/docs/bin/export-api-markdown.rb +53 -0
  39. data/docs/bin/link_versions.rb +54 -0
  40. data/docs/bin/postprocess_api_seo.rb +129 -0
  41. data/docs/bin/prepare_versions.rb +40 -0
  42. data/docs/bin/serve.sh +12 -0
  43. data/docs/bin/versions.yml +14 -0
  44. data/docs/index.md +82 -0
  45. data/examples/plain_ruby.rb +34 -0
  46. data/examples/rails_adapter.rb +47 -0
  47. data/lib/generators/ruby_llm/browser/install_generator.rb +29 -0
  48. data/lib/generators/ruby_llm/browser/templates/create_ruby_llm_browser_tables.rb.tt +60 -0
  49. data/lib/ruby_llm/browser/approval.rb +128 -0
  50. data/lib/ruby_llm/browser/approval_store.rb +38 -0
  51. data/lib/ruby_llm/browser/backend.rb +99 -0
  52. data/lib/ruby_llm/browser/budget.rb +90 -0
  53. data/lib/ruby_llm/browser/capabilities.rb +76 -0
  54. data/lib/ruby_llm/browser/configuration.rb +74 -0
  55. data/lib/ruby_llm/browser/context.rb +117 -0
  56. data/lib/ruby_llm/browser/doctor.rb +134 -0
  57. data/lib/ruby_llm/browser/engine.rb +326 -0
  58. data/lib/ruby_llm/browser/errors.rb +86 -0
  59. data/lib/ruby_llm/browser/fake_backend.rb +134 -0
  60. data/lib/ruby_llm/browser/immutable.rb +19 -0
  61. data/lib/ruby_llm/browser/in_memory_approval_store.rb +67 -0
  62. data/lib/ruby_llm/browser/in_memory_operation_store.rb +174 -0
  63. data/lib/ruby_llm/browser/operation.rb +218 -0
  64. data/lib/ruby_llm/browser/operation_store.rb +92 -0
  65. data/lib/ruby_llm/browser/policy.rb +298 -0
  66. data/lib/ruby_llm/browser/rails/active_record_approval_store.rb +72 -0
  67. data/lib/ruby_llm/browser/rails/active_record_operation_store.rb +231 -0
  68. data/lib/ruby_llm/browser/rails/actor_record.rb +13 -0
  69. data/lib/ruby_llm/browser/rails/approval_record.rb +49 -0
  70. data/lib/ruby_llm/browser/rails/operation_job.rb +163 -0
  71. data/lib/ruby_llm/browser/rails/operation_record.rb +56 -0
  72. data/lib/ruby_llm/browser/rails/reaper.rb +117 -0
  73. data/lib/ruby_llm/browser/rails/schema.rb +81 -0
  74. data/lib/ruby_llm/browser/rails.rb +79 -0
  75. data/lib/ruby_llm/browser/redactor.rb +159 -0
  76. data/lib/ruby_llm/browser/result.rb +102 -0
  77. data/lib/ruby_llm/browser/stagehand_backend.rb +332 -0
  78. data/lib/ruby_llm/browser/tool.rb +70 -0
  79. data/lib/ruby_llm/browser/tools/act.rb +91 -0
  80. data/lib/ruby_llm/browser/tools/extract.rb +60 -0
  81. data/lib/ruby_llm/browser/tools/inspect.rb +38 -0
  82. data/lib/ruby_llm/browser/url_guard.rb +253 -0
  83. data/lib/ruby_llm/browser/version.rb +9 -0
  84. data/lib/ruby_llm/browser.rb +79 -0
  85. metadata +267 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runs once per build, after Jekyll reads the site.
4
+ #
5
+ # - docs_unreleased is true for the /next/ build, and for an unversioned build
6
+ # while _config.yml says `released: false`. Pages use it to show the install
7
+ # command for the default branch. A build of a release tag is never
8
+ # unreleased, even when that tag forgot to set `released: true`.
9
+ # - A versioned build takes `version` from _data/versions.yml, which
10
+ # bin/prepare_versions.rb fills in from the tag, so a tag that forgot to bump
11
+ # `version` still shows the right install command.
12
+ # - An absolute canonical_url on this site becomes a path, so the theme adds
13
+ # this build's base path (/next/, /v1/) and the page stays in its own
14
+ # build's llms.txt and sitemap.
15
+ # - llms.txt names the documented version, and links to the site's /api/
16
+ # point at this build's API reference.
17
+ Jekyll::Hooks.register :site, :post_read, priority: :high do |site|
18
+ versions = site.data['versions'].is_a?(Hash) ? site.data['versions'] : {}
19
+ current = versions['current'].to_s
20
+ if current.empty?
21
+ site.config['docs_unreleased'] = site.config['released'] == false
22
+ else
23
+ site.config['docs_unreleased'] = [ 'next', 'Next (unreleased)' ].include?(current)
24
+ release = versions['release'].to_s
25
+ site.config['version'] = release unless release.empty?
26
+ end
27
+
28
+ origin = site.config['url'].to_s.delete_suffix('/')
29
+ base = site.config['baseurl'].to_s.delete_suffix('/')
30
+ root = "#{origin}#{versions.fetch('base', base).to_s.delete_suffix('/')}"
31
+ unless origin.empty?
32
+ (site.pages + site.collections.values.flat_map(&:docs)).each do |page|
33
+ canonical = page.data['canonical_url']
34
+ next unless canonical.is_a?(String) && canonical.start_with?("#{root}/")
35
+
36
+ page.data['canonical_url'] = canonical.delete_prefix(root)
37
+ end
38
+ end
39
+
40
+ llms = site.config.dig('jekyll_vitepress', 'llms')
41
+ next unless llms.is_a?(Hash) && !current.empty?
42
+
43
+ label = current == 'next' ? 'Next (unreleased)' : current
44
+ llms['description'] = "Developer documentation for #{site.config['title']} #{label}."
45
+ llms['details'] = llms['details'].to_s.gsub("#{root}/api/", "#{origin}#{base}/api/") unless origin.empty?
46
+ end
@@ -0,0 +1,29 @@
1
+ ---
2
+ layout: default
3
+ title: API Reference
4
+ nav_order: 1
5
+ guide: false
6
+ description: Find the RubyLLM::Browser classes for tools, policy, results, backends, and Rails jobs.
7
+ ---
8
+
9
+ # {{ page.title }}
10
+
11
+ {{ page.description }}
12
+ {: .fs-6 .fw-300 }
13
+
14
+ The [generated RDoc reference]({{ '/api/' | relative_url }}) documents classes and public methods from the Ruby source. Start with the task you need to perform:
15
+
16
+ | Task | Public API |
17
+ | --- | --- |
18
+ | Build trusted inputs for an operation | `Context`, `Policy`, `Budget` |
19
+ | Inspect a page or extract structured data | `Inspect`, `Extract`, `Engine` |
20
+ | Simulate one host-approved action offline | `Act`, `Approval`, `ApprovalStore` |
21
+ | Read a bounded result | `Result` |
22
+ | Use an offline backend | `FakeBackend` |
23
+ | Adapt the Stagehand Ruby SDK | `StagehandBackend`, `Capabilities`, `Doctor` |
24
+ | Persist operation or approval state | `OperationStore`, `ApprovalStore` and their implementations |
25
+ | Run Rails background jobs and cleanup | `RubyLLM::Browser::Rails::OperationJob`, `Reaper` |
26
+
27
+ Start with [Getting Started]({% link _getting_started/getting-started.md %}) for a runnable offline example. The [API and configuration guide]({% link API.md %}) explains inputs, results, limits, and errors.
28
+
29
+ The reference is generated from source comments. Build it locally with `docs/bin/build-api.sh tmp/api`.
@@ -0,0 +1,23 @@
1
+ ---
2
+ layout: default
3
+ title: Release History
4
+ nav_order: 9
5
+ guide: false
6
+ description: Follow release tags and their documentation for RubyLLM::Browser.
7
+ ---
8
+
9
+ # {{ page.title }}
10
+
11
+ {{ page.description }}
12
+ {: .fs-6 .fw-300 }
13
+
14
+ {% if site.docs_unreleased %}The current source build follows `main`. The latest stable release is `0.1.0`.
15
+ {% else %}This documentation describes the `{{ site.version }}` release. Use the tag and
16
+ release notes to identify the exact source for this version.
17
+ {% endif %}
18
+
19
+ | Release | Date | Highlights |
20
+ | --- | --- | --- |
21
+ | [0.1.0](https://github.com/yorzi/ruby_llm-browser/releases/tag/v0.1.0) | 2026-10-18 | Offline read-only Inspect and Extract, Rails adapter, and Act simulation; live Stagehand and live Act are unsupported |
22
+
23
+ For changes to existing applications, follow [Upgrading]({% link _reference/upgrading.md %}).
@@ -0,0 +1,33 @@
1
+ ---
2
+ layout: default
3
+ title: Upgrading
4
+ nav_order: 2
5
+ description: Check the supported upgrade path before moving a RubyLLM::Browser application to a new version.
6
+ ---
7
+
8
+ # {{ page.title }}
9
+
10
+ {{ page.description }}
11
+ {: .fs-6 .fw-300 }
12
+
13
+ After reading this guide, you will know:
14
+
15
+ * What changed in the first public release.
16
+ * Where to find migration steps for earlier development snapshots.
17
+ * What evidence must exist before a release tag is treated as supported.
18
+
19
+ ## Public Releases
20
+
21
+ Version `{{ site.version }}` is the first public release. It has no earlier public version
22
+ to migrate from. Install it with `bundle add ruby_llm-browser --version "~> {{ site.version }}"`.
23
+
24
+ ## Earlier Development Snapshots
25
+
26
+ If you ran an earlier development Rails generator, do not drop existing operation data
27
+ or rerun a create-table migration. Follow the additive migration instructions under
28
+ "Upgrading an earlier development snapshot" in the [Rails guide]({% link RAILS.md %}). Drain workers during the migration and do not reuse idempotency keys for legacy rows that lack request digests.
29
+
30
+ ## Next Steps
31
+
32
+ * Review the [release history]({% link _reference/release-history.md %}) for version records.
33
+ * Review the [release audit]({% link RELEASE_AUDIT.md %}) before enabling a live backend.
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # Build the RDoc API reference, with a Markdown copy of every class page and
3
+ # the guides' search and social metadata, into <output-dir>.
4
+ # docs/bin/build-api.sh <output-dir> [source-root]
5
+ # bin/build.sh sets SITE_URL to the build's address (such as
6
+ # https://example.com/next) and ROOT_URL to the production site's.
7
+ set -euo pipefail
8
+
9
+ out="${1:?usage: build-api.sh <output-dir> [source-root]}"
10
+ docs="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
11
+ source_root="${2:-$(git -C "$docs" rev-parse --show-toplevel)}"
12
+ case "$out" in /*) ;; *) out="$PWD/$out" ;; esac
13
+
14
+ ( cd "$source_root" && ruby -rrdoc/rdoc -e 'RDoc::RDoc.new.document(ARGV)' -- --output "$out" --quiet lib )
15
+ ruby "$docs/bin/export-api-markdown.rb" "$out" "$source_root" "RubyLLM::Browser"
16
+
17
+ main="RubyLLM::Browser"
18
+ if [[ -n "$main" && -f "$out/$main.html" ]]; then
19
+ # Both pages sit at the same depth, so the module page's relative links work at /api/.
20
+ cp "$out/$main.html" "$out/index.html"
21
+ fi
22
+
23
+ ruby "$docs/bin/postprocess_api_seo.rb" "$out" "$docs/_config.yml" "$main.html"
data/docs/bin/build.sh ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bash
2
+ # Build the docs into docs/_site.
3
+ # Once the latest release tag has docs, that release is built at /, the working
4
+ # tree at /next/, and the newest tag of each older major version that has docs
5
+ # at /vN/. Until then, the working tree is built at /.
6
+ # docs/bin/build.sh BASE=/repo docs/bin/build.sh SITE=/tmp/out docs/bin/build.sh
7
+ # BASE is the site's base path. It defaults to baseurl in _config.yml; the
8
+ # workflow sets it from GitHub Pages, and an empty BASE means the site is at the
9
+ # root of its domain.
10
+ set -euo pipefail
11
+
12
+ unset RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLE_BIN BUNDLE_APP_CONFIG
13
+
14
+ docs="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
15
+ repo="$(git -C "$docs" rev-parse --show-toplevel)"
16
+ repo="$(cd "$repo" && pwd -P)"
17
+ docs="$(cd "$docs" && pwd -P)"
18
+ rel="${docs#"$repo"/}"
19
+ site_input="${SITE:-$docs/_site}"
20
+ site_input="${site_input%/}"
21
+ site_name="${site_input##*/}"
22
+ site_parent="${site_input%/*}"
23
+ if [[ "$site_input" == /* ]]; then
24
+ [[ -n "$site_parent" ]] || site_parent="/"
25
+ else
26
+ [[ -n "$site_parent" ]] || site_parent="."
27
+ site_parent="$PWD/$site_parent"
28
+ fi
29
+ site_parent="$(cd "$site_parent" && pwd -P)"
30
+ if [[ -z "$site_name" || "$site_name" == "." || "$site_name" == ".." ]]; then
31
+ echo "Refusing unsafe site output path: ${SITE:-$docs/_site}" >&2
32
+ exit 1
33
+ fi
34
+ site="$site_parent/$site_name"
35
+ if [[ "$site" == "/" || "$site" == "$repo" || "$site" == "$docs" || -L "$site" ]]; then
36
+ echo "Refusing unsafe site output path: $site" >&2
37
+ exit 1
38
+ fi
39
+ if [[ "$site" == "$repo/"* && "$site" != "$docs/_site" ]]; then
40
+ echo "Refusing site output inside the source tree: $site" >&2
41
+ exit 1
42
+ fi
43
+ marker="${site}.ruby-llm-browser-generated"
44
+ marker_contents="Generated by docs/bin/build.sh for $site"
45
+ if [[ -L "$marker" ]]; then
46
+ echo "Refusing to replace an existing unowned output directory: $site" >&2
47
+ echo "Choose a new SITE path or remove that output directory yourself." >&2
48
+ exit 1
49
+ fi
50
+ if [[ -e "$site" ]] && [[ ! -d "$site" || ! -f "$marker" || "$(<"$marker")" != "$marker_contents" ]]; then
51
+ echo "Refusing to replace an existing unowned output directory: $site" >&2
52
+ echo "Choose a new SITE path or remove that output directory yourself." >&2
53
+ exit 1
54
+ fi
55
+ gemfile="$docs/Gemfile"
56
+ read_config() { ruby -ryaml -e 'puts YAML.load_file(ARGV.fetch(0))[ARGV.fetch(1)].to_s.chomp("/")' "$docs/_config.yml" "$1"; }
57
+ origin="$(read_config url)"
58
+ BASE="${BASE-$(read_config baseurl)}"
59
+ BASE="${BASE%/}"
60
+
61
+ # A tag has docs when its tree holds this site's _config.yml on the same theme.
62
+ has_docs() { git -C "$repo" show "$1:$rel/_config.yml" 2>/dev/null | grep -q 'jekyll-vitepress-theme'; }
63
+ major() { local version="${1#v}"; echo "${version%%.*}"; }
64
+
65
+ tags="$(git -C "$repo" tag --list --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' || true)"
66
+ latest="$(printf '%s\n' "$tags" | head -n 1)"
67
+ release=""
68
+ if [[ -n "$latest" ]] && has_docs "$latest"; then
69
+ release="$latest"
70
+ fi
71
+
72
+ build_api() {
73
+ local output="$1" source_root="$2" prefix="$3"
74
+ if [[ -x "$docs/bin/build-api.sh" ]]; then
75
+ SITE_URL="$origin$prefix" ROOT_URL="$origin$BASE" "$docs/bin/build-api.sh" "$output/api" "$source_root"
76
+ fi
77
+ }
78
+
79
+ if [[ -z "$release" ]]; then
80
+ ( cd "$docs" && BUNDLE_GEMFILE="$gemfile" bundle exec jekyll build --baseurl "$BASE" -d "$site" --quiet )
81
+ build_api "$site" "$repo" "$BASE"
82
+ printf '%s\n' "$marker_contents" > "$marker"
83
+ echo "Built the working tree at ${BASE:-/}. No release has docs yet, so there is no /next/."
84
+ exit 0
85
+ fi
86
+
87
+ archives=()
88
+ top="$(major "$release")"
89
+ seen=" "
90
+ while read -r tag; do
91
+ [[ -n "$tag" ]] || continue
92
+ number="$(major "$tag")"
93
+ if (( number >= top )) || [[ "$seen" == *" $number "* ]]; then continue; fi
94
+ seen="$seen$number "
95
+ if has_docs "$tag"; then archives+=("$tag"); fi
96
+ done <<< "$tags"
97
+
98
+ workspace="$(mktemp -d)"
99
+ trap 'rm -rf "$workspace"' EXIT
100
+
101
+ extract() {
102
+ local tag="$1" target="$2" path
103
+ local paths=("$rel")
104
+ for path in lib .rdoc_options README.md; do
105
+ if git -C "$repo" cat-file -e "$tag:$path" 2>/dev/null; then paths+=("$path"); fi
106
+ done
107
+ mkdir -p "$target"
108
+ git -C "$repo" archive "$tag" -- "${paths[@]}" | tar -x -C "$target"
109
+ if [[ ! -f "$target/.rdoc_options" && -f "$repo/.rdoc_options" ]]; then
110
+ cp "$repo/.rdoc_options" "$target/.rdoc_options"
111
+ fi
112
+ }
113
+
114
+ prepare() {
115
+ local source="$1" channel="$2"
116
+ mkdir -p "$source/_data" "$source/_plugins"
117
+ cp "$docs/bin/versions.yml" "$source/_data/versions.yml"
118
+ cp "$docs/_plugins/versioned_docs.rb" "$source/_plugins/"
119
+ ruby "$docs/bin/prepare_versions.rb" "$source/_data/versions.yml" "$channel" "$BASE" "$release" \
120
+ ${archives[@]+"${archives[@]}"}
121
+ }
122
+
123
+ build() {
124
+ local source="$1" output="$2" prefix="$3" source_root="$4"
125
+ ( cd "$source" && BUNDLE_GEMFILE="$gemfile" bundle exec jekyll build --baseurl "$prefix" -d "$output" --quiet )
126
+ build_api "$output" "$source_root" "$prefix"
127
+ }
128
+
129
+ extract "$release" "$workspace/stable"
130
+ prepare "$workspace/stable/$rel" stable
131
+ build "$workspace/stable/$rel" "$workspace/stable-out" "$BASE" "$workspace/stable"
132
+
133
+ mkdir -p "$workspace/next"
134
+ rsync -a --exclude=_site --exclude=vendor --exclude=.jekyll-cache --exclude=.bundle "$docs/" "$workspace/next/"
135
+ prepare "$workspace/next" next
136
+ build "$workspace/next" "$workspace/next-out" "$BASE/next" "$repo"
137
+
138
+ channels=(next)
139
+ for tag in ${archives[@]+"${archives[@]}"}; do
140
+ channel="v$(major "$tag")"
141
+ extract "$tag" "$workspace/$channel"
142
+ prepare "$workspace/$channel/$rel" "$channel"
143
+ build "$workspace/$channel/$rel" "$workspace/$channel-out" "$BASE/$channel" "$workspace/$channel"
144
+ channels+=("$channel")
145
+ done
146
+
147
+ rm -rf "$site"
148
+ mkdir -p "$site"
149
+ cp -a "$workspace/stable-out/." "$site/"
150
+ for channel in "${channels[@]}"; do
151
+ mkdir -p "$site/$channel"
152
+ cp -a "$workspace/$channel-out/." "$site/$channel/"
153
+ done
154
+ ruby "$docs/bin/link_versions.rb" "$site" "$origin" "$BASE" "${channels[@]}"
155
+ printf '%s\n' "$marker_contents" > "$marker"
156
+
157
+ summary="Built $release at ${BASE:-/} and the working tree at $BASE/next/"
158
+ for tag in ${archives[@]+"${archives[@]}"}; do
159
+ summary="$summary, $tag at $BASE/v$(major "$tag")/"
160
+ done
161
+ echo "$summary."
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Writes a Markdown page for every documented class and module, plus an
4
+ # index.md, next to the RDoc HTML. Coding assistants read these.
5
+ # export-api-markdown.rb <output-dir> <source-root> <project-name>
6
+
7
+ require 'fileutils'
8
+ require 'open3'
9
+ require 'rbconfig'
10
+ require 'tmpdir'
11
+
12
+ output_dir = File.expand_path(ARGV.fetch(0))
13
+ source_root = File.expand_path(ARGV.fetch(1))
14
+ project = ARGV.fetch(2)
15
+ rdoc = [ RbConfig.ruby, Gem.bin_path('rdoc', 'rdoc') ]
16
+ ri = [ RbConfig.ruby, Gem.bin_path('rdoc', 'ri') ]
17
+
18
+ def capture!(*command, chdir:)
19
+ stdout, stderr, status = Open3.capture3(*command, chdir: chdir)
20
+ abort "#{command.join(' ')} failed\n#{stderr}" unless status.success?
21
+
22
+ stdout
23
+ end
24
+
25
+ def clean(markdown, ri_dir)
26
+ markdown
27
+ .gsub(/\s*\(from\s+#{Regexp.escape(ri_dir)}\)/m, '')
28
+ .gsub(%r{<code>(.*?)</code>}m, '`\1`')
29
+ .gsub(/[ \t]+$/, '')
30
+ .gsub(/\n{3,}/, "\n\n")
31
+ .strip
32
+ end
33
+
34
+ FileUtils.mkdir_p(output_dir)
35
+
36
+ Dir.mktmpdir('api-ri') do |tmp|
37
+ ri_dir = File.join(tmp, 'ri')
38
+ capture!(*rdoc, '--format', 'ri', '--output', ri_dir, '--quiet', 'lib', chdir: source_root)
39
+ options = [ '--no-standard-docs', '--doc-dir', ri_dir, '--format', 'markdown', '--width', '1000' ]
40
+ names = capture!(*ri, *options, '--list', chdir: source_root).lines.map(&:strip).reject(&:empty?).sort
41
+
42
+ index = [ "# #{project} API Reference", '', '<!-- Generated from RDoc. Do not edit. -->', '',
43
+ "Markdown pages for every public #{project} class and module.", '' ]
44
+ names.each do |name|
45
+ path = "#{name.gsub('::', '/')}.md"
46
+ destination = File.join(output_dir, path)
47
+ FileUtils.mkdir_p(File.dirname(destination))
48
+ markdown = capture!(*ri, *options, '--all', name, chdir: source_root)
49
+ File.write(destination, "#{clean(markdown, ri_dir)}\n")
50
+ index << "- [#{name}](#{path})"
51
+ end
52
+ File.write(File.join(output_dir, 'index.md'), "#{index.join("\n")}\n")
53
+ end
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Ties the versioned builds together once bin/build.sh has assembled them.
5
+ # A page that only an archived major version still has gets a redirect at its
6
+ # old root URL, so links from before the major release keep working, and
7
+ # robots.txt lists the sitemap of every build under the root.
8
+ # link_versions.rb <site-dir> <origin> <base-path> <channel> [channel ...]
9
+
10
+ require 'cgi'
11
+ require 'fileutils'
12
+ require 'json'
13
+
14
+ site, origin, base, *channels = ARGV
15
+ abort 'usage: link_versions.rb <site-dir> <origin> <base-path> <channel> [channel ...]' if channels.empty?
16
+ site = File.expand_path(site)
17
+
18
+ def redirect_page(path, target, canonical)
19
+ FileUtils.mkdir_p(File.dirname(path))
20
+ escaped = CGI.escapeHTML(target)
21
+ File.write(path, <<~HTML)
22
+ <!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8">
26
+ <title>This page has moved</title>
27
+ <link rel="canonical" href="#{CGI.escapeHTML(canonical)}">
28
+ <meta name="robots" content="noindex,follow">
29
+ <script>window.location.replace(#{JSON.generate(target)} + window.location.search + window.location.hash);</script>
30
+ <meta http-equiv="refresh" content="0;url=#{escaped}">
31
+ </head>
32
+ <body><p>This page has moved to <a href="#{escaped}">#{escaped}</a>.</p></body>
33
+ </html>
34
+ HTML
35
+ end
36
+
37
+ versioned = channels.map { |channel| "#{channel}/" }
38
+ channels.grep(/\Av\d+\z/).each do |archive|
39
+ Dir.glob('**/*.html', base: File.join(site, archive)).sort.each do |relative|
40
+ next if relative == '404.html' || relative.start_with?(*versioned)
41
+ next if File.exist?(File.join(site, relative))
42
+
43
+ target = "#{base}/#{archive}/#{relative.delete_suffix('index.html')}"
44
+ redirect_page(File.join(site, relative), target, "#{origin}#{target}")
45
+ end
46
+ end
47
+
48
+ robots = File.join(site, 'robots.txt')
49
+ if File.file?(robots) && origin.match?(%r{\Ahttps?://})
50
+ sitemaps = channels.select { |channel| File.file?(File.join(site, channel, 'sitemap.xml')) }
51
+ File.open(robots, 'a') do |file|
52
+ sitemaps.each { |channel| file.puts "Sitemap: #{origin}#{base}/#{channel}/sitemap.xml" }
53
+ end
54
+ end
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Gives the RDoc pages the search and social metadata the theme gives the
5
+ # guides: a canonical URL, robots, Open Graph and Twitter image tags, and
6
+ # JSON-LD. Adds the pages to the build's sitemap.xml, and points links to the
7
+ # production site (the .rdoc_options footer) at this build.
8
+ # postprocess_api_seo.rb <api-dir> <_config.yml> <main-page.html>
9
+ # SITE_URL is the build's address and ROOT_URL the production site's; both
10
+ # default to `url` plus `baseurl` from _config.yml.
11
+
12
+ require 'cgi'
13
+ require 'date'
14
+ require 'json'
15
+ require 'pathname'
16
+ require 'yaml'
17
+
18
+ output_dir = Pathname(ARGV.fetch(0)).expand_path
19
+ config_path = Pathname(ARGV.fetch(1)).expand_path
20
+ main_page = ARGV.fetch(2, 'index.html')
21
+ config = YAML.safe_load_file(config_path, permitted_classes: [ Date, Time ], aliases: true) || {}
22
+ trim = ->(url) { url.to_s.sub(%r{/+\z}, '') }
23
+ configured = "#{trim.call(config['url'])}#{trim.call(config['baseurl'])}"
24
+ site_root = trim.call(ENV.fetch('SITE_URL', configured))
25
+ production_root = trim.call(ENV.fetch('ROOT_URL', configured))
26
+ unless site_root.match?(%r{\Ahttps?://})
27
+ warn 'postprocess_api_seo: url in _config.yml is not an absolute http(s) address; skipping API metadata'
28
+ exit
29
+ end
30
+
31
+ name = config['title'].to_s
32
+ description = config['description'].to_s
33
+ language = (config['lang'] || 'en-US').to_s
34
+ seo = config.dig('jekyll_vitepress', 'seo')
35
+ image = seo.is_a?(Hash) ? seo['image'] : nil
36
+ image = image['path'] if image.is_a?(Hash)
37
+ image ||= '/assets/images/social/index.png' if config_path.dirname.join('_plugins', 'social_cards.rb').file?
38
+ image_url = image && (image.to_s.match?(%r{\Ahttps?://}) ? image.to_s : "#{site_root}#{image}")
39
+ default_robots = 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1'
40
+ index_pages = [ 'index.html', main_page ].uniq
41
+ api_urls = []
42
+
43
+ def tag(name, attributes)
44
+ rendered = attributes.map { |key, value| %(#{key}="#{CGI.escapeHTML(value)}") }.join(' ')
45
+ "<#{name} #{rendered}>"
46
+ end
47
+
48
+ # Escapes the characters that would let a value close the ld+json block early.
49
+ def json_ld(graph)
50
+ JSON.generate(graph).gsub(/[<>&

]/) { |char| format('\\u%04x', char.ord) }
51
+ end
52
+
53
+ # A link to the production site moves to this build only when this build has
54
+ # the page, so a footer that names another base path is left alone.
55
+ build_root = output_dir.parent
56
+ in_build = lambda do |route|
57
+ route = route.split(/[?#]/).first.to_s
58
+ candidate = build_root.join(route)
59
+ route.empty? || candidate.file? || candidate.join('index.html').file?
60
+ end
61
+
62
+ output_dir.glob('**/*.html').sort.each do |path|
63
+ relative_path = path.relative_path_from(output_dir).to_s
64
+ html = path.read
65
+ unless production_root == site_root
66
+ html = html.gsub(%r{href="#{Regexp.escape(production_root)}/([^"]*)"}) do
67
+ route = Regexp.last_match(1)
68
+ in_build.call(route) ? %(href="#{site_root}/#{route}") : Regexp.last_match(0)
69
+ end
70
+ end
71
+ redirect = html.match?(/<meta[^>]+http-equiv="refresh"/i)
72
+ target = html[/<meta[^>]+http-equiv="refresh"[^>]+url=([^";]+)[^>]*>/i, 1]
73
+ canonical = if index_pages.include?(relative_path)
74
+ "#{site_root}/api/"
75
+ elsif redirect && target
76
+ "#{site_root}/api/#{target}"
77
+ else
78
+ "#{site_root}/api/#{relative_path}"
79
+ end
80
+ title = CGI.unescapeHTML(html[%r{<title>(.*?)</title>}mi, 1].to_s).strip
81
+ page_description = CGI.unescapeHTML(html[/<meta\s+name="description"\s+content="([^"]*)"/mi, 1].to_s).strip
82
+
83
+ html = html.sub(/<link\s+rel="canonical"\s+href="[^"]*">/i, '')
84
+ metadata = [
85
+ tag('link', 'rel' => 'canonical', 'href' => canonical),
86
+ tag('meta', 'name' => 'robots', 'content' => redirect ? 'noindex,follow' : default_robots),
87
+ tag('meta', 'property' => 'og:url', 'content' => canonical),
88
+ tag('meta', 'property' => 'og:site_name', 'content' => name)
89
+ ]
90
+ if image_url
91
+ # RDoc writes og:title, og:description, and a summary Twitter card already.
92
+ html = html.sub(/(<meta\s+name="twitter:card"\s+content=")summary(")/i, '\1summary_large_image\2')
93
+ metadata << tag('meta', 'property' => 'og:image', 'content' => image_url)
94
+ metadata << tag('meta', 'property' => 'og:image:alt', 'content' => name)
95
+ metadata << tag('meta', 'name' => 'twitter:image', 'content' => image_url)
96
+ end
97
+
98
+ unless redirect
99
+ website_id = "#{site_root}/#website"
100
+ page = {
101
+ '@type' => 'WebPage', '@id' => "#{canonical}#webpage", 'url' => canonical,
102
+ 'name' => title, 'description' => page_description,
103
+ 'isPartOf' => { '@id' => website_id }, 'inLanguage' => language
104
+ }
105
+ graph = [ {
106
+ '@type' => 'WebSite', '@id' => website_id, 'url' => "#{site_root}/",
107
+ 'name' => name, 'description' => description, 'inLanguage' => language
108
+ } ]
109
+ if image_url
110
+ graph << { '@type' => 'ImageObject', '@id' => "#{image_url}#primaryimage", 'url' => image_url,
111
+ 'contentUrl' => image_url, 'caption' => name }
112
+ page['primaryImageOfPage'] = { '@id' => "#{image_url}#primaryimage" }
113
+ end
114
+ graph << page
115
+ metadata << %(<script type="application/ld+json">#{json_ld({ '@context' => 'https://schema.org', '@graph' => graph })}</script>)
116
+ api_urls << canonical
117
+ end
118
+
119
+ html = html.sub(/<head>/i, "<head>\n#{metadata.join("\n")}")
120
+ html = html.sub(/<body\b/i, "</head>\n<body") unless html.match?(%r{</head>}i)
121
+ html = "#{html.rstrip}\n</html>\n" unless html.match?(%r{</html>}i)
122
+ path.write(html)
123
+ end
124
+
125
+ sitemap_path = output_dir.parent.join('sitemap.xml')
126
+ if sitemap_path.file? && !api_urls.empty?
127
+ rows = api_urls.uniq.sort.map { |url| " <url>\n <loc>#{CGI.escapeHTML(url)}</loc>\n </url>" }.join("\n")
128
+ sitemap_path.write(sitemap_path.read.sub(%r{</urlset>}, "#{rows}\n</urlset>"))
129
+ end
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Fills in the version selector for one build: the release numbers, the base
5
+ # path, which entry is current, and one entry per archived major version.
6
+ # prepare_versions.rb <versions.yml> <stable|next|vN> <base-path> <release-tag> [archive-tag ...]
7
+ #
8
+ # It also records `release` (the version this build documents, which
9
+ # _plugins/versioned_docs.rb puts in site.version) and `base` (the site's
10
+ # base path without the version prefix).
11
+
12
+ require 'yaml'
13
+
14
+ path, current, base, release, *archives = ARGV
15
+ abort 'usage: prepare_versions.rb <versions.yml> <channel> <base-path> <release-tag> [archive-tag ...]' unless release
16
+
17
+ number = ->(tag) { tag.delete_prefix('v') }
18
+ major = ->(tag) { number.call(tag).split('.').first }
19
+
20
+ versions = YAML.load_file(path)
21
+ items = versions.fetch('items')
22
+ items.reject! { |item| item['archive'] }
23
+ position = items.index { |item| item['external'] } || items.size
24
+ archives.each_with_index do |tag, offset|
25
+ items.insert(position + offset,
26
+ { 'id' => "v#{major.call(tag)}", 'title' => number.call(tag), 'url' => "/v#{major.call(tag)}/",
27
+ 'archive' => true })
28
+ end
29
+
30
+ items.each do |item|
31
+ item['title'] = "#{number.call(release)} (stable)" if item['id'] == 'stable'
32
+ item['url'] = "#{base}#{item.fetch('url')}" unless item['external']
33
+ end
34
+
35
+ entry = items.find { |item| item['id'] == current } or abort "no version entry with id #{current}"
36
+ documented = archives.find { |tag| "v#{major.call(tag)}" == current } || release
37
+ versions['current'] = entry.fetch('title')
38
+ versions['release'] = number.call(documented)
39
+ versions['base'] = base
40
+ File.write(path, YAML.dump(versions))
data/docs/bin/serve.sh ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # Preview the working tree at http://127.0.0.1:4000 with live reload. Ctrl-C stops it.
3
+ # docs/bin/serve.sh PORT=4002 docs/bin/serve.sh
4
+ set -euo pipefail
5
+
6
+ unset RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLE_BIN BUNDLE_APP_CONFIG
7
+
8
+ docs="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ cd "$docs"
10
+ exec env BUNDLE_GEMFILE="$docs/Gemfile" bundle exec jekyll serve \
11
+ --host 127.0.0.1 --port "${PORT:-4000}" \
12
+ --livereload --livereload-port "${LIVERELOAD_PORT:-35729}"
@@ -0,0 +1,14 @@
1
+ # The version selector. bin/build.sh copies it into each versioned build once
2
+ # a release has docs; prepare_versions.rb fills in the release number.
3
+ current: stable
4
+ items:
5
+ - id: stable
6
+ title: Stable
7
+ url: /
8
+ - id: next
9
+ title: Next (unreleased)
10
+ url: /next/
11
+ - id: changelog
12
+ title: Changelog
13
+ url: https://github.com/yorzi/ruby_llm-browser/releases
14
+ external: true