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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ # Syntax colour palettes. Rouge token classes map to a few roles; each role
5
+ # is a CSS variable, so a theme is just a set of colours for light and dark.
6
+ module CodeTheme
7
+ ROLES = %i[fg bg comment keyword string number function type variable builtin operator punctuation tag attribute escape regex symbol].freeze
8
+
9
+ PALETTES = {
10
+ github: {
11
+ light: { fg: "#24292f", bg: "#f6f8fa", comment: "#6e7781", keyword: "#cf222e", string: "#0a3069", number: "#0550ae", function: "#8250df",
12
+ type: "#953800", variable: "#953800", builtin: "#0550ae", operator: "#cf222e", punctuation: "#24292f", tag: "#116329",
13
+ attribute: "#0550ae", escape: "#0550ae", regex: "#0a3069", symbol: "#0550ae" },
14
+ dark: { fg: "#e6edf3", bg: "#161b22", comment: "#8b949e", keyword: "#ff7b72", string: "#a5d6ff", number: "#79c0ff", function: "#d2a8ff",
15
+ type: "#ffa657", variable: "#ffa657", builtin: "#79c0ff", operator: "#ff7b72", punctuation: "#e6edf3", tag: "#7ee787",
16
+ attribute: "#79c0ff", escape: "#79c0ff", regex: "#a5d6ff", symbol: "#79c0ff" }
17
+ },
18
+ one: {
19
+ light: { fg: "#383a42", bg: "#fafafa", comment: "#a0a1a7", keyword: "#a626a4", string: "#50a14f", number: "#986801", function: "#4078f2",
20
+ type: "#c18401", variable: "#e45649", builtin: "#c18401", operator: "#0184bc", punctuation: "#383a42", tag: "#e45649",
21
+ attribute: "#986801", escape: "#0184bc", regex: "#0184bc", symbol: "#0184bc" },
22
+ dark: { fg: "#abb2bf", bg: "#282c34", comment: "#5c6370", keyword: "#c678dd", string: "#98c379", number: "#d19a66", function: "#61afef",
23
+ type: "#e5c07b", variable: "#e06c75", builtin: "#e5c07b", operator: "#56b6c2", punctuation: "#abb2bf", tag: "#e06c75",
24
+ attribute: "#d19a66", escape: "#56b6c2", regex: "#56b6c2", symbol: "#56b6c2" }
25
+ },
26
+ vscode: {
27
+ light: { fg: "#1f1f1f", bg: "#f8f8f8", comment: "#008000", keyword: "#0000ff", string: "#a31515", number: "#098658", function: "#795e26",
28
+ type: "#267f99", variable: "#001080", builtin: "#267f99", operator: "#1f1f1f", punctuation: "#1f1f1f", tag: "#800000",
29
+ attribute: "#e50000", escape: "#ee0000", regex: "#811f3f", symbol: "#0070c1" },
30
+ dark: { fg: "#d4d4d4", bg: "#1e1e1e", comment: "#6a9955", keyword: "#569cd6", string: "#ce9178", number: "#b5cea8", function: "#dcdcaa",
31
+ type: "#4ec9b0", variable: "#9cdcfe", builtin: "#4ec9b0", operator: "#d4d4d4", punctuation: "#d4d4d4", tag: "#569cd6",
32
+ attribute: "#9cdcfe", escape: "#d7ba7d", regex: "#d16969", symbol: "#4fc1ff" }
33
+ },
34
+ catppuccin: {
35
+ light: { fg: "#4c4f69", bg: "#eff1f5", comment: "#9ca0b0", keyword: "#8839ef", string: "#40a02b", number: "#fe640b", function: "#1e66f5",
36
+ type: "#df8e1d", variable: "#e64553", builtin: "#d20f39", operator: "#04a5e5", punctuation: "#7c7f93", tag: "#1e66f5",
37
+ attribute: "#df8e1d", escape: "#ea76cb", regex: "#ea76cb", symbol: "#dd7878" },
38
+ dark: { fg: "#cdd6f4", bg: "#1e1e2e", comment: "#6c7086", keyword: "#cba6f7", string: "#a6e3a1", number: "#fab387", function: "#89b4fa",
39
+ type: "#f9e2af", variable: "#f38ba8", builtin: "#f38ba8", operator: "#89dceb", punctuation: "#9399b2", tag: "#89b4fa",
40
+ attribute: "#f9e2af", escape: "#f5c2e7", regex: "#f5c2e7", symbol: "#f2cdcd" }
41
+ }
42
+ }.freeze
43
+
44
+ DEFAULT = :one
45
+
46
+ # Rouge short token classes -> role.
47
+ TOKENS = {
48
+ comment: %w[c cm c1 cs cd],
49
+ keyword: %w[k kd kn kp kr kc],
50
+ string: %w[s sb sc sd s2 sh si sx s1 dl],
51
+ symbol: %w[ss],
52
+ regex: %w[sr],
53
+ escape: %w[se],
54
+ number: %w[m mf mh mi mo mb il],
55
+ function: %w[nf fm],
56
+ type: %w[nc nn kt no nd],
57
+ variable: %w[nv vc vg vi vm],
58
+ builtin: %w[nb bp],
59
+ attribute: %w[na py],
60
+ tag: %w[nt],
61
+ operator: %w[o ow],
62
+ punctuation: %w[p]
63
+ }.freeze
64
+
65
+ module_function
66
+
67
+ # Accepts a palette name or a custom hash:
68
+ # { base: :one, light: { keyword: "#..." }, dark: { ... } }
69
+ def resolve(theme)
70
+ case theme
71
+ when Hash
72
+ options = theme.transform_keys(&:to_sym)
73
+ base = PALETTES.fetch((options[:base] || DEFAULT).to_sym) { raise Error, "unknown base code theme #{options[:base].inspect}" }
74
+ { light: base[:light].merge(symbolize(options[:light])), dark: base[:dark].merge(symbolize(options[:dark])) }
75
+ else
76
+ PALETTES.fetch((theme || DEFAULT).to_sym) { raise Error, "unknown code theme #{theme.inspect}; choose one of #{PALETTES.keys.inspect}" }
77
+ end
78
+ end
79
+
80
+ def css(theme)
81
+ palette = resolve(theme)
82
+ light = variables(palette[:light])
83
+ dark = variables(palette[:dark])
84
+ <<~CSS
85
+ .infodocs-code{#{light}}
86
+ @media (prefers-color-scheme: dark){.infodocs-code{#{dark}}}
87
+ .dark .infodocs-code{#{dark}}
88
+ .infodocs-code{margin:1.25em 0;border:1px solid color-mix(in srgb,var(--hl-fg) 16%,transparent);border-radius:.5rem;overflow:hidden;background:var(--hl-bg);color:var(--hl-fg)}
89
+ @media print{body>header,#infodocs-sidebar,[data-infodocs-toc],article nav[aria-label=Pagination],.infodocs-code-copy{display:none!important}.infodocs-code pre{white-space:pre-wrap}}
90
+ .infodocs-numbers{counter-reset:line}
91
+ .infodocs-numbers .line::before{counter-increment:line;content:counter(line);display:inline-block;width:2.25rem;margin-right:1rem;text-align:right;color:color-mix(in srgb,var(--hl-fg) 35%,transparent);user-select:none}
92
+ .highlight .line-hl{display:inline-block;width:100%;background:color-mix(in srgb,var(--hl-fg) 10%,transparent)}
93
+ .infodocs-anchor{margin-left:.4em;font-weight:400;text-decoration:none;opacity:0;color:inherit}
94
+ h1:hover>.infodocs-anchor,h2:hover>.infodocs-anchor,h3:hover>.infodocs-anchor,h4:hover>.infodocs-anchor,.infodocs-anchor:focus{opacity:.4}
95
+ .infodocs-anchor:hover{opacity:.8!important}
96
+ .footnotes{margin-top:2.5rem;padding-top:1rem;border-top:1px solid color-mix(in srgb,currentColor 18%,transparent);font-size:.875em}
97
+ .infodocs-alert{margin:1.25rem 0;padding:.75rem 1rem;border-left:4px solid var(--alert,#3b82f6);border-radius:.375rem;background:color-mix(in srgb,var(--alert,#3b82f6) 10%,transparent)}
98
+ .infodocs-alert>p{margin:.25rem 0}
99
+ .infodocs-alert-title{font-weight:600;color:var(--alert,#3b82f6)}
100
+ .infodocs-alert-tip{--alert:#16a34a}.infodocs-alert-important{--alert:#9333ea}.infodocs-alert-warning{--alert:#d97706}.infodocs-alert-caution{--alert:#dc2626}
101
+ .infodocs-code-bar{display:flex;align-items:center;gap:.75rem;min-height:2.25rem;padding:0 .5rem 0 .875rem;font:500 .75rem/1 ui-sans-serif,system-ui,sans-serif;color:color-mix(in srgb,var(--hl-fg) 65%,transparent);background:color-mix(in srgb,var(--hl-fg) 6%,transparent);border-bottom:1px solid color-mix(in srgb,var(--hl-fg) 12%,transparent)}
102
+ .infodocs-code-file{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--hl-fg)}
103
+ .infodocs-code-copy{margin-left:auto;min-height:1.75rem;padding:0 .625rem;border-radius:.375rem;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer}
104
+ .infodocs-code-copy:hover{background:color-mix(in srgb,var(--hl-fg) 12%,transparent)}
105
+ .infodocs-code pre.highlight{margin:0;border:0;border-radius:0;background:transparent;color:var(--hl-fg);padding:1rem;overflow-x:auto;font:.875rem/1.65 ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,monospace;tab-size:2}
106
+ .infodocs-code pre.highlight code{background:none;padding:0;color:inherit;font:inherit}
107
+ .infodocs-code pre.highlight code::before,.infodocs-code pre.highlight code::after{content:none}
108
+ #{token_rules}
109
+ CSS
110
+ end
111
+
112
+ def variables(colors)
113
+ colors.map { |role, value| "--hl-#{role}:#{value}" }.join(";")
114
+ end
115
+
116
+ def token_rules
117
+ rules = TOKENS.map do |role, classes|
118
+ selector = classes.map { |name| ".infodocs-code .#{name}" }.join(",")
119
+ "#{selector}{color:var(--hl-#{role})}"
120
+ end
121
+ rules << ".infodocs-code .c,.infodocs-code .cm,.infodocs-code .c1,.infodocs-code .cs{font-style:italic}"
122
+ rules << ".infodocs-code .gd{color:var(--hl-keyword)}.infodocs-code .gi{color:var(--hl-string)}.infodocs-code .ge{font-style:italic}.infodocs-code .gs,.infodocs-code .gh,.infodocs-code .gu{font-weight:600}"
123
+ rules.join("\n")
124
+ end
125
+
126
+ def symbolize(hash)
127
+ (hash || {}).to_h.transform_keys(&:to_sym)
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ class Configuration
5
+ AUTH_MODES = %i[none basic devise].freeze
6
+
7
+ attr_reader :paths
8
+ attr_accessor :title, :auth, :basic_auth, :devise, :editable, :index_path, :stylesheets, :code_theme, :repo_url, :repo_branch, :page_authorize, :logo, :favicon, :head_html, :header_links, :mermaid_url
9
+
10
+ def initialize
11
+ @paths = []
12
+ @title = "Docs"
13
+ @auth = :none
14
+ @basic_auth = {}
15
+ @devise = {}
16
+ @editable = false
17
+ @code_theme = CodeTheme::DEFAULT
18
+ @page_authorize = nil
19
+ @mermaid_url = nil
20
+ @logo = nil
21
+ @favicon = nil
22
+ @head_html = nil
23
+ @header_links = []
24
+ @repo_url = nil
25
+ @repo_branch = "main"
26
+ @stylesheets = %w[tailwind]
27
+ @index_path = defined?(Rails) ? Rails.root.join("tmp/infodocs/index.sqlite3") : "tmp/infodocs/index.sqlite3"
28
+ end
29
+
30
+ def paths=(value)
31
+ sources = (value.is_a?(Array) ? value : [value]).compact.map { |entry| Source.wrap(entry) }
32
+ duplicate = sources.map(&:key).tally.find { |_key, count| count > 1 }&.first
33
+ raise Error, "Infodocs sources share the name #{duplicate.inspect}; give them different slugs" if duplicate && sources.size > 1
34
+
35
+ @paths = sources
36
+ end
37
+
38
+ def validate!
39
+ CodeTheme.resolve(code_theme)
40
+ unless header_links.is_a?(Array) && header_links.all? { |link| link.respond_to?(:to_hash) && link.to_hash.transform_keys(&:to_sym).values_at(:label, :url).none?(&:nil?) }
41
+ raise Error, "Infodocs header_links must be an array of { label:, url: } hashes"
42
+ end
43
+ raise Error, "Infodocs page_authorize must respond to #call" if page_authorize && !page_authorize.respond_to?(:call)
44
+
45
+ return if auth.respond_to?(:call) || AUTH_MODES.include?(auth)
46
+
47
+ raise Error, "Infodocs auth must be one of #{AUTH_MODES.inspect} or a callable, got #{auth.inspect}"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Infodocs
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Infodocs
8
+
9
+ initializer "infodocs.importmap", before: "importmap" do |app|
10
+ if app.config.respond_to?(:importmap)
11
+ app.config.importmap.paths << root.join("config/importmap.rb")
12
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
13
+ end
14
+ end
15
+
16
+ initializer "infodocs.assets" do |app|
17
+ if app.config.respond_to?(:assets)
18
+ app.config.assets.paths << root.join("app/javascript")
19
+ app.config.assets.paths << root.join("app/assets/stylesheets")
20
+ end
21
+ end
22
+
23
+ initializer "infodocs.url_helpers" do
24
+ ActiveSupport.on_load(:action_view) { include Infodocs::UrlHelper }
25
+ ActiveSupport.on_load(:action_controller) { include Infodocs::UrlHelper }
26
+ end
27
+
28
+ config.after_initialize do
29
+ Infodocs.config.validate!
30
+ end
31
+
32
+ # Pick up edited .md files without restarting the server.
33
+ initializer "infodocs.reload" do |app|
34
+ next unless Rails.env.development?
35
+
36
+ app.config.to_prepare { Infodocs.reload! }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ # Finds relative links to pages, anchors and files that do not exist.
5
+ class LinkChecker
6
+ Problem = Struct.new(:page, :target, :reason, keyword_init: true)
7
+
8
+ def initialize(tree)
9
+ @tree = tree
10
+ @headings = {}
11
+ end
12
+
13
+ def call
14
+ @tree.pages.sum([]) { |page| check(page) } + hidden_pages.sum([]) { |page| check(page) }
15
+ end
16
+
17
+ private
18
+
19
+ def hidden_pages
20
+ @tree.all_pages - @tree.pages
21
+ end
22
+
23
+ def check(page)
24
+ result = Renderer.new(page).call
25
+ problems = result.links.filter_map do |slug, anchor|
26
+ if @tree.find(slug).nil? && !@tree.node(slug)&.folder?
27
+ Problem.new(page: page, target: slug, reason: "page not found")
28
+ elsif anchor && !headings_of(slug).include?(anchor)
29
+ Problem.new(page: page, target: "#{slug}##{anchor}", reason: "anchor not found")
30
+ end
31
+ end
32
+ problems += result.embeds_missing.map { |path| Problem.new(page: page, target: path, reason: "embedded file not found") }
33
+ problems + result.assets.reject { |path| @tree.asset(path) }.map { |path| Problem.new(page: page, target: path, reason: "file not found") }
34
+ end
35
+
36
+ def headings_of(slug)
37
+ @headings[slug] ||= begin
38
+ page = @tree.find(slug)
39
+ page ? Renderer.new(page).call.headings : []
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ # Content checks beyond broken links (see LinkChecker): missing titles,
5
+ # unknown frontmatter keys, unreachable hidden pages and useless redirects.
6
+ class Linter
7
+ KNOWN_KEYS = %w[title description tags draft redirect_from].freeze
8
+ Problem = Struct.new(:page, :rule, :message, keyword_init: true)
9
+
10
+ def initialize(tree)
11
+ @tree = tree
12
+ end
13
+
14
+ def call
15
+ pages = @tree.all_pages
16
+ linked = pages.flat_map { |page| Renderer.new(page).call.links.map(&:first) }.to_set
17
+ visible = @tree.pages.to_set
18
+ redirects = Hash.new { |hash, key| hash[key] = [] }
19
+
20
+ problems = pages.flat_map do |page|
21
+ page.redirect_from.each { |old| redirects[old] << page }
22
+ check(page, linked, visible)
23
+ end
24
+
25
+ redirects.each do |old, owners|
26
+ owners.each { |page| problems << Problem.new(page: page, rule: "redirect", message: "redirect_from #{old.inspect} is shadowed by an existing page") } if @tree.find(old)
27
+ owners.drop(1).each { |page| problems << Problem.new(page: page, rule: "redirect", message: "redirect_from #{old.inspect} is also claimed by #{owners.first.relative_path}") }
28
+ end
29
+ problems
30
+ end
31
+
32
+ private
33
+
34
+ def check(page, linked, visible)
35
+ problems = []
36
+ unless page.frontmatter["title"] || page.body.match?(/^#\s+\S/)
37
+ problems << Problem.new(page: page, rule: "title", message: "no title: add a `# Heading` or a `title:` in the frontmatter")
38
+ end
39
+ (page.frontmatter.keys - KNOWN_KEYS).each do |key|
40
+ problems << Problem.new(page: page, rule: "frontmatter", message: "unknown frontmatter key #{key.inspect}")
41
+ end
42
+ if !visible.include?(page) && !page.draft? && !linked.include?(page.slug)
43
+ problems << Problem.new(page: page, rule: "orphan", message: "hidden page that no other page links to")
44
+ end
45
+ problems
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Infodocs
7
+ # A single .md file. `slug` is the URL path without extension, relative to
8
+ # its source root and prefixed with the root's index when several roots exist.
9
+ class Page
10
+ FRONTMATTER = /\A---\s*\n(.*?)\n---\s*\n/m
11
+
12
+ attr_reader :file, :slug, :root, :manifest_tags
13
+
14
+ def self.normalize_tags(value)
15
+ list = value.is_a?(String) ? value.split(",") : Array(value)
16
+ list.map { |tag| tag.to_s.strip.downcase.gsub(/\s+/, "-") }.reject(&:empty?).uniq
17
+ end
18
+
19
+ def initialize(file:, slug:, root:, raw: nil, manifest_tags: [])
20
+ @raw = raw
21
+ @manifest_tags = self.class.normalize_tags(manifest_tags)
22
+ @file = Pathname(file)
23
+ @slug = slug
24
+ @root = Pathname(root)
25
+ end
26
+
27
+ def raw
28
+ @raw ||= file.read
29
+ end
30
+
31
+ # Atomic overwrite of the source file; the page object is not reused after.
32
+ def write(content)
33
+ temp = file.sub_ext(".md.tmp-#{Process.pid}")
34
+ temp.write(content.to_s.gsub("\r\n", "\n"))
35
+ File.rename(temp, file)
36
+ ensure
37
+ temp&.delete if temp&.exist?
38
+ end
39
+
40
+ def frontmatter
41
+ @frontmatter ||= begin
42
+ match = raw.match(FRONTMATTER)
43
+ data = match ? YAML.safe_load(match[1], permitted_classes: [Date, Time]) : nil
44
+ data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
45
+ end
46
+ end
47
+
48
+ # Old URLs (slugs) of this page, from the `redirect_from:` frontmatter key.
49
+ def redirect_from
50
+ Array(frontmatter["redirect_from"]).map { |path| path.to_s.strip.delete_prefix("/").delete_suffix("/").delete_suffix(".md") }.reject(&:empty?)
51
+ end
52
+
53
+ # Adds a `redirect_from` entry to the file's frontmatter (creating the block if needed).
54
+ def add_redirect_from(old_slug)
55
+ return if old_slug.blank? || old_slug == slug || redirect_from.include?(old_slug)
56
+
57
+ data = frontmatter.dup
58
+ data["redirect_from"] = redirect_from + [old_slug]
59
+ block = data.to_yaml
60
+ content = raw.match?(FRONTMATTER) ? raw.sub(FRONTMATTER) { "#{block}---\n" } : "#{block}---\n\n#{raw}"
61
+ write(content)
62
+ end
63
+
64
+ # `draft: true` pages are only shown in development.
65
+ def draft?
66
+ frontmatter["draft"] == true
67
+ end
68
+
69
+ # Tags from the `tags:` frontmatter key plus those saved in the folder's `_tree.yml`.
70
+ def tags
71
+ (self.class.normalize_tags(frontmatter["tags"]) + manifest_tags).uniq
72
+ end
73
+
74
+ def body
75
+ @body ||= raw.sub(FRONTMATTER, "")
76
+ end
77
+
78
+ def title
79
+ frontmatter["title"] || body[/^#\s+(.+)$/, 1]&.strip || File.basename(slug).tr("-_", " ").capitalize
80
+ end
81
+
82
+ # Frontmatter `description`, else the first plain paragraph.
83
+ def description
84
+ text = frontmatter["description"] ||
85
+ body.split(/\n{2,}/).map(&:strip).find { |block| !block.empty? && !block.start_with?("#", "```", "|", ">", "-", "*", "<", "!") }
86
+ text.to_s.gsub(/\s+/, " ").gsub(/\[([^\]]*)\]\([^)]*\)/, '\\1').delete("*`_").strip.truncate(200)
87
+ end
88
+
89
+ # Directory the file lives in, as a slug; relative links resolve against it.
90
+ def directory_slug
91
+ landing? ? slug : File.dirname(slug).sub(/\A\.\z/, "")
92
+ end
93
+
94
+ def landing?
95
+ %w[index readme].include?(file.basename(".md").to_s.downcase)
96
+ end
97
+
98
+ # Last commit touching the file; file mtime when git has no answer.
99
+ def updated_at
100
+ out = IO.popen(["git", "log", "-1", "--format=%cI", "--", file.to_s], chdir: file.dirname.to_s, err: File::NULL, &:read).to_s.strip
101
+ out.empty? ? mtime : Time.iso8601(out)
102
+ rescue SystemCallError, ArgumentError
103
+ mtime
104
+ end
105
+
106
+ # Browser URL of the file in the repository (config.repo_url), or nil.
107
+ def repo_edit_url(repo_url, branch, base)
108
+ return if repo_url.blank?
109
+
110
+ relative = file.expand_path.relative_path_from(Pathname(base).expand_path).to_s
111
+ return if relative.start_with?("..")
112
+
113
+ "#{repo_url.to_s.chomp('/')}/edit/#{branch}/#{relative}"
114
+ end
115
+
116
+ def mtime
117
+ file.mtime
118
+ end
119
+
120
+ def relative_path
121
+ file.relative_path_from(root).to_s
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module Infodocs
6
+ # Ruby plus the Rails DSL (associations, callbacks, routes, migrations…)
7
+ # highlighted as builtins. Use ```rails (or ```ruby: it is the same lexer).
8
+ class RailsLexer < Rouge::Lexers::Ruby
9
+ title "Rails"
10
+ desc "Ruby on Rails (Ruby with Rails DSL keywords)"
11
+ tag "rails"
12
+ aliases "rb-rails", "rails-ruby"
13
+ filenames "Rakefile", "*.rake", "*.rb", "*.ru", "Gemfile", "*.gemspec", "*.jbuilder"
14
+
15
+ DSL = %w[
16
+ belongs_to has_one has_many has_and_belongs_to_many has_one_attached has_many_attached has_rich_text
17
+ validates validates_presence_of validates_uniqueness_of validate validates_with
18
+ before_validation after_validation before_save around_save after_save
19
+ before_create around_create after_create before_update around_update after_update
20
+ before_destroy around_destroy after_destroy
21
+ after_commit after_rollback after_create_commit after_update_commit after_destroy_commit after_save_commit
22
+ after_initialize after_find after_touch
23
+ before_action after_action around_action skip_before_action prepend_before_action
24
+ scope default_scope enum delegate attribute store_accessor serialize normalizes accepts_nested_attributes_for
25
+ has_secure_password has_secure_token broadcasts_to
26
+ resources resource namespace scope member collection concern concerns mount root get post put patch delete match
27
+ draw devise_for
28
+ create_table drop_table change_table add_column remove_column rename_column change_column add_index remove_index
29
+ add_reference remove_reference add_foreign_key remove_foreign_key create_join_table
30
+ render redirect_to respond_to head redirect_back
31
+ permit require expect params
32
+ class_attribute included extended prepend_view_path helper_method layout rescue_from
33
+ ].freeze
34
+
35
+ prepend :root do
36
+ rule %r/(?<![.:@$\w])(?:#{DSL.join('|')})\b(?!:)/, Name::Builtin, :expr_start
37
+ end
38
+ end
39
+ end