mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
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:
parent
3fbc925572
commit
17d9679547
16 changed files with 231 additions and 42 deletions
|
|
@ -45,6 +45,10 @@
|
||||||
|
|
||||||
# Ignore development files
|
# Ignore development files
|
||||||
/.devcontainer
|
/.devcontainer
|
||||||
|
pg-dump-all-*
|
||||||
|
*.dump
|
||||||
|
*.sql
|
||||||
|
*.sql.gz
|
||||||
|
|
||||||
# Ignore Docker-related files
|
# Ignore Docker-related files
|
||||||
/.dockerignore
|
/.dockerignore
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -52,3 +52,7 @@ public/vite-test
|
||||||
.vite
|
.vite
|
||||||
|
|
||||||
pg-dump*
|
pg-dump*
|
||||||
|
|
||||||
|
# Generated by `rails docs:generate_llms`
|
||||||
|
/public/llms.txt
|
||||||
|
/public/llms-full.txt
|
||||||
|
|
|
||||||
|
|
@ -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 tailwindcss:build
|
||||||
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
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
|
# Final stage for app image
|
||||||
FROM base
|
FROM base
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
web: bin/rails server -b 0.0.0.0
|
web: bin/rails server -b 0.0.0.0
|
||||||
css: bin/rails tailwindcss:watch
|
css: bin/rails tailwindcss:watch
|
||||||
vite: bin/vite dev
|
vite: bin/vite dev
|
||||||
|
ssr: bin/vite ssr
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@ Comment out the `LOOPS_API_KEY` for the local letter opener, otherwise the app w
|
||||||
## Build & Run the project
|
## Build & Run the project
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ docker compose run --service-ports web /bin/bash
|
$ docker compose up -d
|
||||||
|
$ docker compose exec web /bin/bash
|
||||||
|
|
||||||
# Now, setup the database using:
|
# Now, setup the database using:
|
||||||
app# bin/rails db:create db:schema:load db:seed
|
app# bin/rails db:create db:schema:load db:seed
|
||||||
|
|
|
||||||
|
|
@ -71,22 +71,28 @@ class DocsController < InertiaController
|
||||||
end
|
end
|
||||||
|
|
||||||
content = read_docs_file(file_path)
|
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: {
|
respond_to do |format|
|
||||||
doc_path: doc_path,
|
format.html do
|
||||||
title: title,
|
title = extract_title(content) || doc_path.humanize
|
||||||
rendered_content: rendered_content,
|
rendered_content = render_markdown(content)
|
||||||
breadcrumbs: breadcrumbs,
|
breadcrumbs = build_inertia_breadcrumbs(doc_path)
|
||||||
edit_url: edit_url,
|
edit_url = "https://github.com/hackclub/hackatime/edit/main/docs/#{doc_path}.md"
|
||||||
meta: {
|
|
||||||
description: generate_doc_description(content, title),
|
render inertia: "Docs/Show", props: {
|
||||||
keywords: generate_doc_keywords(doc_path, title)
|
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
|
rescue => e
|
||||||
Rails.logger.error "Error loading docs: #{e.message}"
|
Rails.logger.error "Error loading docs: #{e.message}"
|
||||||
render_not_found
|
render_not_found
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import '@fontsource-variable/spline-sans'
|
import "@fontsource-variable/spline-sans";
|
||||||
import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte'
|
import { createInertiaApp, type ResolvedComponent } from "@inertiajs/svelte";
|
||||||
import { mount } from 'svelte'
|
import AppLayout from "../layouts/AppLayout.svelte";
|
||||||
import AppLayout from '../layouts/AppLayout.svelte'
|
|
||||||
|
const pages = import.meta.glob<ResolvedComponent>("../pages/**/*.svelte", {
|
||||||
|
eager: true,
|
||||||
|
});
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
// Disable progress bar
|
// Disable progress bar
|
||||||
|
|
@ -10,28 +13,14 @@ createInertiaApp({
|
||||||
// progress: false,
|
// progress: false,
|
||||||
|
|
||||||
resolve: (name) => {
|
resolve: (name) => {
|
||||||
const pages = import.meta.glob<ResolvedComponent>('../pages/**/*.svelte', {
|
const component = pages[`../pages/${name}.svelte`];
|
||||||
eager: true,
|
if (!component) {
|
||||||
})
|
console.error(`Missing Inertia page component: '${name}.svelte'`);
|
||||||
const page = pages[`../pages/${name}.svelte`]
|
|
||||||
if (!page) {
|
|
||||||
console.error(`Missing Inertia page component: '${name}.svelte'`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = page.layout === false ? undefined : (page.layout || AppLayout)
|
const layout =
|
||||||
return { default: page.default, layout } as ResolvedComponent
|
component.layout === false ? undefined : component.layout || AppLayout;
|
||||||
},
|
return { default: component.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.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
defaults: {
|
defaults: {
|
||||||
|
|
@ -39,4 +28,4 @@ createInertiaApp({
|
||||||
forceIndicesArrayFormatInFormData: false,
|
forceIndicesArrayFormatInFormData: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
28
app/javascript/ssr/ssr.ts
Normal file
28
app/javascript/ssr/ssr.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -11,6 +11,13 @@ if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
|
||||||
./bin/rails db:prepare
|
./bin/rails db:prepare
|
||||||
# echo "Warming up caches for production deployment..."
|
# echo "Warming up caches for production deployment..."
|
||||||
# ./bin/rails cache:warmup
|
# ./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
|
fi
|
||||||
|
|
||||||
exec "${@}"
|
exec "${@}"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@ InertiaRails.configure do |config|
|
||||||
config.always_include_errors_hash = true
|
config.always_include_errors_hash = true
|
||||||
config.use_script_element_for_initial_page = true
|
config.use_script_element_for_initial_page = true
|
||||||
config.use_data_inertia_head_attribute = true
|
config.use_data_inertia_head_attribute = true
|
||||||
|
config.ssr_enabled = ViteRuby.config.ssr_build_enabled
|
||||||
end
|
end
|
||||||
|
|
|
||||||
1
config/initializers/mime_types.rb
Normal file
1
config/initializers/mime_types.rb
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Mime::Type.register "text/markdown", :md
|
||||||
|
|
@ -115,6 +115,8 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Docs routes
|
# 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", to: "docs#index", as: :docs
|
||||||
get "docs/*path", to: "docs#show", as: :doc
|
get "docs/*path", to: "docs#show", as: :doc
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,8 @@
|
||||||
"autoBuild": true,
|
"autoBuild": true,
|
||||||
"publicOutputDir": "vite-test",
|
"publicOutputDir": "vite-test",
|
||||||
"port": 3037
|
"port": 3037
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"ssrBuildEnabled": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ services:
|
||||||
- POSTGRES_PASSWORD=secureorpheus123
|
- POSTGRES_PASSWORD=secureorpheus123
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
command: ["sleep", "infinity"]
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
|
|
@ -33,4 +34,4 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
harbor_postgres_data:
|
harbor_postgres_data:
|
||||||
bundle_cache:
|
bundle_cache:
|
||||||
node_modules:
|
node_modules:
|
||||||
|
|
|
||||||
112
lib/tasks/docs.rake
Normal file
112
lib/tasks/docs.rake
Normal 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
|
||||||
26
test/controllers/docs_controller_test.rb
Normal file
26
test/controllers/docs_controller_test.rb
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue