infodocs 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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/CONTRIBUTING.md +31 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +341 -0
  6. data/Rakefile +10 -0
  7. data/app/controllers/infodocs/application_controller.rb +68 -0
  8. data/app/controllers/infodocs/assets_controller.rb +15 -0
  9. data/app/controllers/infodocs/editor_controller.rb +36 -0
  10. data/app/controllers/infodocs/indexes_controller.rb +22 -0
  11. data/app/controllers/infodocs/pages_controller.rb +48 -0
  12. data/app/controllers/infodocs/raws_controller.rb +13 -0
  13. data/app/controllers/infodocs/searches_controller.rb +18 -0
  14. data/app/controllers/infodocs/tags_controller.rb +11 -0
  15. data/app/controllers/infodocs/trees_controller.rb +40 -0
  16. data/app/helpers/infodocs/application_helper.rb +70 -0
  17. data/app/javascript/infodocs/application.js +74 -0
  18. data/app/javascript/infodocs/controllers/editor_controller.js +135 -0
  19. data/app/javascript/infodocs/controllers/highlight_controller.js +44 -0
  20. data/app/javascript/infodocs/controllers/mermaid_controller.js +25 -0
  21. data/app/javascript/infodocs/controllers/search_controller.js +125 -0
  22. data/app/javascript/infodocs/controllers/sidebar_controller.js +34 -0
  23. data/app/javascript/infodocs/controllers/toc_controller.js +18 -0
  24. data/app/javascript/infodocs/controllers/tree_editor_controller.js +65 -0
  25. data/app/javascript/infodocs/controllers/tree_state_controller.js +37 -0
  26. data/app/javascript/infodocs/vendor/sortable.esm.js +3378 -0
  27. data/app/views/infodocs/_delete_button.html.erb +6 -0
  28. data/app/views/infodocs/_handle.html.erb +5 -0
  29. data/app/views/infodocs/_tree.html.erb +35 -0
  30. data/app/views/infodocs/editor/edit.html.erb +61 -0
  31. data/app/views/infodocs/pages/empty_folder.html.erb +6 -0
  32. data/app/views/infodocs/pages/not_found.html.erb +11 -0
  33. data/app/views/infodocs/pages/show.html.erb +92 -0
  34. data/app/views/infodocs/searches/index.html.erb +20 -0
  35. data/app/views/infodocs/tags/index.html.erb +26 -0
  36. data/app/views/layouts/infodocs/application.html.erb +104 -0
  37. data/config/importmap.rb +6 -0
  38. data/config/routes.rb +26 -0
  39. data/lib/generators/infodocs/install/install_generator.rb +59 -0
  40. data/lib/generators/infodocs/install/templates/getting-started.md.tt +8 -0
  41. data/lib/generators/infodocs/install/templates/initializer.rb.tt +51 -0
  42. data/lib/generators/infodocs/views/views_generator.rb +13 -0
  43. data/lib/infodocs/code_theme.rb +130 -0
  44. data/lib/infodocs/configuration.rb +50 -0
  45. data/lib/infodocs/engine.rb +39 -0
  46. data/lib/infodocs/link_checker.rb +43 -0
  47. data/lib/infodocs/linter.rb +48 -0
  48. data/lib/infodocs/page.rb +124 -0
  49. data/lib/infodocs/rails_lexer.rb +39 -0
  50. data/lib/infodocs/renderer.rb +231 -0
  51. data/lib/infodocs/search.rb +131 -0
  52. data/lib/infodocs/source.rb +35 -0
  53. data/lib/infodocs/tree.rb +215 -0
  54. data/lib/infodocs/tree_editor.rb +179 -0
  55. data/lib/infodocs/url_helper.rb +23 -0
  56. data/lib/infodocs/version.rb +5 -0
  57. data/lib/infodocs.rb +74 -0
  58. data/lib/tasks/infodocs.rake +63 -0
  59. data/sig/infodocs.rbs +4 -0
  60. metadata +192 -0
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ class TagsController < ApplicationController
5
+ def index
6
+ @tag = Page.normalize_tags(params[:tag]).first
7
+ @tags = visible_tags
8
+ @pages = @tag ? visible_pages.select { |page| page.tags.include?(@tag) } : []
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ # Drag & drop target: moves a page/folder and rewrites `_tree.yml` files.
5
+ class TreesController < ApplicationController
6
+ before_action :ensure_editable!
7
+
8
+ def create
9
+ slug = TreeEditor.new(docs_tree).create(parent: params[:parent].to_s, title: params[:title], kind: params[:kind])
10
+ Infodocs.reload!
11
+ redirect_to(Infodocs.tree.find(slug) ? edit_page_path(slug) : page_path(slug), status: :see_other)
12
+ rescue TreeEditor::Invalid => error
13
+ redirect_back fallback_location: root_path, alert: error.message, status: :see_other
14
+ end
15
+
16
+ def destroy
17
+ parent = TreeEditor.new(docs_tree).delete(slug: params.require(:slug))
18
+ Infodocs.reload!
19
+ render json: { deleted: true, redirect: parent.empty? ? root_path : page_path(parent) }
20
+ rescue TreeEditor::Invalid, ActionController::ParameterMissing => error
21
+ render json: { deleted: false, error: error.message }, status: :unprocessable_entity
22
+ end
23
+
24
+ def tags
25
+ saved = TreeEditor.new(docs_tree).tag(slug: params[:slug].to_s, tags: params[:tags].to_s)
26
+ Infodocs.reload!
27
+ render json: { tags: saved }
28
+ rescue TreeEditor::Invalid => error
29
+ render json: { error: error.message }, status: :unprocessable_entity
30
+ end
31
+
32
+ def move
33
+ TreeEditor.new(docs_tree).move(slug: params.require(:slug), parent: params[:parent].to_s, before: params[:before].presence)
34
+ Infodocs.reload!
35
+ render json: { moved: true }
36
+ rescue TreeEditor::Invalid, ActionController::ParameterMissing => error
37
+ render json: { moved: false, error: error.message }, status: :unprocessable_entity
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ module ApplicationHelper
5
+ # Rouge theme as CSS classes: GitHub light, GitHub dark under prefers-color-scheme.
6
+ # Edit controls: opt-in via config and only for requests from this machine.
7
+ def infodocs_editable?
8
+ Infodocs.editing_enabled? && request.local?
9
+ end
10
+
11
+ # Branding options may be a value or a lambda that receives the view
12
+ # (handy for asset helpers: `->(view) { view.image_path("logo.svg") }`).
13
+ def infodocs_option(name)
14
+ value = Infodocs.config.public_send(name)
15
+ value.respond_to?(:call) ? value.call(self) : value
16
+ end
17
+
18
+ def infodocs_header_links
19
+ Infodocs.config.header_links.map { |link| link.to_hash.transform_keys(&:to_sym) }
20
+ end
21
+
22
+ # Tag the sidebar is filtered by (?tag=…), if it is a known one.
23
+ def infodocs_active_tag
24
+ tag = Page.normalize_tags(params[:tag]).first
25
+ tag if tag && Infodocs.tree.tags.key?(tag)
26
+ end
27
+
28
+ # Sidebar nodes the visitor may see (and, when filtering, only those with the tag).
29
+ def infodocs_filter_nodes(nodes, tag)
30
+ return nodes if tag.nil? && Infodocs.config.page_authorize.nil?
31
+
32
+ nodes.select { |node| infodocs_node_visible?(node, tag) }
33
+ end
34
+
35
+ def infodocs_node_visible?(node, tag)
36
+ if node.leaf?
37
+ page = node.page
38
+ page_allowed?(page) && (tag.nil? || page.tags.include?(tag))
39
+ else
40
+ node.children.reject(&:hidden).any? { |child| infodocs_node_visible?(child, tag) } ||
41
+ (tag.nil? && node.page && page_allowed?(node.page))
42
+ end
43
+ end
44
+
45
+ # [label, slug] for every folder a new page can be created in.
46
+ def infodocs_folder_options(node = Infodocs.tree.root, depth = 0, options = [])
47
+ unless node.slug.empty? && Infodocs.tree.multiple_roots?
48
+ options << [("— " * depth) + (node.slug.empty? ? "Top level" : node.title), node.slug]
49
+ end
50
+ node.children&.each { |child| infodocs_folder_options(child, depth + 1, options) if child.folder? }
51
+ options
52
+ end
53
+
54
+ # Attributes for a delete button handled by the delegated listener in application.js.
55
+ def infodocs_delete_attributes(node)
56
+ pages = node.folder? ? Infodocs.tree.pages.count { |page| page.slug.start_with?("#{node.slug}/") || page.slug == node.slug } : 1
57
+ what = node.folder? ? "the folder \"#{node.title}\" with #{pages} #{'page'.pluralize(pages)} inside" : "\"#{node.title}\""
58
+ { "data-infodocs-delete": "", "data-url": delete_tree_path, "data-slug": node.slug,
59
+ "data-message": "Delete #{what} from disk? Files tracked by git can be restored with git." }
60
+ end
61
+
62
+ def infodocs_deletable?(node)
63
+ infodocs_editable? && !(Infodocs.tree.multiple_roots? && Infodocs.tree.children.include?(node))
64
+ end
65
+
66
+ def infodocs_highlight_css
67
+ Infodocs::CodeTheme.css(Infodocs.config.code_theme)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,74 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import SidebarController from "infodocs/controllers/sidebar_controller"
3
+ import SearchController from "infodocs/controllers/search_controller"
4
+ import MermaidController from "infodocs/controllers/mermaid_controller"
5
+ import EditorController from "infodocs/controllers/editor_controller"
6
+ import TreeEditorController from "infodocs/controllers/tree_editor_controller"
7
+ import TreeStateController from "infodocs/controllers/tree_state_controller"
8
+ import HighlightController from "infodocs/controllers/highlight_controller"
9
+ import TocController from "infodocs/controllers/toc_controller"
10
+
11
+ const application = Application.start()
12
+ application.register("infodocs-sidebar", SidebarController)
13
+ application.register("infodocs-search", SearchController)
14
+ application.register("infodocs-mermaid", MermaidController)
15
+ application.register("infodocs-toc", TocController)
16
+ application.register("infodocs-tree-state", TreeStateController)
17
+ application.register("infodocs-highlight", HighlightController)
18
+ application.register("infodocs-editor", EditorController)
19
+ application.register("infodocs-tree-editor", TreeEditorController)
20
+
21
+ // "Copy Markdown" in the page header: fetches the page source and copies it.
22
+ document.addEventListener("click", async (event) => {
23
+ const button = event.target.closest("[data-infodocs-copy-markdown]")
24
+ if (!button) return
25
+
26
+ const label = button.querySelector("[data-label]")
27
+ try {
28
+ const response = await fetch(button.dataset.infodocsCopyMarkdown, { credentials: "same-origin" })
29
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
30
+ await navigator.clipboard.writeText(await response.text())
31
+ label.textContent = "Copied"
32
+ } catch {
33
+ label.textContent = "Copy failed"
34
+ }
35
+ setTimeout(() => { label.textContent = "Copy Markdown" }, 1500)
36
+ })
37
+
38
+ // Copy buttons on code blocks (also inside the editor preview).
39
+ document.addEventListener("click", async (event) => {
40
+ const button = event.target.closest("[data-infodocs-copy]")
41
+ if (!button) return
42
+
43
+ const code = button.closest(".infodocs-code")?.querySelector("pre code")
44
+ if (!code) return
45
+ try {
46
+ await navigator.clipboard.writeText(code.textContent)
47
+ button.textContent = "Copied"
48
+ } catch {
49
+ button.textContent = "Press ⌘C"
50
+ }
51
+ setTimeout(() => { button.textContent = "Copy" }, 1500)
52
+ })
53
+
54
+ // Delete buttons (development only): confirm, DELETE the page/folder, go to its parent.
55
+ document.addEventListener("click", async (event) => {
56
+ const button = event.target.closest("[data-infodocs-delete]")
57
+ if (!button) return
58
+ event.preventDefault()
59
+ event.stopPropagation()
60
+ if (!window.confirm(button.dataset.message)) return
61
+
62
+ try {
63
+ const response = await fetch(button.dataset.url, {
64
+ method: "DELETE",
65
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content || "" },
66
+ body: JSON.stringify({ slug: button.dataset.slug })
67
+ })
68
+ const data = await response.json()
69
+ if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`)
70
+ window.location.assign(data.redirect)
71
+ } catch (error) {
72
+ window.alert(`Delete failed: ${error.message}`)
73
+ }
74
+ }, true)
@@ -0,0 +1,135 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const WRAP = {
4
+ bold: ["**", "**", "bold"],
5
+ italic: ["_", "_", "italic"],
6
+ code: ["`", "`", "code"]
7
+ }
8
+ const LINE = { h1: "# ", h2: "## ", h3: "### ", list: "- ", task: "- [ ] ", quote: "> " }
9
+ const BLOCK = { ruby: "ruby", js: "js", mermaid: "mermaid" }
10
+
11
+ // Markdown source editor with live server-rendered preview. Saves the raw
12
+ // text to the .md file through PATCH (development only).
13
+ export default class extends Controller {
14
+ static targets = ["input", "preview", "status", "pane", "tab", "tags"]
15
+ static values = { saveUrl: String, previewUrl: String, backUrl: String, tagsUrl: String, slug: String }
16
+
17
+ connect() {
18
+ this.dirty = false
19
+ this.render()
20
+ this.onKey = (event) => {
21
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
22
+ event.preventDefault()
23
+ this.save()
24
+ }
25
+ }
26
+ this.onUnload = (event) => { if (this.dirty) { event.preventDefault(); event.returnValue = "" } }
27
+ document.addEventListener("keydown", this.onKey)
28
+ window.addEventListener("beforeunload", this.onUnload)
29
+ }
30
+
31
+ disconnect() {
32
+ document.removeEventListener("keydown", this.onKey)
33
+ window.removeEventListener("beforeunload", this.onUnload)
34
+ clearTimeout(this.timer)
35
+ }
36
+
37
+ changed() {
38
+ this.dirty = true
39
+ this.#status("Unsaved changes")
40
+ clearTimeout(this.timer)
41
+ this.timer = setTimeout(() => this.render(), 300)
42
+ }
43
+
44
+ keydown(event) {
45
+ if (event.key !== "Tab" || event.metaKey || event.ctrlKey) return
46
+ event.preventDefault()
47
+ this.#insert(" ")
48
+ }
49
+
50
+ show({ params: { pane } }) {
51
+ this.tabTargets.forEach((tab) => tab.setAttribute("aria-selected", tab.dataset.pane === pane))
52
+ const preview = pane === "preview"
53
+ this.inputTarget.classList.toggle("hidden", preview)
54
+ this.inputTarget.classList.toggle("lg:block", true)
55
+ this.paneTarget.classList.toggle("hidden", !preview)
56
+ this.paneTarget.classList.add("lg:block")
57
+ }
58
+
59
+ format({ params: { kind } }) {
60
+ const input = this.inputTarget
61
+ const { selectionStart: start, selectionEnd: end, value } = input
62
+ const selected = value.slice(start, end)
63
+
64
+ if (WRAP[kind]) {
65
+ const [before, after, placeholder] = WRAP[kind]
66
+ this.#replace(start, end, `${before}${selected || placeholder}${after}`, before.length, selected ? null : placeholder.length)
67
+ } else if (kind === "link") {
68
+ const text = selected || "text"
69
+ this.#replace(start, end, `[${text}](https://)`, text.length + 3, 8)
70
+ } else if (LINE[kind]) {
71
+ const lineStart = value.lastIndexOf("\n", start - 1) + 1
72
+ this.#replace(lineStart, lineStart, LINE[kind], LINE[kind].length, 0)
73
+ input.setSelectionRange(start + LINE[kind].length, end + LINE[kind].length)
74
+ } else if (BLOCK[kind]) {
75
+ const body = selected || ""
76
+ this.#replace(start, end, `\n\`\`\`${BLOCK[kind]}\n${body}\n\`\`\`\n`, 5 + BLOCK[kind].length, body.length)
77
+ }
78
+ this.changed()
79
+ input.focus()
80
+ }
81
+
82
+ async save() {
83
+ this.#status("Saving…")
84
+ try {
85
+ const response = await fetch(this.saveUrlValue, {
86
+ method: "PATCH",
87
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": this.#csrf() },
88
+ body: JSON.stringify({ content: this.inputTarget.value })
89
+ })
90
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
91
+ if (this.hasTagsTarget) await this.#saveTags()
92
+ this.dirty = false
93
+ this.#status("Saved — commit the change with git")
94
+ } catch (error) {
95
+ this.#status(`Save failed: ${error.message}`)
96
+ }
97
+ }
98
+
99
+ async #saveTags() {
100
+ const response = await fetch(this.tagsUrlValue, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": this.#csrf() },
103
+ body: JSON.stringify({ slug: this.slugValue, tags: this.tagsTarget.value })
104
+ })
105
+ if (!response.ok) throw new Error(`tags: HTTP ${response.status}`)
106
+ }
107
+
108
+ async render() {
109
+ const token = (this.renderToken = (this.renderToken || 0) + 1)
110
+ try {
111
+ const response = await fetch(this.previewUrlValue, {
112
+ method: "POST",
113
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": this.#csrf() },
114
+ body: JSON.stringify({ content: this.inputTarget.value })
115
+ })
116
+ if (token !== this.renderToken || !response.ok) return
117
+ this.previewTarget.innerHTML = await response.text()
118
+ } catch { /* offline: keep the last preview */ }
119
+ }
120
+
121
+ #replace(start, end, text, selectOffset, selectLength) {
122
+ const input = this.inputTarget
123
+ input.setRangeText(text, start, end, "end")
124
+ if (selectLength != null) input.setSelectionRange(start + selectOffset, start + selectOffset + selectLength)
125
+ }
126
+
127
+ #insert(text) {
128
+ const input = this.inputTarget
129
+ input.setRangeText(text, input.selectionStart, input.selectionEnd, "end")
130
+ this.changed()
131
+ }
132
+
133
+ #status(text) { this.statusTarget.textContent = text }
134
+ #csrf() { return document.querySelector("meta[name=csrf-token]")?.content || "" }
135
+ }
@@ -0,0 +1,44 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Marks the words of the search (?q=…) that led here and scrolls to the first.
4
+ export default class extends Controller {
5
+ connect() {
6
+ const terms = this.#terms(new URLSearchParams(window.location.search).get("q") || "")
7
+ if (terms.length === 0) return
8
+
9
+ const pattern = new RegExp(`(${terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "gi")
10
+ const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT, {
11
+ acceptNode: (node) => node.parentElement.closest("pre, script, style, mark, button") ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
12
+ })
13
+ const texts = []
14
+ while (walker.nextNode()) texts.push(walker.currentNode)
15
+
16
+ let first = null
17
+ texts.forEach((node) => {
18
+ if (!pattern.test(node.nodeValue)) return
19
+ pattern.lastIndex = 0
20
+ const fragment = document.createDocumentFragment()
21
+ node.nodeValue.split(pattern).forEach((part, index) => {
22
+ if (index % 2 === 1) {
23
+ const mark = document.createElement("mark")
24
+ mark.className = "rounded bg-yellow-200 px-0.5 text-inherit dark:bg-yellow-700"
25
+ mark.textContent = part
26
+ first ||= mark
27
+ fragment.append(mark)
28
+ } else {
29
+ fragment.append(part)
30
+ }
31
+ })
32
+ node.replaceWith(fragment)
33
+ })
34
+ first?.scrollIntoView({ block: "center" })
35
+ }
36
+
37
+ // Words from a search query: no exclusions, operators or field filters.
38
+ #terms(query) {
39
+ return query.split(/\s+/)
40
+ .filter((word) => word && !word.startsWith("-") && !/^(tag|path):/i.test(word) && word.toUpperCase() !== "OR")
41
+ .map((word) => word.replace(/^(title|heading):/i, "").replace(/["*]/g, ""))
42
+ .filter((word) => word.length > 1)
43
+ }
44
+ }
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Lazy-loads mermaid only when diagrams exist; follows dark mode and re-runs
4
+ // when the content changes (editor preview).
5
+ export default class extends Controller {
6
+ connect() {
7
+ this.#run()
8
+ this.observer = new MutationObserver(() => this.#run())
9
+ this.observer.observe(this.element, { childList: true, subtree: true })
10
+ }
11
+
12
+ disconnect() { this.observer?.disconnect() }
13
+
14
+ async #run() {
15
+ const nodes = [...this.element.querySelectorAll("pre.mermaid:not([data-processed])")]
16
+ if (nodes.length === 0) return
17
+
18
+ // `config.mermaid_url` overrides the CDN build pinned by the engine.
19
+ const custom = document.querySelector("meta[name=infodocs-mermaid-url]")?.content
20
+ const { default: mermaid } = await import(custom || "mermaid")
21
+ const dark = window.matchMedia("(prefers-color-scheme: dark)").matches || document.documentElement.classList.contains("dark")
22
+ mermaid.initialize({ startOnLoad: false, theme: dark ? "dark" : "default", securityLevel: "strict" })
23
+ try { await mermaid.run({ nodes }) } catch { /* invalid diagram while typing */ }
24
+ }
25
+ }
@@ -0,0 +1,125 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const HISTORY_KEY = "infodocs:search-history"
4
+
5
+ // Cmd/Ctrl+K or "/" focuses the search field. Typing shows live results in a
6
+ // dropdown (arrow keys + Enter); Enter without a selection opens the full page.
7
+ export default class extends Controller {
8
+ static targets = ["input", "results"]
9
+
10
+ connect() {
11
+ this.index = -1
12
+ this.onKey = (event) => {
13
+ const typing = /^(input|textarea|select)$/i.test(event.target.tagName) || event.target.isContentEditable
14
+ if ((event.key === "k" && (event.metaKey || event.ctrlKey)) || (event.key === "/" && !typing)) {
15
+ event.preventDefault()
16
+ this.inputTarget.focus()
17
+ this.inputTarget.select()
18
+ }
19
+ }
20
+ this.onClick = (event) => { if (!this.element.contains(event.target)) this.hide() }
21
+ document.addEventListener("keydown", this.onKey)
22
+ document.addEventListener("click", this.onClick)
23
+ this.inputTarget.addEventListener("input", () => this.schedule())
24
+ this.inputTarget.addEventListener("focus", () => { if (!this.inputTarget.value.trim()) this.showHistory() })
25
+ this.inputTarget.addEventListener("keydown", (event) => this.navigate(event))
26
+ this.element.addEventListener("submit", () => this.remember())
27
+ }
28
+
29
+ disconnect() {
30
+ document.removeEventListener("keydown", this.onKey)
31
+ document.removeEventListener("click", this.onClick)
32
+ clearTimeout(this.timer)
33
+ }
34
+
35
+ schedule() {
36
+ clearTimeout(this.timer)
37
+ const query = this.inputTarget.value.trim()
38
+ if (!query) return this.showHistory()
39
+ this.timer = setTimeout(() => this.fetchResults(query), 150)
40
+ }
41
+
42
+ async fetchResults(query) {
43
+ const url = `${this.element.dataset.infodocsSearchUrl}?q=${encodeURIComponent(query)}`
44
+ try {
45
+ const response = await fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
46
+ if (!response.ok || query !== this.inputTarget.value.trim()) return
47
+ this.render(await response.json(), query)
48
+ } catch { this.hide() }
49
+ }
50
+
51
+ render(results, query) {
52
+ this.resultsTarget.replaceChildren()
53
+ if (results.length === 0) {
54
+ const empty = document.createElement("li")
55
+ empty.className = "px-3 py-2 text-sm text-gray-500"
56
+ empty.textContent = `No results for "${query}"`
57
+ this.resultsTarget.append(empty)
58
+ }
59
+ results.forEach((result) => {
60
+ const item = document.createElement("li")
61
+ const link = document.createElement("a")
62
+ link.href = `${result.url}?q=${encodeURIComponent(this.inputTarget.value.trim())}`
63
+ link.className = "block px-3 py-2 text-sm aria-selected:bg-blue-50 hover:bg-gray-50 dark:aria-selected:bg-gray-800 dark:hover:bg-gray-800"
64
+ const title = document.createElement("span")
65
+ title.className = "block font-medium"
66
+ title.textContent = result.title
67
+ const snippet = document.createElement("span")
68
+ snippet.className = "block truncate text-xs text-gray-500 [&_mark]:bg-yellow-200 dark:[&_mark]:bg-yellow-700"
69
+ snippet.innerHTML = result.snippet // built server-side: escaped text + <mark>
70
+ link.append(title, snippet)
71
+ item.append(link)
72
+ this.resultsTarget.append(item)
73
+ })
74
+ this.index = -1
75
+ this.resultsTarget.hidden = false
76
+ }
77
+
78
+ showHistory() {
79
+ const items = this.history()
80
+ if (items.length === 0) return this.hide()
81
+ this.resultsTarget.replaceChildren()
82
+ items.forEach((query) => {
83
+ const item = document.createElement("li")
84
+ const link = document.createElement("a")
85
+ link.href = `${this.element.action}?q=${encodeURIComponent(query)}`
86
+ link.className = "block px-3 py-2 text-sm text-gray-600 aria-selected:bg-blue-50 hover:bg-gray-50 dark:text-gray-300 dark:aria-selected:bg-gray-800 dark:hover:bg-gray-800"
87
+ link.textContent = query
88
+ item.append(link)
89
+ this.resultsTarget.append(item)
90
+ })
91
+ this.index = -1
92
+ this.resultsTarget.hidden = false
93
+ }
94
+
95
+ navigate(event) {
96
+ const links = [...this.resultsTarget.querySelectorAll("a")]
97
+ if (event.key === "Escape") return this.hide()
98
+ if (this.resultsTarget.hidden || links.length === 0) return
99
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
100
+ event.preventDefault()
101
+ this.index = (this.index + (event.key === "ArrowDown" ? 1 : -1) + links.length) % links.length
102
+ links.forEach((link, i) => link.setAttribute("aria-selected", String(i === this.index)))
103
+ links[this.index].scrollIntoView({ block: "nearest" })
104
+ } else if (event.key === "Enter" && this.index >= 0) {
105
+ event.preventDefault()
106
+ this.remember()
107
+ window.location.href = links[this.index].href
108
+ }
109
+ }
110
+
111
+ hide() { this.resultsTarget.hidden = true; this.index = -1 }
112
+
113
+ history() {
114
+ try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || [] } catch { return [] }
115
+ }
116
+
117
+ remember() {
118
+ const query = this.inputTarget.value.trim()
119
+ if (!query) return
120
+ try {
121
+ const items = [query, ...this.history().filter((item) => item !== query)].slice(0, 5)
122
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(items))
123
+ } catch { /* storage unavailable */ }
124
+ }
125
+ }
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const KEY = "infodocs:sidebar-collapsed"
4
+ const desktop = () => window.matchMedia("(min-width: 1024px)").matches
5
+
6
+ // Below `lg` the sidebar is a drawer; from `lg` up it is a column that can be
7
+ // collapsed (state remembered per browser).
8
+ export default class extends Controller {
9
+ static targets = ["panel", "backdrop"]
10
+
11
+ connect() {
12
+ try { this.#collapse(localStorage.getItem(KEY) === "true") } catch { /* storage unavailable */ }
13
+ }
14
+
15
+ toggle() {
16
+ if (desktop()) {
17
+ const collapsed = this.panelTarget.dataset.collapsed !== "true"
18
+ this.#collapse(collapsed)
19
+ try { localStorage.setItem(KEY, collapsed) } catch { /* storage unavailable */ }
20
+ } else {
21
+ this.#open(this.panelTarget.dataset.open !== "true")
22
+ }
23
+ }
24
+
25
+ close() { this.#open(false) }
26
+
27
+ #collapse(collapsed) { this.panelTarget.dataset.collapsed = collapsed }
28
+
29
+ #open(open) {
30
+ this.panelTarget.dataset.open = open
31
+ this.backdropTarget.classList.toggle("hidden", !open)
32
+ document.body.classList.toggle("overflow-hidden", open)
33
+ }
34
+ }
@@ -0,0 +1,18 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Highlights the TOC entry of the heading currently in view.
4
+ export default class extends Controller {
5
+ static targets = ["link"]
6
+
7
+ connect() {
8
+ const headings = this.linkTargets.map((link) => document.getElementById(link.hash.slice(1))).filter(Boolean)
9
+ this.observer = new IntersectionObserver((entries) => {
10
+ const visible = entries.find((entry) => entry.isIntersecting)
11
+ if (!visible) return
12
+ this.linkTargets.forEach((link) => link.toggleAttribute("aria-current", link.hash === `#${visible.target.id}`))
13
+ }, { rootMargin: "0px 0px -70% 0px" })
14
+ headings.forEach((heading) => this.observer.observe(heading))
15
+ }
16
+
17
+ disconnect() { this.observer?.disconnect() }
18
+ }
@@ -0,0 +1,65 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Development-only drag & drop of pages and folders. Sortable is loaded on
4
+ // first use; every drop is persisted by the server (files + _tree.yml).
5
+ export default class extends Controller {
6
+ static targets = ["nav", "list", "toggle", "status"]
7
+ static values = { url: String }
8
+
9
+ disconnect() { this.#destroy() }
10
+
11
+ async toggle() {
12
+ const on = this.navTarget.dataset.reordering !== "true"
13
+ this.navTarget.dataset.reordering = on
14
+ this.toggleTarget.setAttribute("aria-pressed", on)
15
+ if (on) await this.#enable()
16
+ else this.#destroy()
17
+ this.#message(on ? "Drag the handles to move pages and folders." : "")
18
+ }
19
+
20
+ async #enable() {
21
+ const { default: Sortable } = await import("sortablejs")
22
+ this.sortables = this.listTargets.map((list) => Sortable.create(list, {
23
+ group: "infodocs-tree",
24
+ handle: ".infodocs-handle",
25
+ animation: 150,
26
+ fallbackOnBody: true,
27
+ swapThreshold: 0.65,
28
+ onEnd: (event) => this.#persist(event)
29
+ }))
30
+ this.element.querySelectorAll("details").forEach((details) => { details.open = true })
31
+ }
32
+
33
+ #destroy() {
34
+ this.sortables?.forEach((sortable) => sortable.destroy())
35
+ this.sortables = null
36
+ }
37
+
38
+ async #persist({ item }) {
39
+ const body = {
40
+ slug: item.dataset.slug,
41
+ parent: item.parentElement.dataset.parentSlug || "",
42
+ before: item.nextElementSibling?.dataset.slug || null
43
+ }
44
+ try {
45
+ const response = await fetch(this.urlValue, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content || "" },
48
+ body: JSON.stringify(body)
49
+ })
50
+ const data = await response.json()
51
+ if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`)
52
+ this.#message("Moved. Commit the changes with git.")
53
+ window.location.reload()
54
+ } catch (error) {
55
+ this.#message(`Move failed: ${error.message}`)
56
+ setTimeout(() => window.location.reload(), 1500)
57
+ }
58
+ }
59
+
60
+ #message(text) {
61
+ if (!this.hasStatusTarget) return
62
+ this.statusTarget.textContent = text
63
+ this.statusTarget.classList.toggle("hidden", text === "")
64
+ }
65
+ }