hotdocs 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eda140e352c4f1836ac78536db4c3e594626a8405e0f0963bd636b3263a94280
4
- data.tar.gz: e38d4fcb9d6c56474bd16af2cb64c74aff10737affcb0cb25d7dc192f25e8746
3
+ metadata.gz: e9062e9c27d95f790d4a77b9508b3ac57b518d41573896f358e508d03eb5b676
4
+ data.tar.gz: 29d4da1687c46d5bf2821aae114a96d30191c94204ab6936fd82892f0ed117ce
5
5
  SHA512:
6
- metadata.gz: ae97c6c8c80ec06bcd88e3d64087c0dcf7337a8f054afaa83a97c56c5c1db1924252c8d63ee3c9042586ab3df71c41f886fcbe3f7262effdff812b560cf28623
7
- data.tar.gz: e485b23974e0aeced4827f09a1f1d20b2050c5afff8514436c90d0bf63f01485cf3c13fefab4bdde251055faa471ccf3c6ceb4389c9cc6295496acd0ab0fefa1
6
+ metadata.gz: 568702e50ee8305c0858c46e2acad029e3cf9c3c6f45517fe566fa73980025b67e389316694e8d0ac878f5bea4da98c7cd19923deccaa340192384bb26a66a7c
7
+ data.tar.gz: 9acea8151aa28cb1a6d89d21a45553cba06b86b093ff20aa87f6632a95d9250b09b698bf6c160cfa3f14f5c0905f5472e259e9a2aab7d23b6a9e2d8d4aff176a
data/README.md CHANGED
@@ -22,7 +22,7 @@ HotDocs is a set of optimized Rails components & tools for writing docs:
22
22
  | Styled components you can customize | ✅ | ✅ | ✅ |
23
23
  | Markdown (with syntax highlight & themes) | 🚀 | 👍 | 🚀 |
24
24
  | Static export | 🔜 🚀 | 👍 | 🚀 |
25
- | Search | 🔜 | 🔌 | 🔌 |
25
+ | Search | | 🔌 | 🔌 |
26
26
  | Light / Dark | 🔜 ✅ | 🔌 | ✅ |
27
27
  | Open source | ✅ | ✅ | ✅ |
28
28
  | Free | ✅ | ✅ | ✅ |
@@ -0,0 +1,155 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import lunr from "lunr";
3
+
4
+ export default class extends Controller {
5
+ static targets = ["search", "dialog", "results", "resultTemplate", "data"];
6
+
7
+ connect() {
8
+ this._allowOpening();
9
+ }
10
+
11
+ disconnect() {
12
+ document.removeEventListener("keydown", this.keydownOpen);
13
+ document.removeEventListener("click", this.clickClose, { once: true });
14
+ }
15
+
16
+ open() {
17
+ if (this.searchTarget.open) return;
18
+ this._allowClosing();
19
+ this._initSearch();
20
+ this.searchTarget.showModal();
21
+ }
22
+
23
+ search = debounce(this._search, 200);
24
+
25
+ _allowOpening() {
26
+ this.keydownOpen = (event) => {
27
+ if (this.searchTarget.open || event.key !== "/") return;
28
+ event.preventDefault();
29
+ this.open();
30
+ };
31
+
32
+ document.addEventListener("keydown", this.keydownOpen);
33
+ }
34
+
35
+ _allowClosing() {
36
+ this.clickClose = (event) => {
37
+ if (this.dialogTarget.contains(event.target)) return;
38
+ this.searchTarget.close();
39
+ };
40
+
41
+ document.addEventListener("click", this.clickClose, { once: true });
42
+ }
43
+
44
+ _initSearch() {
45
+ if (this.documents) {
46
+ this.searchTarget.classList.add("loaded");
47
+ return;
48
+ }
49
+ this._createSearchIndex();
50
+ this.searchTarget.classList.add("loaded");
51
+ }
52
+
53
+ _createSearchIndex() {
54
+ const documents = this._getDocuments();
55
+ if (documents.length === 0) return;
56
+ this.documents = documents;
57
+ this.searchIndex = lunr(function () {
58
+ this.ref("title");
59
+ this.field("title", { boost: 5 });
60
+ this.field("text");
61
+ this.metadataWhitelist = ["position"];
62
+ documents.forEach(function (doc) {
63
+ this.add(doc);
64
+ }, this);
65
+ });
66
+ }
67
+
68
+ _getDocuments() {
69
+ const searchData = JSON.parse(this.dataTarget.textContent);
70
+ if (searchData.length === 0) {
71
+ console.warn(
72
+ [
73
+ "The search data is not present in the HTML.",
74
+ "If you are in development, run `bundle exec rails hotdocs:index`.",
75
+ "If you are in production, assets compilation should have taken care of it.",
76
+ ].join(" ")
77
+ );
78
+ }
79
+ return searchData.map((data) => {
80
+ const div = document.createElement("div");
81
+ div.innerHTML = data.html;
82
+ return { ...data, text: div.innerText };
83
+ });
84
+ }
85
+
86
+ _search(event) {
87
+ if (!this.searchIndex) return;
88
+ const query = event.target.value;
89
+ const results = this.searchIndex.search(query).slice(0, 10);
90
+ this._displayResults(results);
91
+ }
92
+
93
+ _displayResults(results) {
94
+ this.resultsTarget.innerHTML = null;
95
+
96
+ results.forEach((result) => {
97
+ const matches = Object.keys(result.matchData.metadata);
98
+ const excerpt = this._withExcerpt(matches, result)[0];
99
+ if (!excerpt) return;
100
+ this.resultsTarget.appendChild(this._createResultElement(excerpt));
101
+ });
102
+ }
103
+
104
+ _withExcerpt(matches, result) {
105
+ return matches.flatMap((match) => {
106
+ return Object.keys(result.matchData.metadata[match]).map((key) => {
107
+ const position = result.matchData.metadata[match][key].position[0];
108
+ const [sliceStart, sliceLength] = key === "text" ? position : [0, 0];
109
+ const doc = this.documents.find((doc) => doc.title === result.ref);
110
+ const excerpt = this._excerpt(doc.text, sliceStart, sliceLength);
111
+ return { ...doc, excerpt };
112
+ });
113
+ });
114
+ }
115
+
116
+ _excerpt(doc, sliceStart, sliceLength) {
117
+ const startPos = Math.max(sliceStart - 80, 0);
118
+ const endPos = Math.min(sliceStart + sliceLength + 80, doc.length);
119
+ return [
120
+ startPos > 0 ? "..." : "",
121
+ doc.slice(startPos, sliceStart),
122
+ "<strong>" +
123
+ escapeHtmlEntities(doc.slice(sliceStart, sliceStart + sliceLength)) +
124
+ "</strong>",
125
+ doc.slice(sliceStart + sliceLength, endPos),
126
+ endPos < doc.length ? "..." : "",
127
+ ].join("");
128
+ }
129
+
130
+ _createResultElement(excerpt) {
131
+ const clone = this.resultTemplateTarget.content.cloneNode(true);
132
+ const li = clone.querySelector("li");
133
+ li.querySelector("h1").innerHTML = `${excerpt.parent} > ${excerpt.title}`;
134
+ li.querySelector("a").innerHTML = excerpt.excerpt;
135
+ li.querySelector("a").href = excerpt.url;
136
+ return clone;
137
+ }
138
+ }
139
+
140
+ function debounce(func, wait) {
141
+ let timeoutId;
142
+
143
+ return function (...args) {
144
+ clearTimeout(timeoutId);
145
+ timeoutId = setTimeout(() => func.apply(this, args), wait);
146
+ };
147
+ }
148
+
149
+ function escapeHtmlEntities(string) {
150
+ return String(string)
151
+ .replace(/&/g, "&amp;")
152
+ .replace(/</g, "&lt;")
153
+ .replace(/>/g, "&gt;")
154
+ .replace(/"/g, "&quot;");
155
+ }
@@ -246,6 +246,127 @@ body {
246
246
  font-weight: bold;
247
247
  }
248
248
 
249
+ /* CSS: SEARCH */
250
+
251
+ :root {
252
+ --search-background-color: #f5f6f7;
253
+ --search-button-background-color: #e9e9e9;
254
+ --search-excerpt-background-color: white;
255
+ --search-excerpt-border-color: #d7d7d7;
256
+ --search-text-color: var(--text-color);
257
+ }
258
+
259
+ [data-theme=dark]:root {
260
+ --search-background-color: #242526;
261
+ --search-button-background-color: #1b1b1b;
262
+ --search-excerpt-background-color: #1b1b1b;
263
+ --search-excerpt-border-color: #535353;
264
+ }
265
+
266
+ .search-button {
267
+ align-items: center;
268
+ background-color: var(--search-background-color);
269
+ border: solid 1px transparent;
270
+ border-radius: 99999px;
271
+ display: flex;
272
+ gap: 0.5ch;
273
+ padding: 0.5rem 0.5rem;
274
+
275
+ @media (min-width: 40rem) {
276
+ padding: 0.25rem 0.5rem;
277
+ }
278
+
279
+ &:hover {
280
+ background: none;
281
+ border: solid 1px var(--nav-link-color);
282
+ }
283
+ }
284
+
285
+ .search-button__icon {
286
+ height: 1.2rem;
287
+ width: 1.2rem;
288
+ }
289
+
290
+ .search-button__label {
291
+ display: none;
292
+
293
+ @media (min-width: 40rem) {
294
+ display: initial;
295
+ }
296
+ }
297
+
298
+ body:has(.search:open), body:has(.search[open]) {
299
+ overflow: hidden;
300
+ }
301
+
302
+ .search {
303
+ background-color: #000000dd;
304
+ bottom: 0;
305
+ color: var(--search-text-color);
306
+ height: 100vh;
307
+ left: 0;
308
+ max-height: 100vh;
309
+ max-width: 100vw;
310
+ padding-inline: 1rem;
311
+ position: fixed;
312
+ right: 0;
313
+ top: 0;
314
+ width: 100vw;
315
+ }
316
+
317
+ ::backdrop {
318
+ display: none;
319
+ }
320
+
321
+ .search__dialog {
322
+ overflow: auto;
323
+ background-color: var(--search-background-color);
324
+ border-radius: 0.375rem;
325
+ max-height: calc(100vh - 120px);
326
+ margin: 60px auto auto;
327
+ max-width: 560px;
328
+ padding: 1rem;
329
+ width: auto;
330
+ }
331
+
332
+ .search__input {
333
+ background-color: var(--search-excerpt-background-color);
334
+ border: 1px solid #808080;
335
+ border-radius: 0.2rem;
336
+ padding: 0.3rem 0.5rem;
337
+ width: 100%;
338
+
339
+ &:focus-visible {
340
+ outline: solid 2px #0077ff;
341
+ }
342
+ }
343
+
344
+ .search__result {
345
+ margin-top: 1.5rem;
346
+
347
+ &:first-child {
348
+ margin-top: 1rem;
349
+ }
350
+ }
351
+
352
+ .search.loaded .search__result--loading {
353
+ display: none;
354
+ }
355
+
356
+ .search__result-excerpt {
357
+ background-color: var(--search-excerpt-background-color);
358
+ border: 1px solid var(--search-excerpt-border-color);
359
+ border-radius: 0.2rem;
360
+ box-shadow: 0 1px 3px 0 #0000001a;
361
+ display: block;
362
+ margin-top: 0.2rem;
363
+ padding: 0.3rem 0.5rem;
364
+
365
+ &:hover {
366
+ outline: solid 2px #0077ff;
367
+ }
368
+ }
369
+
249
370
  /* CSS: MENU */
250
371
 
251
372
  :root {
@@ -363,8 +484,10 @@ body {
363
484
  .main {
364
485
  display: flex;
365
486
  flex-grow: 1;
366
- max-width: 100%;
487
+ margin-inline: auto;
488
+ max-width: 82rem;
367
489
  padding-bottom: var(--content-padding-bottom);
490
+ width: 100%;
368
491
 
369
492
  @media (min-width: 64rem) {
370
493
  width: calc(100% - 20rem);
@@ -9,7 +9,30 @@
9
9
  <%= stylesheet_link_tag "hotdocs/application", media: "all", "data-turbo-track": "reload" %>
10
10
  </head>
11
11
 
12
- <body>
12
+ <body data-controller="search">
13
+ <dialog data-search-target="search" class="search">
14
+ <div data-search-target="dialog" class="search__dialog">
15
+ <input autofocus data-action="input->search#search" type="text" class="search__input"></input>
16
+
17
+ <template data-search-target="resultTemplate">
18
+ <li class="search__result">
19
+ <h1></h1>
20
+ <a href="#" class="search__result-excerpt"></a>
21
+ </li>
22
+ </template>
23
+
24
+ <ul data-search-target="results">
25
+ <li class="search__result search__result--loading">
26
+ Loading index...
27
+ </li>
28
+ </ul>
29
+ </div>
30
+
31
+ <script type="application/json" data-search-target="data">
32
+ <%= raw(Rails.application.assets.resolver.read("search_data.json")&.force_encoding("UTF-8") || [].to_json) %>
33
+ </script>
34
+ </dialog>
35
+
13
36
  <nav class="nav" data-controller="sidenav" data-sidenav-open-class-value="sidenav--open" data-sidenav-main-menu-class-value="sidenav__sections--main">
14
37
  <div class="nav__section">
15
38
  <button class="nav__toggle" type="button" aria-label="Toggle navigation" aria-expanded="false" data-action="click->sidenav#open">
@@ -39,6 +62,14 @@
39
62
  <%= item %>
40
63
  <% end %>
41
64
  </div>
65
+
66
+ <button type="button" data-action="click->search#open:stop" class="search-button">
67
+ <svg class="search-button__icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
68
+ <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
69
+ </svg>
70
+
71
+ <span class="search-button__label">Type / to search</span>
72
+ </button>
42
73
  </div>
43
74
 
44
75
  <div class="sidenav-backdrop"></div>
@@ -3,9 +3,8 @@ require "open3"
3
3
  class MarkdownHandler
4
4
  def self.prepare(engine)
5
5
  # Install npm packages
6
+ # `capture3` raises if deno is not available
6
7
  Open3.capture3("deno --allow-read --allow-env --node-modules-dir=auto #{engine.root.join("lib/hotdocs/markdown.mjs")}", stdin_data: "")
7
- rescue
8
- Rails.logger.info("deno not found: Could not install npm packages.")
9
8
  end
10
9
 
11
10
  def initialize(engine)
@@ -1,3 +1,3 @@
1
1
  module Hotdocs
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -7,7 +7,7 @@ def gem?(name)
7
7
 
8
8
  regex = /gem ["']#{name}["']/
9
9
  if File.readlines(gemfile_path).grep(regex).any?
10
- say "#{name} already installed"
10
+ say "#{name} already bundled"
11
11
  false
12
12
  else
13
13
  run("bundle add #{name}") || abort("Failed to add #{name} to the bundle")
@@ -15,6 +15,19 @@ def gem?(name)
15
15
  end
16
16
  end
17
17
 
18
+ def pin?(name)
19
+ importmap_path = Pathname(destination_root).join("config/importmap.rb")
20
+
21
+ regex = /pin ["']#{name}["']/
22
+ if File.readlines(importmap_path).grep(regex).any?
23
+ say "#{name} already pinned"
24
+ false
25
+ else
26
+ run("bin/importmap pin #{name}") || abort("Failed to pin #{name} to the importmap")
27
+ true
28
+ end
29
+ end
30
+
18
31
  unless system("command -v deno > /dev/null 2>&1")
19
32
  abort "Install deno before running this task. Read more: https://deno.com"
20
33
  end
@@ -27,13 +40,14 @@ gem?("importmap-rails") && run("bin/rails importmap:install")
27
40
  gem?("turbo-rails") && run("bin/rails turbo:install")
28
41
  gem?("stimulus-rails") && run("bin/rails stimulus:install")
29
42
 
30
- generate(:controller, "hotdocs", "index", "--skip-routes --no-helper --no-test-framework --no-view-specs")
31
- remove_file(Pathname(destination_root).join("app/views/hotdocs/index.html.erb"))
32
- inject_into_class(Pathname(destination_root).join("app/controllers/hotdocs_controller.rb"), "HotdocsController", <<-LINES)
33
- helper Hotdocs::Engine.helpers
34
- layout "hotdocs"
43
+ pin?("lunr")
35
44
 
36
- LINES
45
+ create_file(Pathname(destination_root).join("app/controllers/hotdocs_controller.rb"), <<~CONTROLLER)
46
+ class HotdocsController < ApplicationController
47
+ helper Hotdocs::Engine.helpers
48
+ layout "hotdocs"
49
+ end
50
+ CONTROLLER
37
51
 
38
52
  create_file(Pathname(destination_root).join("app/views/layouts/hotdocs.html.erb"), <<~LAYOUT)
39
53
  <% content_for :head do %>
@@ -127,6 +141,18 @@ create_file(Pathname(destination_root).join("app/helpers/hotdocs_helper.rb"), <<
127
141
  HELPER
128
142
 
129
143
  create_file(Pathname(destination_root).join("app/assets/stylesheets/prism.css"), <<~CSS)
144
+ /* Find more themes on: https://github.com/PrismJS/prism-themes */
145
+
146
+ /*
147
+ Darcula theme
148
+
149
+ Adapted from a theme based on:
150
+ IntelliJ Darcula Theme (https://github.com/bulenkov/Darcula)
151
+
152
+ @author Alexandre Paradis <service.paradis@gmail.com>
153
+ @version 1.0
154
+ */
155
+
130
156
  code[class*="language-"],
131
157
  pre[class*="language-"] {
132
158
  color: #a9b7c6;
@@ -278,10 +304,19 @@ create_file(Pathname(destination_root).join("app/assets/stylesheets/prism.css"),
278
304
  }
279
305
  CSS
280
306
 
307
+ empty_directory "app/assets/builds"
308
+ keep_file "app/assets/builds"
309
+ if Pathname(destination_root).join(".gitignore").exist?
310
+ append_to_file(".gitignore", %(\n/app/assets/builds/*\n!/app/assets/builds/.keep\n))
311
+ append_to_file(".gitignore", %(\n/node_modules/\n))
312
+ end
313
+
281
314
  routes_path = Pathname(destination_root).join("config/routes.rb")
282
- regex = /^\s*(?!#)root/
283
- if File.readlines(routes_path).grep(regex).any?
284
- route "get '/hotdocs', to: 'hotdocs#index'"
285
- else
286
- route "root to: 'hotdocs#index'"
315
+ routes = File.readlines(routes_path)
316
+ unless routes.grep(/hotdocs#index/).any?
317
+ if routes.grep(/^\s*(?!#)root/).any?
318
+ route "get '/hotdocs', to: 'hotdocs#index'"
319
+ else
320
+ route "root to: 'hotdocs#index'"
321
+ end
287
322
  end
@@ -1,9 +1,91 @@
1
1
  namespace :hotdocs do
2
2
  desc "Install HotDocs into the app"
3
3
  task :install do
4
- previous_location = ENV["LOCATION"]
5
- ENV["LOCATION"] = File.expand_path("../install/install.rb", __dir__)
6
- Rake::Task["app:template"].invoke
7
- ENV["LOCATION"] = previous_location
4
+ location = File.expand_path("../install/install.rb", __dir__)
5
+ system("#{RbConfig.ruby} ./bin/rails app:template LOCATION=#{location}")
6
+ # Needed for hotdocs:index to find the generated ::HotdocsController
7
+ Rails.application.reloader.reload!
8
+ Rake::Task["hotdocs:index"].invoke
8
9
  end
10
+
11
+ desc "Build search data"
12
+ task index: :environment do
13
+ path = Rails.root.join("app/assets/builds/search_data.json")
14
+ # Propshaft caches the `@load_path`s. Rendering data goes through Propshaft
15
+ # because of the assets, so the file must exist before rendering.
16
+ File.write(path, "")
17
+ data = render_search_data.call.to_json
18
+ File.write(path, data)
19
+ end
20
+ end
21
+
22
+ if Rake::Task.task_defined?("assets:precompile")
23
+ Rake::Task["assets:precompile"].enhance([ "hotdocs:index" ])
24
+ end
25
+
26
+ if Rake::Task.task_defined?("test:prepare")
27
+ Rake::Task["test:prepare"].enhance([ "hotdocs:index" ])
28
+ elsif Rake::Task.task_defined?("spec:prepare")
29
+ Rake::Task["spec:prepare"].enhance([ "hotdocs:index" ])
30
+ elsif Rake::Task.task_defined?("db:test:prepare")
31
+ Rake::Task["db:test:prepare"].enhance([ "hotdocs:index" ])
32
+ end
33
+
34
+ def render_search_data
35
+ renderer = Class.new(::HotdocsController) do
36
+ include Hotdocs::ApplicationHelper
37
+
38
+ def call
39
+ with_no_view_annotations { render_search_data }
40
+ end
41
+
42
+ private
43
+
44
+ def with_no_view_annotations(&)
45
+ annotate = Rails.application.config.action_view.annotate_rendered_view_with_filenames
46
+ Rails.application.config.action_view.annotate_rendered_view_with_filenames = false
47
+ yield
48
+ ensure
49
+ Rails.application.config.action_view.annotate_rendered_view_with_filename = annotate
50
+ end
51
+
52
+ def render_search_data
53
+ pages = pages_from(menu_items)
54
+ $stderr.puts "Indexing #{pages.size} pages:"
55
+ render_pages(pages).tap { $stderr.puts }
56
+ end
57
+
58
+ def render_pages(pages)
59
+ pages.filter_map do |page|
60
+ $stderr.putc "."
61
+ html = render_path(page.fetch(:url))
62
+ next unless html
63
+ { **page, html: html }
64
+ end
65
+ end
66
+
67
+ def pages_from(menu_items, parent = "Docs")
68
+ menu_items
69
+ .filter { _1.fetch(:url).start_with?("/") }
70
+ .flat_map do |item|
71
+ current = { title: item.fetch(:label), parent: parent, url: item.fetch(:url) }
72
+ children = pages_from(item.fetch(:children, []), item.fetch(:label))
73
+ [ current ] + children
74
+ end
75
+ end
76
+
77
+ def render_path(path)
78
+ controller, action = Rails.application.routes.recognize_path(path).values_at(:controller, :action)
79
+ render_to_string("#{controller}/#{action}", layout: false)
80
+ rescue ActionController::RoutingError => error
81
+ logger.info("Skipped building #{path}: #{error}")
82
+ nil
83
+ end
84
+
85
+ def request
86
+ ActionDispatch::TestRequest.create
87
+ end
88
+ end
89
+
90
+ renderer.new
9
91
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotdocs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 3v0k4
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-20 00:00:00.000000000 Z
10
+ date: 2025-04-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -36,6 +36,7 @@ files:
36
36
  - Rakefile
37
37
  - app/assets/images/hotdocs/icon.svg
38
38
  - app/assets/javascript/controllers/accordion_controller.js
39
+ - app/assets/javascript/controllers/search_controller.js
39
40
  - app/assets/javascript/controllers/sidenav_controller.js
40
41
  - app/assets/javascript/controllers/toc_controller.js
41
42
  - app/assets/stylesheets/hotdocs/application.css