jekyll-scry-content 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f17d351b63efd2828cef59265a122db52a4311378b5e67ddf5b5e02331d3c714
4
+ data.tar.gz: b2ca31b11f7d3c3633ae342f5acfe033a71487b805f90c95d56b13c6a5a53686
5
+ SHA512:
6
+ metadata.gz: 5cc856945eba6bff100e2877a9f7329f51f79e4d9061736ff55af120006b9bada3dee7240fda2e5db9908f95d16c5bafc45469f649ed069f9973b869dbf3b3ea
7
+ data.tar.gz: bb5a0c9b2fcb2140c4436725c837d69681f4fc473df6054745ef400b070194a9299922bd47b4f5ba5585bd2a9c09cf09616bc7f356927377d4a843828762d986
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 directsun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # jekyll-scry-content
2
+
3
+ Jekyll plugin that discovers **Scry content gems** (`scry-*`), stages their `content/` files into the site source as symlinks, and merges optional config files referenced from each gem's manifest.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ group :jekyll_plugins do
9
+ gem "jekyll-scry-content", "~> 0.3"
10
+ # Content gems can live in this group or outside it; discovery does not care.
11
+ gem "scry-rpg-callouts", "~> 1.0"
12
+ end
13
+
14
+ gem "scry-seers-sanctum", "~> 3.2"
15
+ ```
16
+
17
+ ```yaml
18
+ # _config.yml
19
+ plugins:
20
+ - jekyll-scry-content
21
+
22
+ scry_content:
23
+ enabled: true
24
+ only: []
25
+ exclude: []
26
+ warn_missing_ruleset: true # default true
27
+ warn_missing_content: true # default true
28
+ ```
29
+
30
+ Pin **gemspec** versions in the Gemfile (`~> 3.2`). Manifest `version` is the product/edition string and may differ; the loader logs both.
31
+
32
+ ## Content gem shape
33
+
34
+ ```
35
+ scry-example/
36
+ ├── lib/
37
+ │ └── scry-example.rb # no-op; so `require` works in :jekyll_plugins
38
+ ├── content/
39
+ │ ├── manifest.yml # schema_version: 1
40
+ │ ├── docs/…
41
+ │ └── assets/…
42
+ └── *.gemspec # metadata["scry_content"] = "true"
43
+ # add_dependency "jekyll-scry-content", "~> 0.3"
44
+ ```
45
+
46
+ The `lib/` file must be named after the gem and should not `require` this plugin or call `register`. Discovery uses gemspec metadata.
47
+
48
+ Content gems should `add_dependency "jekyll-scry-content", "~> 0.3"` now that this plugin is on RubyGems. Keep the loader in the host `:jekyll_plugins` group (and `plugins:` in `_config.yml`) so its hooks actually run — a transitive install alone does not register them.
49
+
50
+ Site-owned files always win over gem symlinks. Staged paths are listed in `.gitignore` between `# BEGIN jekyll-scry-content` markers.
51
+
52
+ ## Manifest `schema_version`
53
+
54
+ Supported: **1**. Missing `schema_version` warns and is treated as 1. An unsupported value fails the build. Excluded gems are not validated.
55
+
56
+ ## Config files
57
+
58
+ A content gem can merge keys into the host site's config by pointing the manifest at a YAML file under `content/`. That file is loaded in memory only — it is **not** staged into the site source, and it must not be named `_config.yml` (Jekyll would treat that as site config).
59
+
60
+ ```yaml
61
+ # content/manifest.yml
62
+ kind: style
63
+ config_file: config.yml
64
+ ```
65
+
66
+ ```yaml
67
+ # content/config.yml
68
+ callouts:
69
+ monster:
70
+ title: Monster
71
+ color: red
72
+ ```
73
+
74
+ Later gems overlay earlier gems; values in the site `_config.yml` win on conflicts. This is how `scry-rpg-callouts` registers Just the Docs callouts.
75
+
76
+ ## Soft dependencies
77
+
78
+ A content gem can declare other content it needs:
79
+
80
+ ```yaml
81
+ requires:
82
+ rulesets: [ose] # ids from ruleset gems (`provides` / ruleset `id`)
83
+ content: [rpg-callouts] # content-gem id or gem name
84
+ ```
85
+
86
+ Missing requirements warn by default (`warn_missing_ruleset` and `warn_missing_content` are `true` if omitted). Set either flag to `false` to silence that check. `missing_ruleset: error` still fails the build; `missing_ruleset: ignore` still disables ruleset warnings when `warn_missing_ruleset` is omitted.
87
+
88
+ Rulesets should stay **host Gemfile** lines, not gemspec runtime dependencies, so a site can omit them.
89
+
90
+ ## Why symlinks?
91
+
92
+ Plugins such as `jekyll-image-links` read map YAML from `site.source` at build time. Staging keeps those paths identical to in-repo adventures without vendoring files in the site repository.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ScryContent
5
+ # Merges YAML from each gem's `config_file` into `site.config` in memory.
6
+ # Files are never staged into the site source, so Jekyll does not read them
7
+ # as `_config.yml`. Later gems overlay earlier gems; site `_config.yml` wins.
8
+ module Config
9
+ module_function
10
+
11
+ def merge!(site, manifests)
12
+ combined = {}
13
+ sources = []
14
+
15
+ manifests.each do |manifest|
16
+ fragment = manifest.config
17
+ next if fragment.empty?
18
+
19
+ combined = deep_merge(combined, fragment)
20
+ sources << manifest
21
+ end
22
+
23
+ return if combined.empty?
24
+
25
+ combined.each do |key, value|
26
+ site.config[key] = deep_merge(value, site.config[key])
27
+ end
28
+
29
+ Jekyll.logger.info(
30
+ "ScryContent:",
31
+ "merged config from #{sources.map(&:describe).join(', ')}"
32
+ )
33
+ end
34
+
35
+ def deep_merge(base, overlay)
36
+ return dup_value(base) if overlay.nil?
37
+ return dup_value(overlay) unless hash?(base) && hash?(overlay)
38
+
39
+ result = stringify_keys(base)
40
+ stringify_keys(overlay).each do |key, value|
41
+ result[key] = result.key?(key) ? deep_merge(result[key], value) : dup_value(value)
42
+ end
43
+ result
44
+ end
45
+
46
+ def hash?(value)
47
+ value.is_a?(Hash)
48
+ end
49
+
50
+ def stringify_keys(hash)
51
+ hash.each_with_object({}) do |(key, value), out|
52
+ out[key.to_s] = value
53
+ end
54
+ end
55
+
56
+ def dup_value(value)
57
+ case value
58
+ when Hash
59
+ value.each_with_object({}) { |(key, child), out| out[key.to_s] = dup_value(child) }
60
+ when Array
61
+ value.map { |child| dup_value(child) }
62
+ else
63
+ value
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ScryContent
5
+ module Discovery
6
+ module_function
7
+
8
+ @explicit = []
9
+
10
+ def register_explicit(gem_name:, manifest: "content/manifest.yml")
11
+ @explicit << { gem_name: gem_name.to_s, manifest: manifest.to_s }
12
+ end
13
+
14
+ def explicit_registrations
15
+ @explicit.dup
16
+ end
17
+
18
+ def reset_explicit!
19
+ @explicit = []
20
+ end
21
+
22
+ def discover(site)
23
+ config = site.config["scry_content"] || {}
24
+ return [] if config["enabled"] == false
25
+
26
+ manifests = []
27
+ seen_gems = {}
28
+
29
+ explicit_registrations.each do |entry|
30
+ spec = Gem.loaded_specs[entry[:gem_name]]
31
+ next unless spec
32
+
33
+ manifests << Manifest.load(
34
+ gem_name: entry[:gem_name],
35
+ gem_path: spec.full_gem_path,
36
+ manifest_rel: entry[:manifest]
37
+ )
38
+ seen_gems[entry[:gem_name]] = true
39
+ end
40
+
41
+ Gem.loaded_specs.each_value do |spec|
42
+ next if seen_gems[spec.name]
43
+ next unless spec.metadata["scry_content"].to_s == "true"
44
+
45
+ manifest_rel = spec.metadata["scry_content_manifest"].to_s
46
+ manifest_rel = "content/manifest.yml" if manifest_rel.empty?
47
+
48
+ manifests << Manifest.load(
49
+ gem_name: spec.name,
50
+ gem_path: spec.full_gem_path,
51
+ manifest_rel: manifest_rel
52
+ )
53
+ end
54
+
55
+ filtered = filter_manifests(manifests, config)
56
+ filtered.each(&:validate_schema!)
57
+ filtered
58
+ end
59
+
60
+ def filter_manifests(manifests, config)
61
+ only = Array(config["only"]).map(&:to_s).reject(&:empty?)
62
+ exclude = Array(config["exclude"]).map(&:to_s).reject(&:empty?)
63
+
64
+ filtered = manifests.select do |manifest|
65
+ next false if exclude.include?(manifest.id) || exclude.include?(manifest.gem_name)
66
+ next true if only.empty?
67
+
68
+ only.include?(manifest.id) || only.include?(manifest.gem_name)
69
+ end
70
+
71
+ dedupe_variants(filtered)
72
+ end
73
+
74
+ # Prefer full-art over placeholders when the same adventure id appears twice.
75
+ def dedupe_variants(manifests)
76
+ by_id = manifests.group_by(&:id)
77
+ chosen = []
78
+
79
+ by_id.each_value do |group|
80
+ if group.length == 1
81
+ chosen << group.first
82
+ next
83
+ end
84
+
85
+ winner = group.max_by(&:variant_priority)
86
+ losers = group.reject { |m| m.equal?(winner) }
87
+ Jekyll.logger.warn(
88
+ "ScryContent:",
89
+ "multiple gems for id=#{winner.id} (#{group.map(&:gem_name).join(', ')}); " \
90
+ "using #{winner.gem_name} (variant=#{winner.variant.inspect})"
91
+ )
92
+ losers.each do |loser|
93
+ Jekyll.logger.warn("ScryContent:", "skipping #{loser.gem_name}")
94
+ end
95
+ chosen << winner
96
+ end
97
+
98
+ chosen
99
+ end
100
+
101
+ def warn_missing_requirements!(manifests, config)
102
+ warn_missing_rulesets!(manifests, config)
103
+ warn_missing_content!(manifests, config)
104
+ end
105
+
106
+ def warn_missing_rulesets!(manifests, config)
107
+ error = config["missing_ruleset"].to_s == "error"
108
+ return unless error || warn_missing_ruleset?(config)
109
+
110
+ provided = manifests.flat_map(&:provides).uniq
111
+ manifests.each do |manifest|
112
+ missing = manifest.requires_rulesets - provided
113
+ next if missing.empty?
114
+
115
+ message = "#{manifest.gem_name} requires ruleset(s) #{missing.join(', ')} but none are loaded"
116
+ raise Jekyll::Errors::FatalException, "ScryContent: #{message}" if error
117
+
118
+ Jekyll.logger.warn("ScryContent:", message)
119
+ end
120
+ end
121
+
122
+ def warn_missing_content!(manifests, config)
123
+ return unless warn_flag?(config, "warn_missing_content")
124
+
125
+ loaded = manifests.flat_map { |manifest| [manifest.id, manifest.gem_name] + manifest.provides }.uniq
126
+ manifests.each do |manifest|
127
+ missing = manifest.requires_content.reject { |id| loaded.include?(id) }
128
+ next if missing.empty?
129
+
130
+ Jekyll.logger.warn(
131
+ "ScryContent:",
132
+ "#{manifest.gem_name} requires content gem(s) #{missing.join(', ')} but none are loaded"
133
+ )
134
+ end
135
+ end
136
+
137
+ # Defaults to true. `missing_ruleset: ignore` still disables when the new key is omitted.
138
+ def warn_missing_ruleset?(config)
139
+ return warn_flag?(config, "warn_missing_ruleset") if config.key?("warn_missing_ruleset")
140
+
141
+ config["missing_ruleset"].to_s != "ignore"
142
+ end
143
+
144
+ def warn_flag?(config, key)
145
+ return true unless config.key?(key)
146
+
147
+ value = config[key]
148
+ return false if value == false || value.nil?
149
+
150
+ !%w[false off ignore].include?(value.to_s.downcase)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ScryContent
5
+ module Hooks
6
+ module_function
7
+
8
+ def apply!(site)
9
+ config = site.config["scry_content"] || {}
10
+ return if config["enabled"] == false
11
+
12
+ manifests = Discovery.discover(site)
13
+ Discovery.warn_missing_requirements!(manifests, config)
14
+ Config.merge!(site, manifests)
15
+ Stager.new(site, manifests).stage!
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ # Stage on every build/reset so pages, assets, and map YAML are visible to
22
+ # the reader and to plugins like jekyll-image-links (including `jekyll serve`).
23
+ Jekyll::Hooks.register :site, :after_reset do |site|
24
+ Jekyll::ScryContent::Hooks.apply!(site)
25
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ScryContent
5
+ class Manifest
6
+ SUPPORTED_SCHEMA_VERSIONS = [1].freeze
7
+ VARIANT_PRIORITY = {
8
+ "full-art" => 2,
9
+ "placeholders" => 1
10
+ }.freeze
11
+
12
+ attr_reader :gem_name, :gem_path, :data, :path
13
+
14
+ def self.load(gem_name:, gem_path:, manifest_rel:)
15
+ require "yaml"
16
+
17
+ path = File.join(gem_path, manifest_rel)
18
+ raise ArgumentError, "scry content manifest missing for #{gem_name}: #{path}" unless File.file?(path)
19
+
20
+ data = YAML.safe_load(File.read(path), permitted_classes: [Date, Time, Symbol], aliases: true)
21
+ raise ArgumentError, "scry content manifest must be a mapping: #{path}" unless data.is_a?(Hash)
22
+
23
+ new(gem_name: gem_name, gem_path: gem_path, path: path, data: data)
24
+ end
25
+
26
+ def initialize(gem_name:, gem_path:, path:, data:)
27
+ @gem_name = gem_name
28
+ @gem_path = gem_path
29
+ @path = path
30
+ @data = data
31
+ end
32
+
33
+ def id
34
+ data["id"] || gem_name
35
+ end
36
+
37
+ def kind
38
+ data["kind"] || "adventure"
39
+ end
40
+
41
+ def variant
42
+ data["variant"].to_s
43
+ end
44
+
45
+ def variant_priority
46
+ VARIANT_PRIORITY.fetch(variant, 0)
47
+ end
48
+
49
+ def title
50
+ data["title"] || id
51
+ end
52
+
53
+ # Integer from the manifest, or nil if omitted.
54
+ def schema_version
55
+ raw = data["schema_version"]
56
+ return nil if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
57
+
58
+ Integer(raw)
59
+ rescue ArgumentError, TypeError
60
+ raise ArgumentError,
61
+ "#{gem_name}: schema_version must be an integer, got #{raw.inspect}"
62
+ end
63
+
64
+ # Gemspec / Bundler version (what a Gemfile pins).
65
+ def gem_version
66
+ spec = Gem.loaded_specs[gem_name]
67
+ spec&.version&.to_s
68
+ end
69
+
70
+ # Product / edition string from the manifest (may differ from gem_version).
71
+ def product_version
72
+ raw = data["version"]
73
+ return nil if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
74
+
75
+ raw.to_s
76
+ end
77
+
78
+ # e.g. "scry-seers-sanctum 3.2.1 (product 3.2)"
79
+ def describe
80
+ label = gem_name.dup
81
+ label << " #{gem_version}" if gem_version && !gem_version.empty?
82
+ label << " (product #{product_version})" if product_version
83
+ label
84
+ end
85
+
86
+ def validate_schema!
87
+ version = schema_version
88
+ if version.nil?
89
+ Jekyll.logger.warn(
90
+ "ScryContent:",
91
+ "#{gem_name}: manifest has no schema_version; assuming #{SUPPORTED_SCHEMA_VERSIONS.max}"
92
+ )
93
+ return
94
+ end
95
+
96
+ return if SUPPORTED_SCHEMA_VERSIONS.include?(version)
97
+
98
+ raise Jekyll::Errors::FatalException,
99
+ "ScryContent: #{gem_name}: unsupported schema_version #{version} " \
100
+ "(jekyll-scry-content #{Jekyll::ScryContent::VERSION} supports " \
101
+ "#{SUPPORTED_SCHEMA_VERSIONS.join(', ')})"
102
+ end
103
+
104
+ def content_root
105
+ File.join(gem_path, "content")
106
+ end
107
+
108
+ def include_globs
109
+ globs = Array(data["include"]).map(&:to_s).reject(&:empty?)
110
+ globs.empty? ? ["docs/**", "assets/**"] : globs
111
+ end
112
+
113
+ def requires_rulesets
114
+ require_ids("rulesets")
115
+ end
116
+
117
+ def requires_content
118
+ require_ids("content")
119
+ end
120
+
121
+ def provides
122
+ list = Array(data["provides"]).map(&:to_s)
123
+ list << id if kind == "ruleset" && !list.include?(id)
124
+ list
125
+ end
126
+
127
+ def nav_order
128
+ nav = data["nav"]
129
+ return nil unless nav.is_a?(Hash)
130
+
131
+ nav["order"]
132
+ end
133
+
134
+ # Path relative to content/, or nil. Inline mappings are not allowed.
135
+ def config_file
136
+ raw = data["config_file"]
137
+ return nil if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
138
+ if raw.is_a?(Hash) || raw.is_a?(Array)
139
+ raise ArgumentError,
140
+ "#{gem_name}: config_file must be a filename (e.g. config.yml), not inline settings"
141
+ end
142
+
143
+ rel = raw.to_s.sub(%r{\A\./}, "")
144
+ raise ArgumentError, "#{gem_name}: config_file is empty" if rel.empty?
145
+ if rel.start_with?("/", "~") || rel.split("/").include?("..")
146
+ raise ArgumentError, "#{gem_name}: config_file must be a path under content/"
147
+ end
148
+ if jekyll_config_basename?(File.basename(rel))
149
+ raise ArgumentError,
150
+ "#{gem_name}: config_file cannot be #{File.basename(rel)} " \
151
+ "(Jekyll reads that name as site config)"
152
+ end
153
+
154
+ rel
155
+ end
156
+
157
+ def config
158
+ rel = config_file
159
+ return {} unless rel
160
+
161
+ require "yaml"
162
+
163
+ path = File.join(content_root, rel)
164
+ unless File.file?(path)
165
+ raise ArgumentError, "#{gem_name}: config_file #{rel.inspect} not found at #{path}"
166
+ end
167
+
168
+ loaded = YAML.safe_load(File.read(path), permitted_classes: [Date, Time, Symbol], aliases: true)
169
+ unless loaded.is_a?(Hash)
170
+ raise ArgumentError, "#{gem_name}: config_file #{rel.inspect} must be a YAML mapping"
171
+ end
172
+
173
+ loaded
174
+ end
175
+
176
+ def skip_stage?(rel)
177
+ return true if rel == "manifest.yml"
178
+ return true if config_file && rel == config_file
179
+ return true if jekyll_config_basename?(File.basename(rel))
180
+
181
+ false
182
+ end
183
+
184
+ def jekyll_config_basename?(name)
185
+ %w[_config.yml _config.yaml _config.toml].include?(name.to_s.downcase)
186
+ end
187
+
188
+ private
189
+
190
+ def require_ids(key)
191
+ requires = data["requires"]
192
+ return [] unless requires.is_a?(Hash)
193
+
194
+ Array(requires[key]).map(&:to_s).reject(&:empty?)
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Jekyll
7
+ module ScryContent
8
+ # Materializes content-gem files into the site source as symlinks so
9
+ # Jekyll and plugins (e.g. jekyll-image-links reading map YAML from
10
+ # site.source) see the same paths as in-repo content.
11
+ #
12
+ # Site-owned real files always win. Managed symlinks are refreshed each run.
13
+ class Stager
14
+ GITIGNORE_BEGIN = "# BEGIN jekyll-scry-content"
15
+ GITIGNORE_END = "# END jekyll-scry-content"
16
+ STATE_FILE = ".scry-staged"
17
+
18
+ def initialize(site, manifests)
19
+ @site = site
20
+ @manifests = manifests
21
+ @source = site.source
22
+ end
23
+
24
+ def stage!
25
+ desired = collect_desired_links
26
+ previous = read_state
27
+ stale = previous - desired.keys
28
+
29
+ stale.each { |rel| remove_managed!(rel) }
30
+
31
+ desired.each do |rel, target|
32
+ ensure_link!(rel, target)
33
+ end
34
+
35
+ write_state(desired.keys.sort)
36
+ update_gitignore!(desired.keys.sort)
37
+
38
+ return if desired.empty?
39
+
40
+ Jekyll.logger.info(
41
+ "ScryContent:",
42
+ "staged #{desired.length} path(s) from #{@manifests.map(&:describe).join(', ')}"
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ def expand_glob(glob)
49
+ # `docs/**` is easier to write in manifests; Ruby glob needs a filename.
50
+ glob.end_with?("/**") ? "#{glob}/*" : glob
51
+ end
52
+
53
+ def collect_desired_links
54
+ desired = {}
55
+
56
+ @manifests.each do |manifest|
57
+ root = Pathname.new(manifest.content_root)
58
+ next unless root.directory?
59
+
60
+ manifest.include_globs.each do |glob|
61
+ pattern = expand_glob(glob)
62
+ Dir.glob(root.join(pattern).to_s, File::FNM_DOTMATCH).each do |absolute|
63
+ next unless File.file?(absolute)
64
+
65
+ rel = Pathname.new(absolute).relative_path_from(root).to_s
66
+ next if manifest.skip_stage?(rel)
67
+
68
+ if desired.key?(rel) && desired[rel] != absolute
69
+ Jekyll.logger.warn(
70
+ "ScryContent:",
71
+ "path conflict for #{rel}; keeping #{desired[rel]}"
72
+ )
73
+ next
74
+ end
75
+
76
+ desired[rel] = absolute
77
+ end
78
+ end
79
+ end
80
+
81
+ desired
82
+ end
83
+
84
+ def ensure_link!(rel, target)
85
+ dest = File.join(@source, rel)
86
+
87
+ if File.exist?(dest) || File.symlink?(dest)
88
+ if managed_symlink?(dest)
89
+ current = File.expand_path(File.readlink(dest), File.dirname(dest))
90
+ return if current == File.expand_path(target)
91
+
92
+ File.unlink(dest)
93
+ else
94
+ Jekyll.logger.warn(
95
+ "ScryContent:",
96
+ "site file wins, skipping gem path #{rel}"
97
+ )
98
+ return
99
+ end
100
+ end
101
+
102
+ FileUtils.mkdir_p(File.dirname(dest))
103
+ File.symlink(target, dest)
104
+ end
105
+
106
+ def remove_managed!(rel)
107
+ dest = File.join(@source, rel)
108
+ # Paths recorded in .scry-staged were created by us; remove if still a symlink.
109
+ return unless File.symlink?(dest)
110
+
111
+ File.unlink(dest)
112
+ prune_empty_dirs!(File.dirname(dest))
113
+ end
114
+
115
+ def managed_symlink?(dest)
116
+ return false unless File.symlink?(dest)
117
+
118
+ target = begin
119
+ File.expand_path(File.readlink(dest), File.dirname(dest))
120
+ rescue Errno::ENOENT, Errno::EINVAL
121
+ return false
122
+ end
123
+
124
+ @manifests.any? do |manifest|
125
+ target.start_with?("#{manifest.content_root}/")
126
+ end
127
+ end
128
+
129
+ def prune_empty_dirs!(dir)
130
+ source_real = File.expand_path(@source)
131
+ current = File.expand_path(dir)
132
+
133
+ while current.start_with?(source_real) && current != source_real
134
+ begin
135
+ Dir.rmdir(current)
136
+ rescue Errno::ENOTEMPTY, Errno::ENOENT
137
+ break
138
+ end
139
+ current = File.dirname(current)
140
+ end
141
+ end
142
+
143
+ def state_path
144
+ File.join(@source, STATE_FILE)
145
+ end
146
+
147
+ def read_state
148
+ return [] unless File.file?(state_path)
149
+
150
+ File.read(state_path).lines.map(&:strip).reject(&:empty?)
151
+ end
152
+
153
+ def write_state(paths)
154
+ if paths.empty?
155
+ FileUtils.rm_f(state_path)
156
+ return
157
+ end
158
+
159
+ File.write(state_path, "#{paths.join("\n")}\n")
160
+ end
161
+
162
+ def update_gitignore!(paths)
163
+ gitignore = File.join(@source, ".gitignore")
164
+ return unless File.file?(gitignore) || !paths.empty?
165
+
166
+ File.write(gitignore, "") unless File.file?(gitignore)
167
+ original = File.read(gitignore)
168
+
169
+ block = [
170
+ GITIGNORE_BEGIN,
171
+ "# Symlinks staged from content gems — do not commit",
172
+ STATE_FILE,
173
+ *paths,
174
+ GITIGNORE_END
175
+ ].join("\n")
176
+
177
+ updated =
178
+ if paths.empty?
179
+ original.sub(
180
+ /\n*#{Regexp.escape(GITIGNORE_BEGIN)}.*?#{Regexp.escape(GITIGNORE_END)}\n*/m,
181
+ "\n"
182
+ )
183
+ elsif original.include?(GITIGNORE_BEGIN) && original.include?(GITIGNORE_END)
184
+ original.sub(
185
+ /#{Regexp.escape(GITIGNORE_BEGIN)}.*?#{Regexp.escape(GITIGNORE_END)}/m,
186
+ block
187
+ )
188
+ else
189
+ "#{original.rstrip}\n\n#{block}\n"
190
+ end
191
+
192
+ File.write(gitignore, updated) if updated != original
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ScryContent
5
+ VERSION = "0.3.0"
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ require_relative "scry_content/version"
6
+ require_relative "scry_content/manifest"
7
+ require_relative "scry_content/discovery"
8
+ require_relative "scry_content/config"
9
+ require_relative "scry_content/stager"
10
+ require_relative "scry_content/hooks"
11
+
12
+ module Jekyll
13
+ module ScryContent
14
+ class << self
15
+ def register(gem_name:, manifest: "content/manifest.yml")
16
+ Discovery.register_explicit(gem_name: gem_name, manifest: manifest)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ require_relative "jekyll/scry_content"
6
+
7
+ # Entry point used when listed under `plugins:` / `group :jekyll_plugins`.
8
+ Jekyll::ScryContent
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-scry-content
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - directsun
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jekyll
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.8'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.8'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.0'
32
+ description: Discovers installed scry-* gems, stages their content into the site source,
33
+ and merges optional config. Pair with adventure, ruleset, and style content gems.
34
+ email: []
35
+ executables: []
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - LICENSE
40
+ - README.md
41
+ - lib/jekyll-scry-content.rb
42
+ - lib/jekyll/scry_content.rb
43
+ - lib/jekyll/scry_content/config.rb
44
+ - lib/jekyll/scry_content/discovery.rb
45
+ - lib/jekyll/scry_content/hooks.rb
46
+ - lib/jekyll/scry_content/manifest.rb
47
+ - lib/jekyll/scry_content/stager.rb
48
+ - lib/jekyll/scry_content/version.rb
49
+ homepage: https://github.com/sunflowermans/jekyll-scry-content
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/sunflowermans/jekyll-scry-content
54
+ source_code_uri: https://github.com/sunflowermans/jekyll-scry-content
55
+ allowed_push_host: https://rubygems.org
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '3.0'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 3.6.9
71
+ specification_version: 4
72
+ summary: Jekyll plugin that loads Scry content gems (pages, assets, and config) into
73
+ a site.
74
+ test_files: []