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,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "commonmarker"
|
|
4
|
+
require "rouge"
|
|
5
|
+
require "cgi"
|
|
6
|
+
require_relative "rails_lexer"
|
|
7
|
+
|
|
8
|
+
module Infodocs
|
|
9
|
+
# Markdown -> HTML (GFM) with Rouge highlighting, heading anchors, a TOC and
|
|
10
|
+
# relative `.md` links rewritten to docs routes. ```mermaid blocks are left
|
|
11
|
+
# as <pre class="mermaid"> for the client.
|
|
12
|
+
class Renderer
|
|
13
|
+
Result = Struct.new(:html, :toc, :mermaid, :links, :assets, :headings, :embeds_missing, keyword_init: true)
|
|
14
|
+
Heading = Struct.new(:level, :text, :id, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
def initialize(page, mount_path: "/docs")
|
|
17
|
+
@page = page
|
|
18
|
+
@mount_path = mount_path.to_s.chomp("/")
|
|
19
|
+
@links = []
|
|
20
|
+
@assets = []
|
|
21
|
+
@embeds_missing = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
doc = Commonmarker.parse(@page.body, options: { render: { header_ids: nil }, extension: { table: true, strikethrough: true, autolink: true, tasklist: true, footnotes: true } })
|
|
26
|
+
headings = []
|
|
27
|
+
seen = Hash.new(0)
|
|
28
|
+
mermaid = false
|
|
29
|
+
|
|
30
|
+
nodes = []
|
|
31
|
+
doc.walk { |node| nodes << node }
|
|
32
|
+
|
|
33
|
+
nodes.each do |node|
|
|
34
|
+
case node.type
|
|
35
|
+
when :heading
|
|
36
|
+
text = plain_text(node)
|
|
37
|
+
id = unique_id(slugify(text), seen)
|
|
38
|
+
headings << Heading.new(level: node.header_level, text: text, id: id)
|
|
39
|
+
inner = node.each.map { |child| child.to_html.chomp }.join
|
|
40
|
+
anchor = %(<a class="infodocs-anchor" href="##{id}" aria-label="Link to this section">#</a>)
|
|
41
|
+
node.replace(raw_html(%(<h#{node.header_level} id="#{id}">#{inner}#{anchor}</h#{node.header_level}>\n)))
|
|
42
|
+
when :link
|
|
43
|
+
node.url = rewrite(node.url)
|
|
44
|
+
when :image
|
|
45
|
+
node.url = rewrite_asset(node.url)
|
|
46
|
+
when :code_block
|
|
47
|
+
lang, file, marks = parse_fence(node.fence_info)
|
|
48
|
+
if lang == "mermaid"
|
|
49
|
+
mermaid = true
|
|
50
|
+
node.replace(raw_html(%(<pre class="mermaid">#{CGI.escapeHTML(node.string_content.chomp)}</pre>\n)))
|
|
51
|
+
else
|
|
52
|
+
code = node.string_content
|
|
53
|
+
if marks[:embed]
|
|
54
|
+
code, lang, file = embedded_source(marks[:embed], lang, file, marks)
|
|
55
|
+
end
|
|
56
|
+
node.replace(raw_html(highlight(code, lang, file, marks)))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Docs come from the host's own repo, so inline HTML is trusted.
|
|
62
|
+
Result.new(html: admonitions(doc.to_html(options: { render: { unsafe: true } })),
|
|
63
|
+
toc: headings.select { |h| h.level.between?(2, 3) }, mermaid: mermaid,
|
|
64
|
+
links: @links, assets: @assets, headings: headings.map(&:id), embeds_missing: @embeds_missing)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
ADMONITIONS = %w[note tip important warning caution].freeze
|
|
70
|
+
ADMONITION = %r{<blockquote>\s*<p>\[!(#{ADMONITIONS.join('|')})\][ \t]*(?:<br />)?\s*(.*?)</blockquote>}mi
|
|
71
|
+
|
|
72
|
+
# GitHub-style alerts: `> [!NOTE]` turns the blockquote into a callout.
|
|
73
|
+
def admonitions(html)
|
|
74
|
+
html.gsub(ADMONITION) do
|
|
75
|
+
kind = Regexp.last_match(1).downcase
|
|
76
|
+
body = Regexp.last_match(2).sub(%r{\A</p>\s*}, "<p>").sub(/\A(?!<p>)/, "<p>")
|
|
77
|
+
%(<div class="infodocs-alert infodocs-alert-#{kind}"><p class="infodocs-alert-title">#{kind.capitalize}</p>#{body}</div>)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Visible text of a heading, including inline code and emphasis.
|
|
82
|
+
def plain_text(node)
|
|
83
|
+
node.each.map { |child| %i[text code].include?(child.type) ? child.string_content.to_s : plain_text(child) }.join
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def raw_html(html)
|
|
87
|
+
Commonmarker::Node.new(:html_block).tap { |node| node.literal = html }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Fence tags people write that Rouge does not know by that name.
|
|
91
|
+
LANGUAGE_ALIASES = {
|
|
92
|
+
"ruby" => "rails", "rb" => "rails", "rails" => "rails", "ruby-on-rails" => "rails",
|
|
93
|
+
"stimulus" => "javascript", "importmap" => "ruby",
|
|
94
|
+
"html+erb" => "erb", "html.erb" => "erb", "zsh" => "shell", "sh" => "shell", "env" => "shell"
|
|
95
|
+
}.freeze
|
|
96
|
+
|
|
97
|
+
LANGUAGE_LABELS = { "javascript" => "JavaScript", "erb" => "ERB", "html" => "HTML", "css" => "CSS", "json" => "JSON", "yaml" => "YAML",
|
|
98
|
+
"sql" => "SQL", "shell" => "Shell", "rails" => "Ruby", "ruby" => "Ruby", "plaintext" => "Text" }.freeze
|
|
99
|
+
|
|
100
|
+
# "ruby {2,4-6} showLineNumbers app/models/note.rb" -> lang, file, marks
|
|
101
|
+
def parse_fence(info)
|
|
102
|
+
words = info.to_s.split
|
|
103
|
+
lang = words.shift
|
|
104
|
+
marks = { numbers: false, lines: [], embed: nil }
|
|
105
|
+
file = []
|
|
106
|
+
if lang&.start_with?("file=")
|
|
107
|
+
words.unshift(lang)
|
|
108
|
+
lang = nil
|
|
109
|
+
end
|
|
110
|
+
words.each do |word|
|
|
111
|
+
if word.start_with?("file=")
|
|
112
|
+
marks[:embed] = word.delete_prefix("file=")
|
|
113
|
+
elsif word == "showLineNumbers"
|
|
114
|
+
marks[:numbers] = true
|
|
115
|
+
elsif (match = word.match(/\A\{([\d,\-]+)\}\z/))
|
|
116
|
+
marks[:lines] = match[1].split(",").flat_map { |part| a, b = part.split("-").map(&:to_i); (a..(b || a)).to_a }
|
|
117
|
+
else
|
|
118
|
+
file << word
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
[lang, file.join(" ").presence, marks]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
EMBED_LIMIT = 200_000
|
|
125
|
+
EMBED_BLOCKED = %r{(\A|/)(\.|credentials|master\.key|secrets?\b)|\.(pem|key|p12)\z}i
|
|
126
|
+
|
|
127
|
+
# `file=app/models/note.rb#L3-12`: the (excerpt of the) file replaces the block body.
|
|
128
|
+
# Only files inside Rails.root are readable; secrets and dotfiles are refused.
|
|
129
|
+
def embedded_source(spec, lang, label, marks)
|
|
130
|
+
path, range = spec.split("#", 2)
|
|
131
|
+
file = embed_file(path)
|
|
132
|
+
unless file
|
|
133
|
+
@embeds_missing << path
|
|
134
|
+
return ["# Cannot embed #{path}: file not found or not allowed\n", lang || "plaintext", label || path]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
lines = file.read(EMBED_LIMIT).to_s.lines
|
|
138
|
+
first, last = range.to_s.match(/\AL(\d+)(?:-L?(\d+))?\z/)&.captures&.map { |n| n&.to_i }
|
|
139
|
+
if first
|
|
140
|
+
last ||= first
|
|
141
|
+
lines = lines[(first - 1)...last].to_a
|
|
142
|
+
marks[:start] = first
|
|
143
|
+
end
|
|
144
|
+
lang ||= Rouge::Lexer.guess_by_filename(file.basename.to_s).tag
|
|
145
|
+
[lines.join, lang, label || path]
|
|
146
|
+
rescue Rouge::Guesser::Ambiguous, Rouge::Guesser::Ambiguous::NoMatch, StandardError
|
|
147
|
+
[lines&.join.to_s, lang || "plaintext", label || path]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def embed_file(path)
|
|
151
|
+
return unless defined?(Rails) && Rails.root && path.present?
|
|
152
|
+
return if path.match?(EMBED_BLOCKED) || path.include?("..")
|
|
153
|
+
|
|
154
|
+
file = Rails.root.join(path)
|
|
155
|
+
return unless file.file?
|
|
156
|
+
|
|
157
|
+
real = file.realpath
|
|
158
|
+
root = Rails.root.realpath
|
|
159
|
+
real if real.to_s.start_with?("#{root}/") && !real.to_s.match?(EMBED_BLOCKED)
|
|
160
|
+
rescue SystemCallError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Highlighted HTML with one <span class="line"> per source line.
|
|
165
|
+
def highlight_lines(lexer, code, highlighted)
|
|
166
|
+
lines = [+""]
|
|
167
|
+
lexer.lex(code).each do |token, value|
|
|
168
|
+
value.split(/(\n)/).each do |piece|
|
|
169
|
+
if piece == "\n"
|
|
170
|
+
lines << +""
|
|
171
|
+
elsif !piece.empty?
|
|
172
|
+
lines.last << Rouge::Formatters::HTML.new.format([[token, piece]])
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
lines.pop if lines.last.empty?
|
|
177
|
+
lines.each_with_index.map do |html, index|
|
|
178
|
+
css = highlighted.include?(index + 1) ? "line line-hl" : "line"
|
|
179
|
+
%(<span class="#{css}">#{html}\n</span>)
|
|
180
|
+
end.join
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def highlight(code, lang, file = nil, marks = { numbers: false, lines: [] })
|
|
184
|
+
name = LANGUAGE_ALIASES.fetch(lang.to_s.downcase, lang)
|
|
185
|
+
lexer = (name && Rouge::Lexer.find(name)) || Rouge::Lexers::PlainText
|
|
186
|
+
body = highlight_lines(lexer, code, marks[:lines])
|
|
187
|
+
label = LANGUAGE_LABELS.fetch(lexer.tag, lexer.tag.capitalize)
|
|
188
|
+
bar = %(<span class="infodocs-code-lang">#{CGI.escapeHTML(label)}</span>)
|
|
189
|
+
bar += %(<span class="infodocs-code-file">#{CGI.escapeHTML(file)}</span>) if file.present?
|
|
190
|
+
bar += %(<button type="button" class="infodocs-code-copy" data-infodocs-copy>Copy</button>)
|
|
191
|
+
%(<div class="infodocs-code"><div class="infodocs-code-bar">#{bar}</div><pre class="highlight#{' infodocs-numbers' if marks[:numbers]}"#{%( style="counter-reset:line #{marks[:start] - 1}") if marks[:start] && marks[:start] > 1}><code class="language-#{CGI.escapeHTML(lexer.tag)}">#{body}</code></pre></div>\n)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def rewrite(url)
|
|
195
|
+
return url if url.nil? || url.empty? || url.match?(%r{\A([a-z][a-z0-9+.-]*:|//|#)}i)
|
|
196
|
+
|
|
197
|
+
path, anchor = url.split("#", 2)
|
|
198
|
+
return rewrite_asset(url) unless path.end_with?(".md")
|
|
199
|
+
|
|
200
|
+
target = Pathname([@page.directory_slug, path.delete_suffix(".md")].reject(&:empty?).join("/")).cleanpath.to_s.delete_prefix("./")
|
|
201
|
+
target = target.sub(%r{/?(index|readme)\z}i, "")
|
|
202
|
+
@links << [target, anchor]
|
|
203
|
+
[File.join(@mount_path, target), anchor].compact.join("#")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Relative files next to the page (images, PDFs) are served by the engine.
|
|
207
|
+
def rewrite_asset(url)
|
|
208
|
+
return url if url.nil? || url.empty? || url.match?(%r{\A([a-z][a-z0-9+.-]*:|//|#|/)}i)
|
|
209
|
+
|
|
210
|
+
path = url.split(/[?#]/, 2).first
|
|
211
|
+
return url unless Tree::ASSET_TYPES.key?(File.extname(path).downcase)
|
|
212
|
+
|
|
213
|
+
target = Pathname([@page.directory_slug, path].reject(&:empty?).join("/")).cleanpath.to_s.delete_prefix("./")
|
|
214
|
+
return url if target.start_with?("..")
|
|
215
|
+
|
|
216
|
+
@assets << target
|
|
217
|
+
File.join(@mount_path, "_asset", target)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def slugify(text)
|
|
221
|
+
text.downcase.gsub(/[^\p{L}\p{N}\s_-]/, "").strip.gsub(/\s+/, "-")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def unique_id(base, seen)
|
|
225
|
+
base = "section" if base.empty?
|
|
226
|
+
count = seen[base]
|
|
227
|
+
seen[base] += 1
|
|
228
|
+
count.zero? ? base : "#{base}-#{count}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "cgi"
|
|
6
|
+
|
|
7
|
+
module Infodocs
|
|
8
|
+
# SQLite FTS5 index over all pages. Rebuilt incrementally by file mtime.
|
|
9
|
+
class Search
|
|
10
|
+
Result = Struct.new(:slug, :title, :snippet, :score, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
def initialize(tree, db_path:)
|
|
13
|
+
@tree = tree
|
|
14
|
+
FileUtils.mkdir_p(File.dirname(db_path)) unless db_path == ":memory:"
|
|
15
|
+
@db = SQLite3::Database.new(db_path.to_s)
|
|
16
|
+
@db.execute(<<~SQL)
|
|
17
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(
|
|
18
|
+
slug UNINDEXED, path, title, headings, body, mtime UNINDEXED,
|
|
19
|
+
tokenize = 'porter unicode61 remove_diacritics 2'
|
|
20
|
+
)
|
|
21
|
+
SQL
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reindex!
|
|
25
|
+
known = @db.execute("SELECT slug, mtime FROM docs").to_h
|
|
26
|
+
current = @tree.pages.to_h { |page| [page.slug, page] }
|
|
27
|
+
|
|
28
|
+
@db.transaction do
|
|
29
|
+
(known.keys - current.keys).each { |slug| @db.execute("DELETE FROM docs WHERE slug = ?", [slug]) }
|
|
30
|
+
current.each do |slug, page|
|
|
31
|
+
next if known[slug] == page.mtime.to_f.to_s
|
|
32
|
+
|
|
33
|
+
@db.execute("DELETE FROM docs WHERE slug = ?", [slug])
|
|
34
|
+
@db.execute("INSERT INTO docs (slug, path, title, headings, body, mtime) VALUES (?, ?, ?, ?, ?, ?)",
|
|
35
|
+
[slug, slug.tr("/", " "), fold(page.title), fold(headings(page)), fold(page.body), page.mtime.to_f.to_s])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Query syntax: words (AND), "phrase", OR, -exclude, prefix*, title:x, path:x.
|
|
41
|
+
def search(query, limit: 20)
|
|
42
|
+
tags = Page.normalize_tags(query.to_s.scan(/(?<!\S)tag:(\S+)/).flatten)
|
|
43
|
+
query = query.to_s.gsub(/(?<!\S)tag:\S+/, " ").strip
|
|
44
|
+
return tagged_results(tags, limit) if query.empty? && tags.any?
|
|
45
|
+
return filter_tags(search_text(query, limit: tags.any? ? 200 : limit), tags).first(limit) if tags.any?
|
|
46
|
+
|
|
47
|
+
search_text(query, limit: limit)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def search_text(query, limit:)
|
|
51
|
+
match = Query.new(fold(query)).to_fts
|
|
52
|
+
return [] if match.empty?
|
|
53
|
+
|
|
54
|
+
sql = <<~SQL
|
|
55
|
+
SELECT slug, title, bm25(docs, 0, 2.0, 10.0, 4.0, 1.0)
|
|
56
|
+
FROM docs WHERE docs MATCH ? ORDER BY 3 LIMIT ?
|
|
57
|
+
SQL
|
|
58
|
+
terms = fold(query).scan(/(?<![-:])\b[\p{L}\p{N}]{2,}/).uniq
|
|
59
|
+
@db.execute(sql, [match, limit]).filter_map do |slug, title, score|
|
|
60
|
+
page = @tree.find(slug) or next
|
|
61
|
+
Result.new(slug: slug, title: page.title, snippet: snippet(page.body, terms), score: score)
|
|
62
|
+
end
|
|
63
|
+
rescue SQLite3::SQLException
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def tagged_results(tags, limit)
|
|
70
|
+
pages = @tree.pages.select { |page| (tags - page.tags).empty? }.first(limit)
|
|
71
|
+
pages.map { |page| Result.new(slug: page.slug, title: page.title, snippet: CGI.escapeHTML(page.description), score: 0) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def filter_tags(results, tags)
|
|
75
|
+
results.select { |result| (tags - (@tree.find(result.slug)&.tags || [])).empty? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# unicode61 folds most diacritics but not ł/Ł; fold it ourselves (same
|
|
79
|
+
# length, so offsets in the folded text map onto the original).
|
|
80
|
+
def fold(text)
|
|
81
|
+
text.to_s.tr("łŁ", "lL")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def snippet(body, terms, width: 160)
|
|
85
|
+
folded = fold(body).downcase
|
|
86
|
+
hit = terms.filter_map { |term| folded.index(term) }.min || 0
|
|
87
|
+
from = [hit - width / 4, 0].max
|
|
88
|
+
excerpt = body[from, width]
|
|
89
|
+
html = CGI.escapeHTML(excerpt)
|
|
90
|
+
terms.each { |term| html = html.gsub(/#{Regexp.escape(CGI.escapeHTML(term))}/i) { |m| "<mark>#{m}</mark>" } }
|
|
91
|
+
"#{'…' if from.positive?}#{html}…"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def headings(page)
|
|
95
|
+
page.body.scan(/^\#{1,6}\s+(.+)$/).flatten.join("\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class Query
|
|
99
|
+
FIELDS = { "title" => "title", "path" => "path", "heading" => "headings" }.freeze
|
|
100
|
+
|
|
101
|
+
def initialize(raw)
|
|
102
|
+
@raw = raw.to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_fts
|
|
106
|
+
tokens = @raw.scan(/-?(?:\w+:)?"[^"]*"|\S+/)
|
|
107
|
+
parts = []
|
|
108
|
+
tokens.each do |token|
|
|
109
|
+
negate = token.start_with?("-")
|
|
110
|
+
token = token.delete_prefix("-")
|
|
111
|
+
if token.upcase == "OR"
|
|
112
|
+
parts << "OR"
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
field, term = token.include?(":") && FIELDS.key?(token.split(":", 2).first) ? token.split(":", 2) : [nil, token]
|
|
116
|
+
term = term.delete('"') unless term.start_with?('"')
|
|
117
|
+
prefix = term.end_with?("*")
|
|
118
|
+
term = term.delete_suffix("*")
|
|
119
|
+
next if term.delete('"').strip.empty?
|
|
120
|
+
|
|
121
|
+
quoted = term.start_with?('"') ? term : %("#{term.gsub('"', '')}")
|
|
122
|
+
quoted += "*" if prefix
|
|
123
|
+
quoted = "#{FIELDS[field]} : #{quoted}" if field
|
|
124
|
+
parts << (negate ? "NOT #{quoted}" : quoted)
|
|
125
|
+
end
|
|
126
|
+
parts.shift while parts.first == "OR" || parts.first&.start_with?("NOT")
|
|
127
|
+
parts.join(" ")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Infodocs
|
|
6
|
+
# One docs directory. Plain paths take slug/title from the directory name;
|
|
7
|
+
# `{ path:, slug:, title: }` overrides them.
|
|
8
|
+
Source = Struct.new(:path, :slug, :title, keyword_init: true) do
|
|
9
|
+
SLUG = /\A[\p{L}\p{N}][\p{L}\p{N}_-]*\z/
|
|
10
|
+
|
|
11
|
+
def self.wrap(value)
|
|
12
|
+
return value if value.is_a?(Source)
|
|
13
|
+
|
|
14
|
+
if value.respond_to?(:to_hash)
|
|
15
|
+
options = value.to_hash.transform_keys(&:to_sym)
|
|
16
|
+
path = options.fetch(:path) { raise Error, "Infodocs source needs a :path, got #{value.inspect}" }
|
|
17
|
+
new(path: Pathname(path.to_s), slug: options[:slug]&.to_s, title: options[:title]&.to_s).validate!
|
|
18
|
+
else
|
|
19
|
+
new(path: Pathname(value.to_s)).validate!
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate!
|
|
24
|
+
raise Error, "Infodocs source slug #{slug.inspect} may only contain letters, digits, - and _" if slug && !slug.match?(SLUG)
|
|
25
|
+
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Explicit slug/title put the directory in its own folder even when it is
|
|
30
|
+
# the only source.
|
|
31
|
+
def explicit? = !slug.nil? || !title.nil?
|
|
32
|
+
|
|
33
|
+
def key = slug || path.basename.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Infodocs
|
|
7
|
+
# Docs hierarchy built from directories of .md files. Each directory may hold
|
|
8
|
+
# a `_tree.yml`:
|
|
9
|
+
#
|
|
10
|
+
# title: Guides # folder title in the sidebar
|
|
11
|
+
# order: [intro, setup] # entry names (file without .md, or subfolder)
|
|
12
|
+
# hidden: [drafts] # not shown in the sidebar, still routable
|
|
13
|
+
#
|
|
14
|
+
# Entries not listed in `order` follow alphabetically. `index.md` (or
|
|
15
|
+
# `README.md`) in a directory is that folder's landing page.
|
|
16
|
+
class Tree
|
|
17
|
+
MANIFEST = "_tree.yml"
|
|
18
|
+
INDEX_NAMES = %w[index readme].freeze
|
|
19
|
+
|
|
20
|
+
Node = Struct.new(:name, :title, :slug, :page, :children, :hidden, :path, keyword_init: true) do
|
|
21
|
+
def folder? = !children.nil?
|
|
22
|
+
def leaf? = !folder?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :root, :sources
|
|
26
|
+
|
|
27
|
+
def self.build(paths, drafts: true)
|
|
28
|
+
sources = (paths.is_a?(Array) ? paths : [paths]).map { |entry| Source.wrap(entry) }.select { |source| source.path.directory? }
|
|
29
|
+
new(sources, drafts: drafts)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(sources, drafts: true)
|
|
33
|
+
@sources = sources
|
|
34
|
+
@drafts = drafts
|
|
35
|
+
@root = build_root
|
|
36
|
+
@pages = {}
|
|
37
|
+
index_pages(@root)
|
|
38
|
+
@redirects = @pages.values.each_with_object({}) { |page, map| page.redirect_from.each { |old| map[old] ||= page.slug } }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def children
|
|
42
|
+
root.children
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Several sources, or one with an explicit slug/title, appear as folders.
|
|
46
|
+
# First readable page inside a folder (depth first), for folders without a landing page.
|
|
47
|
+
def first_page_under(node)
|
|
48
|
+
node.children.to_a.reject(&:hidden).each do |child|
|
|
49
|
+
return child.page if child.leaf?
|
|
50
|
+
|
|
51
|
+
found = first_page_under(child) || child.page
|
|
52
|
+
return found if found
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
ASSET_TYPES = {
|
|
58
|
+
".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif",
|
|
59
|
+
".webp" => "image/webp", ".avif" => "image/avif", ".svg" => "image/svg+xml",
|
|
60
|
+
".pdf" => "application/pdf", ".mp4" => "video/mp4", ".webm" => "video/webm"
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# Non-markdown file (image, pdf, ...) addressed like a page slug plus the
|
|
64
|
+
# file name. Only whitelisted types inside a source, never `_`/`.` entries.
|
|
65
|
+
def asset(path)
|
|
66
|
+
parts = path.to_s.split("/")
|
|
67
|
+
return if parts.empty? || parts.any? { |part| part.empty? || part.start_with?("_", ".") }
|
|
68
|
+
|
|
69
|
+
source, inner = multiple_roots? ? [@sources.find { |s| s.key == parts.first }, parts.drop(1)] : [@sources.first, parts]
|
|
70
|
+
return if source.nil? || inner.empty?
|
|
71
|
+
|
|
72
|
+
file = source.path.join(*inner)
|
|
73
|
+
return unless file.file? && ASSET_TYPES.key?(file.extname.downcase)
|
|
74
|
+
return unless file.realpath.to_s.start_with?("#{source.path.realpath}/")
|
|
75
|
+
|
|
76
|
+
file
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def multiple_roots?
|
|
80
|
+
@sources.size > 1 || @sources.first&.explicit? || false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def node(slug)
|
|
84
|
+
slug = slug.to_s.delete_prefix("/").delete_suffix("/")
|
|
85
|
+
return root if slug.empty?
|
|
86
|
+
|
|
87
|
+
find_path(root, slug)&.last
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# { "tag" => number of visible pages }, alphabetical.
|
|
91
|
+
def tags
|
|
92
|
+
pages.flat_map(&:tags).tally.sort.to_h
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def tagged(tag)
|
|
96
|
+
tag = Page.normalize_tags(tag).first
|
|
97
|
+
pages.select { |page| page.tags.include?(tag) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Every page, hidden ones included.
|
|
101
|
+
def all_pages
|
|
102
|
+
@pages.values
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Pages in sidebar order (hidden ones excluded); drives prev/next.
|
|
106
|
+
def pages
|
|
107
|
+
@ordered ||= flatten(root).reject(&:hidden).map(&:page)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Current slug for an old URL listed in some page's `redirect_from`, unless a page lives there now.
|
|
111
|
+
def redirect_for(path)
|
|
112
|
+
path = path.to_s.delete_prefix("/").delete_suffix("/")
|
|
113
|
+
return if @pages.key?(path)
|
|
114
|
+
|
|
115
|
+
@redirects[path]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def find(slug)
|
|
119
|
+
@pages[slug.to_s.delete_prefix("/").delete_suffix("/")]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Nodes from the top level down to the one with `slug` (for breadcrumbs).
|
|
123
|
+
def trail(slug)
|
|
124
|
+
slug = slug.to_s.delete_prefix("/").delete_suffix("/")
|
|
125
|
+
find_path(root, slug) || []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def neighbours(page)
|
|
129
|
+
index = pages.index(page)
|
|
130
|
+
return [nil, nil] unless index
|
|
131
|
+
|
|
132
|
+
[index.positive? ? pages[index - 1] : nil, pages[index + 1]]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def build_root
|
|
138
|
+
if multiple_roots?
|
|
139
|
+
folders = @sources.map { |source| build_folder(source.path, slug: source.key, name: source.key, title: source.title) }
|
|
140
|
+
Node.new(name: "", title: nil, slug: "", page: nil, children: folders, hidden: false, path: nil)
|
|
141
|
+
else
|
|
142
|
+
build_folder(@sources.first.path, slug: "", name: "", title: nil)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build_folder(dir, slug:, name:, title:)
|
|
147
|
+
manifest = read_manifest(dir)
|
|
148
|
+
tag_map = manifest["tags"].is_a?(Hash) ? manifest["tags"].transform_keys(&:to_s) : {}
|
|
149
|
+
entries = {}
|
|
150
|
+
landing = nil
|
|
151
|
+
|
|
152
|
+
dir.children.sort.each do |entry|
|
|
153
|
+
base = entry.basename.to_s
|
|
154
|
+
next if base.start_with?("_", ".")
|
|
155
|
+
|
|
156
|
+
if entry.directory?
|
|
157
|
+
child_slug = [slug, base].reject(&:empty?).join("/")
|
|
158
|
+
node = build_folder(entry, slug: child_slug, name: base, title: nil)
|
|
159
|
+
entries[base] = node if node.children.any? || node.page || entry.join(MANIFEST).file?
|
|
160
|
+
elsif entry.extname == ".md"
|
|
161
|
+
stem = entry.basename(".md").to_s
|
|
162
|
+
if INDEX_NAMES.include?(stem.downcase) && landing.nil?
|
|
163
|
+
candidate = Page.new(file: entry, slug: slug, root: dir)
|
|
164
|
+
landing = candidate if @drafts || !candidate.draft?
|
|
165
|
+
next
|
|
166
|
+
end
|
|
167
|
+
page_slug = [slug, stem].reject(&:empty?).join("/")
|
|
168
|
+
page = Page.new(file: entry, slug: page_slug, root: dir, manifest_tags: tag_map[stem])
|
|
169
|
+
next if page.draft? && !@drafts
|
|
170
|
+
entries[stem] = Node.new(name: stem, title: page.title, slug: page_slug, page: page, children: nil, hidden: false, path: entry)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
hidden = Array(manifest["hidden"]).map(&:to_s)
|
|
175
|
+
order = Array(manifest["order"]).map(&:to_s)
|
|
176
|
+
sorted = order.filter_map { |key| entries[key] } + (entries.keys - order).sort.map { |key| entries[key] }
|
|
177
|
+
sorted.each { |node| node.hidden = hidden.include?(node.name) }
|
|
178
|
+
|
|
179
|
+
Node.new(name: name, title: title || manifest["title"] || landing&.title || name.tr("-_", " ").capitalize,
|
|
180
|
+
slug: slug, page: landing, children: sorted, hidden: false, path: dir)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def read_manifest(dir)
|
|
184
|
+
file = dir.join(MANIFEST)
|
|
185
|
+
return {} unless file.file?
|
|
186
|
+
|
|
187
|
+
data = YAML.safe_load(file.read)
|
|
188
|
+
data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def index_pages(node)
|
|
192
|
+
@pages[node.slug] = node.page if node.page && !(node.slug.empty? && node.page.nil?)
|
|
193
|
+
node.children&.each { |child| index_pages(child) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def find_path(node, slug)
|
|
197
|
+
return [] if node.slug.empty? && slug.empty?
|
|
198
|
+
return [node] if node.slug == slug
|
|
199
|
+
|
|
200
|
+
node.children&.each do |child|
|
|
201
|
+
found = find_path(child, slug)
|
|
202
|
+
return (node.slug.empty? ? [] : [node]) + found if found
|
|
203
|
+
end
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def flatten(node)
|
|
208
|
+
list = []
|
|
209
|
+
list << node if node.page && !node.hidden
|
|
210
|
+
node.children&.each { |child| list.concat(flatten(child)) unless child.hidden }
|
|
211
|
+
list
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|