hotdocs 0.1.0 → 0.3.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: a04eac67f8afb072c5b6ef87e98bac8316659fe4da338d64b1fdb85b66334fd8
4
+ data.tar.gz: 542ea09084b1e53da0657368a3a8b4c559dbf9e0a6305be13f1ae2726ddf04f7
5
5
  SHA512:
6
- metadata.gz: ae97c6c8c80ec06bcd88e3d64087c0dcf7337a8f054afaa83a97c56c5c1db1924252c8d63ee3c9042586ab3df71c41f886fcbe3f7262effdff812b560cf28623
7
- data.tar.gz: e485b23974e0aeced4827f09a1f1d20b2050c5afff8514436c90d0bf63f01485cf3c13fefab4bdde251055faa471ccf3c6ceb4389c9cc6295496acd0ab0fefa1
6
+ metadata.gz: 7b8fea60fa67ca77c4b4655e928429483b0a77779843c64863fce8e8f49dfaba102896e54c1b36151a19ad107bf5540fab6b5663727d791e71db27081acefedd
7
+ data.tar.gz: fcbfa4cf654c14cb2eb023c3905399479bf784c1dd349f99ff2357e3d7687f342a230b61eceb5b37c8f8ecf444b8b460608be5af07affeede0186c6707e3a95b
data/README.md CHANGED
@@ -20,9 +20,9 @@ HotDocs is a set of optimized Rails components & tools for writing docs:
20
20
  | Embed docs in an existing Rails app | ✅ | ❌ | ❌ |
21
21
  | Standalone docs | ✅ | ✅ | ✅ |
22
22
  | Styled components you can customize | ✅ | ✅ | ✅ |
23
- | Markdown (with syntax highlight & themes) | 🚀 | 👍 | 🚀 |
24
- | Static export | 🔜 🚀 | 👍 | 🚀 |
25
- | Search | 🔜 | 🔌 | 🔌 |
23
+ | Markdown (with syntax highlight & themes) | | | |
24
+ | Static export | | | |
25
+ | Search | | 🔌 | 🔌 |
26
26
  | Light / Dark | 🔜 ✅ | 🔌 | ✅ |
27
27
  | Open source | ✅ | ✅ | ✅ |
28
28
  | Free | ✅ | ✅ | ✅ |
@@ -0,0 +1,29 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ host: String,
6
+ path: String,
7
+ fallback: String,
8
+ };
9
+
10
+ connect() {
11
+ if (!this.element.id) {
12
+ throw new Error("Element must have an id.");
13
+ }
14
+ this.element.innerHTML = this.fallbackValue || "Loading...";
15
+ this.element.style.visibility = "";
16
+ this.fetchElement();
17
+ }
18
+
19
+ fetchElement() {
20
+ fetch(`${this.hostValue}${this.pathValue}`)
21
+ .then((response) => response.text())
22
+ .then((text) => {
23
+ const parser = new DOMParser();
24
+ const doc = parser.parseFromString(text, "text/html");
25
+ const fetchedElement = doc.querySelector(`#${this.element.id}`);
26
+ this.element.innerHTML = fetchedElement.innerHTML;
27
+ });
28
+ }
29
+ }
@@ -0,0 +1,162 @@
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);
14
+ }
15
+
16
+ open() {
17
+ if (this.searchTarget.open) return;
18
+ this._allowClosing();
19
+ this._initSearch();
20
+ this.searchTarget.showModal();
21
+ }
22
+
23
+ close() {
24
+ this.searchTarget.close();
25
+ document.removeEventListener("click", this._clickClose);
26
+ }
27
+
28
+ search = debounce(this._search, 200);
29
+
30
+ _allowOpening() {
31
+ this.keydownOpen = (event) => {
32
+ if (this.searchTarget.open || event.key !== "/") return;
33
+ event.preventDefault();
34
+ this.open();
35
+ };
36
+
37
+ document.addEventListener("keydown", this.keydownOpen);
38
+ }
39
+
40
+ _clickClose = (event) => {
41
+ if (!this.searchTarget.open) return;
42
+ if (this.dialogTarget.contains(event.target)) return;
43
+ this.close();
44
+ };
45
+
46
+ _allowClosing() {
47
+ document.removeEventListener("click", this._clickClose);
48
+ document.addEventListener("click", this._clickClose);
49
+ }
50
+
51
+ _initSearch() {
52
+ if (this.documents) {
53
+ this.searchTarget.classList.add("loaded");
54
+ return;
55
+ }
56
+ this._createSearchIndex();
57
+ this.searchTarget.classList.add("loaded");
58
+ }
59
+
60
+ _createSearchIndex() {
61
+ const documents = this._getDocuments();
62
+ if (documents.length === 0) return;
63
+ this.documents = documents;
64
+ this.searchIndex = lunr(function () {
65
+ this.ref("title");
66
+ this.field("title", { boost: 5 });
67
+ this.field("text");
68
+ this.metadataWhitelist = ["position"];
69
+ documents.forEach(function (doc) {
70
+ this.add(doc);
71
+ }, this);
72
+ });
73
+ }
74
+
75
+ _getDocuments() {
76
+ const searchData = JSON.parse(this.dataTarget.textContent);
77
+ if (searchData.length === 0) {
78
+ console.warn(
79
+ [
80
+ "The search data is not present in the HTML.",
81
+ "If you are in development, run `bundle exec rails hotdocs:index`.",
82
+ "If you are in production, assets compilation should have taken care of it.",
83
+ ].join(" ")
84
+ );
85
+ }
86
+ return searchData.map((data) => {
87
+ const div = document.createElement("div");
88
+ div.innerHTML = data.html;
89
+ return { ...data, text: div.innerText };
90
+ });
91
+ }
92
+
93
+ _search(event) {
94
+ if (!this.searchIndex) return;
95
+ const query = event.target.value;
96
+ const results = this.searchIndex.search(query).slice(0, 10);
97
+ this._displayResults(results);
98
+ }
99
+
100
+ _displayResults(results) {
101
+ this.resultsTarget.innerHTML = null;
102
+
103
+ results.forEach((result) => {
104
+ const matches = Object.keys(result.matchData.metadata);
105
+ const excerpt = this._withExcerpt(matches, result)[0];
106
+ if (!excerpt) return;
107
+ this.resultsTarget.appendChild(this._createResultElement(excerpt));
108
+ });
109
+ }
110
+
111
+ _withExcerpt(matches, result) {
112
+ return matches.flatMap((match) => {
113
+ return Object.keys(result.matchData.metadata[match]).map((key) => {
114
+ const position = result.matchData.metadata[match][key].position[0];
115
+ const [sliceStart, sliceLength] = key === "text" ? position : [0, 0];
116
+ const doc = this.documents.find((doc) => doc.title === result.ref);
117
+ const excerpt = this._excerpt(doc.text, sliceStart, sliceLength);
118
+ return { ...doc, excerpt };
119
+ });
120
+ });
121
+ }
122
+
123
+ _excerpt(doc, sliceStart, sliceLength) {
124
+ const startPos = Math.max(sliceStart - 80, 0);
125
+ const endPos = Math.min(sliceStart + sliceLength + 80, doc.length);
126
+ return [
127
+ startPos > 0 ? "..." : "",
128
+ doc.slice(startPos, sliceStart),
129
+ "<strong>" +
130
+ escapeHtmlEntities(doc.slice(sliceStart, sliceStart + sliceLength)) +
131
+ "</strong>",
132
+ doc.slice(sliceStart + sliceLength, endPos),
133
+ endPos < doc.length ? "..." : "",
134
+ ].join("");
135
+ }
136
+
137
+ _createResultElement(excerpt) {
138
+ const clone = this.resultTemplateTarget.content.cloneNode(true);
139
+ const li = clone.querySelector("li");
140
+ li.querySelector("h1").innerHTML = `${excerpt.parent} > ${excerpt.title}`;
141
+ li.querySelector("a").innerHTML = excerpt.excerpt;
142
+ li.querySelector("a").href = excerpt.url;
143
+ return clone;
144
+ }
145
+ }
146
+
147
+ function debounce(func, wait) {
148
+ let timeoutId;
149
+
150
+ return function (...args) {
151
+ clearTimeout(timeoutId);
152
+ timeoutId = setTimeout(() => func.apply(this, args), wait);
153
+ };
154
+ }
155
+
156
+ function escapeHtmlEntities(string) {
157
+ return String(string)
158
+ .replace(/&/g, "&amp;")
159
+ .replace(/</g, "&lt;")
160
+ .replace(/>/g, "&gt;")
161
+ .replace(/"/g, "&quot;");
162
+ }
@@ -73,7 +73,7 @@ body {
73
73
  --nav-background-color: white;
74
74
  --nav-link-active-color: var(--link-active-color);
75
75
  --nav-link-color: var(--link-color);
76
- --nav-padding-horizontal: 1rem;
76
+ --nav-padding-horizontal: 1.5rem;
77
77
  --nav-padding-vertical: 0.5rem;
78
78
  --nav-shadow: 0 1px 2px 0 #0000001a;
79
79
  --nav-title-color: #1c1e21;
@@ -246,6 +246,163 @@ 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
+ height: 100dvh;
308
+ left: 0;
309
+ max-height: 100vh;
310
+ height: 100dvh;
311
+ max-width: 100vw;
312
+ max-width: 100dvw;
313
+ position: fixed;
314
+ right: 0;
315
+ top: 0;
316
+ width: 100vw;
317
+ width: 100dvw;
318
+
319
+ @media (min-width: 64rem) {
320
+ padding-inline: 1rem;
321
+ }
322
+ }
323
+
324
+ ::backdrop {
325
+ display: none;
326
+ }
327
+
328
+ .search__dialog {
329
+ background-color: var(--search-background-color);
330
+ height: 100vh;
331
+ height: 100dvh;
332
+ max-height: 100vh;
333
+ max-height: 100dvh;
334
+ max-width: 100vw;
335
+ max-width: 100dvw;
336
+ overflow: auto;
337
+ padding: 1rem;
338
+ width: 100vw;
339
+ width: 100dvw;
340
+
341
+ @media (min-width: 64rem) {
342
+ border-radius: 0.375rem;
343
+ height: auto;
344
+ margin: 60px auto auto;
345
+ max-height: calc(100vh - 120px);
346
+ max-height: calc(100dvh - 120px);
347
+ max-width: 800px;
348
+ width: auto;
349
+ }
350
+ }
351
+
352
+ .search__header {
353
+ align-items: center;
354
+ display: flex;
355
+ gap: 1rem;
356
+ }
357
+
358
+ .search__input {
359
+ background-color: var(--search-excerpt-background-color);
360
+ border: 1px solid #808080;
361
+ border-radius: 0.2rem;
362
+ flex: 1 0 auto;
363
+ padding: 0.3rem 0.5rem;
364
+
365
+ &:focus-visible {
366
+ outline: solid 2px #0077ff;
367
+ }
368
+ }
369
+
370
+ .search__dismiss {
371
+ color: var(--search-text-color);
372
+ height: 1rem;
373
+ width: 1rem;
374
+
375
+ @media (min-width: 64rem) {
376
+ display: none;
377
+ }
378
+ }
379
+
380
+ .search__result {
381
+ margin-top: 1.5rem;
382
+
383
+ &:first-child {
384
+ margin-top: 1rem;
385
+ }
386
+ }
387
+
388
+ .search.loaded .search__result--loading {
389
+ display: none;
390
+ }
391
+
392
+ .search__result-excerpt {
393
+ background-color: var(--search-excerpt-background-color);
394
+ border: 1px solid var(--search-excerpt-border-color);
395
+ border-radius: 0.2rem;
396
+ box-shadow: 0 1px 3px 0 #0000001a;
397
+ display: block;
398
+ margin-top: 0.2rem;
399
+ padding: 0.3rem 0.5rem;
400
+
401
+ &:hover {
402
+ outline: solid 2px #0077ff;
403
+ }
404
+ }
405
+
249
406
  /* CSS: MENU */
250
407
 
251
408
  :root {
@@ -363,8 +520,10 @@ body {
363
520
  .main {
364
521
  display: flex;
365
522
  flex-grow: 1;
366
- max-width: 100%;
523
+ margin-inline: auto;
524
+ max-width: 82rem;
367
525
  padding-bottom: var(--content-padding-bottom);
526
+ width: 100%;
368
527
 
369
528
  @media (min-width: 64rem) {
370
529
  width: calc(100% - 20rem);
@@ -565,7 +724,7 @@ body {
565
724
  .footer {
566
725
  background-color: var(--footer-background-color);
567
726
  flex: 0 0 auto;
568
- padding: 4rem 1rem;
727
+ padding: 4rem var(--nav-padding-horizontal);
569
728
  }
570
729
 
571
730
  .footer__sections {
@@ -603,6 +762,7 @@ body {
603
762
  }
604
763
 
605
764
  .credits {
765
+ padding-block: 1rem;
606
766
  text-align: center;
607
767
  }
608
768
 
@@ -644,9 +804,9 @@ body {
644
804
  width: 1rem;
645
805
  }
646
806
 
647
- /* CSS: ADMONITIONS */
807
+ /* CSS: ALERTS */
648
808
 
649
- .admonition {
809
+ .alert {
650
810
  border: 1px solid;
651
811
  border-left: 0.5rem solid;
652
812
  border-radius: 0.375rem;
@@ -654,44 +814,44 @@ body {
654
814
  padding: 1rem;
655
815
  }
656
816
 
657
- .admonition--tip {
817
+ .alert--tip {
658
818
  background: #00940011;
659
819
  border-color: #009400;
660
820
  }
661
821
 
662
- .admonition--info {
822
+ .alert--info {
663
823
  background: #87cef911;
664
824
  border-color: #87cef9;
665
825
  }
666
826
 
667
- .admonition--warning {
827
+ .alert--warning {
668
828
  background: #fea50011;
669
829
  border-color: #fea500;
670
830
  }
671
831
 
672
- .admonition--danger {
832
+ .alert--danger {
673
833
  background: #db153b11;
674
834
  border-color: #db153b;
675
835
  }
676
836
 
677
- .admonition__header {
837
+ .alert__header {
678
838
  align-items: center;
679
839
  display: flex;
680
840
  gap: 0.5ch;
681
841
  margin-bottom: 1rem;
682
842
  }
683
843
 
684
- .admonition__icon {
844
+ .alert__icon {
685
845
  height: 1rem;
686
846
  width: 1rem;
687
847
  }
688
848
 
689
- .admonition__label {
849
+ .alert__label {
690
850
  font-weight: bold;
691
851
  margin: 0;
692
852
  text-transform: uppercase;
693
853
  }
694
854
 
695
- .admonition__content > :last-of-type {
855
+ .alert__content > :last-of-type {
696
856
  margin-bottom: 0;
697
857
  }
@@ -24,10 +24,11 @@ module Hotdocs
24
24
  [ *Array(old), *Array(new) ].join(" ")
25
25
  end
26
26
 
27
+ # Needs to be on one line otherwise kramdown chokes
27
28
  link_to(options, html_options) do
28
29
  concat(content_tag(:span, name))
29
30
 
30
- concat(<<~SVG.html_safe)
31
+ concat(<<~SVG.gsub(/\n/, "").html_safe)
31
32
  <svg aria-hidden="true" viewBox="0 0 24 24" class="external-link__icon">
32
33
  <path fill="currentColor" d="M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035 4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"></path>
33
34
  </svg>
@@ -70,6 +71,16 @@ module Hotdocs
70
71
  end
71
72
  end
72
73
 
74
+ def fetcher(id:, path:, fallback: nil, &)
75
+ data = {
76
+ controller: "fetcher",
77
+ "fetcher-host-value": fetcher_host,
78
+ "fetcher-path-value": path,
79
+ "fetcher-fallback-value": fallback
80
+ }
81
+ content_tag(:div, id: id, data: data, style: "visibility: hidden;", &)
82
+ end
83
+
73
84
  private
74
85
 
75
86
  def active_link?(url)