decant 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14dd6a76377d655591855a2f8346fecdb9e771baa8645637fa6a6a0029ad2e36
4
- data.tar.gz: 0f546e42c17ae806f44a7ea1ef1164f2de7d5aca03fef4a365e2566f7a46204e
3
+ metadata.gz: 90e12a57f5b2470e597cf6070cedd064bd26bb1680fa9e14a337a9ec8c712888
4
+ data.tar.gz: 9ebce2f4febbfa2449cf671ab75781463df89b37e63359320014710dd613baae
5
5
  SHA512:
6
- metadata.gz: 108a666e9621a0ff56cdebe935a9eb8e2bd62e932005ec324431d314ac1e573972b314836f37cf4c1b5de9c1b8542027f46e018cd3bedbaf7ba4514cdeb461df
7
- data.tar.gz: 289c4a5fa4a02cd47487e2da069d85b1f8b4f7a878fa940f62d45eaf25c4d9a6d14090d43323d0412c0ba5d8c6fbbe17a78587c1a2765434241f43252143ac2e
6
+ metadata.gz: df551129f9cbf4222331f01cad5ddf4651fa631ad5a6d7b2fa2dc4f83f7f908e12d4c958278bf96a3e59d4e6cd019d788d6cc4b4fec7dfeb38fd439eb1cd724f
7
+ data.tar.gz: 351ffd284c342a388f324019d09c97bb19d8761b0f90457cb49aa4d855e54c1c2f5c54960303e4d30b82eb75876a3a2f25828e281df57f58954d7136528ebc77
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## Version 0.3.0 - 2024-11-19
4
+
5
+ - Add `Content#relative_path` which returns a file's relative path within its collection.
6
+
7
+ ## Version 0.2.0 - 2024-10-13
8
+
9
+ - Add support for a content instance knowing its own `#slug` - its extension-less relative path within its collection:
10
+
11
+ ```ruby
12
+ Page = Decant.define(dir: 'content', ext: 'md')
13
+
14
+ page = Page.find('features/slugs')
15
+ page.path.expand_path # => "/Users/dave/my-website/content/features/slugs.md"
16
+ page.slug # => "features/slugs"
17
+ ```
18
+ - `Collection` is no immutable and can no longer be changed after initialisation.
19
+
3
20
  ## Version 0.1.0 - 2024-08-11
4
21
 
5
22
  - Initial release
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Ruby](https://github.com/benpickles/decant/actions/workflows/main.yml/badge.svg)](https://github.com/benpickles/decant/actions/workflows/main.yml)
4
4
 
5
- A frontmatter-aware wrapper around a directory of static content.
5
+ A dependency-free frontmatter-aware framework-agnostic wrapper around a directory of static content.
6
6
 
7
7
  ## Installation
8
8
 
@@ -22,9 +22,8 @@ Page = Decant.define(dir: '_pages', ext: 'md') do
22
22
  frontmatter :title
23
23
 
24
24
  # Add custom methods - it's a standard Ruby class.
25
- def html
26
- # Decant doesn't know about Markdown etc so you should bring your own.
27
- Kramdown::Document.new(content).to_html
25
+ def shouty
26
+ "#{title.upcase}!!!"
28
27
  end
29
28
  end
30
29
  ```
@@ -45,10 +44,10 @@ You can fetch a `Page` instance by `.find`ing it by its extension-less path with
45
44
 
46
45
  ```ruby
47
46
  about = Page.find('about')
48
- about.content # => "About\n\nMore words.\n"
47
+ about.content # => "# About\n\nMore words.\n"
49
48
  about.frontmatter # => {:title=>"About", :stuff=>"nonsense"}
50
- about.html # => "<h1 id=\"about\">About</h1>\n\n<p>More words.</p>\n"
51
49
  about.title # => "About"
50
+ about.shouty # => "ABOUT!!!"
52
51
  ```
53
52
 
54
53
  ## Contributing
@@ -1,38 +1,86 @@
1
1
  # frozen_string_literal: true
2
2
  require 'pathname'
3
+ require_relative 'path_utils'
3
4
 
4
5
  module Decant
5
6
  class Collection
6
- attr_reader :dir, :ext
7
+ # @return [Pathname]
8
+ attr_reader :dir
7
9
 
10
+ # @return [String, nil]
11
+ attr_reader :ext
12
+
13
+ # @param dir [Pathname, String]
14
+ # @param ext [String, nil]
8
15
  def initialize(dir:, ext: nil)
9
16
  self.dir = dir
10
17
  self.ext = ext
11
18
  end
12
19
 
13
- def dir=(value)
14
- @dir = Pathname.new(value)
20
+ # @return [Array<Pathname>]
21
+ def entries
22
+ glob('**/*')
15
23
  end
16
24
 
17
- def entries
18
- glob("**/*#{ext}")
25
+ # If {#ext} is defined then +pattern+ MUST NOT include the file's extension
26
+ # as it will automatically be added, if {#ext} is +nil+ then +pattern+ MUST
27
+ # include the file's extension - essentially becoming the file's full
28
+ # relative path within {#dir}.
29
+ #
30
+ # Technically +pattern+ can be any pattern supported by +Dir.glob+ though
31
+ # it's more likely to simply be a file name.
32
+ #
33
+ # @param pattern [String]
34
+ #
35
+ # @return [Pathname, nil]
36
+ def find(pattern)
37
+ glob(pattern).first
19
38
  end
20
39
 
21
- def ext=(value)
22
- if value
23
- @ext = value.start_with?('.') ? value : ".#{value}"
24
- else
25
- @ext = value
26
- end
40
+ # @param pattern [String]
41
+ #
42
+ # @return [Array<Pathname>]
43
+ def glob(pattern)
44
+ dir.glob("#{pattern}#{ext}").select { |path| path.file? }
27
45
  end
28
46
 
29
- def find(path)
30
- pattern = "#{path}#{ext}"
31
- glob(pattern).first
47
+ # The relative path of +path+ within {#dir}.
48
+ #
49
+ # @param path [Pathname]
50
+ #
51
+ # @return [String]
52
+ def relative_path_for(path)
53
+ path.relative_path_from(dir).to_s
32
54
  end
33
55
 
34
- def glob(pattern)
35
- dir.glob(pattern).select { |path| path.file? }
56
+ # The extension-less relative path of +path+ within {#dir}.
57
+ #
58
+ # @param path [Pathname]
59
+ #
60
+ # @return [String]
61
+ def slug_for(path)
62
+ relative_path = relative_path_for(path)
63
+
64
+ # The collection has no configured extension, files are identified by
65
+ # their full (relative) path so there's no extension to remove.
66
+ return relative_path if @delete_ext_regexp.nil?
67
+
68
+ relative_path.sub(@delete_ext_regexp, '')
36
69
  end
70
+
71
+ private
72
+ def dir=(value)
73
+ @dir = Pathname.new(value)
74
+ end
75
+
76
+ def ext=(value)
77
+ if value
78
+ @ext = value.start_with?('.') ? value : ".#{value}"
79
+ @delete_ext_regexp = PathUtils.delete_ext_regexp(ext)
80
+ else
81
+ @ext = nil
82
+ @delete_ext_regexp = nil
83
+ end
84
+ end
37
85
  end
38
86
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'errors'
3
+ require_relative 'file'
4
+
5
+ module Decant
6
+ class Content < File
7
+ class << self
8
+ # When using {Decant.define} the returned {Content} subclass comes with
9
+ # its own {Collection} instance.
10
+ #
11
+ # @return [Collection]
12
+ attr_reader :collection
13
+
14
+ # Return all matching files within {.collection}.
15
+ #
16
+ # @return [Array<Content>]
17
+ def all
18
+ collection.entries.map { |path| new(path) }
19
+ end
20
+
21
+ # Find a file within the {.collection} by passing its relative path.
22
+ #
23
+ # @example Find a specific nested file within a content directory
24
+ # Page = Decant.define(dir: 'content', ext: '.md')
25
+ #
26
+ # # Return an instance for the file `content/features/nesting.md`.
27
+ # Page.find('features/nesting')
28
+ #
29
+ # @param pattern [String]
30
+ #
31
+ # @return [Content]
32
+ #
33
+ # @raise [FileNotFound] if a matching file cannot be found
34
+ def find(pattern)
35
+ path = collection.find(pattern)
36
+ raise FileNotFound, %(Couldn't find "#{pattern}" in "#{collection.dir}") unless path
37
+ new(path)
38
+ end
39
+
40
+ # Define convenience frontmatter readers - see {Decant.define}.
41
+ #
42
+ # @param attrs [Array<Symbol>] a list of convenience frontmatter readers.
43
+ def frontmatter(*attrs)
44
+ attrs.each do |name|
45
+ define_method name do
46
+ frontmatter&.[](name.to_sym)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # The relative path of the file within its collection.
53
+ #
54
+ # @example
55
+ # Page = Decant.define(dir: 'content', ext: 'md')
56
+ #
57
+ # page = Page.find('features/slugs')
58
+ # page.path.expand_path # => "/Users/dave/my-website/content/features/slugs.md"
59
+ # page.relative_path # => "features/slugs.md"
60
+ #
61
+ # @return [String]
62
+ def relative_path
63
+ self.class.collection.relative_path_for(path)
64
+ end
65
+
66
+ # The extension-less relative path of the file within its collection.
67
+ #
68
+ # @example
69
+ # Page = Decant.define(dir: 'content', ext: 'md')
70
+ #
71
+ # page = Page.find('features/slugs')
72
+ # page.path.expand_path # => "/Users/dave/my-website/content/features/slugs.md"
73
+ # page.slug # => "features/slugs"
74
+ #
75
+ # @return [String]
76
+ def slug
77
+ self.class.collection.slug_for(path)
78
+ end
79
+ end
80
+ end
data/lib/decant/file.rb CHANGED
@@ -3,25 +3,46 @@ require_relative 'frontmatter'
3
3
 
4
4
  module Decant
5
5
  class File
6
+ # @return [Pathname]
6
7
  attr_reader :path
7
8
 
9
+ # @param path [Pathname]
8
10
  def initialize(path)
9
11
  @path = path
10
12
  end
11
13
 
14
+ # The "content" part of the file at {#path} - everything after the end of
15
+ # the frontmatter definition (see {Frontmatter.load} for more about
16
+ # frontmatter).
17
+ #
18
+ # @return [String]
12
19
  def content
13
20
  frontmatter_content[1]
14
21
  end
15
22
 
23
+ # The frontmatter data contained in the file at {#path} or +nil+ if there's
24
+ # none (see {Frontmatter.load} for more about frontmatter).
25
+ #
26
+ # @return [Hash<Symbol, anything>, nil]
16
27
  def frontmatter
17
28
  frontmatter_content[0]
18
29
  end
19
30
 
31
+ # When passing a +key+ the return value indicates whether {#frontmatter} has
32
+ # the +key+, when no +key+ is passed it indicates whether the file has any
33
+ # frontmatter at all.
34
+ #
35
+ # @param key [Symbol, nil]
36
+ #
37
+ # @return [Boolean]
20
38
  def frontmatter?(key = nil)
21
39
  return false if frontmatter.nil?
22
40
  key ? frontmatter.key?(key) : true
23
41
  end
24
42
 
43
+ # The full untouched contents of the file at {#path}.
44
+ #
45
+ # @return [String]
25
46
  def read
26
47
  path.read
27
48
  end
@@ -4,6 +4,33 @@ require 'yaml'
4
4
 
5
5
  module Decant
6
6
  module Frontmatter
7
+ # Parse a +String+ input (the contents of a file) into its frontmatter /
8
+ # content constituents.
9
+ #
10
+ # For frontmatter to be valid/detected the +input+ must start with a line
11
+ # consisting of three dashes +---+, then the YAML, then another line of
12
+ # three dashes. The returned +Hash+ will have +Symbol+ keys.
13
+ #
14
+ # Technically frontmatter can be any valid YAML not just key/value pairs but
15
+ # this would be very unusual and wouldn't be compatible with other
16
+ # frontmatter-related expectations like {Content.frontmatter}.
17
+ #
18
+ # @example Input with valid frontmatter
19
+ # ---
20
+ # title: Frontmatter
21
+ # ---
22
+ # The rest of the content
23
+ #
24
+ # @example Result of loading the above input
25
+ # Decant::Frontmatter.load(string)
26
+ # # => [{:title=>"Frontmatter"}, "The rest of the content"]
27
+ #
28
+ # @param input [String]
29
+ #
30
+ # @return [Array([Hash<Symbol, anything>, nil], String)] a frontmatter /
31
+ # content tuple. If +input+ contains frontmatter then the YAML will be
32
+ # parsed into a +Hash+ with +Symbol+ keys, if it doesn't have frontmatter
33
+ # then it will be +nil+.
7
34
  def self.load(input)
8
35
  return [nil, input] unless input.start_with?("---\n")
9
36
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+ require 'strscan'
3
+
4
+ module Decant
5
+ module PathUtils
6
+ # Generate a regular expression to strip a matching extension from a string.
7
+ # Supports similar shell-like pattern syntax as +Dir.glob+ including +.*+
8
+ # to remove any extension and +.{a,b}+ to remove either an +.a+ or +.b+
9
+ # extension. Used internally by {Content#slug}.
10
+ #
11
+ # @param pattern [String]
12
+ #
13
+ # @return [Regexp]
14
+ #
15
+ # @raise [RegexpError] if the regular expression cannot be generated, for
16
+ # instance if +pattern+ includes unbalanced shell-like expansion brackets
17
+ def self.delete_ext_regexp(pattern)
18
+ scanner = StringScanner.new(pattern)
19
+ regexp = String.new
20
+
21
+ while (ch = scanner.getch)
22
+ regexp << case ch
23
+ when '.' then '\.'
24
+ when '{' then '(?:'
25
+ when ',' then '|'
26
+ when '}' then ')'
27
+ when '*' then '[^\.]+'
28
+ else
29
+ ch
30
+ end
31
+ end
32
+
33
+ regexp << '$'
34
+
35
+ Regexp.new(regexp)
36
+ end
37
+ end
38
+ end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Decant
3
- VERSION = '0.1.0'
3
+ VERSION = '0.3.0'
4
4
  end
data/lib/decant.rb CHANGED
@@ -1,13 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative 'decant/collection'
3
- require_relative 'decant/content_methods'
4
- require_relative 'decant/file'
3
+ require_relative 'decant/content'
5
4
  require_relative 'decant/version'
6
5
 
7
6
  module Decant
7
+ # Defines a new {Content} subclass and assigns it a new {Collection} with
8
+ # +dir+/+ext+. Passing a block lets you to declare convenience frontmatter
9
+ # readers and add your own methods.
10
+ #
11
+ # @example
12
+ # Page = Decant.define(dir: 'content', ext: 'md') do
13
+ # frontmatter :title
14
+ #
15
+ # def shouty
16
+ # "#{title.upcase}!!!"
17
+ # end
18
+ # end
19
+ #
20
+ # # Given a file `content/about.md` with the following contents:
21
+ # #
22
+ # # ---
23
+ # # title: About
24
+ # # ---
25
+ # # About Decant
26
+ #
27
+ # about = Page.find('about')
28
+ # about.content # => "About Decant"
29
+ # about.frontmatter # => {:title=>"About"}
30
+ # about.title # => "About"
31
+ # about.shouty # => "ABOUT!!!"
32
+ #
33
+ # @param dir [Pathname, String]
34
+ # @param ext [String, nil]
35
+ #
36
+ # @yield pass an optional block to declare convenience frontmatter readers
37
+ # with {Content.frontmatter} and add your own methods to the class.
38
+ #
39
+ # @return [Class<Content>]
8
40
  def self.define(dir:, ext: nil, &block)
9
- Class.new(File) do
10
- extend ContentMethods
41
+ Class.new(Content) do
11
42
  @collection = Collection.new(dir: dir, ext: ext)
12
43
  class_eval(&block) if block_given?
13
44
  end
metadata CHANGED
@@ -1,16 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: decant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Pickles
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-11 00:00:00.000000000 Z
11
+ date: 2024-11-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: A frontmatter-aware wrapper around a directory of static content
13
+ description: A dependency-free frontmatter-aware framework-agnostic wrapper around
14
+ a directory of static content
14
15
  email:
15
16
  - spideryoung@gmail.com
16
17
  executables: []
@@ -25,10 +26,11 @@ files:
25
26
  - Rakefile
26
27
  - lib/decant.rb
27
28
  - lib/decant/collection.rb
28
- - lib/decant/content_methods.rb
29
+ - lib/decant/content.rb
29
30
  - lib/decant/errors.rb
30
31
  - lib/decant/file.rb
31
32
  - lib/decant/frontmatter.rb
33
+ - lib/decant/path_utils.rb
32
34
  - lib/decant/version.rb
33
35
  homepage: https://github.com/benpickles/decant
34
36
  licenses:
@@ -53,8 +55,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
53
55
  - !ruby/object:Gem::Version
54
56
  version: '0'
55
57
  requirements: []
56
- rubygems_version: 3.5.17
58
+ rubygems_version: 3.5.16
57
59
  signing_key:
58
60
  specification_version: 4
59
- summary: A frontmatter-aware wrapper around a directory of static content
61
+ summary: A dependency-free frontmatter-aware framework-agnostic wrapper around a directory
62
+ of static content
60
63
  test_files: []
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
- require_relative 'errors'
3
-
4
- module Decant
5
- module ContentMethods
6
- attr_reader :collection
7
-
8
- def all
9
- collection.entries.map { |path| new(path) }
10
- end
11
-
12
- def find(pattern)
13
- path = collection.find(pattern)
14
- raise FileNotFound, %(Couldn't find "#{pattern}" in "#{collection.dir}") unless path
15
- new(path)
16
- end
17
-
18
- def frontmatter(*attrs)
19
- attrs.each do |name|
20
- define_method name do
21
- frontmatter&.[](name.to_sym)
22
- end
23
- end
24
- end
25
- end
26
- end