Class: Markdowndocs::Documentation

Inherits:
Object
  • Object
show all
Defined in:
app/models/markdowndocs/documentation.rb

Overview

Documentation PORO (Plain Old Ruby Object) Represents markdown documentation files from a configurable directory. Handles metadata extraction, frontmatter parsing, and category associations.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(file_path) ⇒ Documentation

Returns a new instance of Documentation.



10
11
12
13
14
15
16
# File 'app/models/markdowndocs/documentation.rb', line 10

def initialize(file_path)
  @file_path = file_path
  @slug = derive_slug
  @path_slug = derive_path_slug
  
  @category = assign_category
end

Instance Attribute Details

#categoryObject (readonly)

Returns the value of attribute category.



8
9
10
# File 'app/models/markdowndocs/documentation.rb', line 8

def category
  @category
end

#descriptionObject (readonly)

Returns the value of attribute description.



8
9
10
# File 'app/models/markdowndocs/documentation.rb', line 8

def description
  @description
end

#file_pathObject (readonly)

Returns the value of attribute file_path.



8
9
10
# File 'app/models/markdowndocs/documentation.rb', line 8

def file_path
  @file_path
end

#keywordsObject (readonly)

Returns the value of attribute keywords.



8
9
10
# File 'app/models/markdowndocs/documentation.rb', line 8

def keywords
  @keywords
end

#path_slugObject (readonly)

Returns the value of attribute path_slug.



8
9
10
# File 'app/models/markdowndocs/documentation.rb', line 8

def path_slug
  @path_slug
end

#slugObject (readonly)

Returns the value of attribute slug.



8
9
10
# File 'app/models/markdowndocs/documentation.rb', line 8

def slug
  @slug
end

#titleObject (readonly)

Returns the value of attribute title.



8
9
10
# File 'app/models/markdowndocs/documentation.rb', line 8

def title
  @title
end

Class Method Details

.allObject



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'app/models/markdowndocs/documentation.rb', line 18

def self.all
  docs_path = Markdowndocs.config.resolved_docs_path
  return [] unless docs_path.exist?

  docs_root_real = docs_path.realpath

  files = Dir.glob(docs_path.join("*.md"))

  modes = Markdowndocs.config.modes
  modes.each do |mode|
    mode_dir = docs_path.join(mode)
    next unless mode_dir.exist?
    next unless inside_docs_path?(mode_dir, docs_root_real)
    files.concat(Dir.glob(mode_dir.join("*.md")))
  end

  # Drop symlink-escapes from the merged file list.
  files = files.select do |f|
    inside_docs_path?(Pathname.new(f), docs_root_real)
  end

  warn_about_non_mode_subdirectories(docs_path, modes, docs_root_real)

  files.map { |f| new(Pathname.new(f)) }.sort_by(&:path_slug)
end

.by_category(category) ⇒ Object



118
119
120
# File 'app/models/markdowndocs/documentation.rb', line 118

def self.by_category(category)
  all.select { |doc| doc.category == category }
end

.find_by_slug(slug, mode: nil) ⇒ Object

Resolves a doc by slug. When ‘mode:` is given, prefers the mode-scoped file (docs/<mode>/<slug>.md) and falls back to the root (docs/<slug>.md) if visible_to?(mode) passes. With `mode: nil`, only the root is checked.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'app/models/markdowndocs/documentation.rb', line 81

def self.find_by_slug(slug, mode: nil)
  return nil if slug.blank?
  return nil if slug.include?("..") || slug.include?("/")

  docs_path = Markdowndocs.config.resolved_docs_path
  docs_root_real = docs_path.realpath

  if mode.present? && Markdowndocs.config.modes.include?(mode.to_s)
    scoped = docs_path.join(mode.to_s, "#{slug}.md")
    return new(scoped) if scoped.exist? && inside_docs_path?(scoped, docs_root_real)
  end

  root = docs_path.join("#{slug}.md")
  return nil unless root.exist? && inside_docs_path?(root, docs_root_real)

  doc = new(root)
  return nil unless doc.visible_to?(mode)

  doc
rescue => e
  Rails.logger.error("Error finding documentation by slug '#{slug}': #{e.message}")
  nil
end

.grouped_by_category(mode: nil) ⇒ Object

When ‘mode:` is given, filters out docs whose `audience:` excludes that mode AND drops categories that end up empty (so the index sidebar doesn’t render headers with no children).

Resolves slugs by matching against ‘path_slug` on the full discovered set so that path-prefixed slugs like “technical/architecture” (which `find_by_slug` rejects as directory traversal) are found correctly.



129
130
131
132
133
134
135
136
137
138
# File 'app/models/markdowndocs/documentation.rb', line 129

def self.grouped_by_category(mode: nil)
  all_docs = all
  Markdowndocs.config.categories.each_with_object({}) do |(category, slugs), hash|
    docs = slugs.filter_map do |slug|
      doc = all_docs.find { |d| d.path_slug == slug }
      doc if doc&.visible_to?(mode)
    end
    hash[category] = docs unless docs.empty?
  end
end

.inside_docs_path?(file_path, docs_root_real) ⇒ Boolean

Returns true when file_path resolves (after following symlinks) to a location inside docs_root_real. Defense against symlinks that point outside the docs tree. Callers outside this class (e.g. smart-nav in PreferencesController) use this to keep reachability checks aligned with what the show action would actually serve.

Returns:

  • (Boolean)


110
111
112
113
114
115
116
# File 'app/models/markdowndocs/documentation.rb', line 110

def self.inside_docs_path?(file_path, docs_root_real)
  resolved = file_path.realpath
  resolved.to_s == docs_root_real.to_s ||
    resolved.to_s.start_with?(docs_root_real.to_s + File::SEPARATOR)
rescue Errno::ENOENT, Errno::ELOOP
  false
end

Instance Method Details

#audienceObject

The audience(s) this doc is written for. Resolution order:

1. `audience:` frontmatter (DEPRECATED in 0.7.0, removed in 1.0.0)
2. Parent directory name when it matches a configured mode
3. All configured modes (root file with no override — visible everywhere)


181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'app/models/markdowndocs/documentation.rb', line 181

def audience
  @audience ||= begin
    parsed = parse_frontmatter
    raw = parsed[:frontmatter]["audience"]

    if raw
      emit_audience_deprecation_warning_once
    end

    case raw
    when Array then raw.map(&:to_s)
    when String then [raw]
    when nil
      scope = audience_from_path
      scope ? [scope] : Markdowndocs.config.modes.dup
    else Markdowndocs.config.modes.dup
    end
  end
end

#available_modesObject



157
158
159
160
161
162
163
# File 'app/models/markdowndocs/documentation.rb', line 157

def available_modes
  @available_modes ||= begin
    parsed = parse_frontmatter
    modes = parsed[:frontmatter]["modes"]
    modes.is_a?(Array) ? modes.map(&:to_s) : Markdowndocs.config.modes.dup
  end
end

#cache_keyObject



147
148
149
# File 'app/models/markdowndocs/documentation.rb', line 147

def cache_key
  "#{path_slug.tr("/", "-")}-#{mtime.to_i}"
end

#code_contentObject

Returns text extracted from fenced code blocks for search indexing.



225
226
227
228
229
# File 'app/models/markdowndocs/documentation.rb', line 225

def code_content
  parsed = parse_frontmatter
  blocks = parsed[:markdown].scan(/```\w*\n([\s\S]*?)```/)
  blocks.flatten.join(" ").gsub(/\s+/, " ").strip
end

#contentObject



140
141
142
143
144
145
# File 'app/models/markdowndocs/documentation.rb', line 140

def content
  @content ||= file_path.read
rescue => e
  Rails.logger.error("Error reading documentation file '#{file_path}': #{e.message}")
  ""
end

#default_modeObject



165
166
167
168
169
170
171
# File 'app/models/markdowndocs/documentation.rb', line 165

def default_mode
  @default_mode ||= begin
    parsed = parse_frontmatter
    mode = parsed[:frontmatter]["default_mode"]
    mode.present? ? mode.to_s : Markdowndocs.config.default_mode
  end
end

#mtimeObject



151
152
153
154
155
# File 'app/models/markdowndocs/documentation.rb', line 151

def mtime
  @mtime ||= file_path.mtime
rescue
  Time.current
end

#plain_text_contentObject

Returns content stripped of frontmatter, markdown syntax, and HTML tags for use in search indexing.



211
212
213
214
215
216
217
218
219
220
221
222
# File 'app/models/markdowndocs/documentation.rb', line 211

def plain_text_content
  parsed = parse_frontmatter
  text = parsed[:markdown]
  text = text.gsub(/^#+\s*/, "")          # headings
  text = text.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') # links
  text = text.gsub(/[*_~`]/, "")          # emphasis markers
  text = text.gsub(/```[\s\S]*?```/, "")  # fenced code blocks
  text = text.gsub(/<[^>]+>/, "")         # HTML tags
  text = text.gsub(/^\s*[-*+]\s/, "")     # list markers
  text = text.gsub(/\n{2,}/, "\n")        # collapse blank lines
  text.strip
end

#supports_mode?(mode) ⇒ Boolean

Returns:

  • (Boolean)


173
174
175
# File 'app/models/markdowndocs/documentation.rb', line 173

def supports_mode?(mode)
  available_modes.include?(mode.to_s)
end

#visible_to?(mode) ⇒ Boolean

Whether this doc should be surfaced to a viewer in the given mode. ‘nil` mode is treated as “no filter” — useful for callers that don’t care about audience (search indexer, admin tools).

Returns:

  • (Boolean)


204
205
206
207
# File 'app/models/markdowndocs/documentation.rb', line 204

def visible_to?(mode)
  return true if mode.nil?
  audience.include?(mode.to_s)
end