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 +7 -0
- data/LICENSE +21 -0
- data/README.md +92 -0
- data/lib/jekyll/scry_content/config.rb +68 -0
- data/lib/jekyll/scry_content/discovery.rb +154 -0
- data/lib/jekyll/scry_content/hooks.rb +25 -0
- data/lib/jekyll/scry_content/manifest.rb +198 -0
- data/lib/jekyll/scry_content/stager.rb +196 -0
- data/lib/jekyll/scry_content/version.rb +7 -0
- data/lib/jekyll/scry_content.rb +20 -0
- data/lib/jekyll-scry-content.rb +8 -0
- metadata +74 -0
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,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
|
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: []
|