skriptorium 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6c1f3c1ca334b99f0e8e827ca7bacefe81274ee0d9009ba4112fb2f8d1160319
4
+ data.tar.gz: 6003aca232e58bb29e2b93071b782719522c169a38c234cd24b66ccda06a0d37
5
+ SHA512:
6
+ metadata.gz: ee1968df490ba67d9850d75404187efce07b92a949f65d51e1664789a4f25df745ad8d6f7f35b8d7ed0168237eb4e09ca3736270710e59a875148b8bc988f44c
7
+ data.tar.gz: c65f86bd24f7c903bf507226234cab84a6f73093c8a60938a2cc3af4a81009eb180e06d654b72ec49327a1b6f3322215563de437428917af7de9e6a5759bbaa8
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,124 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ skriptorium (0.1.1)
5
+ activesupport (~> 8.1)
6
+ base64 (~> 0.3)
7
+ faraday (~> 2.14)
8
+ front_matter_parser (~> 1.0)
9
+ redcarpet (~> 3.6)
10
+ reverse_markdown (~> 3.0)
11
+ rss (~> 0.3)
12
+ russian (~> 1.0)
13
+
14
+ GEM
15
+ remote: https://rubygems.org/
16
+ specs:
17
+ activesupport (8.1.3.1)
18
+ base64
19
+ bigdecimal
20
+ concurrent-ruby (~> 1.0, >= 1.3.1)
21
+ connection_pool (>= 2.2.5)
22
+ drb
23
+ i18n (>= 1.6, < 2)
24
+ json
25
+ logger (>= 1.4.2)
26
+ minitest (>= 5.1)
27
+ securerandom (>= 0.3)
28
+ tzinfo (~> 2.0, >= 2.0.5)
29
+ uri (>= 0.13.1)
30
+ addressable (2.9.0)
31
+ public_suffix (>= 2.0.2, < 8.0)
32
+ base64 (0.3.0)
33
+ bigdecimal (4.1.2)
34
+ concurrent-ruby (1.3.8)
35
+ connection_pool (3.0.2)
36
+ crack (1.0.1)
37
+ bigdecimal
38
+ rexml
39
+ diff-lcs (1.6.2)
40
+ drb (2.2.3)
41
+ faraday (2.14.3)
42
+ faraday-net_http (>= 2.0, < 3.5)
43
+ json
44
+ logger
45
+ faraday-net_http (3.4.4)
46
+ net-http (~> 0.5)
47
+ front_matter_parser (1.0.1)
48
+ hashdiff (1.2.1)
49
+ i18n (1.15.2)
50
+ concurrent-ruby (~> 1.0)
51
+ json (2.21.2)
52
+ logger (1.7.0)
53
+ minitest (6.0.6)
54
+ drb (~> 2.0)
55
+ prism (~> 1.5)
56
+ net-http (0.9.1)
57
+ uri (>= 0.11.1)
58
+ nokogiri (1.19.4-aarch64-linux-gnu)
59
+ racc (~> 1.4)
60
+ nokogiri (1.19.4-aarch64-linux-musl)
61
+ racc (~> 1.4)
62
+ nokogiri (1.19.4-arm-linux-gnu)
63
+ racc (~> 1.4)
64
+ nokogiri (1.19.4-arm-linux-musl)
65
+ racc (~> 1.4)
66
+ nokogiri (1.19.4-arm64-darwin)
67
+ racc (~> 1.4)
68
+ nokogiri (1.19.4-x86_64-darwin)
69
+ racc (~> 1.4)
70
+ nokogiri (1.19.4-x86_64-linux-gnu)
71
+ racc (~> 1.4)
72
+ nokogiri (1.19.4-x86_64-linux-musl)
73
+ racc (~> 1.4)
74
+ prism (1.9.0)
75
+ public_suffix (7.0.5)
76
+ racc (1.8.1)
77
+ redcarpet (3.6.1)
78
+ reverse_markdown (3.0.2)
79
+ nokogiri
80
+ rexml (3.4.4)
81
+ rspec (3.13.2)
82
+ rspec-core (~> 3.13.0)
83
+ rspec-expectations (~> 3.13.0)
84
+ rspec-mocks (~> 3.13.0)
85
+ rspec-core (3.13.6)
86
+ rspec-support (~> 3.13.0)
87
+ rspec-expectations (3.13.5)
88
+ diff-lcs (>= 1.2.0, < 2.0)
89
+ rspec-support (~> 3.13.0)
90
+ rspec-mocks (3.13.8)
91
+ diff-lcs (>= 1.2.0, < 2.0)
92
+ rspec-support (~> 3.13.0)
93
+ rspec-support (3.13.7)
94
+ rss (0.3.3)
95
+ rexml
96
+ russian (1.0.0)
97
+ i18n (>= 1.14.8, < 2)
98
+ securerandom (0.4.1)
99
+ tzinfo (2.0.6)
100
+ concurrent-ruby (~> 1.0)
101
+ uri (1.1.1)
102
+ webmock (3.26.2)
103
+ addressable (>= 2.8.0)
104
+ crack (>= 0.3.2)
105
+ hashdiff (>= 0.4.0, < 2.0.0)
106
+
107
+ PLATFORMS
108
+ aarch64-linux-gnu
109
+ aarch64-linux-musl
110
+ arm-linux-gnu
111
+ arm-linux-musl
112
+ arm64-darwin
113
+ x86_64-darwin
114
+ x86_64-linux-gnu
115
+ x86_64-linux-musl
116
+
117
+ DEPENDENCIES
118
+ bundler (~> 2.0, >= 2.0.1)
119
+ rspec (~> 3.13)
120
+ skriptorium!
121
+ webmock (~> 3.26)
122
+
123
+ BUNDLED WITH
124
+ 2.7.2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samoilenko Yuri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Skriptorium
2
+
3
+ > Personal CLI tool for automatic article publishing across multiple platforms.
4
+
5
+ <div align="center">
6
+
7
+ [![AI-Assisted](https://img.shields.io/badge/AI-Assisted-10b981?style=flat-square&logo=robotframework&logoColor=white)](./AGENTS.md)
8
+ [![OpenCode](https://img.shields.io/badge/Built_with-OpenCode-6366f1?style=flat-square&logo=opencode&logoColor=white)](https://opencode.ai)
9
+ [![Primary](https://img.shields.io/badge/primary-Gitlab-fc6d26?style=flat-square&logo=git&logoColor=white)](https://gitlab.com/kinnalru/skriptorium)
10
+
11
+ [![Gem Version](https://img.shields.io/gem/v/skriptorium?style=flat-square&logo=ruby&logoColor=white)](https://rubygems.org/gems/skriptorium)
12
+ [![License](https://img.shields.io/gitlab/license/kinnalru/skriptorium?style=flat-square)](https://gitlab.com/kinnalru/skriptorium)
13
+ [![Contributors](https://img.shields.io/gitlab/contributors/kinnalru/skriptorium?style=flat-square)](https://gitlab.com/kinnalru/skriptorium/-/graphs/master)
14
+ [![Last Commit](https://img.shields.io/gitlab/last-commit/kinnalru/skriptorium?style=flat-square&logo=gitlab)](https://gitlab.com/kinnalru/skriptorium/-/commits/master)
15
+
16
+
17
+ </div>
18
+
19
+ ---
20
+
21
+ ## Features
22
+
23
+ - **Multi-source ingestion** — fetch articles from
24
+ - **Dev.to** (Markdown)
25
+ - **RSS feeds** (HTML)
26
+ - **Multi-platform publishing** — publish to **dev.to**, **hashnode**, **GitHub Pages**, **Blogger**, and **local files**
27
+ - **Idempotent sync** — re-runs do nothing when no new posts are available
28
+ - **Environment variable interpolation** — keep secrets out of config files with `${VAR}` syntax
29
+ - **Stable article identifiers** — detect already-published articles across all destinations
30
+ - **Dry-run mode** — preview what would be published without making changes
31
+ - **Built-in validation** — validate configuration before running sync
32
+
33
+ ## Quick Start
34
+
35
+ ```bash
36
+ # Run in dry-run mode to preview
37
+ skriptorium sync --config config.yml --dry-run
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ All settings live in a single YAML file (default: `config.yml`).
43
+ See [`config.yml.example`](config.yml.example) for a complete template.
44
+
45
+ Key points:
46
+ - Only **one** source can be configured
47
+ - Multiple destinations are supported
48
+ - Secrets use `${ENV_VAR}` interpolation — never hardcode API keys
49
+
50
+
51
+ ## License
52
+
53
+ [MIT](LICENSE)
data/bin/google.rb ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'socket'
4
+ require 'uri'
5
+ require 'faraday'
6
+ require 'json'
7
+
8
+ CLIENT_ID = ENV.fetch('GOOGLE_CLIENT_ID')
9
+ CLIENT_SECRET = ENV.fetch('GOOGLE_SECRET')
10
+ SCOPE = 'https://www.googleapis.com/auth/blogger'
11
+ REDIRECT_URI = 'http://localhost:8085'
12
+ AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
13
+ TOKEN_URL = 'https://oauth2.googleapis.com/token'
14
+
15
+ # Step 1: Build authorization URL
16
+ # access_type=offline -> request refresh token
17
+ # prompt=consent -> force consent screen so refresh_token is always returned
18
+ auth_params = {
19
+ 'client_id' => CLIENT_ID,
20
+ 'redirect_uri' => REDIRECT_URI,
21
+ 'scope' => SCOPE,
22
+ 'response_type' => 'code',
23
+ 'access_type' => 'offline',
24
+ 'prompt' => 'consent'
25
+ }
26
+ auth_uri = "#{AUTH_URL}?#{URI.encode_www_form(auth_params)}"
27
+
28
+ puts <<~MSG
29
+
30
+ ============================================================
31
+ Open this URL in your browser and authorize:
32
+ #{auth_uri}
33
+ ============================================================
34
+
35
+ Waiting for authorization on #{REDIRECT_URI}...
36
+ MSG
37
+
38
+ # Step 2: Start a tiny HTTP server to catch the redirect
39
+ server = TCPServer.new(8085)
40
+
41
+ client = server.accept
42
+ request = client.gets
43
+
44
+
45
+ puts request
46
+
47
+ # Parse authorization code from request line, e.g.:
48
+ # GET /?code=4/abc...&scope=... HTTP/1.1
49
+ match = request.match(/code=([^&\s]+)/)
50
+ puts match
51
+
52
+ code = match[1] if match
53
+
54
+ # Send a simple response back to the browser
55
+ client.print <<~HTTP
56
+ HTTP/1.1 200 OK\r\n
57
+ Content-Type: text/html; charset=utf-8\r\n
58
+ Connection: close\r\n
59
+ \r\n
60
+ <h1>Authorization complete!</h1>
61
+ <p>You can close this tab and return to the terminal.</p>
62
+ HTTP
63
+
64
+ client.close
65
+ server.close
66
+
67
+ unless code
68
+ puts "\nError: no authorization code received."
69
+ exit 1
70
+ end
71
+
72
+ puts "Authorization code received. Exchanging for tokens..."
73
+
74
+ # Step 3: Exchange code for tokens
75
+ response = Faraday.post(TOKEN_URL, {
76
+ 'grant_type' => 'authorization_code',
77
+ 'code' => code,
78
+ 'redirect_uri' => REDIRECT_URI,
79
+ 'client_id' => CLIENT_ID,
80
+ 'client_secret' => CLIENT_SECRET
81
+ })
82
+
83
+ unless response.success?
84
+ puts "Token exchange failed: #{response.status} #{response.body}"
85
+ exit 1
86
+ end
87
+
88
+ data = JSON.parse(response.body)
89
+
90
+ if data['error']
91
+ puts "Error: #{data['error']} - #{data['error_description']}"
92
+ exit 1
93
+ end
94
+
95
+ puts "\nSuccess!"
96
+ puts "access_token: #{data['access_token']}"
97
+ puts "refresh_token: #{data['refresh_token']}" if data['refresh_token']
98
+ puts "expires_in: #{data['expires_in']}"
99
+ puts "token_type: #{data['token_type']}"
data/bin/skriptorium ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'skriptorium'
5
+
6
+ class SkriptoriumCLI
7
+
8
+ def self.run(args)
9
+ command = args.shift || 'sync'
10
+
11
+ case command
12
+ when 'sync', 's'
13
+ run_sync(args)
14
+ when 'validate', 'v'
15
+ run_validate(args)
16
+ when 'version'
17
+ puts "Skriptorium #{Skriptorium::VERSION}"
18
+ when 'help', '-h', '--help'
19
+ print_help
20
+ else
21
+ $stderr.puts "Unknown command: #{command}"
22
+ print_help
23
+ exit 1
24
+ end
25
+ rescue Skriptorium::Error => e
26
+ $stderr.puts "Error: #{e.message}"
27
+ exit 1
28
+ end
29
+
30
+ def self.run_sync(args)
31
+ options = { config: 'config.yml', dry_run: false, verbose: false }
32
+
33
+ OptionParser.new do |opts|
34
+ opts.banner = "Usage: skriptorium sync [options]"
35
+ opts.separator ""
36
+ opts.separator "Options:"
37
+
38
+ opts.on("-c", "--config PATH", "Path to configuration file (default: config.yml)") do |path|
39
+ options[:config] = path
40
+ end
41
+
42
+ opts.on("-n", "--dry-run", "Show what would be published without actually doing it") do
43
+ options[:dry_run] = true
44
+ end
45
+
46
+ opts.on("-v", "--verbose", "Enable verbose output") do
47
+ options[:verbose] = true
48
+ end
49
+
50
+ opts.on("-h", "--help", "Show this help") do
51
+ puts opts
52
+ exit
53
+ end
54
+ end.parse!(args)
55
+
56
+ unless File.exist?(options[:config])
57
+ $stderr.puts "Config file not found: #{options[:config]}"
58
+ exit 1
59
+ end
60
+
61
+ Skriptorium.run(options[:config], dry_run: options[:dry_run], verbose: options[:verbose])
62
+ end
63
+
64
+ def self.run_validate(args)
65
+ options = { config: 'config.yml' }
66
+
67
+ OptionParser.new do |opts|
68
+ opts.banner = "Usage: skriptorium validate [options]"
69
+ opts.separator ""
70
+ opts.separator "Options:"
71
+
72
+ opts.on("-c", "--config PATH", "Path to configuration file (default: config.yml)") do |path|
73
+ options[:config] = path
74
+ end
75
+
76
+ opts.on("-h", "--help", "Show this help") do
77
+ puts opts
78
+ exit
79
+ end
80
+ end.parse!(args)
81
+
82
+ unless File.exist?(options[:config])
83
+ $stderr.puts "Config file not found: #{options[:config]}"
84
+ exit 1
85
+ end
86
+
87
+ config = Skriptorium::Config.load(options[:config])
88
+ puts "Configuration is valid."
89
+ puts "Source: #{config.source['type']}"
90
+ puts "Destinations: #{config.destinations.map { |d| d['type'] }.join(', ')}"
91
+ rescue => e
92
+ $stderr.puts "Invalid configuration: #{e.message}"
93
+ exit 1
94
+ end
95
+
96
+ def self.print_help
97
+ puts <<~HELP
98
+ Skriptorium #{Skriptorium::VERSION}
99
+
100
+ Usage: skriptorium COMMAND [options]
101
+
102
+ Commands:
103
+ sync Synchronize latest articles from source to destinations
104
+ validate Validate configuration file
105
+ version Show version
106
+ help Show this help
107
+
108
+ Global options:
109
+ -c, --config PATH Path to configuration file (default: config.yml)
110
+ -n, --dry-run Show what would be published without actually doing it
111
+ -v, --verbose Enable verbose output
112
+ -h, --help Show help for a command
113
+ HELP
114
+ end
115
+ end
116
+
117
+ SkriptoriumCLI.run(ARGV)
@@ -0,0 +1,27 @@
1
+ source:
2
+ type: dev_to
3
+ username: your_username
4
+ limit: 10
5
+
6
+ destinations:
7
+ - type: dev_to
8
+ api_key: ${DEV_TO_API_KEY}
9
+ organization_id: null
10
+
11
+ - type: hashnode
12
+ username: ${HASHNODE_USERNAME}
13
+ storage_state_path: ${HASHNODE_STORAGE_STATE_PATH}
14
+ playwright_cli_executable_path: ./node_modules/.bin/playwright-core
15
+
16
+ - type: github_pages
17
+ repo: ${GITHUB_REPO}
18
+ token: ${GITHUB_TOKEN}
19
+ branch: master
20
+ path: _posts
21
+
22
+ - type: blogger
23
+ blog_id: ${BLOGGER_BLOG_ID}
24
+ api_key: ${BLOGGER_API_KEY}
25
+ client_id: ${BLOGGER_CLIENT_ID}
26
+ client_secret: ${BLOGGER_CLIENT_SECRET}
27
+ refresh_token: ${BLOGGER_REFRESH_TOKEN}
@@ -0,0 +1,114 @@
1
+ require 'active_support/core_ext/hash'
2
+ require 'redcarpet'
3
+ require 'russian'
4
+
5
+ module Skriptorium
6
+ class Article
7
+ attr_reader :id, :title, :content, :tags, :canonical_url, :published_at, :cover_image, :slug
8
+
9
+ FRONT_MATTER_RX = /---([\s\S]*?)---/.freeze
10
+ FRONT_MATTER_LINE_RX = /^(?<key>\w+)\s*:\s*(?<value>.*)/.freeze
11
+
12
+ def initialize(attrs)
13
+ @id = attrs[:id]
14
+ @title = attrs.fetch(:title).to_s.strip
15
+ @tags = attrs[:tags] || []
16
+ @canonical_url = attrs[:canonical_url]&.strip
17
+ @published_at = attrs[:published_at] || DateTime.now
18
+ @cover_image = attrs[:cover_image]
19
+
20
+ @slug = attrs[:slug]&.strip || stable_slug
21
+
22
+ @markdown = attrs[:markdown]
23
+ @html = attrs[:html]
24
+ raw = attrs[:content].to_s.strip
25
+ @content = if markdown?
26
+ @front_matter = parse_front_matter(raw) || {}.with_indifferent_access
27
+ raw.gsub(/---([\s\S]*?)---/, '').strip
28
+ else
29
+ @front_matter = {}.with_indifferent_access
30
+ raw
31
+ end
32
+ end
33
+
34
+ def same?(other)
35
+ (@slug && @slug == other.slug) ||
36
+ (@title && @title == other.title) ||
37
+ (@canonical_url && @canonical_url == other.canonical_url)
38
+ end
39
+
40
+ def front_matter(key = nil)
41
+ key ? @front_matter&.fetch(key, nil) : @front_matter
42
+ end
43
+
44
+ def stable_slug
45
+ sl = Russian.transliterate(@title)
46
+ sl.downcase.strip.gsub(/[\W\-_]+/, '_').gsub(/_$/, '').gsub(/^_/, '')
47
+ end
48
+
49
+ def markdown?
50
+ @markdown
51
+ end
52
+
53
+ def html?
54
+ @html
55
+ end
56
+
57
+ def to_html
58
+ html? ? @content : markdown_to_html(@content)
59
+ end
60
+
61
+ def to_markdown(fm = {})
62
+ c = if markdown?
63
+ @content
64
+ else
65
+ html_to_markdown(@content)
66
+ end.strip
67
+
68
+ header = front_matter.merge(
69
+ {
70
+ title: title,
71
+ tags: [tags].flatten.compact,
72
+ canonical_url: canonical_url,
73
+ slug: stable_slug,
74
+ cover_image: cover_image,
75
+ image: cover_image,
76
+ published_at: published_at&.strftime('%Y-%m-%d %H:%M:%S %z'),
77
+ date: published_at&.strftime('%Y-%m-%d %H:%M:%S %z')
78
+ }.compact.with_indifferent_access
79
+ ).merge(fm.with_indifferent_access)
80
+
81
+ [
82
+ header.to_hash.sort.to_h.to_yaml.strip,
83
+ '---',
84
+ c
85
+ ].join("\n")
86
+ end
87
+
88
+ def markdown_to_html(data)
89
+ renderer = Redcarpet::Render::HTML.new(prettify: true)
90
+ md = Redcarpet::Markdown.new(renderer, fenced_code_blocks: true)
91
+ md.render(data)
92
+ end
93
+
94
+ def html_to_markdown(data)
95
+ ReverseMarkdown.convert(data)
96
+ end
97
+
98
+ def parse_front_matter(data)
99
+ if (md_fm = data.match(FRONT_MATTER_RX))
100
+ front_matter = md_fm[1].strip.split("\n").map do |line|
101
+ md = line.match(FRONT_MATTER_LINE_RX)
102
+ "#{md[:key]}: '#{md[:value]}'"
103
+ end.join("\n")
104
+ YAML.safe_load(front_matter).with_indifferent_access
105
+ end
106
+ end
107
+
108
+ def self.parse_date(str)
109
+ DateTime.parse(str) if str
110
+ rescue StandardError
111
+ nil
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,27 @@
1
+ require 'active_support/core_ext/hash'
2
+ require 'active_support/notifications'
3
+ require 'active_support/cache'
4
+ require 'active_support/cache/memory_store'
5
+
6
+ module Skriptorium
7
+ class BaseDestination
8
+ attr_reader :config, :cache
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ @cache = ActiveSupport::Cache::MemoryStore.new
13
+ end
14
+
15
+ def with_cache(*args, **kwargs, &block)
16
+ @cache.fetch([self.class.to_s, *args], **kwargs, &block)
17
+ end
18
+
19
+ def exists?(article)
20
+ raise NotImplementedError, "#{self.class} must implement #exists?"
21
+ end
22
+
23
+ def publish(article)
24
+ raise NotImplementedError, "#{self.class} must implement #publish"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ module Skriptorium
2
+ class BaseSource
3
+ attr_reader :config, :limit
4
+
5
+ def initialize(config)
6
+ @config = config
7
+ @limit = config['limit'] || 10
8
+ @filter = config['filter']
9
+ end
10
+
11
+ # @return [Array<Article>]
12
+ def fetch
13
+ raise NotImplementedError, "#{self.class} must implement #fetch_latest"
14
+ end
15
+
16
+ def fetch_latest
17
+ filtered = apply_filter(fetch)
18
+ filtered.first(@limit)
19
+ end
20
+
21
+ def apply_filter(stream)
22
+ return stream unless @filter
23
+
24
+ stream.select do |article|
25
+ pass = @filter.all? do |name, opts|
26
+ case name
27
+ when 'front_matter'
28
+ opts.all? do |k, v|
29
+ article.front_matter(k.strip) == v.to_s.strip
30
+ end
31
+ else
32
+ raise "Invalid filter #{name}: #{opts.inspect}"
33
+ end
34
+ end
35
+ # puts " article \"#{article.title}\" pass=#{pass}"
36
+ pass
37
+ end
38
+ end
39
+ end
40
+ end