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