decant 0.1.0 → 0.2.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: ff0fe21b57ea4db012b2cedce3a14b6e8b34186a85071525949431524f0cdc4b
4
+ data.tar.gz: a6953eace2994504c734d002f8357aacb1b0563a6a31513684826888c7aefe59
5
5
  SHA512:
6
- metadata.gz: 108a666e9621a0ff56cdebe935a9eb8e2bd62e932005ec324431d314ac1e573972b314836f37cf4c1b5de9c1b8542027f46e018cd3bedbaf7ba4514cdeb461df
7
- data.tar.gz: 289c4a5fa4a02cd47487e2da069d85b1f8b4f7a878fa940f62d45eaf25c4d9a6d14090d43323d0412c0ba5d8c6fbbe17a78587c1a2765434241f43252143ac2e
6
+ metadata.gz: 66aee785137091d9a134161e3b45cd60b85e7e8d1d4f5a9a7601d4b389b83a1f408d0b08bd15460a101379c00137d568ee6070a699b28571dfaab6aea1a8ec40
7
+ data.tar.gz: fc536bc67d8a596add87ebb75ded2cd81bbbda22f93e0ae7b1b9e6de01fc46c5b565b83415a119bf4edf37552e36efc98ca91550784cfb91e54e0781e03decd7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## Version 0.2.0 - 2024-10-13
4
+
5
+ - Add support for a content instance knowing its own `#slug` - its relative path within its collection:
6
+
7
+ ```ruby
8
+ Page = Decant.define(dir: 'content', ext: 'md')
9
+
10
+ page = Page.find('features/slugs')
11
+ page.path.expand_path # => "/Users/dave/my-website/content/features/slugs.md"
12
+ page.slug # => "features/slugs"
13
+ ```
14
+ - `Collection` is no immutable and can no longer be changed after initialisation.
15
+
3
16
  ## Version 0.1.0 - 2024-08-11
4
17
 
5
18
  - Initial release
data/README.md CHANGED
@@ -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,77 @@
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)
15
- end
16
-
20
+ # @return [Array<Pathname>]
17
21
  def entries
18
- glob("**/*#{ext}")
22
+ glob('**/*')
19
23
  end
20
24
 
21
- def ext=(value)
22
- if value
23
- @ext = value.start_with?('.') ? value : ".#{value}"
24
- else
25
- @ext = value
26
- end
27
- end
28
-
29
- def find(path)
30
- pattern = "#{path}#{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)
31
37
  glob(pattern).first
32
38
  end
33
39
 
40
+ # @param pattern [String]
41
+ #
42
+ # @return [Array<Pathname>]
34
43
  def glob(pattern)
35
- dir.glob(pattern).select { |path| path.file? }
44
+ dir.glob("#{pattern}#{ext}").select { |path| path.file? }
45
+ end
46
+
47
+ # The extension-less relative path of +path+ within {#dir}.
48
+ #
49
+ # @param path [Pathname]
50
+ #
51
+ # @return [String]
52
+ def slug_for(path)
53
+ relative_path = path.relative_path_from(dir).to_s
54
+
55
+ # The collection has no configured extension, files are identified by
56
+ # their full (relative) path so there's no extension to remove.
57
+ return relative_path if @delete_ext_regexp.nil?
58
+
59
+ relative_path.sub(@delete_ext_regexp, '')
36
60
  end
61
+
62
+ private
63
+ def dir=(value)
64
+ @dir = Pathname.new(value)
65
+ end
66
+
67
+ def ext=(value)
68
+ if value
69
+ @ext = value.start_with?('.') ? value : ".#{value}"
70
+ @delete_ext_regexp = PathUtils.delete_ext_regexp(ext)
71
+ else
72
+ @ext = nil
73
+ @delete_ext_regexp = nil
74
+ end
75
+ end
37
76
  end
38
77
  end
@@ -0,0 +1,66 @@
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 extension-less 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.slug # => "features/slugs"
60
+ #
61
+ # @return [String]
62
+ def slug
63
+ self.class.collection.slug_for(path)
64
+ end
65
+ end
66
+ 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.2.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,14 +1,14 @@
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.2.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-10-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A frontmatter-aware wrapper around a directory of static content
14
14
  email:
@@ -25,10 +25,11 @@ files:
25
25
  - Rakefile
26
26
  - lib/decant.rb
27
27
  - lib/decant/collection.rb
28
- - lib/decant/content_methods.rb
28
+ - lib/decant/content.rb
29
29
  - lib/decant/errors.rb
30
30
  - lib/decant/file.rb
31
31
  - lib/decant/frontmatter.rb
32
+ - lib/decant/path_utils.rb
32
33
  - lib/decant/version.rb
33
34
  homepage: https://github.com/benpickles/decant
34
35
  licenses:
@@ -53,7 +54,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
53
54
  - !ruby/object:Gem::Version
54
55
  version: '0'
55
56
  requirements: []
56
- rubygems_version: 3.5.17
57
+ rubygems_version: 3.5.16
57
58
  signing_key:
58
59
  specification_version: 4
59
60
  summary: A frontmatter-aware wrapper around a directory of static content
@@ -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