-
Notifications
You must be signed in to change notification settings - Fork 140
Expand file tree
/
Copy pathdocs_controller.rb
More file actions
303 lines (257 loc) · 10.3 KB
/
Copy pathdocs_controller.rb
File metadata and controls
303 lines (257 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
class DocsController < InertiaController
layout "inertia"
inertia_share docs_nav: -> { docs_nav_props }
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" ], [ "Helix", "helix" ],
[ "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 docs_nav_props
current_path = request.path
{
current_path: current_path,
home_url: docs_path,
api_docs_url: "/api-docs",
github_url: "https://github.com/hackclub/hackatime",
slack_url: "https://hackclub.slack.com/archives/C07MQ845X1F",
sections: [
{
title: "Getting Started",
links: [
{ label: "Quick Start", href: doc_path("getting-started/quick-start") },
{ label: "Installation", href: doc_path("getting-started/installation") },
{ label: "Configuration", href: doc_path("getting-started/configuration") }
]
},
{
title: "Developers",
links: [
{ label: "API Docs", href: "/api-docs/" },
{ label: "OAuth Apps", href: doc_path("oauth/oauth-apps") }
]
}
],
popular_editors: POPULAR_EDITORS,
all_editors: ALL_EDITORS
}
end
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