From 17d96795479a61f9cfa7483718c2bc09a32d9d1f Mon Sep 17 00:00:00 2001 From: Mahad Kalam <55807755+skyfallwastaken@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:34:06 +0000 Subject: [PATCH] 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> --- .dockerignore | 4 + .gitignore | 4 + Dockerfile | 3 + Procfile.dev | 1 + README.md | 3 +- app/controllers/docs_controller.rb | 36 +++++--- app/javascript/entrypoints/inertia.ts | 39 +++----- app/javascript/ssr/ssr.ts | 28 ++++++ bin/docker-entrypoint | 7 ++ config/initializers/inertia_rails.rb | 1 + config/initializers/mime_types.rb | 1 + config/routes.rb | 2 + config/vite.json | 3 + docker-compose.yml | 3 +- lib/tasks/docs.rake | 112 +++++++++++++++++++++++ test/controllers/docs_controller_test.rb | 26 ++++++ 16 files changed, 231 insertions(+), 42 deletions(-) create mode 100644 app/javascript/ssr/ssr.ts create mode 100644 config/initializers/mime_types.rb create mode 100644 lib/tasks/docs.rake create mode 100644 test/controllers/docs_controller_test.rb diff --git a/.dockerignore b/.dockerignore index 325bfc0..774d68c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -45,6 +45,10 @@ # Ignore development files /.devcontainer +pg-dump-all-* +*.dump +*.sql +*.sql.gz # Ignore Docker-related files /.dockerignore diff --git a/.gitignore b/.gitignore index 2f34940..0b0e4f1 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ public/vite-test .vite pg-dump* + +# Generated by `rails docs:generate_llms` +/public/llms.txt +/public/llms-full.txt diff --git a/Dockerfile b/Dockerfile index 253d008..8d13673 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Procfile.dev b/Procfile.dev index 9fb91fa..097d94c 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -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 diff --git a/README.md b/README.md index c3f6dfa..0bfc809 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/controllers/docs_controller.rb b/app/controllers/docs_controller.rb index 8c6fe68..5d254e2 100644 --- a/app/controllers/docs_controller.rb +++ b/app/controllers/docs_controller.rb @@ -71,22 +71,28 @@ class DocsController < InertiaController end content = read_docs_file(file_path) - title = extract_title(content) || doc_path.humanize - rendered_content = render_markdown(content) - breadcrumbs = build_inertia_breadcrumbs(doc_path) - edit_url = "https://github.com/hackclub/hackatime/edit/main/docs/#{doc_path}.md" - render inertia: "Docs/Show", props: { - doc_path: doc_path, - title: title, - rendered_content: rendered_content, - breadcrumbs: breadcrumbs, - edit_url: edit_url, - meta: { - description: generate_doc_description(content, title), - keywords: generate_doc_keywords(doc_path, title) - } - } + 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) + edit_url = "https://github.com/hackclub/hackatime/edit/main/docs/#{doc_path}.md" + + render inertia: "Docs/Show", props: { + doc_path: doc_path, + title: title, + rendered_content: rendered_content, + breadcrumbs: breadcrumbs, + edit_url: edit_url, + meta: { + description: generate_doc_description(content, title), + 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 diff --git a/app/javascript/entrypoints/inertia.ts b/app/javascript/entrypoints/inertia.ts index 70cbe6b..dafd1dc 100644 --- a/app/javascript/entrypoints/inertia.ts +++ b/app/javascript/entrypoints/inertia.ts @@ -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("../pages/**/*.svelte", { + eager: true, +}); createInertiaApp({ // Disable progress bar @@ -10,28 +13,14 @@ createInertiaApp({ // progress: false, resolve: (name) => { - const pages = import.meta.glob('../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, }, }, -}) +}); diff --git a/app/javascript/ssr/ssr.ts b/app/javascript/ssr/ssr.ts new file mode 100644 index 0000000..8483f14 --- /dev/null +++ b/app/javascript/ssr/ssr.ts @@ -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("../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 }); + }, + }), +); diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index b713c71..21d1c8c 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -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 "${@}" diff --git a/config/initializers/inertia_rails.rb b/config/initializers/inertia_rails.rb index 06e1f8f..e29fcf9 100644 --- a/config/initializers/inertia_rails.rb +++ b/config/initializers/inertia_rails.rb @@ -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 diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb new file mode 100644 index 0000000..dd166ff --- /dev/null +++ b/config/initializers/mime_types.rb @@ -0,0 +1 @@ +Mime::Type.register "text/markdown", :md diff --git a/config/routes.rb b/config/routes.rb index 3cd7098..d297e91 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/vite.json b/config/vite.json index 22f4a6d..3caac1d 100644 --- a/config/vite.json +++ b/config/vite.json @@ -12,5 +12,8 @@ "autoBuild": true, "publicOutputDir": "vite-test", "port": 3037 + }, + "production": { + "ssrBuildEnabled": true } } diff --git a/docker-compose.yml b/docker-compose.yml index 5c75e62..cfbc60f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - POSTGRES_PASSWORD=secureorpheus123 depends_on: - db + command: ["sleep", "infinity"] db: image: postgres:16 @@ -33,4 +34,4 @@ services: volumes: harbor_postgres_data: bundle_cache: - node_modules: \ No newline at end of file + node_modules: diff --git a/lib/tasks/docs.rake b/lib/tasks/docs.rake new file mode 100644 index 0000000..aed426e --- /dev/null +++ b/lib/tasks/docs.rake @@ -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 diff --git a/test/controllers/docs_controller_test.rb b/test/controllers/docs_controller_test.rb new file mode 100644 index 0000000..f015cbd --- /dev/null +++ b/test/controllers/docs_controller_test.rb @@ -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