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,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
+