Skip to content

Commit

Permalink
Refactor front matter handling and extract behavior into loaders (#778)
Browse files Browse the repository at this point in the history
* Extract front matter loading into defined classes

This change refactors the loading of front matter into a series of
classes. This is a first step toward making front matter loaders
pluggable to allow, for example, [TOML][1]- or [KDL][2]-based front
matter.

[1]: https://toml.io
[2]: https://kdl.dev/

* Remove unused constants

These were developed prior to FrontMatterImporter and are no longer used.

* Consolidate front matter code in a module

This change reorganizes the front matter-related code so it is fully
contained within the Bridgetown::FrontMatter module. That makes it
easier to find the functionality and standardizes the way that
Bridgetown refers to front matter.

It also adds deprecated constant proxies for backwards-compatibility.
It's unclear whether the moved modules were public API or not so the
proxies make it so updating will not break anyone's site without
warning.

* Remove unnecessary include

This is already done by the include of the importer so isn't necessary
at this point.
  • Loading branch information
michaelherold authored Feb 17, 2024
1 parent 94ec530 commit b22ddfe
Show file tree
Hide file tree
Showing 31 changed files with 682 additions and 319 deletions.
2 changes: 1 addition & 1 deletion bridgetown-builder/lib/bridgetown-builder/dsl/resources.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def resource
end

def add_resource(collection_name, path, &block) # rubocop:todo Metrics/AbcSize
data = Bridgetown::Utils::RubyFrontMatter.new(scope: self).tap do |fm|
data = Bridgetown::FrontMatter::RubyFrontMatter.new(scope: self).tap do |fm|
fm.define_singleton_method(:___) do |hsh|
hsh.each do |k, v|
fm.set k, v
Expand Down
13 changes: 11 additions & 2 deletions bridgetown-core/lib/bridgetown-core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,7 @@ module Bridgetown
autoload :EntryFilter, "bridgetown-core/entry_filter"
# TODO: we have too many errors! This is silly
autoload :Errors, "bridgetown-core/errors"
autoload :FrontmatterDefaults, "bridgetown-core/frontmatter_defaults"
autoload :FrontMatterImporter, "bridgetown-core/concerns/front_matter_importer"
autoload :FrontMatter, "bridgetown-core/front_matter"
autoload :GeneratedPage, "bridgetown-core/generated_page"
autoload :Hooks, "bridgetown-core/hooks"
autoload :Layout, "bridgetown-core/layout"
Expand All @@ -110,6 +109,16 @@ module Bridgetown
autoload :Watcher, "bridgetown-core/watcher"
autoload :YAMLParser, "bridgetown-core/yaml_parser"

FrontmatterDefaults = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
"FrontmatterDefaults",
"Bridgetown::FrontMatter::Defaults"
)

FrontMatterImporter = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
"FrontMatterImporter",
"Bridgetown::FrontMatter::Importer"
)

# extensions
require "bridgetown-core/commands/registrations"
require "bridgetown-core/plugin"
Expand Down
3 changes: 1 addition & 2 deletions bridgetown-core/lib/bridgetown-core/collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ def read

next if File.basename(file_path).starts_with?("_")

if label == "data" || Utils.has_yaml_header?(full_path) ||
Utils.has_rbfm_header?(full_path)
if label == "data" || FrontMatter::Loaders.front_matter?(full_path)
read_resource(full_path)
else
read_static_file(file_path, full_path)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ def defaults_reader
@defaults_reader ||= Bridgetown::DefaultsReader.new(self)
end

# Returns the current instance of {FrontmatterDefaults} or
# creates a new instance {FrontmatterDefaults} if it doesn't already exist.
# Returns the current instance of {FrontMatter::Defaults} or
# creates a new instance {FrontMatter::Defaults} if it doesn't already exist.
#
# @return [FrontmatterDefaults]
# Returns an instance of {FrontmatterDefaults}
# @return [FrontMatter::Defaults]
# Returns an instance of {FrontMatter::Defaults}
def frontmatter_defaults
@frontmatter_defaults ||= Bridgetown::FrontmatterDefaults.new(self)
@frontmatter_defaults ||= Bridgetown::FrontMatter::Defaults.new(self)
end

# Prefix a path or paths with the {#root_dir} directory.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module Bridgetown
class Configuration
class ConfigurationDSL < Bridgetown::Utils::RubyFrontMatter
class ConfigurationDSL < Bridgetown::FrontMatter::RubyFrontMatter
attr_reader :context

# @yieldself [ConfigurationDSL]
Expand Down
11 changes: 11 additions & 0 deletions bridgetown-core/lib/bridgetown-core/front_matter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Bridgetown
module FrontMatter
autoload :Defaults, "bridgetown-core/front_matter/defaults"
autoload :Importer, "bridgetown-core/front_matter/importer"
autoload :Loaders, "bridgetown-core/front_matter/loaders"
autoload :RubyDSL, "bridgetown-core/front_matter/ruby"
autoload :RubyFrontMatter, "bridgetown-core/front_matter/ruby"
end
end
225 changes: 225 additions & 0 deletions bridgetown-core/lib/bridgetown-core/front_matter/defaults.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
# frozen_string_literal: true

module Bridgetown
module FrontMatter
# This class handles custom defaults for front matter settings.
# It is exposed via the frontmatter_defaults method on the site class.
class Defaults
# @return [Bridgetown::Site]
attr_reader :site

def initialize(site)
@site = site
@defaults_cache = {}
end

def reset
@glob_cache = {}
@defaults_cache = {}
end

def ensure_time!(set)
return set unless set.key?("values") && set["values"].key?("date")
return set if set["values"]["date"].is_a?(Time)

set["values"]["date"] = Utils.parse_date(
set["values"]["date"],
"An invalid date format was found in a front-matter default set: #{set}"
)
set
end

# Collects a hash with all default values for a resource
#
# @param path [String] the relative path of the resource
# @param collection_name [Symbol] :posts, :pages, etc.
#
# @return [Hash] all default values (an empty hash if there are none)
def all(path, collection_name)
if @defaults_cache.key?([path, collection_name])
return @defaults_cache[[path, collection_name]]
end

defaults = {}
merge_data_cascade_for_path(path, defaults)

old_scope = nil
matching_sets(path, collection_name).each do |set|
if has_precedence?(old_scope, set["scope"])
defaults = Utils.deep_merge_hashes(defaults, set["values"])
old_scope = set["scope"]
else
defaults = Utils.deep_merge_hashes(set["values"], defaults)
end
end

@defaults_cache[[path, collection_name]] = defaults
end

private

def merge_data_cascade_for_path(path, merged_data)
absolute_path = site.in_source_dir(path)
site.defaults_reader.path_defaults
.select { |k, _v| absolute_path.include? k }
.sort_by { |k, _v| k.length }
.each do |defaults|
merged_data.merge!(defaults[1])
end
end

# Checks if a given default setting scope matches the given path and collection
#
# scope - the hash indicating the scope, as defined in bridgetown.config.yml
# path - the path to check for
# collection - the collection (:posts or :pages) to check for
#
# Returns true if the scope applies to the given collection and path
def applies?(scope, path, collection)
applies_collection?(scope, collection) && applies_path?(scope, path)
end

def applies_path?(scope, path)
rel_scope_path = scope["path"]
return true if !rel_scope_path.is_a?(String) || rel_scope_path.empty?

sanitized_path = strip_collections_dir(sanitize_path(path))

if rel_scope_path.include?("*")
glob_scope(sanitized_path, rel_scope_path)
else
path_is_subpath?(sanitized_path, strip_collections_dir(rel_scope_path))
end
end

def glob_scope(sanitized_path, rel_scope_path)
site_source = Pathname.new(site.source)
abs_scope_path = site_source.join(rel_scope_path).to_s

glob_cache(abs_scope_path).each do |scope_path|
scope_path = Pathname.new(scope_path).relative_path_from(site_source).to_s
scope_path = strip_collections_dir(scope_path)
Bridgetown.logger.debug "Globbed Scope Path:", scope_path
return true if path_is_subpath?(sanitized_path, scope_path)
end
false
end

def glob_cache(path)
@glob_cache ||= {}
@glob_cache[path] ||= Dir.glob(path)
end

def path_is_subpath?(path, parent_path)
path.start_with?(parent_path)
end

def strip_collections_dir(path)
collections_dir = site.config["collections_dir"]
slashed_coll_dir = collections_dir.empty? ? "/" : "#{collections_dir}/"
return path if collections_dir.empty? || !path.to_s.start_with?(slashed_coll_dir)

path.sub(slashed_coll_dir, "")
end

# Determines whether the scope applies to collection.
# The scope applies to the collection if:
# 1. no 'collection' is specified
# 2. the 'collection' in the scope is the same as the collection asked about
#
# @param scope [Hash] the defaults set being asked about
# @param collection [Symbol] the collection of the resource being processed
#
# @return [Boolean] whether either of the above conditions are satisfied
def applies_collection?(scope, collection)
!scope.key?("collection") || scope["collection"].eql?(collection.to_s)
end

# Checks if a given set of default values is valid
#
# @param set [Hash] the default value hash as defined in bridgetown.config.yml
#
# @return [Boolean] if the set is valid and can be used
def valid?(set)
set.is_a?(Hash) && set["values"].is_a?(Hash)
end

# Determines if a new scope has precedence over an old one
#
# old_scope - the old scope hash, or nil if there's none
# new_scope - the new scope hash
#
# Returns true if the new scope has precedence over the older
# rubocop: disable Naming/PredicateName
def has_precedence?(old_scope, new_scope)
return true if old_scope.nil?

new_path = sanitize_path(new_scope["path"])
old_path = sanitize_path(old_scope["path"])

if new_path.length != old_path.length
new_path.length >= old_path.length
elsif new_scope.key?("collection")
true
else
!old_scope.key? "collection"
end
end
# rubocop: enable Naming/PredicateName

# Collects a list of sets that match the given path and collection
#
# @return [Array<Hash>]
def matching_sets(path, collection)
@matched_set_cache ||= {}
@matched_set_cache[path] ||= {}
@matched_set_cache[path][collection] ||= valid_sets.select do |set|
!set.key?("scope") || applies?(set["scope"], path, collection)
end
end

# Returns a list of valid sets
#
# This is not cached to allow plugins to modify the configuration
# and have their changes take effect
#
# @return [Array<Hash>]
def valid_sets
sets = site.config["defaults"]
return [] unless sets.is_a?(Array)

sets.filter_map do |set|
if valid?(set)
massage_scope!(set)
# TODO: is this trip really necessary?
ensure_time!(set)
else
Bridgetown.logger.warn "Defaults:", "An invalid front-matter default set was found:"
Bridgetown.logger.warn set.to_s
nil
end
end
end

# Set path to blank if not specified and alias older type to collection
def massage_scope!(set)
set["scope"] ||= {}
set["scope"]["path"] ||= ""
return unless set["scope"]["type"] && !set["scope"]["collection"]

set["scope"]["collection"] = set["scope"]["type"]
end

SANITIZATION_REGEX = %r!\A/|(?<=[^/])\z!.freeze

# Sanitizes the given path by removing a leading and adding a trailing slash
def sanitize_path(path)
if path.nil? || path.empty?
""
else
path.gsub(SANITIZATION_REGEX, "")
end
end
end
end
end
34 changes: 34 additions & 0 deletions bridgetown-core/lib/bridgetown-core/front_matter/importer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module Bridgetown
module FrontMatter
module Importer
# Requires klass#content and klass#front_matter_line_count accessors
def self.included(klass)
klass.include Bridgetown::FrontMatter::RubyDSL
end

def read_front_matter(file_path)
file_contents = File.read(
file_path, **Bridgetown::Utils.merged_file_read_opts(Bridgetown::Current.site, {})
)
fm_result = nil
Loaders.for(self).each do |loader|
fm_result = loader.read(file_contents, file_path: file_path) and break
end

if fm_result
self.content = fm_result.content
self.front_matter_line_count = fm_result.line_count
fm_result.front_matter
elsif is_a?(Layout)
self.content = file_contents
{}
else
yaml_data = YAMLParser.load_file(file_path)
(yaml_data.is_a?(Array) ? { rows: yaml_data } : yaml_data)
end
end
end
end
end
Loading

0 comments on commit b22ddfe

Please sign in to comment.