hackatime/app/controllers/docs_controller.rb
Mahad Kalam 8d00418059
Spring cleaning (#1074)
* fix: use owner/repo format for project badges

Updates badge URLs to use GitHub-style owner/repo format (e.g., "hackclub/hackatime")
instead of project names. This ensures compatibility with external badge services that
expect repository paths.

Changes:
- Add Repository#full_path method to get owner/repo format
- Update settings controller to pass both display names and repo paths
- Update Badges component to display project names but use repo paths in URLs

* fix: improve user lookup in API v1 stats endpoint

Use the robust lookup_user method for username parameter in the /api/v1/stats endpoint
to ensure consistent user lookup across all API endpoints. This properly handles Slack
UIDs (HCA IDs), numeric user IDs, and usernames in the correct priority order.

* fix: reduce clutter on new user homepage

Simplify the new user experience by:
- Removing redundant "Hello friend" text from setup notice (header already provides context)
- Hiding GitHub link banner when setup notice is shown to focus user on primary action

This reduces visual clutter and helps new users focus on completing setup first.

* fix: enable full app layout for new OAuth application page

Remove layout=false directive that was preventing the app header and navigation
from appearing on the new OAuth application creation page.

* fix: add antigravity editor to docs

Add documentation for Antigravity, a VSCode fork from Google with built-in AI
features. Includes setup instructions for tracking time with Hackatime using the
WakaTime extension.

* fix: improve stat card subtitle positioning

Remove absolute positioning from subtitle text to allow it to flow naturally after
the main value. This prevents the subtitle from being pushed to the bottom when
other cards have longer content.

* fix: align settings action buttons to card end on larger screens

Remove width constraint from footer to allow action buttons to align to the right
edge of the full card width instead of being constrained to a narrower container.

* fix: improve heartbeat importer visibility on light themes

Update import provider cards and radio buttons to have better contrast on light themes:
- Use bg-surface-100 instead of bg-darker for better card visibility
- Increase radio button border thickness and use darker border color
- Add hover and focus states for better interactivity

* Split up settings controller + perf + goal display

* Make stat card subtitles larger

* Fix AG + VS Code

* Remove Shiba refs

* Bundle update
2026-03-15 15:26:32 +00:00

270 lines
9.3 KiB
Ruby

class DocsController < InertiaController
layout "inertia"
POPULAR_EDITORS = [
[ "VS Code", "vs-code" ], [ "PyCharm", "pycharm" ], [ "IntelliJ IDEA", "intellij-idea" ],
[ "Sublime Text", "sublime-text" ], [ "Vim", "vim" ], [ "Neovim", "neovim" ],
[ "Android Studio", "android-studio" ], [ "Xcode", "xcode" ], [ "Unity", "unity" ],
[ "Godot", "godot" ], [ "Cursor", "cursor" ], [ "Zed", "zed" ],
[ "Terminal", "terminal" ], [ "WebStorm", "webstorm" ], [ "Eclipse", "eclipse" ],
[ "Emacs", "emacs" ], [ "Jupyter", "jupyter" ], [ "OnShape", "onshape" ]
].freeze
ALL_EDITORS = [
[ "Android Studio", "android-studio" ], [ "Antigravity", "antigravity" ], [ "AppCode", "appcode" ],
[ "Aptana", "aptana" ], [ "Arduino IDE", "arduino-ide" ], [ "Azure Data Studio", "azure-data-studio" ],
[ "Brackets", "brackets" ],
[ "C++ Builder", "c++-builder" ],
[ "CLion", "clion" ], [ "Cloud9", "cloud9" ], [ "Coda", "coda" ],
[ "CodeTasty", "codetasty" ], [ "Cursor", "cursor" ], [ "DataGrip", "datagrip" ],
[ "DataSpell", "dataspell" ], [ "DBeaver", "dbeaver" ], [ "Delphi", "delphi" ],
[ "Eclipse", "eclipse" ],
[ "Emacs", "emacs" ], [ "Eric", "eric" ],
[ "Figma", "figma" ], [ "Gedit", "gedit" ],
[ "Godot", "godot" ], [ "GoLand", "goland" ], [ "HBuilder X", "hbuilder-x" ],
[ "IntelliJ IDEA", "intellij-idea" ], [ "Jupyter", "jupyter" ],
[ "Kakoune", "kakoune" ], [ "Kate", "kate" ], [ "Komodo", "komodo" ],
[ "Micro", "micro" ], [ "MPS", "mps" ], [ "Neovim", "neovim" ],
[ "NetBeans", "netbeans" ], [ "Notepad++", "notepad++" ], [ "Nova", "nova" ],
[ "Obsidian", "obsidian" ], [ "OnShape", "onshape" ], [ "Oxygen", "oxygen" ],
[ "PhpStorm", "phpstorm" ], [ "Postman", "postman" ],
[ "Processing", "processing" ], [ "Pulsar", "pulsar" ], [ "PyCharm", "pycharm" ],
[ "ReClassEx", "reclassex" ], [ "Rider", "rider" ], [ "Roblox Studio", "roblox-studio" ],
[ "RubyMine", "rubymine" ], [ "RustRover", "rustrover" ],
[ "SiYuan", "siyuan" ], [ "Sketch", "sketch" ], [ "SlickEdit", "slickedit" ],
[ "SQL Server Management Studio", "sql-server-management-studio" ],
[ "Sublime Text", "sublime-text" ], [ "Terminal", "terminal" ],
[ "TeXstudio", "texstudio" ], [ "TextMate", "textmate" ], [ "Trae", "trae" ],
[ "Unity", "unity" ], [ "Unreal Engine 4", "unreal-engine-4" ],
[ "Vim", "vim" ], [ "Visual Studio", "visual-studio" ], [ "VS Code", "vs-code" ],
[ "WebStorm", "webstorm" ], [ "Windsurf", "windsurf" ], [ "Wing", "wing" ],
[ "Xcode", "xcode" ], [ "Zed", "zed" ],
[ "Swift Playgrounds", "swift-playgrounds" ]
].sort_by { |editor| editor[0] }.freeze
# Docs are publicly accessible - no authentication required
def index
@page_title = "Hackatime Docs - Setup Guides for 75+ Code Editors & IDEs"
@meta_description = "Get started with Hackatime in minutes. Step-by-step setup guides for VS Code, JetBrains, vim, Neovim, Sublime Text, and 70+ more editors and IDEs."
render inertia: "Docs/Index", props: {
popular_editors: POPULAR_EDITORS,
all_editors: ALL_EDITORS
}
end
def show
doc_path = sanitize_path(params[:path] || "index")
if doc_path.start_with?("api")
redirect_to "/api-docs", allow_other_host: false and return
end
file_path = safe_docs_path("#{doc_path}.md")
unless File.exist?(file_path)
# Try with index.md in the directory
dir_path = safe_docs_path(doc_path, "index.md")
if File.exist?(dir_path)
file_path = dir_path
else
render_not_found and return
end
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)
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
report_error(e, message: "Error loading docs")
render_not_found
end
private
def sanitize_path(path)
# Remove any directory traversal attempts and normalize path
return "index" if path.blank?
clean_path = path.to_s.split("/").reject(&:empty?).join("/").gsub("..", "")
clean_path = clean_path.gsub(/[^a-zA-Z0-9\-_+\/]/, "")
clean_path.present? ? clean_path : "index"
end
def safe_docs_path(*parts)
# Build a safe path within the docs directory
docs_root = Rails.root.join("docs")
full_path = docs_root.join(*parts)
# Ensure the path is within the docs directory
unless full_path.to_s.start_with?(docs_root.to_s)
raise ArgumentError, "Path traversal attempted"
end
full_path
end
def read_docs_file(file_path)
# Safely read a file from the docs directory
unless file_path.to_s.start_with?(Rails.root.join("docs").to_s)
raise ArgumentError, "File not in docs directory"
end
File.read(file_path)
end
def docs_structure
docs_dir = Rails.root.join("docs")
return {} unless Dir.exist?(docs_dir)
structure = {}
Dir.glob("#{docs_dir}/**/*.md").each do |file|
relative_path = Pathname.new(file).relative_path_from(docs_dir).to_s
path_parts = relative_path.sub(/\.md$/, "").split("/")
current = structure
path_parts[0..-2].each do |part|
current[part] ||= {}
current = current[part]
end
current[path_parts.last] = relative_path.sub(/\.md$/, "")
end
structure
end
def build_inertia_breadcrumbs(path)
parts = path.split("/")
breadcrumbs = [ { name: "Docs", href: docs_path, is_link: true } ]
current_path = ""
parts.each_with_index do |part, index|
current_path = current_path.empty? ? part : "#{current_path}/#{part}"
# Check if this path exists as a file
file_exists = File.exist?(safe_docs_path("#{current_path}.md")) ||
File.exist?(safe_docs_path(current_path, "index.md"))
# Only make it a link if the file exists, or if it's the current page (last item)
is_last = index == parts.length - 1
if file_exists || is_last
breadcrumbs << { name: part.titleize, href: doc_path(current_path), is_link: !is_last }
else
breadcrumbs << { name: part.titleize, href: nil, is_link: false }
end
end
breadcrumbs
end
def extract_title(content)
lines = content.lines
title_line = lines.find { |line| line.start_with?("# ") }
title_line&.sub(/^# /, "")&.strip
end
# removes .md extension from links
class DocsRenderer < Redcarpet::Render::HTML
def link(link, title, content)
if link && !link.match?(/\A[a-z]+:/)
link = link.sub(/\.md(?=[#?]|$)/, "")
end
attributes = "href=\"#{link}\""
attributes += " title=\"#{title}\"" if title
"<a #{attributes}>#{content}</a>"
end
end
def render_markdown(content)
renderer = DocsRenderer.new(
filter_html: true,
no_links: false,
no_images: false,
with_toc_data: true,
hard_wrap: true
)
markdown = Redcarpet::Markdown.new(
renderer,
autolink: true,
tables: true,
fenced_code_blocks: true,
strikethrough: true,
lax_spacing: true,
space_after_headers: true,
superscript: false
)
markdown.render(content)
end
def render_not_found
render inertia: "Errors/NotFound", props: {
status_code: 404,
title: "Page Not Found",
message: "The documentation page you were looking for doesn't exist."
}, status: :not_found
end
# Make these helper methods available to views
helper_method :generate_doc_description, :generate_doc_keywords
def generate_doc_description(content, title)
# Extract first paragraph or use title
lines = content.lines.map(&:strip).reject(&:empty?)
first_paragraph = lines.find { |line| !line.start_with?("#") && line.length > 20 }
if first_paragraph
# Clean up markdown and truncate
description = first_paragraph.gsub(/\[([^\]]*)\]\([^)]*\)/, '\1') # Remove markdown links
.gsub(/[*_`]/, "") # Remove formatting
.strip
description.length > 155 ? "#{description[0..155]}..." : description
else
"#{title} - Complete documentation for Hackatime, the free and open source time tracker by Hack Club"
end
end
def generate_doc_keywords(doc_path, title)
base_keywords = %w[hackatime hack club open source tracker time tracking coding documentation]
# Add path-specific keywords
path_keywords = case doc_path
when /getting-started/
%w[setup installation quick start guide tutorial]
when /api/
%w[api rest endpoints authentication]
when /editors/
editor_name = doc_path.split("/").last
[ "#{editor_name} plugin", "#{editor_name} integration", "#{editor_name} setup" ]
else
[ title.downcase.split.join(" ") ]
end
(base_keywords + path_keywords).uniq.join(", ")
end
end