jekyll-carve 0.1.0 → 0.1.2

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: 1fd1b876054b8355dc08cdf7569c46cafd968c9e8874c65cd4e6e2866f29d6e7
4
+ data.tar.gz: c4866d9b82ff1d2bf963eb8945fa4a67b70b94aaa4d3e78b300229d369cefc4d
5
5
  SHA512:
6
- metadata.gz: b5cd475edd480cd1465c4114b13ecd817c916342e0b073939cc773e3e09f85d8a294aa55b3ed6eb28b56889de1cce67e3a3c91a2716ff53f1a8d5d36935c40b0
7
- data.tar.gz: 79a03d86ffe55a4c7fcd8c0f23d071ff6479745d241b4f216775d27eb4d122e91c2b1c76fca5228e877a9279dcd0152abf69339a83f6c62bee59a719e914851a
6
+ metadata.gz: b0c225c70722c0ed014f0ade97aae1b534992dd6830b02acf47a27b1914fe92208f478aefb72ec071794be0fd9e33f75be25a365fe55ef5f3b7be0b8e23b1f0a
7
+ data.tar.gz: 6c86ca36aec167aa996d1b01df87113e9e0ef8796f97a0cde5c4a1e4f6badc6b0edbad271886c7cd192a7e1167f7c23042952e74641907cae0e1a87f7e65f2e2
data/README.md CHANGED
@@ -51,6 +51,131 @@ 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
+
133
+ ### `carve.includes` - pulling one file into another
134
+
135
+ A Carve page can pull another file in with `{{ path }}`. It stays literal until
136
+ the site asks:
137
+
138
+ ```yaml
139
+ carve:
140
+ includes: true
141
+ ```
142
+
143
+ Includes need carve-lang 0.1.4 or later. On an older engine, a page that
144
+ reaches them stops the build with a message naming the upgrade.
145
+
146
+ Paths resolve relative to the file that wrote them, and nothing resolves outside
147
+ the containment root. The root is Jekyll's source directory unless
148
+ `carve.include_root` names another one, and an include that would leave it is
149
+ not expanded.
150
+
151
+ ```yaml
152
+ carve:
153
+ includes: true
154
+ include_root: /srv/shared/fragments
155
+ ```
156
+
157
+ `carve.include_root` must be an **absolute** path. A relative one is refused
158
+ rather than resolved, because resolving it lands on whatever directory the build
159
+ ran from, which is not a root anyone chose. The page being rendered has to sit
160
+ inside the root too.
161
+
162
+ A target that cannot be read is reported against the page that asked for it, and
163
+ the directive is left as written. The message does not say whether the file was
164
+ missing or refused by containment: both report `include-unresolved`, so a page
165
+ cannot be used to probe the filesystem. The class is at `--verbose`.
166
+
167
+ Under `jekyll serve`, a page is rebuilt when a target it read changes, because
168
+ every resolved target is registered with Jekyll's regeneration path.
169
+
170
+ A conversion with no page behind it - the converter called directly on a String -
171
+ leaves the directive literal, since there is no file for a relative path to
172
+ resolve against.
173
+
174
+ > [!NOTE]
175
+ > Fragments under the source directory are pages in their own right, so Jekyll
176
+ > will publish them. Exclude them in `_config.yml`, or keep them outside the
177
+ > source and point `include_root` at the tree above both.
178
+
54
179
  ## Usage
55
180
 
56
181
  Create a page with a `.crv` extension. It MUST begin with Jekyll
@@ -107,16 +232,11 @@ has already removed the leading block by the time Carve runs.
107
232
  | ------ | -------- |
108
233
  | `matches(ext)` | `true` for `.crv` (with or without the leading dot, case-insensitive), `false` otherwise. |
109
234
  | `output_ext(ext)` | `".html"` (includes the dot so Jekyll emits `page.html`). |
110
- | `convert(content)` | `Carve.to_html(content, extensions: configured)` - the rendered HTML. |
235
+ | `convert(content)` | `Carve.to_html(content, extensions: configured, symbols: configured)` - the rendered HTML. |
111
236
  | `carve_extensions` | The extension list read from `carve.extensions` in `_config.yml` (empty by default). |
237
+ | `carve_symbols` | The `:name:` map read from `carve.symbols` in `_config.yml`, or `nil` when the key is absent. Resolved once per build. |
238
+ | `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
239
 
113
240
  ## Development
114
241
 
115
- ```sh
116
- bundle install
117
- rspec # run the converter unit tests
118
- ```
119
-
120
- ## License
121
-
122
- MIT, markup-carve.
242
+ Contributor setup, testing, and maintenance notes are in the [development guide](docs/development.md).
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module Carve
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.2"
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,52 @@ 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
+ document = Rendering.current
63
+ root = include_root
64
+ if root.nil? || document.nil? || document.path.to_s.empty?
65
+ return ::Carve.to_html(content.to_s,
66
+ extensions: carve_extensions,
67
+ symbols: carve_symbols)
68
+ end
69
+ # carve-lang added this entry point in 0.1.4; the floor stays lower for sites without includes.
70
+ unless ::Carve.respond_to?(:to_html_with_includes)
71
+ raise ArgumentError,
72
+ "carve.includes: the installed carve-lang (#{::Carve::VERSION}) has no " \
73
+ "to_html_with_includes; upgrade it to 0.1.4 or later, or set carve.includes: false"
74
+ end
75
+
76
+ begin
77
+ result = ::Carve.to_html_with_includes(content.to_s,
78
+ root: root,
79
+ source_path: document.path,
80
+ extensions: carve_extensions,
81
+ symbols: carve_symbols)
82
+ rescue ArgumentError => e
83
+ # A relative root, or a page outside it. Both are decided by the
84
+ # engine; what this adds is the key and the page to look at.
85
+ raise ArgumentError,
86
+ "carve.include_root: #{document.relative_path || document.path}: #{e.message}"
87
+ end
88
+ report_includes(result, document, root)
89
+ result[:value]
90
+ end
91
+
92
+ # The containment root configured under `carve.include_root`, or nil when
93
+ # `carve.includes` is off.
94
+ #
95
+ # A configured value is handed to the engine exactly as written. The
96
+ # engine refuses a relative root, and that refusal is what keeps
97
+ # containment off whatever directory the build ran from; expanding it
98
+ # here would mean it never fires. The default is Jekyll's own source
99
+ # directory, which this package derives rather than reads, so that one is
100
+ # expanded.
101
+ def include_root
102
+ return nil unless carve_config["includes"]
103
+
104
+ configured = carve_config["include_root"]
105
+ return configured.to_s if configured.is_a?(String) && !configured.empty?
106
+
107
+ File.expand_path(site_source)
61
108
  end
62
109
 
63
110
  # Carve extensions configured under `carve.extensions` in _config.yml.
@@ -65,11 +112,273 @@ module Jekyll
65
112
  # Returns an Array of extension names (Strings/Symbols passed through to
66
113
  # the engine). Empty when nothing is configured.
67
114
  def carve_extensions
68
- carve_config = @config.is_a?(Hash) ? @config["carve"] : nil
69
- return [] unless carve_config.is_a?(Hash)
70
-
71
115
  Array(carve_config["extensions"])
72
116
  end
117
+
118
+ # The `:name:` symbol map configured under `carve.symbols` in _config.yml.
119
+ #
120
+ # Carve parses `:name:` in core, but what a name renders as is a render
121
+ # option, so a document reaching the engine without a map renders the
122
+ # shortcode as its own source text.
123
+ #
124
+ # The key accepts three shapes:
125
+ #
126
+ # carve:
127
+ # symbols: # a mapping, written inline
128
+ # smile: "😄"
129
+ #
130
+ # carve:
131
+ # symbols: _data/symbols.json # a path to a JSON object
132
+ #
133
+ # carve:
134
+ # symbols: # both, merged left to right
135
+ # - _data/emoji.json
136
+ # - { ship: "🚀" }
137
+ #
138
+ # A path is resolved against the site source and clamped inside it, so it
139
+ # names a file in the project and never one outside it.
140
+ #
141
+ # SECURITY: the engine substitutes a symbol value as TRUSTED RAW output -
142
+ # it is NOT escaped, so `{ "l" => "<img src='/l.svg'>" }` emits a real
143
+ # element. carve-rb states the rule this inherits: "NEVER build a symbols
144
+ # map out of untrusted / user-supplied input." That is why the only
145
+ # inputs here are _config.yml and files at paths named in it. Page
146
+ # content and front matter cannot reach this method - `convert` ignores
147
+ # its argument when building the map, and Jekyll hands a converter no
148
+ # front matter at all.
149
+ #
150
+ # Returns a Hash of String name => String value, or nil when nothing is
151
+ # configured (nil, so the engine keeps its own default rather than being
152
+ # told there are no symbols).
153
+ def carve_symbols
154
+ return @symbols if defined?(@symbols)
155
+
156
+ @symbols = build_symbols
157
+ end
158
+
159
+ # Drop the cached map, so the next render resolves it again.
160
+ #
161
+ # Called from the `:site, :after_reset` hook at the bottom of this file,
162
+ # which is what makes the map resolve once per BUILD rather than once per
163
+ # process. Jekyll instantiates converters from `Site#initialize` while
164
+ # `Site#process` calls only `reset`, so under `jekyll serve --watch` ONE
165
+ # converter instance serves every rebuild - and a map memoized outright
166
+ # would keep serving a file the author has since edited.
167
+ #
168
+ # Returns nothing.
169
+ def reset_symbols
170
+ remove_instance_variable(:@symbols) if defined?(@symbols)
171
+ end
172
+
173
+ private
174
+
175
+ # Surface what expansion degraded, and record what it read.
176
+ def report_includes(result, document, root)
177
+ page = document.relative_path || document.path
178
+ Array(result[:warnings]).each do |warning|
179
+ origin = warning[:file]
180
+ where = origin && origin != page ? "#{page} (#{origin})" : page
181
+ Jekyll.logger.warn "Carve:", "#{where}: #{warning[:rule]}: #{warning[:message]}"
182
+ end
183
+ suppressed = result[:suppressedWarnings].to_i
184
+ if suppressed.positive?
185
+ Jekyll.logger.warn "Carve:",
186
+ "#{page}: #{suppressed} further include warnings suppressed"
187
+ end
188
+ Array(result[:dependencies]).each do |dependency|
189
+ # A containment refusal and a missing file are the same warning, so a
190
+ # page cannot probe the filesystem (spec I7). The class that was
191
+ # collapsed is here, for the build's own log.
192
+ if dependency[:denial]
193
+ Jekyll.logger.debug "Carve:",
194
+ "#{page}: include #{dependency[:path]}: #{dependency[:denial]}"
195
+ next
196
+ end
197
+
198
+ register_dependency(document, File.join(root, dependency[:path].to_s))
199
+ end
200
+ end
201
+
202
+ # Tell Jekyll the page has to be rebuilt when this target changes.
203
+ #
204
+ # Only a RESOLVED target is registered. An unresolved dependency reports
205
+ # the directive as written rather than a path relative to the root, so
206
+ # joining it onto the root names the wrong file: a nested `missing.crv`
207
+ # would be recorded against the root rather than against the directory
208
+ # its parent sits in. A fragment appearing later is covered by Jekyll's
209
+ # own watch over the source directory.
210
+ def register_dependency(document, target)
211
+ regenerator = document.site&.regenerator
212
+ return unless regenerator.respond_to?(:add_dependency)
213
+
214
+ regenerator.add_dependency(document.path, target)
215
+ end
216
+
217
+ # The `carve` table from _config.yml, or an empty Hash.
218
+ def carve_config
219
+ config = @config.is_a?(Hash) ? @config["carve"] : nil
220
+ config.is_a?(Hash) ? config : {}
221
+ end
222
+
223
+ # The configured symbol sources, always as an Array.
224
+ #
225
+ # NOT `Array(value)`: a Hash passed to Kernel#Array comes back as an
226
+ # array of PAIRS ({"a" => "b"} becomes [["a", "b"]]), which would silently
227
+ # shred an inline mapping into something that is no longer a map.
228
+ def symbol_sources
229
+ value = carve_config["symbols"]
230
+ case value
231
+ when nil then []
232
+ when Array then value
233
+ else [value]
234
+ end
235
+ end
236
+
237
+ # The site source directory a symbol path is resolved against.
238
+ def site_source
239
+ source = @config.is_a?(Hash) ? @config["source"].to_s : ""
240
+ source.empty? ? Dir.pwd : source
241
+ end
242
+
243
+ # One configured path, resolved and confined to the site source.
244
+ #
245
+ # Two layers, because they stop different things.
246
+ #
247
+ # `Jekyll.sanitized_path` clamps the LEXICAL path: "../../etc/passwd"
248
+ # comes back as "<source>/etc/passwd", so a written path cannot name a
249
+ # file outside the project.
250
+ #
251
+ # A symlink is not lexical. A link INSIDE the source pointing at a file
252
+ # outside it survives that clamp, and `File.read` follows it - which on a
253
+ # host building a site it did not write would read and potentially
254
+ # publish that file, through a converter that declares `safe true`. So
255
+ # the resolved path is compared against the site source with both sides
256
+ # fully resolved. Jekyll takes the same posture in safe mode: its reader
257
+ # excludes symlinked entries outright.
258
+ def symbol_path(relative_path)
259
+ path = Jekyll.sanitized_path(site_source, relative_path)
260
+ real, root = begin
261
+ [File.realpath(path), File.realpath(site_source)]
262
+ rescue SystemCallError
263
+ # Nothing there to resolve. load_symbol_file raises naming the path.
264
+ return path
265
+ end
266
+ return real if real == root || real.start_with?(root + File::SEPARATOR)
267
+
268
+ raise ArgumentError,
269
+ "carve.symbols: #{relative_path} resolves to #{real}, which is " \
270
+ "outside the site source #{root}"
271
+ end
272
+
273
+ # Merge every configured source, left to right, into one map.
274
+ def build_symbols
275
+ merged = symbol_sources.each_with_object({}) do |source, out|
276
+ case source
277
+ when Hash then out.merge!(normalize_symbols(source, "carve.symbols"))
278
+ when String then out.merge!(load_symbol_file(source))
279
+ else
280
+ raise ArgumentError,
281
+ "carve.symbols: expected a mapping or a path to a JSON file, " \
282
+ "got #{source.class}"
283
+ end
284
+ end
285
+ merged.empty? ? nil : merged
286
+ end
287
+
288
+ # Read one JSON file named by carve.symbols.
289
+ #
290
+ # A misconfigured map is raised rather than warned past: every page loses
291
+ # every symbol, and a build that renders `:smile:` as text with a line in
292
+ # the log is the version of this that costs an afternoon.
293
+ def load_symbol_file(relative_path)
294
+ path = symbol_path(relative_path)
295
+ raise ArgumentError, "carve.symbols: no such file: #{path}" unless File.file?(path)
296
+
297
+ begin
298
+ parsed = JSON.parse(File.read(path))
299
+ rescue JSON::ParserError => e
300
+ raise ArgumentError, "carve.symbols: #{path} is not valid JSON: #{e.message}"
301
+ end
302
+ unless parsed.is_a?(Hash)
303
+ raise ArgumentError,
304
+ "carve.symbols: #{path} must hold a JSON object mapping a name " \
305
+ "to its value, got #{parsed.class}"
306
+ end
307
+
308
+ normalize_symbols(parsed, path)
309
+ end
310
+
311
+ # Check one source and key it by String name.
312
+ #
313
+ # These are Hash keys of a shortcode map, NOT Ruby Symbols - `carve.symbols`
314
+ # and the `Strings/Symbols passed through` of `carve_extensions` are
315
+ # different things that share a word. A name IS coerced, because YAML
316
+ # hands back a Symbol or a boolean for some unquoted keys (`:on:` is a
317
+ # perfectly ordinary shortcode to want) and a name never reaches output.
318
+ #
319
+ # A VALUE is not coerced. It goes into the page RAW, so `count: 1` or a
320
+ # nested mapping is a mistake worth stopping the build for rather than
321
+ # something to stringify and emit. The engine draws the same line: it
322
+ # raises TypeError on a non-String value.
323
+ def normalize_symbols(map, origin)
324
+ map.each_with_object({}) do |(name, value), out|
325
+ unless value.is_a?(String)
326
+ raise ArgumentError,
327
+ "#{origin}: the value for #{name.to_s.inspect} must be a string, " \
328
+ "got #{value.class}"
329
+ end
330
+
331
+ out[name.to_s] = value
332
+ end
333
+ end
334
+ end
335
+
336
+ # Which document Jekyll is rendering right now.
337
+ #
338
+ # `Jekyll::Converter#convert` is handed a String and nothing else, so a
339
+ # converter cannot see the file the body came from - and an include has to
340
+ # resolve against that file. The `:pre_render` hook carries it, and Jekyll
341
+ # renders documents one at a time, so the current one is a thread-local
342
+ # rather than a queue.
343
+ module Rendering
344
+ KEY = :jekyll_carve_document
345
+
346
+ module_function
347
+
348
+ def current
349
+ Thread.current[KEY]
350
+ end
351
+
352
+ def with(document)
353
+ Thread.current[KEY] = document
354
+ end
355
+
356
+ def clear
357
+ Thread.current[KEY] = nil
358
+ end
73
359
  end
74
360
  end
75
361
  end
362
+
363
+ Jekyll::Hooks.register %i[pages documents], :pre_render do |document|
364
+ Jekyll::Carve::Rendering.with(document)
365
+ end
366
+
367
+ Jekyll::Hooks.register %i[pages documents], :post_render do |_document|
368
+ Jekyll::Carve::Rendering.clear
369
+ end
370
+
371
+ # Resolve the symbol map once per build rather than once per process.
372
+ #
373
+ # `Site#reset` runs at the top of every `Site#process`, including every rebuild
374
+ # under `jekyll serve --watch`, and it is the only site event that fires on a
375
+ # rebuild without also re-instantiating the converters. See
376
+ # Jekyll::Carve::Converter#reset_symbols.
377
+ #
378
+ # `converters` is nil the first time this fires: `Site#initialize` calls `reset`
379
+ # BEFORE `setup`, and `setup` is what builds the converter list.
380
+ Jekyll::Hooks.register :site, :after_reset do |site|
381
+ Array(site.converters).each do |converter|
382
+ converter.reset_symbols if converter.is_a?(Jekyll::Carve::Converter)
383
+ end
384
+ 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.2
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-09-22 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: