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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d5439f1f1f23a853c560a40a84f7b210a2625682f4b5a66483cfb23361744f4
4
- data.tar.gz: 87ae6478fa6a2af0e2026611f41fb4d2118677496222325d89e9f8cb03a7156e
3
+ metadata.gz: f2fa9dda47fb228d8edba3147048e720d9ee7995a14c38b9f4cac9cf8bfe8f6e
4
+ data.tar.gz: 83252c17f31ccdf1f34b64785dbf928fc96619e2a35e243facef5543a71439b8
5
5
  SHA512:
6
- metadata.gz: b5cd475edd480cd1465c4114b13ecd817c916342e0b073939cc773e3e09f85d8a294aa55b3ed6eb28b56889de1cce67e3a3c91a2716ff53f1a8d5d36935c40b0
7
- data.tar.gz: 79a03d86ffe55a4c7fcd8c0f23d071ff6479745d241b4f216775d27eb4d122e91c2b1c76fca5228e877a9279dcd0152abf69339a83f6c62bee59a719e914851a
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 # run the converter unit tests
198
+ bundle exec rspec # run the converter unit tests
118
199
  ```
119
200
 
120
- ## License
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
- MIT, markup-carve.
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`.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module Carve
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
7
7
  end
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, extensions: carve_extensions)
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.0
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-19 00:00:00.000000000 Z
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: