decant 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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