jekyll-awesome-nav 0.0.1

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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/.copier-answers.ci.yml +12 -0
  3. data/.devcontainer/devcontainer.json +35 -0
  4. data/.devcontainer/post-create.sh +19 -0
  5. data/.rubocop.yml +43 -0
  6. data/.ruby-version +1 -0
  7. data/.vscode/tasks.json +70 -0
  8. data/AGENTS.md +287 -0
  9. data/CODE_OF_CONDUCT.md +84 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +176 -0
  12. data/Rakefile +11 -0
  13. data/jekyll-awesome-nav.gemspec +35 -0
  14. data/lib/jekyll/awesome_nav/config.rb +31 -0
  15. data/lib/jekyll/awesome_nav/generator.rb +50 -0
  16. data/lib/jekyll/awesome_nav/nav_file.rb +14 -0
  17. data/lib/jekyll/awesome_nav/nav_file_loader.rb +140 -0
  18. data/lib/jekyll/awesome_nav/nav_file_options.rb +69 -0
  19. data/lib/jekyll/awesome_nav/nav_resolver.rb +370 -0
  20. data/lib/jekyll/awesome_nav/navigation_result.rb +150 -0
  21. data/lib/jekyll/awesome_nav/node.rb +64 -0
  22. data/lib/jekyll/awesome_nav/page_set.rb +31 -0
  23. data/lib/jekyll/awesome_nav/serializer.rb +26 -0
  24. data/lib/jekyll/awesome_nav/sort_options.rb +91 -0
  25. data/lib/jekyll/awesome_nav/tree_builder.rb +75 -0
  26. data/lib/jekyll/awesome_nav/utils.rb +94 -0
  27. data/lib/jekyll/awesome_nav/version.rb +19 -0
  28. data/lib/jekyll/awesome_nav.rb +26 -0
  29. data/lib/jekyll-awesome-nav.rb +3 -0
  30. data/site/_config.yml +33 -0
  31. data/site/_includes/awesome-nav-demo-tree.html +15 -0
  32. data/site/_includes/awesome-nav-tree.html +19 -0
  33. data/site/_layouts/awesome_nav_demo.html +128 -0
  34. data/site/docs/getting-started.md +68 -0
  35. data/site/docs/guides/.nav.yml +7 -0
  36. data/site/docs/guides/config.md +37 -0
  37. data/site/docs/guides/data.md +40 -0
  38. data/site/docs/guides/index.md +15 -0
  39. data/site/docs/guides/install.md +53 -0
  40. data/site/docs/guides/layouts.md +116 -0
  41. data/site/docs/guides/overrides.md +42 -0
  42. data/site/docs/index.md +35 -0
  43. data/site/index.md +66 -0
  44. metadata +111 -0
data/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # Jekyll Awesome Nav
2
+
3
+ `jekyll-awesome-nav` builds a full navigation tree from a folder hierarchy and lets any directory replace its subtree with a local `.nav.yml` file.
4
+
5
+ The plugin is designed around the behavior described in [AGENTS.md](AGENTS.md):
6
+
7
+ - navigation is generated from `site.pages` under one configured root
8
+ - directories become sections and pages become leaves
9
+ - `index.md` sets a section title and URL
10
+ - `.nav.yml` replaces a directory subtree without merging
11
+ - every page under the root gets the same full tree plus local subtree data
12
+
13
+ ## Installation
14
+
15
+ Add the gem to your Jekyll site's `Gemfile`:
16
+
17
+ ```ruby
18
+ gem "jekyll-awesome-nav"
19
+ ```
20
+
21
+ Then enable it in `_config.yml`:
22
+
23
+ ```yaml
24
+ plugins:
25
+ - jekyll-awesome-nav
26
+
27
+ awesome_nav:
28
+ enabled: true
29
+ root: docs
30
+ nav_filename: .nav.yml
31
+ ```
32
+
33
+ ## Exposed Page Data
34
+
35
+ Each page under the configured root receives:
36
+
37
+ - `page.awesome_nav`: the full navigation tree rooted at `awesome_nav.root`
38
+ - `page.awesome_nav_local`: the local subtree for the page's directory
39
+ - `page.awesome_nav_dir`: the directory supplying the active nav context
40
+ - `page.breadcrumbs`: breadcrumb items derived from the final tree
41
+ - `page.awesome_nav_previous`: the previous linked nav item, when one exists
42
+ - `page.awesome_nav_next`: the next linked nav item, when one exists
43
+
44
+ The same data is also exposed on `site.config` as `awesome_nav_tree`, `awesome_nav_local_map`, and
45
+ `awesome_nav_files`.
46
+
47
+ Titles resolve in this order:
48
+
49
+ 1. `nav_title`
50
+ 2. `title`
51
+ 3. filename fallback
52
+
53
+ ## `.nav.yml` Format
54
+
55
+ Overrides use a top-level `nav:` entry:
56
+
57
+ ```yaml
58
+ nav:
59
+ - Guides: index.md
60
+ - Install: install.md
61
+ - Config: config.md
62
+ ```
63
+
64
+ Override item rules:
65
+
66
+ - paths are resolved through `site.pages`
67
+ - relative paths are resolved from the `.nav.yml` directory first, then from `awesome_nav.root`
68
+ - directory paths insert the generated directory section at that position
69
+ - glob entries expand generated pages or directories using Ruby's stdlib glob matching
70
+ - external URLs are preserved
71
+ - override order is preserved exactly as written
72
+ - manual sections are preserved as grouping sections unless they intentionally wrap the current directory
73
+
74
+ Useful glob examples:
75
+
76
+ ```yaml
77
+ nav:
78
+ - "*"
79
+ - "*.md"
80
+ - "*/"
81
+ - "**/*.md"
82
+ - glob: "*"
83
+ ```
84
+
85
+ Recursive glob entries such as `**/*.md` are inserted as a flat list at that position. They do not preserve
86
+ the matched files' directory nesting.
87
+
88
+ Use manual sections to group items without linking the group itself:
89
+
90
+ ```yaml
91
+ nav:
92
+ - Main:
93
+ - getting-started.md
94
+ - guides
95
+ - More Resources:
96
+ - Website: https://example.com
97
+ ```
98
+
99
+ Use `append_unmatched` to append generated local items that were not matched by the manual nav. Child `.nav.yml`
100
+ files inherit the closest parent setting unless they set their own value:
101
+
102
+ ```yaml
103
+ append_unmatched: true
104
+ nav:
105
+ - getting-started.md
106
+ - guides
107
+ ```
108
+
109
+ Use `sort` to order generated batches from glob entries and `append_unmatched`. Manual entries stay in the order
110
+ you write them:
111
+
112
+ ```yaml
113
+ sort:
114
+ direction: asc
115
+ type: natural
116
+ by: filename
117
+ sections: last
118
+ ignore_case: true
119
+ nav:
120
+ - intro.md
121
+ - "*.md"
122
+ ```
123
+
124
+ `sort` is a file-level option. Per-glob options such as `- glob: "*"` with nested `sort:` are not currently
125
+ supported.
126
+
127
+ Use `ignore` to exclude generated items from glob entries and `append_unmatched`. Manual entries are still honored
128
+ when you list them explicitly:
129
+
130
+ ```yaml
131
+ ignore:
132
+ - "*.hidden.md"
133
+ - drafts/
134
+ nav:
135
+ - visible.md
136
+ - "*.md"
137
+ ```
138
+
139
+ Use `hide` in a directory's `.nav.yml` to keep that directory out of generated batches, explicit directory
140
+ references, and child nav-file processing:
141
+
142
+ ```yaml
143
+ hide: true
144
+ ```
145
+
146
+ Options-only `.nav.yml` files are valid, so a hidden directory does not need a `nav:` array.
147
+
148
+ ## Documentation Site
149
+
150
+ The source for the plugin documentation site lives in [`site/`](site). From the gem root, run:
151
+
152
+ ```sh
153
+ bundle exec jekyll serve --source site
154
+ ```
155
+
156
+ The docs site renders `page.awesome_nav`, `page.awesome_nav_local`, `page.breadcrumbs`, and previous/next links
157
+ through a small layout in `site/_layouts/docs.html`.
158
+
159
+ ## Development
160
+
161
+ Install dependencies and run the test suite:
162
+
163
+ ```sh
164
+ bundle install
165
+ bundle exec rake test
166
+ ```
167
+
168
+ You can also open an interactive console with:
169
+
170
+ ```sh
171
+ bin/console
172
+ ```
173
+
174
+ ## License
175
+
176
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |test|
7
+ test.libs << "test"
8
+ test.pattern = "test/**/*_test.rb"
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/jekyll/awesome_nav/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "jekyll-awesome-nav"
7
+ spec.version = Jekyll::AwesomeNav::VERSION
8
+ spec.authors = ["Allison Thackston"]
9
+ spec.email = ["allison@allisonthackston.com"]
10
+
11
+ spec.summary = "Folder-based navigation for Jekyll with local subtree overrides."
12
+ spec.description = "Build a full navigation tree from a docs directory and let any folder replace its subtree with a local _nav.yml file."
13
+ spec.homepage = "https://github.com/PrimerPages/jekyll-awesome-nav"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
20
+ spec.metadata["documentation_uri"] = "https://primerpages.github.io/jekyll-awesome-nav/"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (File.expand_path(f) == __FILE__) ||
28
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
29
+ end
30
+ end
31
+ spec.bindir = "exe"
32
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ["lib"]
34
+ spec.add_dependency "jekyll", ">= 3.9", "< 5.0"
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module AwesomeNav
5
+ class Config
6
+ DEFAULTS = {
7
+ "enabled" => true,
8
+ "root" => "docs",
9
+ "nav_filename" => ".nav.yml"
10
+ }.freeze
11
+
12
+ def initialize(raw_config)
13
+ raise Error, "awesome_nav config must be a mapping" unless raw_config.nil? || raw_config.is_a?(Hash)
14
+
15
+ @data = DEFAULTS.merge(raw_config || {})
16
+ end
17
+
18
+ def enabled?
19
+ @data["enabled"]
20
+ end
21
+
22
+ def root_dir
23
+ Utils.normalize_dir(@data["root"])
24
+ end
25
+
26
+ def nav_filename
27
+ @data["nav_filename"].to_s
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module AwesomeNav
5
+ class Generator < Jekyll::Generator
6
+ safe true
7
+ priority :low
8
+
9
+ def generate(site)
10
+ config = Config.new(site.config["awesome_nav"])
11
+ return unless config.enabled?
12
+
13
+ pages = PageSet.new(site, config)
14
+ return if pages.empty?
15
+
16
+ tree = TreeBuilder.new(pages: pages, root_dir: config.root_dir).build
17
+ nav_map = NavFileLoader.new(site: site, config: config).load
18
+ resolved_tree = NavResolver.new(root_dir: config.root_dir, nav_map: nav_map).apply(tree, config.root_dir)
19
+ result = NavigationResult.new(
20
+ tree: resolved_tree,
21
+ root_dir: config.root_dir,
22
+ root_page: pages.root_page,
23
+ nav_map: nav_map
24
+ )
25
+
26
+ pages.each do |page|
27
+ page_dir = Utils.source_dir_for(page)
28
+ nav_dir = result.nav_dir_for(page_dir)
29
+ page_url = Utils.normalize_url(page.url)
30
+ page.data["awesome_nav"] = deep_copy(result.serialized_tree)
31
+ page.data["awesome_nav_local"] = deep_copy(result.local_nav_for(nav_dir))
32
+ page.data["awesome_nav_dir"] = nav_dir
33
+ page.data["breadcrumbs"] = result.breadcrumbs_for(page)
34
+ page.data["awesome_nav_previous"] = deep_copy(result.nav_entry_for(page_url)&.fetch("previous", nil))
35
+ page.data["awesome_nav_next"] = deep_copy(result.nav_entry_for(page_url)&.fetch("next", nil))
36
+ end
37
+
38
+ site.config["awesome_nav_tree"] = deep_copy(result.serialized_tree)
39
+ site.config["awesome_nav_local_map"] = deep_copy(result.serialized_local_nav_map)
40
+ site.config["awesome_nav_files"] = deep_copy(result.serialized_nav_files)
41
+ end
42
+
43
+ private
44
+
45
+ def deep_copy(value)
46
+ Marshal.load(Marshal.dump(value))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module AwesomeNav
5
+ class NavFile
6
+ attr_reader :items, :options
7
+
8
+ def initialize(items:, options:)
9
+ @items = items
10
+ @options = options
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Jekyll
6
+ module AwesomeNav
7
+ class NavFileLoader
8
+ OPTION_KEYS = %w[append_unmatched hide ignore sort].freeze
9
+
10
+ def initialize(site:, config:)
11
+ @site = site
12
+ @config = config
13
+ @page_urls_by_path = build_page_url_index
14
+ end
15
+
16
+ def load
17
+ pattern = File.join(@site.source, @config.root_dir, "**", @config.nav_filename)
18
+
19
+ Dir.glob(pattern).each_with_object({}) do |file, memo|
20
+ dir = Utils.normalize_dir(Utils.relative_dir(@site.source, File.dirname(file)))
21
+ items = load_file(file, dir)
22
+ memo[dir] = items if items
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def load_file(file, dir)
29
+ data = YAML.safe_load_file(file, permitted_classes: [], aliases: false)
30
+ raise Error, "expected a mapping" unless data.is_a?(Hash)
31
+
32
+ nav = data["nav"]
33
+ raise Error, "expected nav to be an array" if data.key?("nav") && !nav.is_a?(Array)
34
+ raise Error, "expected nav to be an array or supported options" unless data.key?("nav") || options_only?(data)
35
+
36
+ items = Array(nav).map.with_index do |item, index|
37
+ normalize_item(item, file, dir, (index + 1).to_s)
38
+ end
39
+ NavFile.new(items: items, options: NavFileOptions.from(data))
40
+ rescue Psych::Exception, Error => e
41
+ Jekyll.logger.warn("AwesomeNav:", "Could not load #{file}: #{e.message}")
42
+ nil
43
+ end
44
+
45
+ def options_only?(data)
46
+ data.keys.any? { |key| OPTION_KEYS.include?(key.to_s) }
47
+ end
48
+
49
+ def normalize_item(item, file, dir, index_label)
50
+ case item
51
+ when Hash
52
+ normalize_mapping(item, file, dir, index_label)
53
+ when String
54
+ normalize_string(item, dir)
55
+ else
56
+ raise Error, "item #{index_label} in #{file} must be a mapping or path string"
57
+ end
58
+ end
59
+
60
+ def normalize_mapping(item, file, dir, index_label)
61
+ raise Error, "item #{index_label} in #{file} must have exactly one entry" unless item.length == 1
62
+
63
+ title, value = item.first
64
+ return normalize_glob(value, dir, index_label, file) if title.to_s == "glob"
65
+
66
+ title = title.to_s.strip
67
+ raise Error, "item #{index_label} in #{file} is missing a title" if title.empty?
68
+
69
+ case value
70
+ when Array
71
+ children = value.map.with_index do |child, child_index|
72
+ normalize_item(child, file, dir, "#{index_label}.#{child_index + 1}")
73
+ end
74
+ Node.section(
75
+ dir: nil,
76
+ title: title,
77
+ url: nil,
78
+ children: children,
79
+ path: nil,
80
+ filename: nil
81
+ )
82
+ when String
83
+ normalize_string(value, dir, title: title)
84
+ else
85
+ raise Error, "value for item #{index_label} in #{file} must be a path or array"
86
+ end
87
+ end
88
+
89
+ def normalize_string(value, dir, title: nil)
90
+ value = value.to_s.strip
91
+ raise Error, "navigation path cannot be empty" if value.empty?
92
+
93
+ return Node.page(dir: nil, title: title || value, url: value, path: value, filename: File.basename(value)) if Utils.external_url?(value)
94
+
95
+ Node.reference(dir: dir, title: title, target: value)
96
+ end
97
+
98
+ def normalize_glob(value, dir, index_label, file)
99
+ raise Error, "glob item #{index_label} in #{file} must be a path string" unless value.is_a?(String)
100
+
101
+ normalize_string(value, dir)
102
+ end
103
+
104
+ def build_page_url_index
105
+ @site.pages.each_with_object({}) do |page, index|
106
+ [page.path, page.relative_path, page.instance_variable_get(:@relative_path)].compact.each do |path|
107
+ normalized = Utils.normalize_dir(path)
108
+ index[normalized] = Utils.normalize_url(page.url)
109
+ index[without_index(normalized)] = Utils.normalize_url(page.url) if Utils.index_page?(page)
110
+ end
111
+ end
112
+ end
113
+
114
+ def section_url_for(dir)
115
+ normalized = Utils.normalize_dir(dir)
116
+ @page_urls_by_path[normalized] || @page_urls_by_path[File.join(normalized, "index.md")]
117
+ end
118
+
119
+ def source_path_for_section(dir)
120
+ normalized = Utils.normalize_dir(dir)
121
+ [File.join(normalized, "index.md"), normalized].find { |candidate| @page_urls_by_path.key?(candidate) } || normalized
122
+ end
123
+
124
+ def without_index(path)
125
+ path.sub(%r{(^|/)index(\.[^./]+)?\z}, "")
126
+ end
127
+
128
+ def dir_for(url)
129
+ return nil unless url
130
+ return nil if Utils.external_url?(url)
131
+
132
+ path = Utils.normalize_url(url).sub(%r{\A/}, "").sub(%r{/\z}, "")
133
+ return "" if path.empty?
134
+
135
+ segments = path.split("/")
136
+ File.extname(segments.last).empty? ? segments.join("/") : segments[0...-1].join("/")
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module AwesomeNav
5
+ class NavFileOptions
6
+ UNSET = Object.new.freeze
7
+
8
+ attr_reader :ignore_patterns, :sort_options
9
+
10
+ def self.from(data)
11
+ new(
12
+ append_unmatched: data.key?("append_unmatched") ? data["append_unmatched"] : UNSET,
13
+ hide: data.key?("hide") ? data["hide"] : UNSET,
14
+ ignore: data.key?("ignore") ? data["ignore"] : UNSET,
15
+ sort: data.key?("sort") ? data["sort"] : UNSET
16
+ )
17
+ end
18
+
19
+ def initialize(append_unmatched: UNSET, hide: UNSET, ignore: UNSET, sort: UNSET)
20
+ @append_unmatched = append_unmatched
21
+ @hide = hide
22
+ @ignore_patterns = ignore == UNSET ? UNSET : normalize_ignore_patterns(ignore)
23
+ @sort_options = sort == UNSET ? UNSET : SortOptions.from(sort)
24
+ end
25
+
26
+ def append_unmatched_or(inherited)
27
+ return inherited if @append_unmatched == UNSET
28
+
29
+ !!@append_unmatched
30
+ end
31
+
32
+ def ignore_patterns_or(inherited)
33
+ return inherited if @ignore_patterns == UNSET
34
+
35
+ @ignore_patterns
36
+ end
37
+
38
+ def sort_options_or(inherited)
39
+ return inherited if @sort_options == UNSET
40
+
41
+ @sort_options
42
+ end
43
+
44
+ def hide?
45
+ @hide != UNSET && !!@hide
46
+ end
47
+
48
+ private
49
+
50
+ def normalize_ignore_patterns(value)
51
+ patterns =
52
+ case value
53
+ when String
54
+ [value]
55
+ when Array
56
+ value
57
+ else
58
+ raise Error, "ignore must be a path string or array of path strings"
59
+ end
60
+
61
+ patterns.map do |pattern|
62
+ raise Error, "ignore patterns must be path strings" unless pattern.is_a?(String)
63
+
64
+ pattern
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end