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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/CONTRIBUTING.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +341 -0
- data/Rakefile +10 -0
- data/app/controllers/infodocs/application_controller.rb +68 -0
- data/app/controllers/infodocs/assets_controller.rb +15 -0
- data/app/controllers/infodocs/editor_controller.rb +36 -0
- data/app/controllers/infodocs/indexes_controller.rb +22 -0
- data/app/controllers/infodocs/pages_controller.rb +48 -0
- data/app/controllers/infodocs/raws_controller.rb +13 -0
- data/app/controllers/infodocs/searches_controller.rb +18 -0
- data/app/controllers/infodocs/tags_controller.rb +11 -0
- data/app/controllers/infodocs/trees_controller.rb +40 -0
- data/app/helpers/infodocs/application_helper.rb +70 -0
- data/app/javascript/infodocs/application.js +74 -0
- data/app/javascript/infodocs/controllers/editor_controller.js +135 -0
- data/app/javascript/infodocs/controllers/highlight_controller.js +44 -0
- data/app/javascript/infodocs/controllers/mermaid_controller.js +25 -0
- data/app/javascript/infodocs/controllers/search_controller.js +125 -0
- data/app/javascript/infodocs/controllers/sidebar_controller.js +34 -0
- data/app/javascript/infodocs/controllers/toc_controller.js +18 -0
- data/app/javascript/infodocs/controllers/tree_editor_controller.js +65 -0
- data/app/javascript/infodocs/controllers/tree_state_controller.js +37 -0
- data/app/javascript/infodocs/vendor/sortable.esm.js +3378 -0
- data/app/views/infodocs/_delete_button.html.erb +6 -0
- data/app/views/infodocs/_handle.html.erb +5 -0
- data/app/views/infodocs/_tree.html.erb +35 -0
- data/app/views/infodocs/editor/edit.html.erb +61 -0
- data/app/views/infodocs/pages/empty_folder.html.erb +6 -0
- data/app/views/infodocs/pages/not_found.html.erb +11 -0
- data/app/views/infodocs/pages/show.html.erb +92 -0
- data/app/views/infodocs/searches/index.html.erb +20 -0
- data/app/views/infodocs/tags/index.html.erb +26 -0
- data/app/views/layouts/infodocs/application.html.erb +104 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +26 -0
- data/lib/generators/infodocs/install/install_generator.rb +59 -0
- data/lib/generators/infodocs/install/templates/getting-started.md.tt +8 -0
- data/lib/generators/infodocs/install/templates/initializer.rb.tt +51 -0
- data/lib/generators/infodocs/views/views_generator.rb +13 -0
- data/lib/infodocs/code_theme.rb +130 -0
- data/lib/infodocs/configuration.rb +50 -0
- data/lib/infodocs/engine.rb +39 -0
- data/lib/infodocs/link_checker.rb +43 -0
- data/lib/infodocs/linter.rb +48 -0
- data/lib/infodocs/page.rb +124 -0
- data/lib/infodocs/rails_lexer.rb +39 -0
- data/lib/infodocs/renderer.rb +231 -0
- data/lib/infodocs/search.rb +131 -0
- data/lib/infodocs/source.rb +35 -0
- data/lib/infodocs/tree.rb +215 -0
- data/lib/infodocs/tree_editor.rb +179 -0
- data/lib/infodocs/url_helper.rb +23 -0
- data/lib/infodocs/version.rb +5 -0
- data/lib/infodocs.rb +74 -0
- data/lib/tasks/infodocs.rake +63 -0
- data/sig/infodocs.rbs +4 -0
- 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
|
+
}
|