llms.txt/llms-full.txt/.md docs routes, SSR (#977)

* llms.txt/llms-full.txt/.md docs routes, SSR

* Fixes!

* Tests!

* More fixes

* Fix SSR!

* Update bin/docker-entrypoint

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Use Bun for SSR!

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Mahad Kalam 2026-02-18 08:34:06 +00:00 committed by GitHub
parent 3fbc925572
commit 17d9679547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 231 additions and 42 deletions

View file

@ -45,6 +45,10 @@
# Ignore development files
/.devcontainer
pg-dump-all-*
*.dump
*.sql
*.sql.gz
# Ignore Docker-related files
/.dockerignore

4
.gitignore vendored
View file

@ -52,3 +52,7 @@ public/vite-test
.vite
pg-dump*
# Generated by `rails docs:generate_llms`
/public/llms.txt
/public/llms-full.txt

View file

@ -69,6 +69,9 @@ RUN bundle exec bootsnap precompile app/ lib/
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails tailwindcss:build
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Generate static llms.txt files for LLM consumption
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails docs:generate_llms
# Final stage for app image
FROM base

View file

@ -1,3 +1,4 @@
web: bin/rails server -b 0.0.0.0
css: bin/rails tailwindcss:watch
vite: bin/vite dev
ssr: bin/vite ssr

View file

@ -34,7 +34,8 @@ Comment out the `LOOPS_API_KEY` for the local letter opener, otherwise the app w
## Build & Run the project
```sh
$ docker compose run --service-ports web /bin/bash
$ docker compose up -d
$ docker compose exec web /bin/bash
# Now, setup the database using:
app# bin/rails db:create db:schema:load db:seed

View file

@ -71,6 +71,9 @@ class DocsController < InertiaController
end
content = read_docs_file(file_path)
respond_to do |format|
format.html do
title = extract_title(content) || doc_path.humanize
rendered_content = render_markdown(content)
breadcrumbs = build_inertia_breadcrumbs(doc_path)
@ -87,6 +90,9 @@ class DocsController < InertiaController
keywords: generate_doc_keywords(doc_path, title)
}
}
end
format.md { render plain: content, content_type: "text/markdown" }
end
rescue => e
Rails.logger.error "Error loading docs: #{e.message}"
render_not_found

View file

@ -1,7 +1,10 @@
import '@fontsource-variable/spline-sans'
import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte'
import { mount } from 'svelte'
import AppLayout from '../layouts/AppLayout.svelte'
import "@fontsource-variable/spline-sans";
import { createInertiaApp, type ResolvedComponent } from "@inertiajs/svelte";
import AppLayout from "../layouts/AppLayout.svelte";
const pages = import.meta.glob<ResolvedComponent>("../pages/**/*.svelte", {
eager: true,
});
createInertiaApp({
// Disable progress bar
@ -10,28 +13,14 @@ createInertiaApp({
// progress: false,
resolve: (name) => {
const pages = import.meta.glob<ResolvedComponent>('../pages/**/*.svelte', {
eager: true,
})
const page = pages[`../pages/${name}.svelte`]
if (!page) {
console.error(`Missing Inertia page component: '${name}.svelte'`)
const component = pages[`../pages/${name}.svelte`];
if (!component) {
console.error(`Missing Inertia page component: '${name}.svelte'`);
}
const layout = page.layout === false ? undefined : (page.layout || AppLayout)
return { default: page.default, layout } as ResolvedComponent
},
setup({ el, App, props }) {
if (el) {
mount(App, { target: el, props })
} else {
console.error(
'Missing root element.\n\n' +
'If you see this error, it probably means you load Inertia.js on non-Inertia pages.\n' +
'Consider moving <%= vite_typescript_tag "inertia" %> to the Inertia-specific layout instead.',
)
}
const layout =
component.layout === false ? undefined : component.layout || AppLayout;
return { default: component.default, layout } as ResolvedComponent;
},
defaults: {
@ -39,4 +28,4 @@ createInertiaApp({
forceIndicesArrayFormatInFormData: false,
},
},
})
});

28
app/javascript/ssr/ssr.ts Normal file
View file

@ -0,0 +1,28 @@
import "@fontsource-variable/spline-sans";
import { createInertiaApp, type ResolvedComponent } from "@inertiajs/svelte";
import createServer from "@inertiajs/svelte/server";
import { render } from "svelte/server";
import AppLayout from "../layouts/AppLayout.svelte";
const pages = import.meta.glob<ResolvedComponent>("../pages/**/*.svelte", {
eager: true,
});
createServer((page) =>
createInertiaApp({
page,
resolve: (name) => {
const component = pages[`../pages/${name}.svelte`];
if (!component) {
console.error(`Missing Inertia page component: '${name}.svelte'`);
}
const layout =
component.layout === false ? undefined : component.layout || AppLayout;
return { default: component.default, layout } as ResolvedComponent;
},
setup({ App, props }) {
return render(App, { props });
},
}),
);

View file

@ -11,6 +11,13 @@ if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
./bin/rails db:prepare
# echo "Warming up caches for production deployment..."
# ./bin/rails cache:warmup
# Start the Inertia SSR server in the background if the SSR bundle exists
SSR_ENTRYPOINT="public/vite-ssr/ssr.js"
if [ -f "$SSR_ENTRYPOINT" ]; then
echo "Starting Inertia SSR server..."
(while true; do bun "$SSR_ENTRYPOINT"; echo "SSR server exited, restarting..."; sleep 1; done) &
fi
fi
exec "${@}"

View file

@ -6,4 +6,5 @@ InertiaRails.configure do |config|
config.always_include_errors_hash = true
config.use_script_element_for_initial_page = true
config.use_data_inertia_head_attribute = true
config.ssr_enabled = ViteRuby.config.ssr_build_enabled
end

View file

@ -0,0 +1 @@
Mime::Type.register "text/markdown", :md

View file

@ -115,6 +115,8 @@ Rails.application.routes.draw do
end
# Docs routes
# Note: llms.txt and llms-full.txt are served as static files from public/
# Generate them with: rails docs:generate_llms
get "docs", to: "docs#index", as: :docs
get "docs/*path", to: "docs#show", as: :doc

View file

@ -12,5 +12,8 @@
"autoBuild": true,
"publicOutputDir": "vite-test",
"port": 3037
},
"production": {
"ssrBuildEnabled": true
}
}

View file

@ -18,6 +18,7 @@ services:
- POSTGRES_PASSWORD=secureorpheus123
depends_on:
- db
command: ["sleep", "infinity"]
db:
image: postgres:16

112
lib/tasks/docs.rake Normal file
View file

@ -0,0 +1,112 @@
namespace :docs do
desc "Generate static llms.txt and llms-full.txt files in public/"
task generate_llms: :environment do
puts "Generating llms.txt..."
generate_llms_txt
puts "✓ public/llms.txt"
puts "Generating llms-full.txt..."
generate_llms_full_txt
puts "✓ public/llms-full.txt"
puts "Done!"
end
def generate_llms_txt
popular_editors = DocsController::POPULAR_EDITORS
other_editors = DocsController::ALL_EDITORS - popular_editors
lines = []
lines << "# Hackatime"
lines << ""
lines << "> Hackatime is a free, open source coding time tracker built by Hack Club. It automatically tracks your coding time across 70+ editors and IDEs using existing WakaTime plugins. Privacy-first, completely free, with no premium features or paywalls."
lines << ""
lines << "For complete documentation in a single file, see [Full documentation](https://hackatime.hackclub.com/llms-full.txt)."
lines << ""
lines << "## Getting Started"
lines << "- [Quick Start Guide](https://hackatime.hackclub.com/docs/getting-started/quick-start.md): Get up and running with Hackatime in under 5 minutes"
lines << "- [Installation](https://hackatime.hackclub.com/docs/getting-started/installation.md): Add WakaTime plugins to your editor"
lines << "- [Configuration](https://hackatime.hackclub.com/docs/getting-started/configuration.md): Advanced setup including GitHub integration, time zones, and privacy settings"
lines << ""
lines << "## Popular Editors"
popular_editors.each do |name, slug|
lines << "- [#{name}](https://hackatime.hackclub.com/docs/editors/#{slug}.md): Set up time tracking in #{name}"
end
lines << ""
lines << "## Integrations"
lines << "- [OAuth Apps](https://hackatime.hackclub.com/docs/oauth/oauth-apps.md): Build integrations with Hackatime using OAuth 2.0"
lines << "- [API Documentation](https://hackatime.hackclub.com/api-docs): Interactive API reference for the Hackatime API"
lines << ""
lines << "## Optional"
other_editors.each do |name, slug|
lines << "- [#{name}](https://hackatime.hackclub.com/docs/editors/#{slug}.md): Set up time tracking in #{name}"
end
File.write(Rails.root.join("public", "llms.txt"), lines.join("\n") + "\n")
end
def generate_llms_full_txt
docs_root = Rails.root.join("docs")
all_editors = DocsController::ALL_EDITORS
lines = []
lines << "# Hackatime - Complete Documentation"
lines << ""
lines << "> Hackatime is a free, open source coding time tracker built by Hack Club. It automatically tracks your coding time across 70+ editors and IDEs using existing WakaTime plugins. Privacy-first, completely free, with no premium features or paywalls. Compatible with the WakaTime ecosystem -- just point your existing WakaTime plugin at Hackatime's API endpoint."
lines << ""
lines << "## Key Features"
lines << ""
lines << "- Automatic time tracking with no manual timers"
lines << "- Language and project insights"
lines << "- Leaderboards to compare with other Hack Club members"
lines << "- Privacy-first: only metadata tracked, never actual code"
lines << "- GitHub project linking for leaderboard visibility"
lines << "- OAuth 2.0 API for third-party integrations"
lines << "- Completely free with no paywalls"
lines << ""
lines << "## How It Works"
lines << ""
lines << "Hackatime works with any WakaTime plugin. Users configure their `~/.wakatime.cfg` file to point to Hackatime's API endpoint (`https://hackatime.hackclub.com/api/hackatime/v1`) instead of WakaTime's servers. All existing WakaTime editor plugins then send heartbeat data to Hackatime automatically."
lines << ""
lines << "## Getting Started"
{ "Quick Start" => "getting-started/quick-start.md",
"Installation" => "getting-started/installation.md",
"Configuration" => "getting-started/configuration.md" }.each do |title, path|
file = docs_root.join(path)
if File.exist?(file)
lines << ""
lines << "### #{title}"
lines << File.read(file)
else
Rails.logger.warn("docs:generate_llms - missing #{path}")
end
end
lines << ""
lines << "## OAuth & API"
oauth_file = docs_root.join("oauth", "oauth-apps.md")
if File.exist?(oauth_file)
lines << ""
lines << "### OAuth Apps"
lines << File.read(oauth_file)
else
Rails.logger.warn("docs:generate_llms - missing oauth/oauth-apps.md")
end
lines << ""
lines << "## Editor Setup Guides"
all_editors.each do |name, slug|
file = docs_root.join("editors", "#{slug}.md")
next unless File.exist?(file)
lines << ""
lines << "### #{name}"
lines << File.read(file)
end
File.write(Rails.root.join("public", "llms-full.txt"), lines.join("\n") + "\n")
end
end

View file

@ -0,0 +1,26 @@
require "test_helper"
class DocsControllerTest < ActionDispatch::IntegrationTest
# -- docs show .md format --
test "docs show returns markdown content when requested with .md format" do
get "/docs/getting-started/quick-start.md"
assert_response :success
assert_match %r{text/markdown}, response.content_type
end
test "docs show .md format returns raw markdown content" do
get "/docs/getting-started/quick-start.md"
expected_content = File.read(Rails.root.join("docs", "getting-started", "quick-start.md"))
assert_equal expected_content, response.body
end
test "docs show returns HTML/Inertia by default" do
get "/docs/getting-started/quick-start"
assert_response :success
assert_inertia_component "Docs/Show"
end
end