jekyll-carve 0.1.0 → 0.1.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.
- checksums.yaml +4 -4
- data/README.md +106 -4
- data/lib/jekyll/carve/version.rb +1 -1
- data/lib/jekyll-carve.rb +195 -4
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f2fa9dda47fb228d8edba3147048e720d9ee7995a14c38b9f4cac9cf8bfe8f6e
|
|
4
|
+
data.tar.gz: 83252c17f31ccdf1f34b64785dbf928fc96619e2a35e243facef5543a71439b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d9230de7ea7aed19ff33d752aee2c53c8529cf622e742134e41b22d188f61460ea2813b1f34ea82c3544101281004d4e0c42c18b44c5af4f4174bfd52cc178cb
|
|
7
|
+
data.tar.gz: 4f50c860251b43b10ea16807c6f8624226c6399a3044c1d1a063020fceb70ab994aa03b9161370ec0d6d472757a0ca544360fa25f0bb6dd757c4d1de58b8435c
|
data/README.md
CHANGED
|
@@ -51,6 +51,85 @@ hyphenated/underscored forms, passed straight through to the engine). When the
|
|
|
51
51
|
key is absent, no extensions are enabled. The recognized extensions are listed
|
|
52
52
|
in `Carve::EXTENSIONS`; an unknown name raises `ArgumentError` at build time.
|
|
53
53
|
|
|
54
|
+
### `carve.symbols` - what `:smile:` renders as
|
|
55
|
+
|
|
56
|
+
Carve parses `:name:` in core, no extension needed, but what a name renders as
|
|
57
|
+
is a render option. With no map configured a shortcode renders as its own
|
|
58
|
+
source text:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
Ship it :smile:
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<p>Ship it :smile:</p>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`carve.symbols` supplies the map. It takes a mapping written inline:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
carve:
|
|
72
|
+
symbols:
|
|
73
|
+
smile: "😄"
|
|
74
|
+
ship: "🚀"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
or a path to a JSON object, so a large map does not have to live in
|
|
78
|
+
`_config.yml`:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
carve:
|
|
82
|
+
symbols: _data/symbols.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"smile": "😄",
|
|
88
|
+
"ship": "🚀"
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
or a list mixing both, merged left to right, so a generated map can carry a few
|
|
93
|
+
site-specific overrides:
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
carve:
|
|
97
|
+
symbols:
|
|
98
|
+
- _data/emoji.json
|
|
99
|
+
- ship: "🚀"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A path is resolved against the site source and confined to it, symlinks
|
|
103
|
+
included - a link inside the source pointing at a file outside it is refused
|
|
104
|
+
rather than followed. A name that
|
|
105
|
+
is not in the map keeps rendering as its own text, and the map does not loosen
|
|
106
|
+
Carve's word-boundary rule: `10:30:` and `a:smile:b` are not shortcodes, and
|
|
107
|
+
`` `:smile:` `` inside a code span stays code.
|
|
108
|
+
|
|
109
|
+
No emoji table ships with this plugin. Jekyll has no emoji database in core, so
|
|
110
|
+
bundling one here would be a second source of truth next to whatever your
|
|
111
|
+
Markdown pages already use (`jemoji`, for instance) - and the two would drift.
|
|
112
|
+
Point `carve.symbols` at a JSON file you generate from that same source and
|
|
113
|
+
both page types resolve one map.
|
|
114
|
+
|
|
115
|
+
A misconfigured map fails the build rather than being warned past: a missing
|
|
116
|
+
file, invalid JSON, a JSON top level that is not an object, or a value that is
|
|
117
|
+
not a string each raise `ArgumentError` naming the file or the key. A value is
|
|
118
|
+
not coerced, because it reaches the page raw - `count: 1` is a mistake worth
|
|
119
|
+
stopping for, not something to stringify and emit. A NAME is coerced, since
|
|
120
|
+
YAML hands back a Symbol or a boolean for some unquoted keys and a name never
|
|
121
|
+
reaches the output.
|
|
122
|
+
|
|
123
|
+
> [!WARNING]
|
|
124
|
+
> A symbol value is inserted as **trusted raw output**. It is not escaped, so
|
|
125
|
+
> `smile: "<img src='/s.svg'>"` emits a real `<img>` element rather than
|
|
126
|
+
> escaped text. This is deliberate in the engine - `carve-lang` documents it as
|
|
127
|
+
> "NEVER build a symbols map out of untrusted / user-supplied input" - and it
|
|
128
|
+
> is why this plugin reads the map only from `_config.yml` and from files at
|
|
129
|
+
> paths named there. Never generate `carve.symbols` from page content, from
|
|
130
|
+
> front matter, from a comment system, or from anything else a visitor can
|
|
131
|
+
> influence.
|
|
132
|
+
|
|
54
133
|
## Usage
|
|
55
134
|
|
|
56
135
|
Create a page with a `.crv` extension. It MUST begin with Jekyll
|
|
@@ -107,16 +186,39 @@ has already removed the leading block by the time Carve runs.
|
|
|
107
186
|
| ------ | -------- |
|
|
108
187
|
| `matches(ext)` | `true` for `.crv` (with or without the leading dot, case-insensitive), `false` otherwise. |
|
|
109
188
|
| `output_ext(ext)` | `".html"` (includes the dot so Jekyll emits `page.html`). |
|
|
110
|
-
| `convert(content)` | `Carve.to_html(content, extensions: configured)` - the rendered HTML. |
|
|
189
|
+
| `convert(content)` | `Carve.to_html(content, extensions: configured, symbols: configured)` - the rendered HTML. |
|
|
111
190
|
| `carve_extensions` | The extension list read from `carve.extensions` in `_config.yml` (empty by default). |
|
|
191
|
+
| `carve_symbols` | The `:name:` map read from `carve.symbols` in `_config.yml`, or `nil` when the key is absent. Resolved once per build. |
|
|
192
|
+
| `reset_symbols` | Drops the cached map. Called from the `site, after_reset` hook this plugin registers, which is what scopes the cache to a build so `jekyll serve --watch` picks up an edit to a symbol file. |
|
|
112
193
|
|
|
113
194
|
## Development
|
|
114
195
|
|
|
115
196
|
```sh
|
|
116
197
|
bundle install
|
|
117
|
-
rspec
|
|
198
|
+
bundle exec rspec # run the converter unit tests
|
|
118
199
|
```
|
|
119
200
|
|
|
120
|
-
|
|
201
|
+
### Which engine you are testing against
|
|
202
|
+
|
|
203
|
+
`bundle install` here does **not** resolve `carve-lang` from RubyGems. `Gemfile`
|
|
204
|
+
pins the engine to a carve-rb revision, so a development run measures one exact
|
|
205
|
+
engine build rather than whatever the registry serves that day, and
|
|
206
|
+
`script/verify_engine_pin.rb` refuses when the Gemfile, the resolved bundle and
|
|
207
|
+
the loaded library disagree. CI runs it before the suite:
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
bundle exec ruby script/verify_engine_pin.rb
|
|
211
|
+
# carve-lang 0.1.2 from carve-rb b7f3a91a... (Gemfile pins b7f3a91a4192)
|
|
212
|
+
```
|
|
121
213
|
|
|
122
|
-
|
|
214
|
+
An installed copy of this gem resolves differently, through the range
|
|
215
|
+
`jekyll-carve.gemspec` declares - today `>= 0.1.1, < 0.2.0`. The two are
|
|
216
|
+
deliberately not the same engine, and both are checked:
|
|
217
|
+
`spec/engine_floor_spec.rb` asks whether the engine under `bundle exec rspec`
|
|
218
|
+
still does what the floor claims, and `script/consumer_engine_probe.rb` asks the
|
|
219
|
+
same of an actual `gem install`, daily in `Engine drift` and again before every
|
|
220
|
+
release.
|
|
221
|
+
|
|
222
|
+
There is no committed `Gemfile.lock`, and that is deliberate - the reasoning,
|
|
223
|
+
including why a lockfile would be a weaker pin here and what it would cost the
|
|
224
|
+
Ruby 3.1 job, is written at the top of `Gemfile`.
|
data/lib/jekyll/carve/version.rb
CHANGED
data/lib/jekyll-carve.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
require "jekyll"
|
|
4
6
|
require "carve"
|
|
5
7
|
|
|
@@ -57,7 +59,9 @@ module Jekyll
|
|
|
57
59
|
#
|
|
58
60
|
# Returns the rendered HTML String.
|
|
59
61
|
def convert(content)
|
|
60
|
-
::Carve.to_html(content.to_s,
|
|
62
|
+
::Carve.to_html(content.to_s,
|
|
63
|
+
extensions: carve_extensions,
|
|
64
|
+
symbols: carve_symbols)
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
# Carve extensions configured under `carve.extensions` in _config.yml.
|
|
@@ -65,11 +69,198 @@ module Jekyll
|
|
|
65
69
|
# Returns an Array of extension names (Strings/Symbols passed through to
|
|
66
70
|
# the engine). Empty when nothing is configured.
|
|
67
71
|
def carve_extensions
|
|
68
|
-
carve_config = @config.is_a?(Hash) ? @config["carve"] : nil
|
|
69
|
-
return [] unless carve_config.is_a?(Hash)
|
|
70
|
-
|
|
71
72
|
Array(carve_config["extensions"])
|
|
72
73
|
end
|
|
74
|
+
|
|
75
|
+
# The `:name:` symbol map configured under `carve.symbols` in _config.yml.
|
|
76
|
+
#
|
|
77
|
+
# Carve parses `:name:` in core, but what a name renders as is a render
|
|
78
|
+
# option, so a document reaching the engine without a map renders the
|
|
79
|
+
# shortcode as its own source text.
|
|
80
|
+
#
|
|
81
|
+
# The key accepts three shapes:
|
|
82
|
+
#
|
|
83
|
+
# carve:
|
|
84
|
+
# symbols: # a mapping, written inline
|
|
85
|
+
# smile: "😄"
|
|
86
|
+
#
|
|
87
|
+
# carve:
|
|
88
|
+
# symbols: _data/symbols.json # a path to a JSON object
|
|
89
|
+
#
|
|
90
|
+
# carve:
|
|
91
|
+
# symbols: # both, merged left to right
|
|
92
|
+
# - _data/emoji.json
|
|
93
|
+
# - { ship: "🚀" }
|
|
94
|
+
#
|
|
95
|
+
# A path is resolved against the site source and clamped inside it, so it
|
|
96
|
+
# names a file in the project and never one outside it.
|
|
97
|
+
#
|
|
98
|
+
# SECURITY: the engine substitutes a symbol value as TRUSTED RAW output -
|
|
99
|
+
# it is NOT escaped, so `{ "l" => "<img src='/l.svg'>" }` emits a real
|
|
100
|
+
# element. carve-rb states the rule this inherits: "NEVER build a symbols
|
|
101
|
+
# map out of untrusted / user-supplied input." That is why the only
|
|
102
|
+
# inputs here are _config.yml and files at paths named in it. Page
|
|
103
|
+
# content and front matter cannot reach this method - `convert` ignores
|
|
104
|
+
# its argument when building the map, and Jekyll hands a converter no
|
|
105
|
+
# front matter at all.
|
|
106
|
+
#
|
|
107
|
+
# Returns a Hash of String name => String value, or nil when nothing is
|
|
108
|
+
# configured (nil, so the engine keeps its own default rather than being
|
|
109
|
+
# told there are no symbols).
|
|
110
|
+
def carve_symbols
|
|
111
|
+
return @symbols if defined?(@symbols)
|
|
112
|
+
|
|
113
|
+
@symbols = build_symbols
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Drop the cached map, so the next render resolves it again.
|
|
117
|
+
#
|
|
118
|
+
# Called from the `:site, :after_reset` hook at the bottom of this file,
|
|
119
|
+
# which is what makes the map resolve once per BUILD rather than once per
|
|
120
|
+
# process. Jekyll instantiates converters from `Site#initialize` while
|
|
121
|
+
# `Site#process` calls only `reset`, so under `jekyll serve --watch` ONE
|
|
122
|
+
# converter instance serves every rebuild - and a map memoized outright
|
|
123
|
+
# would keep serving a file the author has since edited.
|
|
124
|
+
#
|
|
125
|
+
# Returns nothing.
|
|
126
|
+
def reset_symbols
|
|
127
|
+
remove_instance_variable(:@symbols) if defined?(@symbols)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# The `carve` table from _config.yml, or an empty Hash.
|
|
133
|
+
def carve_config
|
|
134
|
+
config = @config.is_a?(Hash) ? @config["carve"] : nil
|
|
135
|
+
config.is_a?(Hash) ? config : {}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The configured symbol sources, always as an Array.
|
|
139
|
+
#
|
|
140
|
+
# NOT `Array(value)`: a Hash passed to Kernel#Array comes back as an
|
|
141
|
+
# array of PAIRS ({"a" => "b"} becomes [["a", "b"]]), which would silently
|
|
142
|
+
# shred an inline mapping into something that is no longer a map.
|
|
143
|
+
def symbol_sources
|
|
144
|
+
value = carve_config["symbols"]
|
|
145
|
+
case value
|
|
146
|
+
when nil then []
|
|
147
|
+
when Array then value
|
|
148
|
+
else [value]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The site source directory a symbol path is resolved against.
|
|
153
|
+
def site_source
|
|
154
|
+
source = @config.is_a?(Hash) ? @config["source"].to_s : ""
|
|
155
|
+
source.empty? ? Dir.pwd : source
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# One configured path, resolved and confined to the site source.
|
|
159
|
+
#
|
|
160
|
+
# Two layers, because they stop different things.
|
|
161
|
+
#
|
|
162
|
+
# `Jekyll.sanitized_path` clamps the LEXICAL path: "../../etc/passwd"
|
|
163
|
+
# comes back as "<source>/etc/passwd", so a written path cannot name a
|
|
164
|
+
# file outside the project.
|
|
165
|
+
#
|
|
166
|
+
# A symlink is not lexical. A link INSIDE the source pointing at a file
|
|
167
|
+
# outside it survives that clamp, and `File.read` follows it - which on a
|
|
168
|
+
# host building a site it did not write would read and potentially
|
|
169
|
+
# publish that file, through a converter that declares `safe true`. So
|
|
170
|
+
# the resolved path is compared against the site source with both sides
|
|
171
|
+
# fully resolved. Jekyll takes the same posture in safe mode: its reader
|
|
172
|
+
# excludes symlinked entries outright.
|
|
173
|
+
def symbol_path(relative_path)
|
|
174
|
+
path = Jekyll.sanitized_path(site_source, relative_path)
|
|
175
|
+
real, root = begin
|
|
176
|
+
[File.realpath(path), File.realpath(site_source)]
|
|
177
|
+
rescue SystemCallError
|
|
178
|
+
# Nothing there to resolve. load_symbol_file raises naming the path.
|
|
179
|
+
return path
|
|
180
|
+
end
|
|
181
|
+
return real if real == root || real.start_with?(root + File::SEPARATOR)
|
|
182
|
+
|
|
183
|
+
raise ArgumentError,
|
|
184
|
+
"carve.symbols: #{relative_path} resolves to #{real}, which is " \
|
|
185
|
+
"outside the site source #{root}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Merge every configured source, left to right, into one map.
|
|
189
|
+
def build_symbols
|
|
190
|
+
merged = symbol_sources.each_with_object({}) do |source, out|
|
|
191
|
+
case source
|
|
192
|
+
when Hash then out.merge!(normalize_symbols(source, "carve.symbols"))
|
|
193
|
+
when String then out.merge!(load_symbol_file(source))
|
|
194
|
+
else
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
"carve.symbols: expected a mapping or a path to a JSON file, " \
|
|
197
|
+
"got #{source.class}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
merged.empty? ? nil : merged
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Read one JSON file named by carve.symbols.
|
|
204
|
+
#
|
|
205
|
+
# A misconfigured map is raised rather than warned past: every page loses
|
|
206
|
+
# every symbol, and a build that renders `:smile:` as text with a line in
|
|
207
|
+
# the log is the version of this that costs an afternoon.
|
|
208
|
+
def load_symbol_file(relative_path)
|
|
209
|
+
path = symbol_path(relative_path)
|
|
210
|
+
raise ArgumentError, "carve.symbols: no such file: #{path}" unless File.file?(path)
|
|
211
|
+
|
|
212
|
+
begin
|
|
213
|
+
parsed = JSON.parse(File.read(path))
|
|
214
|
+
rescue JSON::ParserError => e
|
|
215
|
+
raise ArgumentError, "carve.symbols: #{path} is not valid JSON: #{e.message}"
|
|
216
|
+
end
|
|
217
|
+
unless parsed.is_a?(Hash)
|
|
218
|
+
raise ArgumentError,
|
|
219
|
+
"carve.symbols: #{path} must hold a JSON object mapping a name " \
|
|
220
|
+
"to its value, got #{parsed.class}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
normalize_symbols(parsed, path)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Check one source and key it by String name.
|
|
227
|
+
#
|
|
228
|
+
# These are Hash keys of a shortcode map, NOT Ruby Symbols - `carve.symbols`
|
|
229
|
+
# and the `Strings/Symbols passed through` of `carve_extensions` are
|
|
230
|
+
# different things that share a word. A name IS coerced, because YAML
|
|
231
|
+
# hands back a Symbol or a boolean for some unquoted keys (`:on:` is a
|
|
232
|
+
# perfectly ordinary shortcode to want) and a name never reaches output.
|
|
233
|
+
#
|
|
234
|
+
# A VALUE is not coerced. It goes into the page RAW, so `count: 1` or a
|
|
235
|
+
# nested mapping is a mistake worth stopping the build for rather than
|
|
236
|
+
# something to stringify and emit. The engine draws the same line: it
|
|
237
|
+
# raises TypeError on a non-String value.
|
|
238
|
+
def normalize_symbols(map, origin)
|
|
239
|
+
map.each_with_object({}) do |(name, value), out|
|
|
240
|
+
unless value.is_a?(String)
|
|
241
|
+
raise ArgumentError,
|
|
242
|
+
"#{origin}: the value for #{name.to_s.inspect} must be a string, " \
|
|
243
|
+
"got #{value.class}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
out[name.to_s] = value
|
|
247
|
+
end
|
|
248
|
+
end
|
|
73
249
|
end
|
|
74
250
|
end
|
|
75
251
|
end
|
|
252
|
+
|
|
253
|
+
# Resolve the symbol map once per build rather than once per process.
|
|
254
|
+
#
|
|
255
|
+
# `Site#reset` runs at the top of every `Site#process`, including every rebuild
|
|
256
|
+
# under `jekyll serve --watch`, and it is the only site event that fires on a
|
|
257
|
+
# rebuild without also re-instantiating the converters. See
|
|
258
|
+
# Jekyll::Carve::Converter#reset_symbols.
|
|
259
|
+
#
|
|
260
|
+
# `converters` is nil the first time this fires: `Site#initialize` calls `reset`
|
|
261
|
+
# BEFORE `setup`, and `setup` is what builds the converter list.
|
|
262
|
+
Jekyll::Hooks.register :site, :after_reset do |site|
|
|
263
|
+
Array(site.converters).each do |converter|
|
|
264
|
+
converter.reset_symbols if converter.is_a?(Jekyll::Carve::Converter)
|
|
265
|
+
end
|
|
266
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jekyll-carve
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- markup-carve
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: carve-lang
|
|
@@ -17,6 +17,9 @@ dependencies:
|
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
19
|
version: 0.1.1
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: 0.2.0
|
|
20
23
|
type: :runtime
|
|
21
24
|
prerelease: false
|
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -24,6 +27,9 @@ dependencies:
|
|
|
24
27
|
- - ">="
|
|
25
28
|
- !ruby/object:Gem::Version
|
|
26
29
|
version: 0.1.1
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.2.0
|
|
27
33
|
- !ruby/object:Gem::Dependency
|
|
28
34
|
name: jekyll
|
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -68,7 +74,8 @@ files:
|
|
|
68
74
|
homepage: https://github.com/markup-carve/jekyll-carve
|
|
69
75
|
licenses:
|
|
70
76
|
- MIT
|
|
71
|
-
metadata:
|
|
77
|
+
metadata:
|
|
78
|
+
rubygems_mfa_required: 'true'
|
|
72
79
|
post_install_message:
|
|
73
80
|
rdoc_options: []
|
|
74
81
|
require_paths:
|