diff --git a/.rubocop.yml b/.rubocop.yml index 671d371d..3675ec84 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -54,4 +54,6 @@ FormatString: CaseIndentation: IndentWhenRelativeTo: end TrivialAccessors: - ExactNameMatch: true \ No newline at end of file + ExactNameMatch: true +PerceivedComplexity: + Enabled: false \ No newline at end of file diff --git a/middleman-core/features/collections.feature b/middleman-core/features/collections.feature new file mode 100644 index 00000000..5020c147 --- /dev/null +++ b/middleman-core/features/collections.feature @@ -0,0 +1,145 @@ +Feature: Collections + Scenario: Lazy query + Given a fixture app "collections-app" + And a file named "config.rb" with: + """ + articles1 = collection :articles1, resources.select { |r| + matcher = ::Middleman::Util::UriTemplates.uri_template('blog1/{year}-{month}-{day}-{title}.html') + ::Middleman::Util::UriTemplates.extract_params(matcher, ::Middleman::Util.normalize_path(r.url)) + } + + everything = resources.select do |r| + true + end + + def get_tags(resource) + if resource.data.tags.is_a? String + resource.data.tags.split(',').map(&:strip) + else + resource.data.tags + end + end + + def group_lookup(resource, sum) + results = Array(get_tags(resource)).map(&:to_s).map(&:to_sym) + + results.each do |k| + sum[k] ||= [] + sum[k] << resource + end + end + + tags = everything + .select { |resource| resource.data.tags } + .each_with_object({}, &method(:group_lookup)) + + class Wrapper + attr_reader :stuff + + def initialize + @stuff = Set.new + end + + def <<((k, v)) + @stuff << k + self + end + end + + collection :wrapped, tags.reduce(Wrapper.new, :<<) + + set :tags, tags # Expose to templates + + collection :first_tag, tags.keys.sort.first + """ + And a file named "source/index.html.erb" with: + """ + <% collection(:articles1).each do |article| %> + Article1: <%= article.data.title %> + <% end %> + + Tag Count: <%= collection(:wrapped).stuff.length %> + + <% config[:tags].value.each do |k, items| %> + Tag: <%= k %> (<%= items.length %>) + <% items.each do |article| %> + Article (<%= k %>): <%= article.data.title %> + <% end %> + <% end %> + + First Tag: <%= collection(:first_tag) %> + """ + Given the Server is running at "collections-app" + When I go to "index.html" + Then I should see 'Article1: Blog1 Newer Article' + And I should see 'Article1: Blog1 Another Article' + And I should see 'Tag: foo (4)' + And I should see 'Article (foo): Blog1 Newer Article' + And I should see 'Article (foo): Blog1 Another Article' + And I should see 'Article (foo): Blog2 Newer Article' + And I should see 'Article (foo): Blog2 Another Article' + And I should see 'Tag: bar (2)' + And I should see 'Article (bar): Blog1 Newer Article' + And I should see 'Article (bar): Blog2 Newer Article' + And I should see 'Tag: 120 (1)' + And I should see 'Article (120): Blog1 Another Article' + And I should see 'First Tag: 120' + And I should see 'Tag Count: 3' + + Scenario: Collected resources update with file changes + Given a fixture app "collections-app" + And a file named "config.rb" with: + """ + collection :articles, resources.select { |r| + matcher = ::Middleman::Util::UriTemplates.uri_template('blog2/{year}-{month}-{day}-{title}.html') + ::Middleman::Util::UriTemplates.extract_params(matcher, ::Middleman::Util.normalize_path(r.url)) + } + """ + And a file named "source/index.html.erb" with: + """ + <% collection(:articles).each do |article| %> + Article: <%= article.data.title || article.source_file[:relative_path] %> + <% end %> + """ + Given the Server is running at "collections-app" + When I go to "index.html" + Then I should not see "Article: index.html.erb" + Then I should see 'Article: Blog2 Newer Article' + And I should see 'Article: Blog2 Another Article' + + And the file "source/blog2/2011-01-02-another-article.html.markdown" has the contents + """ + --- + title: "Blog3 Another Article" + date: 2011-01-02 + tags: + - foo + --- + + Another Article Content + + """ + When I go to "index.html" + Then I should see "Article: Blog2 Newer Article" + And I should not see "Article: Blog2 Another Article" + And I should see 'Article: Blog3 Another Article' + + And the file "source/blog2/2011-01-01-new-article.html.markdown" is removed + When I go to "index.html" + Then I should not see "Article: Blog2 Newer Article" + And I should see 'Article: Blog3 Another Article' + + And the file "source/blog2/2014-01-02-yet-another-article.html.markdown" has the contents + """ + --- + title: "Blog2 Yet Another Article" + date: 2011-01-02 + tags: + - foo + --- + + Yet Another Article Content + """ + When I go to "index.html" + And I should see 'Article: Blog3 Another Article' + And I should see 'Article: Blog2 Yet Another Article' diff --git a/middleman-core/features/console.feature b/middleman-core/features/console.feature index c3400e3b..1bd55809 100644 --- a/middleman-core/features/console.feature +++ b/middleman-core/features/console.feature @@ -1,8 +1,9 @@ Feature: Console Scenario: Enter and exit the console - Given I run `middleman console` interactively - When I type "puts 'Hello from the console.'" + Given a fixture app "large-build-app" + When I run `middleman console` interactively + And I type "puts 'Hello from the console.'" And I type "exit" Then it should pass with: """ diff --git a/middleman-core/features/paginate.feature b/middleman-core/features/paginate.feature new file mode 100644 index 00000000..9338e019 --- /dev/null +++ b/middleman-core/features/paginate.feature @@ -0,0 +1,204 @@ +Feature: Pagination + Scenario: Basic configuration + Given a fixture app "paginate-app" + And a file named "config.rb" with: + """ + articles = resources.select { |r| + matcher = ::Middleman::Util::UriTemplates.uri_template('blog/2011-{remaining}') + ::Middleman::Util::UriTemplates.extract_params(matcher, ::Middleman::Util.normalize_path(r.url)) + } + + articles.sort { |a, b| + b.data.date <=> a.data.date + }.per_page(5) do |items, num, meta, is_last| + page_path = num == 1 ? '/2011/index.html' : "/2011/page/#{num}.html" + + prev_page = case num + when 1 + nil + when 2 + '/2011/index.html' + when 3 + "/2011/page/#{num-1}.html" + end + + next_page = is_last ? nil : "/2011/page/#{num+1}.html" + + proxy page_path, "/archive/2011/index.html", locals: { + items: items, + pagination: meta, + prev_page: prev_page, + next_page: next_page + } + end + + def get_tags(resource) + if resource.data.tags.is_a? String + resource.data.tags.split(',').map(&:strip) + else + resource.data.tags + end + end + + def group_lookup(resource, sum) + results = Array(get_tags(resource)).map(&:to_s).map(&:to_sym) + + results.each do |k| + sum[k] ||= [] + sum[k] << resource + end + end + + tags = articles + .select { |resource| resource.data.tags } + .each_with_object({}, &method(:group_lookup)) + + tags.each do |k, articles_in_tag| + articles_in_tag.sort { |a, b| + b.data.date <=> a.data.date + }.per_page(2).each do |items, num, meta, is_last| + page_path = num == 1 ? "/tags/#{k}.html" : "/tags/#{k}/page/#{num}.html" + + prev_page = case num + when 1 + nil + when 2 + "/tags/#{k}.html" + when 3 + "/tags/#{k}/page/#{num-1}.html" + end + + next_page = is_last ? nil : "/tags/#{k}/page/#{num+1}.html" + + proxy page_path, "/archive/2011/index.html", locals: { + items: items, + pagination: meta, + prev_page: prev_page, + next_page: next_page + } + end + end + """ + And the Server is running + When I go to "/2011/index.html" + Then I should see "Paginate: true" + Then I should see "Article Count: 5" + Then I should see "Page Num: 1" + Then I should see "Num Pages: 2" + Then I should see "Per Page: 5" + Then I should see "Page Start: 1" + Then I should see "Page End: 5" + Then I should see "Next Page: '/2011/page/2.html'" + Then I should see "Prev Page: ''" + Then I should not see "/blog/2011-01-01-test-article.html" + Then I should not see "/blog/2011-01-02-test-article.html" + Then I should see "/blog/2011-01-03-test-article.html" + Then I should see "/blog/2011-01-04-test-article.html" + Then I should see "/blog/2011-01-05-test-article.html" + Then I should see "/blog/2011-02-01-test-article.html" + Then I should see "/blog/2011-02-02-test-article.html" + + When I go to "/2011/page/2.html" + Then I should see "Article Count: 2" + Then I should see "Page Num: 2" + Then I should see "Page Start: 6" + Then I should see "Page End: 7" + Then I should see "Next Page: ''" + Then I should see "Prev Page: '/2011/'" + Then I should see "/2011-01-01-test-article.html" + Then I should see "/2011-01-02-test-article.html" + Then I should not see "/2011-01-03-test-article.html" + Then I should not see "/2011-01-04-test-article.html" + Then I should not see "/2011-01-05-test-article.html" + Then I should not see "/2011-02-01-test-article.html" + Then I should not see "/2011-02-02-test-article.html" + + When I go to "/tags/bar.html" + Then I should see "Paginate: true" + Then I should see "Article Count: 2" + Then I should see "Page Num: 1" + Then I should see "Num Pages: 3" + Then I should see "Per Page: 2" + Then I should see "Page Start: 1" + Then I should see "Page End: 2" + Then I should see "Next Page: '/tags/bar/page/2.html'" + Then I should see "Prev Page: ''" + Then I should see "/2011-02-02-test-article.html" + Then I should see "/2011-02-01-test-article.html" + Then I should not see "/2011-02-05-test-article.html" + Then I should not see "/2011-01-04-test-article.html" + Then I should not see "/2011-01-03-test-article.html" + + Scenario: Custom pager method + Given a fixture app "paginate-app" + And a file named "config.rb" with: + """ + def items_per_page(all_items) + [ + all_items.shift(2), + all_items + ] + end + + articles = resources.select { |r| + matcher = ::Middleman::Util::UriTemplates.uri_template('blog/2011-{remaining}') + ::Middleman::Util::UriTemplates.extract_params(matcher, ::Middleman::Util.normalize_path(r.url)) + } + + articles.sort { |a, b| + b.data.date <=> a.data.date + }.per_page(method(:items_per_page).to_proc).each do |items, num, meta, is_last| + page_path = num == 1 ? '/2011/index.html' : "/2011/page/#{num}.html" + + prev_page = case num + when 1 + nil + when 2 + '/2011/index.html' + when 3 + "/2011/page/#{num-1}.html" + end + + next_page = is_last ? nil : "/2011/page/#{num+1}.html" + + proxy page_path, "/archive/2011/index.html", locals: { + items: items, + pagination: meta, + prev_page: prev_page, + next_page: next_page + } + end + """ + And the Server is running + When I go to "/2011/index.html" + Then I should see "Paginate: true" + Then I should see "Article Count: 2" + Then I should see "Page Num: 1" + Then I should see "Num Pages: 2" + Then I should see "Per Page: 2" + Then I should see "Page Start: 1" + Then I should see "Page End: 2" + Then I should see "Next Page: '/2011/page/2.html'" + Then I should see "Prev Page: ''" + Then I should not see "/blog/2011-01-01-test-article.html" + Then I should not see "/blog/2011-01-02-test-article.html" + Then I should not see "/blog/2011-01-03-test-article.html" + Then I should not see "/blog/2011-01-04-test-article.html" + Then I should not see "/blog/2011-01-05-test-article.html" + Then I should see "/blog/2011-02-01-test-article.html" + Then I should see "/blog/2011-02-02-test-article.html" + + When I go to "/2011/page/2.html" + Then I should see "Article Count: 5" + Then I should see "Page Num: 2" + Then I should see "Page Start: 3" + Then I should see "Page End: 7" + Then I should see "Next Page: ''" + Then I should see "Prev Page: '/2011/'" + Then I should see "/2011-01-01-test-article.html" + Then I should see "/2011-01-02-test-article.html" + Then I should see "/2011-01-03-test-article.html" + Then I should see "/2011-01-04-test-article.html" + Then I should see "/2011-01-05-test-article.html" + Then I should not see "/2011-02-01-test-article.html" + Then I should not see "/2011-02-02-test-article.html" diff --git a/middleman-core/fixtures/collections-app/config.rb b/middleman-core/fixtures/collections-app/config.rb new file mode 100644 index 00000000..ae9cb817 --- /dev/null +++ b/middleman-core/fixtures/collections-app/config.rb @@ -0,0 +1,16 @@ +collection :articles, + where: proc { |resource| + uri_match resource.url, 'blog/{year}-{month}-{day}-{title}.html' + } + +collection :tags, + where: proc { |resource| + resource.data.tags + }, + group_by: proc { |resource| + if resource.data.tags.is_a? String + resource.data.tags.split(',').map(&:strip) + else + resource.data.tags + end + } \ No newline at end of file diff --git a/middleman-core/fixtures/collections-app/source/blog1/2011-01-01-new-article.html.markdown b/middleman-core/fixtures/collections-app/source/blog1/2011-01-01-new-article.html.markdown new file mode 100755 index 00000000..a96bcc00 --- /dev/null +++ b/middleman-core/fixtures/collections-app/source/blog1/2011-01-01-new-article.html.markdown @@ -0,0 +1,7 @@ +--- +title: "Blog1 Newer Article" +date: 2011-01-01 +tags: foo, bar +--- + +Newer Article Content diff --git a/middleman-core/fixtures/collections-app/source/blog1/2011-01-02-another-article.html.markdown b/middleman-core/fixtures/collections-app/source/blog1/2011-01-02-another-article.html.markdown new file mode 100755 index 00000000..feb086af --- /dev/null +++ b/middleman-core/fixtures/collections-app/source/blog1/2011-01-02-another-article.html.markdown @@ -0,0 +1,9 @@ +--- +title: "Blog1 Another Article" +date: 2011-01-02 +tags: + - foo + - 120 +--- + +Another Article Content diff --git a/middleman-core/fixtures/collections-app/source/blog2/2011-01-01-new-article.html.markdown b/middleman-core/fixtures/collections-app/source/blog2/2011-01-01-new-article.html.markdown new file mode 100755 index 00000000..125b71fa --- /dev/null +++ b/middleman-core/fixtures/collections-app/source/blog2/2011-01-01-new-article.html.markdown @@ -0,0 +1,7 @@ +--- +title: "Blog2 Newer Article" +date: 2011-01-01 +tags: foo, bar +--- + +Newer Article Content diff --git a/middleman-core/fixtures/collections-app/source/blog2/2011-01-02-another-article.html.markdown b/middleman-core/fixtures/collections-app/source/blog2/2011-01-02-another-article.html.markdown new file mode 100755 index 00000000..18656883 --- /dev/null +++ b/middleman-core/fixtures/collections-app/source/blog2/2011-01-02-another-article.html.markdown @@ -0,0 +1,8 @@ +--- +title: "Blog2 Another Article" +date: 2011-01-02 +tags: + - foo +--- + +Another Article Content diff --git a/middleman-core/fixtures/collections-app/source/index.html.erb b/middleman-core/fixtures/collections-app/source/index.html.erb new file mode 100644 index 00000000..0d6f2ad5 --- /dev/null +++ b/middleman-core/fixtures/collections-app/source/index.html.erb @@ -0,0 +1,26 @@ + + + + + + + +<% collected.articles.each do |article| %> +
  • + Article: <%= article.data.title %> + +
  • +<% end %> + + +<% collected[:tags].each do |k, items| %> +
  • + <%= k %> + <% items.each do |article| %> + <%= article.data.title %> + <% end %> +
  • +<% end %> + + + diff --git a/middleman-core/fixtures/paginate-app/config.rb b/middleman-core/fixtures/paginate-app/config.rb new file mode 100644 index 00000000..e69de29b diff --git a/middleman-core/fixtures/paginate-app/source/archive/2011/index.html.erb b/middleman-core/fixtures/paginate-app/source/archive/2011/index.html.erb new file mode 100644 index 00000000..952be1ad --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/archive/2011/index.html.erb @@ -0,0 +1,20 @@ +Year: '<%#= year %>' + +Paginate: <%= !!pagination %> +Article Count: <%= items.length %> +<% if pagination %> +Page Num: <%= pagination.page_number %> +Num Pages: <%= pagination.num_pages %> +Per Page: <%= pagination.per_page %> +Page Start: <%= pagination.page_start %> +Page End: <%= pagination.page_end %> +Next Page: '<%= sitemap.find_resource_by_destination_path(next_page).url if next_page %>' +Prev Page: '<%= sitemap.find_resource_by_destination_path(prev_page).url if prev_page %>' +<% end %> + +<% items.each do |article| %> +
    + <%= article.data.title %> + <%= article.url %> +
    +<% end %> diff --git a/middleman-core/fixtures/paginate-app/source/blog/2011-01-01-test-article.html.markdown b/middleman-core/fixtures/paginate-app/source/blog/2011-01-01-test-article.html.markdown new file mode 100755 index 00000000..42301a3b --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/blog/2011-01-01-test-article.html.markdown @@ -0,0 +1,6 @@ +--- +title: "Test Article" +date: 2011-01-01 +tags: foo +--- +Test Article Content diff --git a/middleman-core/fixtures/paginate-app/source/blog/2011-01-02-test-article.html.markdown b/middleman-core/fixtures/paginate-app/source/blog/2011-01-02-test-article.html.markdown new file mode 100755 index 00000000..fc31d996 --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/blog/2011-01-02-test-article.html.markdown @@ -0,0 +1,6 @@ +--- +title: "Test Article" +date: 2011-01-02 +tags: foo +--- +Test Article Content diff --git a/middleman-core/fixtures/paginate-app/source/blog/2011-01-03-test-article.html.markdown b/middleman-core/fixtures/paginate-app/source/blog/2011-01-03-test-article.html.markdown new file mode 100755 index 00000000..bad0a289 --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/blog/2011-01-03-test-article.html.markdown @@ -0,0 +1,6 @@ +--- +title: "Test Article" +date: 2011-01-03 +tags: bar +--- +Test Article Content diff --git a/middleman-core/fixtures/paginate-app/source/blog/2011-01-04-test-article.html.markdown b/middleman-core/fixtures/paginate-app/source/blog/2011-01-04-test-article.html.markdown new file mode 100755 index 00000000..25702e8e --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/blog/2011-01-04-test-article.html.markdown @@ -0,0 +1,6 @@ +--- +title: "Test Article" +date: 2011-01-04 +tags: bar +--- +Test Article Content diff --git a/middleman-core/fixtures/paginate-app/source/blog/2011-01-05-test-article.html.markdown b/middleman-core/fixtures/paginate-app/source/blog/2011-01-05-test-article.html.markdown new file mode 100755 index 00000000..2efb79a0 --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/blog/2011-01-05-test-article.html.markdown @@ -0,0 +1,6 @@ +--- +title: "Test Article" +date: 2011-01-05 +tags: bar +--- +Test Article Content diff --git a/middleman-core/fixtures/paginate-app/source/blog/2011-02-01-test-article.html.markdown b/middleman-core/fixtures/paginate-app/source/blog/2011-02-01-test-article.html.markdown new file mode 100755 index 00000000..cd8e2201 --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/blog/2011-02-01-test-article.html.markdown @@ -0,0 +1,6 @@ +--- +title: "Test Article" +date: 2011-02-01 +tags: bar +--- +Test Article Content diff --git a/middleman-core/fixtures/paginate-app/source/blog/2011-02-02-test-article.html.markdown b/middleman-core/fixtures/paginate-app/source/blog/2011-02-02-test-article.html.markdown new file mode 100755 index 00000000..701c25c7 --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/blog/2011-02-02-test-article.html.markdown @@ -0,0 +1,6 @@ +--- +title: "Test Article" +date: 2011-02-02 +tags: bar +--- +Test Article Content diff --git a/middleman-core/fixtures/paginate-app/source/index.html.erb b/middleman-core/fixtures/paginate-app/source/index.html.erb new file mode 100644 index 00000000..976fc5d9 --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/index.html.erb @@ -0,0 +1,15 @@ +Paginate: <%= paginate %> +Article Count: <%= page_articles.size %> +<% if paginate %> +Page Num: <%= page_number %> +Num Pages: <%= num_pages %> +Per Page: <%= per_page %> +Page Start: <%= page_start %> +Page End: <%= page_end %> +Next Page: '<%= next_page.url if next_page %>' +Prev Page: '<%= prev_page.url if prev_page %>' +<% end %> + +<% page_articles.each do |article| %> +
  • <%= article.title %>
  • +<% end %> diff --git a/middleman-core/fixtures/paginate-app/source/tag.html.erb b/middleman-core/fixtures/paginate-app/source/tag.html.erb new file mode 100644 index 00000000..f098d947 --- /dev/null +++ b/middleman-core/fixtures/paginate-app/source/tag.html.erb @@ -0,0 +1,23 @@ +--- +pageable: true +per_page: 2 +--- +Tag: <%= tagname %> + +Paginate: <%= paginate %> +Article Count: <%= page_articles.size %> +<% if paginate %> +Page Num: <%= page_number %> +Num Pages: <%= num_pages %> +Per Page: <%= per_page %> +Page Start: <%= page_start %> +Page End: <%= page_end %> +Next Page: '<%= next_page.url if next_page %>' +Prev Page: '<%= prev_page.url if prev_page %>' +<% end %> + +<% if page_articles %> + <% page_articles.each do |article| %> +
  • <%= article.title %>
  • + <% end %> +<% end %> diff --git a/middleman-core/lib/middleman-core/contracts.rb b/middleman-core/lib/middleman-core/contracts.rb index ed3fcf97..9d7e77ee 100644 --- a/middleman-core/lib/middleman-core/contracts.rb +++ b/middleman-core/lib/middleman-core/contracts.rb @@ -59,6 +59,21 @@ if ENV['TEST'] || ENV['CONTRACTS'] == 'true' end end + # class MethodDefined + # def self.[](val) + # @lookup ||= {} + # @lookup[val] ||= new(val) + # end + + # def initialize(val) + # @val = val + # end + + # def valid?(val) + # val.method_defined? @val + # end + # end + ResourceList = Contracts::ArrayOf[IsA['Middleman::Sitemap::Resource']] end else @@ -141,6 +156,9 @@ else class Frozen < Callable end + + # class MethodDefined < Callable + # end end end diff --git a/middleman-core/lib/middleman-core/core_extensions.rb b/middleman-core/lib/middleman-core/core_extensions.rb index 5f246d36..ea31f89a 100644 --- a/middleman-core/lib/middleman-core/core_extensions.rb +++ b/middleman-core/lib/middleman-core/core_extensions.rb @@ -52,6 +52,11 @@ Middleman::Extensions.register :routing, auto_activate: :before_configuration do Middleman::CoreExtensions::Routing end +Middleman::Extensions.register :collections, auto_activate: :before_configuration do + require 'middleman-core/core_extensions/collections' + Middleman::CoreExtensions::Collections::CollectionsExtension +end + ### # Setup Optional Extensions ### diff --git a/middleman-core/lib/middleman-core/core_extensions/collections.rb b/middleman-core/lib/middleman-core/core_extensions/collections.rb new file mode 100644 index 00000000..714e1f51 --- /dev/null +++ b/middleman-core/lib/middleman-core/core_extensions/collections.rb @@ -0,0 +1,82 @@ +require 'middleman-core/core_extensions/collections/pagination' +require 'middleman-core/core_extensions/collections/step_context' +require 'middleman-core/core_extensions/collections/lazy_root' +require 'middleman-core/core_extensions/collections/lazy_step' + +# Super "class-y" injection of array helpers +class Array + include Middleman::Pagination::ArrayHelpers +end + +module Middleman + module CoreExtensions + module Collections + class CollectionsExtension < Extension + # This should run after most other sitemap manipulators so that it + # gets a chance to modify any new resources that get added. + self.resource_list_manipulator_priority = 110 + + attr_accessor :root_collector, :leaves + + def initialize(app, options_hash={}, &block) + super + + @leaves = Set.new + @collectors_by_name = {} + @values_by_name = {} + + @root_collector = LazyCollectorRoot.new(self) + end + + Contract None => Any + def before_configuration + @leaves.clear + + app.add_to_config_context :resources, &method(:root_collector) + app.add_to_config_context :collection, &method(:register_collector) + end + + Contract Symbol, LazyCollectorStep => Any + def register_collector(label, endpoint) + @collectors_by_name[label] = endpoint + end + + Contract Symbol => Any + def collector_value(label) + @values_by_name[label] + end + + Contract ResourceList => ResourceList + def manipulate_resource_list(resources) + @root_collector.realize!(resources) + + ctx = StepContext.new + leaves = @leaves.dup + + @collectors_by_name.each do |k, v| + @values_by_name[k] = v.value(ctx) + leaves.delete v + end + + # Execute code paths + leaves.each do |v| + v.value(ctx) + end + + # Inject descriptors + resources + ctx.descriptors.map { |d| d.to_resource(app) } + end + + helpers do + def collection(label) + extensions[:collections].collector_value(label) + end + + def pagination + current_resource.data.pagination + end + end + end + end + end +end diff --git a/middleman-core/lib/middleman-core/core_extensions/collections/lazy_root.rb b/middleman-core/lib/middleman-core/core_extensions/collections/lazy_root.rb new file mode 100644 index 00000000..6922e64a --- /dev/null +++ b/middleman-core/lib/middleman-core/core_extensions/collections/lazy_root.rb @@ -0,0 +1,30 @@ +require 'middleman-core/core_extensions/collections/lazy_step' + +module Middleman + module CoreExtensions + module Collections + class LazyCollectorRoot < BasicObject + def initialize(parent) + @data = nil + @parent = parent + end + + def realize!(data) + @data = data + end + + def value(_ctx=nil) + @data + end + + def leaves + @parent.leaves + end + + def method_missing(name, *args, &block) + LazyCollectorStep.new(name, args, block, self) + end + end + end + end +end diff --git a/middleman-core/lib/middleman-core/core_extensions/collections/lazy_step.rb b/middleman-core/lib/middleman-core/core_extensions/collections/lazy_step.rb new file mode 100644 index 00000000..1286af2c --- /dev/null +++ b/middleman-core/lib/middleman-core/core_extensions/collections/lazy_step.rb @@ -0,0 +1,48 @@ +module Middleman + module CoreExtensions + module Collections + class LazyCollectorStep < BasicObject + DELEGATE = [:hash, :eql?] + + def initialize(name, args, block, parent=nil) + @name = name + @args = args + @block = block + + @parent = parent + @result = nil + + leaves << self + end + + def leaves + @parent.leaves + end + + def value(ctx=nil) + data = @parent.value(ctx) + + original_block = @block + + b = if ctx + ::Proc.new do |*args| + ctx.instance_exec(*args, &original_block) + end + else + original_block + end if original_block + + data.send(@name, *@args.deep_dup, &b) + end + + def method_missing(name, *args, &block) + return ::Kernel.send(name, *args, &block) if DELEGATE.include? name + + leaves.delete self + + LazyCollectorStep.new(name, args, block, self) + end + end + end + end +end diff --git a/middleman-core/lib/middleman-core/core_extensions/collections/pagination.rb b/middleman-core/lib/middleman-core/core_extensions/collections/pagination.rb new file mode 100644 index 00000000..24ae8181 --- /dev/null +++ b/middleman-core/lib/middleman-core/core_extensions/collections/pagination.rb @@ -0,0 +1,59 @@ +require 'active_support/core_ext/object/deep_dup' +require 'middleman-core/util' + +module Middleman + module Pagination + module ArrayHelpers + def per_page(per_page) + return enum_for(:per_page, per_page) unless block_given? + + parts = if per_page.respond_to? :call + per_page.call(dup) + else + each_slice(per_page).reduce([]) do |sum, items| + sum << items + end + end + + num_pages = parts.length + collection = self + + current_start_i = 0 + parts.each_with_index do |items, i| + num = i + 1 + + meta = ::Middleman::Pagination.page_locals( + num, + num_pages, + collection, + items, + current_start_i + ) + + yield items, num, meta, num >= num_pages + + current_start_i += items.length + end + end + end + + def self.page_locals(page_num, num_pages, collection, items, page_start) + per_page = items.length + + # Index into collection of the last item of this page + page_end = (page_start + per_page) - 1 + + ::Middleman::Util.recursively_enhance(page_number: page_num, + num_pages: num_pages, + per_page: per_page, + + # The range of item numbers on this page + # (1-based, for showing "Items X to Y of Z") + page_start: page_start + 1, + page_end: [page_end + 1, collection.length].min, + + # Use "collection" in templates. + collection: collection) + end + end +end diff --git a/middleman-core/lib/middleman-core/core_extensions/collections/step_context.rb b/middleman-core/lib/middleman-core/core_extensions/collections/step_context.rb new file mode 100644 index 00000000..740b8188 --- /dev/null +++ b/middleman-core/lib/middleman-core/core_extensions/collections/step_context.rb @@ -0,0 +1,26 @@ +module Middleman + module CoreExtensions + module Collections + class StepContext + def self.add_to_context(name, &func) + send(:define_method, :"_internal_#{name}", &func) + end + + attr_reader :descriptors + + def initialize + @descriptors = [] + end + + def method_missing(name, *args, &block) + internal = :"_internal_#{name}" + if respond_to?(internal) + @descriptors << send(internal, *args, &block) + else + super + end + end + end + end + end +end diff --git a/middleman-core/lib/middleman-core/core_extensions/show_exceptions.rb b/middleman-core/lib/middleman-core/core_extensions/show_exceptions.rb index ba9fdade..7d38812f 100644 --- a/middleman-core/lib/middleman-core/core_extensions/show_exceptions.rb +++ b/middleman-core/lib/middleman-core/core_extensions/show_exceptions.rb @@ -12,7 +12,7 @@ module Middleman::CoreExtensions end def after_configuration - app.use ::Rack::ShowExceptions if app.config[:show_exceptions] + app.use ::Rack::ShowExceptions if !app.build? && app.config[:show_exceptions] end end end diff --git a/middleman-core/lib/middleman-core/sitemap/extensions/proxies.rb b/middleman-core/lib/middleman-core/sitemap/extensions/proxies.rb index 95f30c52..90bff4fc 100644 --- a/middleman-core/lib/middleman-core/sitemap/extensions/proxies.rb +++ b/middleman-core/lib/middleman-core/sitemap/extensions/proxies.rb @@ -1,4 +1,5 @@ require 'middleman-core/sitemap/resource' +require 'middleman-core/core_extensions/collections/step_context' module Middleman module Sitemap @@ -13,6 +14,13 @@ module Middleman @app.define_singleton_method(:proxy, &method(:create_proxy)) @proxy_configs = Set.new + @post_config = false + end + + def after_configuration + @post_config = true + + ::Middleman::CoreExtensions::Collections::StepContext.add_to_context(:proxy, &method(:create_anonymous_proxy)) end # Setup a proxy from a path to a target @@ -27,70 +35,48 @@ module Middleman Contract String, String, Maybe[Hash] => Any def create_proxy(path, target, opts={}) options = opts.dup - @app.ignore(target) if options.delete(:ignore) - metadata = { - options: options, - locals: options.delete(:locals) || {}, - page: options.delete(:data) || {} - } - - @proxy_configs << ProxyConfiguration.new(path: path, target: target, metadata: metadata) - + @proxy_configs << create_anonymous_proxy(path, target, options) @app.sitemap.rebuild_resource_list!(:added_proxy) end + # Setup a proxy from a path to a target + # @param [String] path The new, proxied path to create + # @param [String] target The existing path that should be proxied to. This must be a real resource, not another proxy. + # @option opts [Boolean] ignore Ignore the target from the sitemap (so only the new, proxy resource ends up in the output) + # @option opts [Symbol, Boolean, String] layout The layout name to use (e.g. `:article`) or `false` to disable layout. + # @option opts [Boolean] directory_indexes Whether or not the `:directory_indexes` extension applies to these paths. + # @option opts [Hash] locals Local variables for the template. These will be available when the template renders. + # @option opts [Hash] data Extra metadata to add to the page. This is the same as frontmatter, though frontmatter will take precedence over metadata defined here. Available via {Resource#data}. + # @return [void] + def create_anonymous_proxy(path, target, options={}) + ProxyDescriptor.new( + ::Middleman::Util.normalize_path(path), + ::Middleman::Util.normalize_path(target), + options + ) + end + # Update the main sitemap resource list # @return Array Contract ResourceList => ResourceList def manipulate_resource_list(resources) - resources + @proxy_configs.map do |config| - p = ProxyResource.new( - @app.sitemap, - config.path, - config.target - ) - - p.add_metadata(config.metadata) - p - end + resources + @proxy_configs.map { |c| c.to_resource(@app) } end end - # Configuration for a proxy instance - class ProxyConfiguration - # The path that this proxy will appear at in the sitemap - attr_reader :path - def path=(p) - @path = ::Middleman::Util.normalize_path(p) - end - - # The existing sitemap path that this will proxy to - attr_reader :target - def target=(t) - @target = ::Middleman::Util.normalize_path(t) - end - - # Additional metadata like locals to apply to the proxy - attr_accessor :metadata - - # Create a new proxy configuration from hash options - def initialize(options={}) - options.each do |key, value| - send "#{key}=", value + ProxyDescriptor = Struct.new(:path, :target, :metadata) do + def to_resource(app) + ProxyResource.new(app.sitemap, path, target).tap do |p| + md = metadata.dup + p.add_metadata( + locals: md.delete(:locals) || {}, + page: md.delete(:data) || {}, + options: md + ) end end - - # Two configurations are equal if they reference the same path - def eql?(other) - other.path == path - end - - # Two configurations are equal if they reference the same path - def hash - path.hash - end end end diff --git a/middleman-core/lib/middleman-core/sitemap/resource.rb b/middleman-core/lib/middleman-core/sitemap/resource.rb index ae2ffa73..f7ae229c 100644 --- a/middleman-core/lib/middleman-core/sitemap/resource.rb +++ b/middleman-core/lib/middleman-core/sitemap/resource.rb @@ -165,7 +165,11 @@ module Middleman # Ignore based on the source path (without template extensions) return true if @app.sitemap.ignored?(path) # This allows files to be ignored by their source file name (with template extensions) - !self.is_a?(ProxyResource) && @app.sitemap.ignored?(source_file[:relative_path].to_s) + if !self.is_a?(ProxyResource) && source_file && @app.sitemap.ignored?(source_file[:relative_path].to_s) + true + else + false + end end # The preferred MIME content type for this resource based on extension or metadata @@ -174,6 +178,31 @@ module Middleman def content_type options[:content_type] || ::Rack::Mime.mime_type(ext, nil) end + + def to_s + "#" + end + alias_method :inspect, :to_s # Ruby 2.0 calls inspect for NoMethodError instead of to_s + end + + class StringResource < Resource + def initialize(store, path, contents=nil, &block) + @request_path = path + @contents = block_given? ? block : contents + super(store, path) + end + + def template? + true + end + + def render(*) + @contents.respond_to?(:call) ? @contents.call : @contents + end + + def binary? + false + end end end end diff --git a/middleman-core/lib/middleman-core/sitemap/store.rb b/middleman-core/lib/middleman-core/sitemap/store.rb index 82084c14..067f3f33 100644 --- a/middleman-core/lib/middleman-core/sitemap/store.rb +++ b/middleman-core/lib/middleman-core/sitemap/store.rb @@ -49,11 +49,15 @@ module Middleman # @return [Middleman::Application] attr_reader :app + attr_reader :update_count + # Initialize with parent app # @param [Middleman::Application] app def initialize(app) @app = app @resources = [] + @update_count = 0 + # TODO: Should this be a set or hash? @resource_list_manipulators = [] @needs_sitemap_rebuild = true @@ -187,6 +191,7 @@ module Middleman end invalidate_resources_not_ignored_cache! + @update_count += 1 end end diff --git a/middleman-core/lib/middleman-core/sources.rb b/middleman-core/lib/middleman-core/sources.rb index cd3eab17..2026c32b 100644 --- a/middleman-core/lib/middleman-core/sources.rb +++ b/middleman-core/lib/middleman-core/sources.rb @@ -172,7 +172,7 @@ module Middleman .lazy .select { |d| d.type == type } .map { |d| d.find(path, glob) } - .reject { |d| d.nil? } + .reject(&:nil?) .first end diff --git a/middleman-core/lib/middleman-core/template_context.rb b/middleman-core/lib/middleman-core/template_context.rb index 3021b41e..9deeccb4 100644 --- a/middleman-core/lib/middleman-core/template_context.rb +++ b/middleman-core/lib/middleman-core/template_context.rb @@ -102,7 +102,7 @@ module Middleman partial_file = locate_partial(name) - return "" unless partial_file + return '' unless partial_file raise ::Middleman::TemplateRenderer::TemplateNotFound, "Could not locate partial: #{name}" unless partial_file source_path = sitemap.file_to_path(partial_file) diff --git a/middleman-core/lib/middleman-core/util.rb b/middleman-core/lib/middleman-core/util.rb index 53a183ee..7218e281 100644 --- a/middleman-core/lib/middleman-core/util.rb +++ b/middleman-core/lib/middleman-core/util.rb @@ -14,6 +14,11 @@ require 'rack/mime' # DbC require 'middleman-core/contracts' +# For URI templating +require 'addressable/template' +require 'active_support/inflector' +require 'active_support/inflector/transliterate' + module Middleman module Util include Contracts @@ -384,5 +389,93 @@ module Middleman resource_url end end + + # Handy methods for dealing with URI templates. Mix into whatever class. + module UriTemplates + module_function + + # Given a URI template string, make an Addressable::Template + # This supports the legacy middleman-blog/Sinatra style :colon + # URI templates as well as RFC6570 templates. + # + # @param [String] tmpl_src URI template source + # @return [Addressable::Template] a URI template + def uri_template(tmpl_src) + # Support the RFC6470 templates directly if people use them + if tmpl_src.include?(':') + tmpl_src = tmpl_src.gsub(/:([A-Za-z0-9]+)/, '{\1}') + end + + Addressable::Template.new ::Middleman::Util.normalize_path(tmpl_src) + end + + # Apply a URI template with the given data, producing a normalized + # Middleman path. + # + # @param [Addressable::Template] template + # @param [Hash] data + # @return [String] normalized path + def apply_uri_template(template, data) + ::Middleman::Util.normalize_path Addressable::URI.unencode(template.expand(data)).to_s + end + + # Use a template to extract parameters from a path, and validate some special (date) + # keys. Returns nil if the special keys don't match. + # + # @param [Addressable::Template] template + # @param [String] path + def extract_params(template, path) + template.extract(path, BlogTemplateProcessor) + end + + # Parameterize a string preserving any multibyte characters + def safe_parameterize(str) + sep = '-' + + # Reimplementation of http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-parameterize that preserves un-transliterate-able multibyte chars. + parameterized_string = ActiveSupport::Inflector.transliterate(str.to_s).downcase + parameterized_string.gsub!(/[^a-z0-9\-_\?]+/, sep) + + parameterized_string.chars.to_a.each_with_index do |char, i| + next unless char == '?' && str[i].bytes.count != 1 + parameterized_string[i] = str[i] + end + + re_sep = Regexp.escape(sep) + # No more than one of the separator in a row. + parameterized_string.gsub!(/#{re_sep}{2,}/, sep) + # Remove leading/trailing separator. + parameterized_string.gsub!(/^#{re_sep}|#{re_sep}$/, '') + + parameterized_string + end + + # Convert a date into a hash of components to strings + # suitable for using in a URL template. + # @param [DateTime] date + # @return [Hash] parameters + def date_to_params(date) + { + year: date.year.to_s, + month: date.month.to_s.rjust(2, '0'), + day: date.day.to_s.rjust(2, '0') + } + end + end + + # A special template processor that validates date fields + # and has an extra-permissive default regex. + # + # See https://github.com/sporkmonger/addressable/blob/master/lib/addressable/template.rb#L279 + class BlogTemplateProcessor + def self.match(name) + case name + when 'year' then '\d{4}' + when 'month' then '\d{2}' + when 'day' then '\d{2}' + else '.*?' + end + end + end end end diff --git a/middleman-core/middleman-core.gemspec b/middleman-core/middleman-core.gemspec index 397928dc..b3c3782d 100644 --- a/middleman-core/middleman-core.gemspec +++ b/middleman-core/middleman-core.gemspec @@ -29,6 +29,7 @@ Gem::Specification.new do |s| # Helpers s.add_dependency('activesupport', ['~> 4.1.0']) s.add_dependency('padrino-helpers', ['~> 0.12.3']) + s.add_dependency("addressable", ["~> 2.3.5"]) # Watcher s.add_dependency('listen', ['>= 2.7.9', '< 3.0'])