├── .ruby-version
├── .github
├── CODEOWNERS
├── dependabot.yml
└── workflows
│ ├── ci.yml
│ └── publish.yml
├── spec
├── support
│ ├── fixtures
│ │ ├── sample_post_no_links.txt
│ │ ├── sample_post_no_links.json
│ │ ├── sample_post.txt
│ │ ├── sample_post_no_links.html
│ │ ├── sample_post.json
│ │ ├── sample_post_anchors_only.html
│ │ └── sample_post.html
│ └── fixtures_helper.rb
├── lib
│ ├── webmention
│ │ ├── error_response_spec.rb
│ │ ├── response_spec.rb
│ │ ├── parsers
│ │ │ ├── plaintext_parser_results_spec.rb
│ │ │ ├── json_parser_results_spec.rb
│ │ │ └── html_parser_results_spec.rb
│ │ └── request_perform_spec.rb
│ ├── webmention_send_webmentions_spec.rb
│ ├── webmention_send_webmention_spec.rb
│ ├── webmention_mentioned_urls_spec.rb
│ └── webmention_verify_webmention_spec.rb
└── spec_helper.rb
├── .devcontainer
├── setup.sh
└── devcontainer.json
├── Rakefile
├── .irbrc
├── .editorconfig
├── .rubocop.yml
├── bin
├── rspec
├── rubocop
└── console
├── CODE_OF_CONDUCT.md
├── .simplecov
├── Gemfile
├── lib
├── webmention
│ ├── parsers
│ │ ├── plaintext_parser.rb
│ │ ├── json_parser.rb
│ │ └── html_parser.rb
│ ├── parser.rb
│ ├── error_response.rb
│ ├── url.rb
│ ├── response.rb
│ ├── verification.rb
│ ├── request.rb
│ └── client.rb
└── webmention.rb
├── LICENSE
├── .git-blame-ignore-revs
├── .gitignore
├── webmention.gemspec
├── CONTRIBUTING.md
├── README.md
├── CHANGELOG.md
└── USAGE.md
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.4.6
2 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * jgarber623
2 |
--------------------------------------------------------------------------------
/spec/support/fixtures/sample_post_no_links.txt:
--------------------------------------------------------------------------------
1 | Hello, world!
2 |
--------------------------------------------------------------------------------
/spec/support/fixtures/sample_post_no_links.json:
--------------------------------------------------------------------------------
1 | ["Hello, world!"]
2 |
--------------------------------------------------------------------------------
/spec/support/fixtures/sample_post.txt:
--------------------------------------------------------------------------------
1 | Hello, world!
2 |
3 | https://aaronpk.example/post/100
4 |
--------------------------------------------------------------------------------
/.devcontainer/setup.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -euo pipefail
4 | IFS=$'\n\t'
5 | set -vx
6 |
7 | bundle install
8 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/gem_tasks"
4 |
5 | require "rspec/core/rake_task"
6 |
7 | RSpec::Core::RakeTask.new
8 |
9 | task default: :spec
10 |
--------------------------------------------------------------------------------
/.irbrc:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "irb/completion"
4 |
5 | IRB.conf[:HISTORY_FILE] = ".irb_history"
6 | IRB.conf[:USE_AUTOCOMPLETE] = false
7 | IRB.conf[:USE_PAGER] = false
8 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | indent_size = 2
8 | indent_style = space
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | inherit_from:
2 | - https://rubocop.jgarber.cc/rubocop.yml
3 | - https://rubocop.jgarber.cc/rubocop-rspec.yml
4 |
5 | plugins: rubocop-packaging
6 |
7 | AllCops:
8 | TargetRubyVersion: "2.7"
9 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: "monthly"
8 | assignees:
9 | - "jgarber623"
10 |
--------------------------------------------------------------------------------
/bin/rspec:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "rubygems"
5 | require "bundler/setup"
6 |
7 | ARGV.unshift("--require", "spec_helper")
8 |
9 | load Gem.bin_path("rspec-core", "rspec")
10 |
--------------------------------------------------------------------------------
/spec/support/fixtures/sample_post_no_links.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Sample Post
4 |
5 |
6 |
7 | Hello, world!
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | By contributing to and participating in the development of indieweb-endpoints-ruby, you acknowledge that you have read and agree to the [IndieWeb Code of Conduct](https://indieweb.org/code-of-conduct).
4 |
--------------------------------------------------------------------------------
/spec/support/fixtures_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module FixturesHelper
4 | def load_fixture(file_name, file_type = "html")
5 | File.read(File.join(Dir.pwd, "spec/support/fixtures/#{file_name}.#{file_type}"))
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/bin/rubocop:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "rubygems"
5 | require "bundler/setup"
6 |
7 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
8 |
9 | load Gem.bin_path("rubocop", "rubocop")
10 |
--------------------------------------------------------------------------------
/spec/lib/webmention/error_response_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention::ErrorResponse do
4 | subject(:error_response) { described_class.new("foo", request) }
5 |
6 | let(:request) { instance_double(Webmention::Request) }
7 |
8 | it { is_expected.not_to be_ok }
9 | it { is_expected.to have_attributes(message: "foo", request: request) }
10 | end
11 |
--------------------------------------------------------------------------------
/.simplecov:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | formatters = SimpleCov::Formatter.from_env(ENV)
4 |
5 | if RSpec.configuration.files_to_run.length > 1
6 | require "simplecov-console"
7 |
8 | formatters << SimpleCov::Formatter::Console
9 | end
10 |
11 | SimpleCov.start do
12 | enable_coverage :branch
13 |
14 | formatter SimpleCov::Formatter::MultiFormatter.new(formatters)
15 | end
16 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | # Specify gem's dependencies in webmention.gemspec
6 | gemspec
7 |
8 | gem "irb"
9 | gem "rake"
10 | gem "rspec"
11 | gem "rubocop"
12 | gem "rubocop-packaging"
13 | gem "rubocop-performance"
14 | gem "rubocop-rake"
15 | gem "rubocop-rspec"
16 | gem "simplecov"
17 | gem "simplecov-console"
18 | gem "webmock"
19 |
--------------------------------------------------------------------------------
/spec/support/fixtures/sample_post.json:
--------------------------------------------------------------------------------
1 | [
2 | [
3 | {
4 | "rels": {
5 | "target": "https://target.example/post/100"
6 | }
7 | },
8 | {
9 | "rels": {
10 | "target": "https://target.example/post/200"
11 | }
12 | }
13 | ],
14 | {
15 | "rels": {
16 | "url": "https://target.example/post/100"
17 | }
18 | },
19 | ["Hello", "world!"]
20 | ]
21 |
--------------------------------------------------------------------------------
/spec/lib/webmention/response_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention::Response do
4 | subject(:response) { described_class.new(http_response, request) }
5 |
6 | let(:http_response) { instance_double(HTTP::Response) }
7 | let(:request) { instance_double(Webmention::Request) }
8 |
9 | it { is_expected.to be_ok }
10 | it { is_expected.to have_attributes(request: request) }
11 | end
12 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundler/setup"
5 | require "webmention"
6 |
7 | # You can add fixtures and/or initialization code here to make experimenting
8 | # with your gem easier. You can also use a different console, if you like.
9 |
10 | # (If you use this, don't forget to add pry to your Gemfile!)
11 | # require "pry"
12 | # Pry.start
13 |
14 | ENV["IRBRC"] = ".irbrc"
15 |
16 | require "irb"
17 | IRB.start(__FILE__)
18 |
--------------------------------------------------------------------------------
/lib/webmention/parsers/plaintext_parser.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | module Parsers
5 | # @api private
6 | class PlaintextParser < Parser
7 | @mime_types = ["text/plain"]
8 |
9 | Client.register_parser(self)
10 |
11 | # @return [Array] An array of absolute URLs.
12 | def results
13 | @results ||= URI::DEFAULT_PARSER.extract(response_body, ["http", "https"])
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2013 by Aaron Parecki
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/spec/lib/webmention_send_webmentions_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention, ".send_webmentions" do
4 | subject(:response) { described_class.send_webmentions(*target_urls) }
5 |
6 | let(:source_url) { "https://jgarber.example/foo" }
7 |
8 | let(:target_urls) do
9 | [
10 | "https://aaronpk.example/bar",
11 | "https://adactio.example/biz",
12 | "https://tantek.example/baz",
13 | ]
14 | end
15 |
16 | before do
17 | target_urls.each { |target_url| stub_request(:get, target_url) }
18 | end
19 |
20 | it { is_expected.to be_a(Array) }
21 | end
22 |
--------------------------------------------------------------------------------
/spec/support/fixtures/sample_post_anchors_only.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Sample Post
4 |
5 |
6 |
9 |
10 | Link to Target 1
11 | Link to Target 2
12 | Relative Link to Source 1
13 | Relative Link to Self
14 | Invalid Link
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/spec/lib/webmention/parsers/plaintext_parser_results_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention::Parsers::PlaintextParser, "#results" do
4 | subject(:results) { described_class.new(response_body, "https://jgarber.example/foo").results }
5 |
6 | context "when response body contains no URLs" do
7 | let(:response_body) { load_fixture(:sample_post_no_links, "txt") }
8 |
9 | it { is_expected.to eq([]) }
10 | end
11 |
12 | context "when response body contains URLs" do
13 | let(:response_body) { load_fixture(:sample_post, "txt") }
14 |
15 | it { is_expected.to eq(["https://aaronpk.example/post/100"]) }
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Since git version 2.23, git-blame has a feature to ignore certain commits.
2 | #
3 | # This file contains a list of commits that are not likely what you are looking
4 | # for in `git blame`. You can set this file as a default ignore file for blame
5 | # by running the following command:
6 | #
7 | # $ git config blame.ignoreRevsFile .git-blame-ignore-revs
8 |
9 | c7f71425322c9f2e1b279ac5fa3a075fbd8ddc22
10 | 1cae17eba50450ded5189de3fb5a59eed225b5a8
11 | 604b1d7de4709812f393980acd773318265d2c06
12 | ae980701869802ee34dc4134d7d74246f7788db8
13 | b31ac9550ad91e1dab2d282c75df7fa979defb56
14 | be81aa06d5fb8dc39d92c9642aaa81d1951167de
15 | 56a2cd08586e8bbe243bf27441c7b6ef5afb4670
16 | a4c09cf5915cb15b6a7de17022b81a34fb64fdb0
17 |
--------------------------------------------------------------------------------
/spec/lib/webmention/request_perform_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention::Request, "#perform" do
4 | subject(:response) { request.perform }
5 |
6 | let(:url) { "https://jgarber.example" }
7 | let(:request) { described_class.new(:get, url) }
8 |
9 | context "when an exception is raised" do
10 | before do
11 | stub_request(:get, url).to_raise(HTTP::ConnectionError.new("foo"))
12 | end
13 |
14 | it { is_expected.to have_attributes(message: "foo", ok?: false, request: request) }
15 | end
16 |
17 | context "when no exception is raised" do
18 | before { stub_request(:get, url) }
19 |
20 | it { is_expected.to have_attributes(code: 200, ok?: true, request: request) }
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/webmention/parser.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | # @api private
5 | class Parser
6 | URI_REGEXP = URI::DEFAULT_PARSER.make_regexp(["http", "https"]).freeze
7 |
8 | public_constant :URI_REGEXP
9 |
10 | class << self
11 | # @return [Array]
12 | attr_reader :mime_types
13 | end
14 |
15 | # @param response_body [HTTP::Response::Body, String, #to_s]
16 | # @param response_uri [String, HTTP::URI, #to_s]
17 | def initialize(response_body, response_uri)
18 | @response_body = response_body.to_s
19 | @response_uri = HTTP::URI.parse(response_uri.to_s)
20 | end
21 |
22 | private
23 |
24 | # @return [String]
25 | attr_reader :response_body
26 |
27 | # @return [HTTP::URI]
28 | attr_reader :response_uri
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/spec/support/fixtures/sample_post.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Sample Post
4 |
5 |
6 |
9 |
10 | Link to Target 1
11 | Link to Target 2
12 | Duplicate Link to Target 2
13 | 
14 | Absolute Link to Self
15 | Invalid Link
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/spec/lib/webmention/parsers/json_parser_results_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention::Parsers::JsonParser, "#results" do
4 | subject(:results) { described_class.new(response_body, "https://jgarber.example/foo").results }
5 |
6 | context "when response body contains no URLs" do
7 | let(:response_body) { load_fixture(:sample_post_no_links, "json") }
8 |
9 | it { is_expected.to eq([]) }
10 | end
11 |
12 | context "when response body contains URLs" do
13 | let(:response_body) { load_fixture(:sample_post, "json") }
14 |
15 | let(:extracted_urls) do
16 | [
17 | "https://target.example/post/100",
18 | "https://target.example/post/200",
19 | "https://target.example/post/100",
20 | ]
21 | end
22 |
23 | it { is_expected.to eq(extracted_urls) }
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [main]
7 | workflow_call:
8 | workflow_dispatch:
9 |
10 | jobs:
11 | lint:
12 | name: Lint
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v6
16 | - uses: ruby/setup-ruby@v1
17 | with:
18 | bundler-cache: true
19 | - run: bin/rubocop --color
20 | test:
21 | name: Test (Ruby ${{ matrix.ruby }})
22 | runs-on: ubuntu-latest
23 | needs: lint
24 | strategy:
25 | fail-fast: false
26 | matrix:
27 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3"]
28 | steps:
29 | - uses: actions/checkout@v6
30 | - uses: ruby/setup-ruby@v1
31 | with:
32 | bundler-cache: true
33 | ruby-version: ${{ matrix.ruby }}
34 | - run: bin/rspec --force-color --format documentation
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | *.rbc
3 | /.config
4 | /.ruby-lsp
5 | /coverage/
6 | /InstalledFiles
7 | /pkg/
8 | /spec/reports/
9 | /spec/examples.txt
10 | /test/tmp/
11 | /test/version_tmp/
12 | /tmp/
13 |
14 | # Used by dotenv library to load environment variables.
15 | # .env
16 |
17 | # Ignore history files.
18 | .irb_history
19 | .rdbg_history
20 |
21 | # Documentation cache and generated files:
22 | /.yardoc/
23 | /_yardoc/
24 | /doc/
25 | /rdoc/
26 |
27 | # Environment normalization:
28 | /.bundle/
29 | /vendor/bundle
30 | /lib/bundler/man/
31 |
32 | # for a library or gem, you might want to ignore these files since the code is
33 | # intended to run in multiple environments; otherwise, check them in:
34 | Gemfile.lock
35 | # .ruby-version
36 | # .ruby-gemset
37 |
38 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
39 | .rvmrc
40 |
41 | # Used by RuboCop. Remote config files pulled in from inherit_from directive.
42 | .rubocop-https?--*
43 |
--------------------------------------------------------------------------------
/lib/webmention/parsers/json_parser.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | module Parsers
5 | # @api private
6 | class JsonParser < Parser
7 | @mime_types = ["application/json"]
8 |
9 | Client.register_parser(self)
10 |
11 | # @return [Array] An array of absolute URLs.
12 | def results
13 | @results ||= extract_urls_from(JSON.parse(response_body))
14 | end
15 |
16 | private
17 |
18 | # @param objs [Array]
19 | #
20 | # @return [Array]
21 | def extract_urls_from(*objs)
22 | objs.flat_map do |obj|
23 | return obj.flat_map { |value| extract_urls_from(value) }.compact if obj.is_a?(Array)
24 | return extract_urls_from(obj.values) if obj.is_a?(Hash)
25 |
26 | obj if obj.is_a?(String) && obj.match?(Parser::URI_REGEXP)
27 | end
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/webmention/error_response.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | class ErrorResponse
5 | # @return [String]
6 | attr_reader :message
7 |
8 | # @return [Request]
9 | attr_reader :request
10 |
11 | # Create a new {ErrorResponse}.
12 | #
13 | # Instances of this class represent HTTP requests that generated errors
14 | # (e.g. connection error, SSL error) or that could not locate a Webmention
15 | # endpoint. The nature of the error is captured in the {#message} instance
16 | # method.
17 | #
18 | # @param message [String]
19 | # @param request [Request]
20 | def initialize(message, request)
21 | @message = message
22 | @request = request
23 | end
24 |
25 | # :nocov:
26 | # @return [String]
27 | def inspect
28 | "#<#{self.class}:#{format("%#0x", object_id)} " \
29 | "message: #{message}>"
30 | end
31 | # :nocov:
32 |
33 | # @return [Boolean]
34 | def ok?
35 | false
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/spec/lib/webmention/parsers/html_parser_results_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention::Parsers::HtmlParser, "#results" do
4 | subject(:results) { described_class.new(response_body, "https://jgarber.example/foo").results }
5 |
6 | context "when response body contains no URLs" do
7 | let(:response_body) { load_fixture(:sample_post_no_links) }
8 |
9 | it { is_expected.to eq([]) }
10 | end
11 |
12 | context "when response body contains URLs within an h-entry" do
13 | let(:response_body) { load_fixture(:sample_post) }
14 |
15 | let(:extracted_urls) do
16 | [
17 | "https://aaronpk.example/post/1",
18 | "https://aaronpk.example/post/2",
19 | "https://aaronpk.example/post/2",
20 | "https://jgarber.example",
21 | "https://aaronpk.example/image.jpg",
22 | "https://aaronpk.example/image-1x.jpg",
23 | "https://aaronpk.example/image-2x.jpg",
24 | ]
25 | end
26 |
27 | it { is_expected.to eq(extracted_urls) }
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/webmention/url.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | class Url
5 | extend Forwardable
6 |
7 | # @return [HTTP::URI]
8 | attr_reader :uri
9 |
10 | # @!method
11 | # @return [String]
12 | def_delegator :uri, :to_s
13 |
14 | # Create a new {Url}.
15 | #
16 | # @param url [String, HTTP::URI, #to_s] An absolute URL.
17 | def initialize(url)
18 | @uri = HTTP::URI.parse(url.to_s)
19 | end
20 |
21 | # :nocov:
22 | # @return [String]
23 | def inspect
24 | "#<#{self.class}:#{format("%#0x", object_id)} " \
25 | "uri: #{uri}>"
26 | end
27 | # :nocov:
28 |
29 | # @return [Response, ErrorResponse]
30 | def response
31 | @response ||= Request.get(uri)
32 | end
33 |
34 | # @return [String, nil]
35 | def webmention_endpoint
36 | @webmention_endpoint ||= IndieWeb::Endpoints::Parser.new(response).to_h[:webmention] if response.ok?
37 | end
38 |
39 | # @return [Boolean]
40 | def webmention_endpoint?
41 | !webmention_endpoint.nil?
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/webmention.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Gem::Specification.new do |spec|
4 | spec.required_ruby_version = ">= 2.7"
5 |
6 | spec.name = "webmention"
7 | spec.version = "9.0.0"
8 | spec.authors = ["Jason Garber"]
9 | spec.email = ["jason@sixtwothree.org"]
10 |
11 | spec.summary = "A Ruby gem for sending and verifying Webmention notifications."
12 | spec.description = spec.summary
13 | spec.homepage = "https://github.com/indieweb/webmention-client-ruby"
14 | spec.license = "Apache-2.0"
15 |
16 | spec.files = Dir["lib/**/*"].reject { |f| File.directory?(f) }
17 | spec.files += ["LICENSE", "CHANGELOG.md", "CONTRIBUTING.md", "README.md", "USAGE.md"]
18 | spec.files += ["webmention.gemspec"]
19 |
20 | spec.require_paths = ["lib"]
21 |
22 | spec.metadata = {
23 | "bug_tracker_uri" => "#{spec.homepage}/issues",
24 | "changelog_uri" => "#{spec.homepage}/releases/tag/v#{spec.version}",
25 | "documentation_uri" => "https://rubydoc.info/gems/#{spec.name}/#{spec.version}",
26 | "homepage_uri" => spec.homepage,
27 | "rubygems_mfa_required" => "true",
28 | "source_code_uri" => "#{spec.homepage}/tree/v#{spec.version}",
29 | }
30 |
31 | spec.add_dependency "http", "~> 5.3"
32 | spec.add_dependency "indieweb-endpoints", "~> 10.0"
33 | spec.add_dependency "nokogiri", ">= 1.14"
34 | end
35 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/ruby
3 | {
4 | "name": "webmention-client-ruby",
5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6 | "image": "mcr.microsoft.com/devcontainers/base:1-bookworm",
7 |
8 | // Features to add to the dev container. More info: https://containers.dev/features.
9 | "features": {
10 | "ghcr.io/rails/devcontainer/features/ruby:2": {
11 | "version": "3.4.6"
12 | }
13 | },
14 |
15 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
16 | // "forwardPorts": [],
17 |
18 | // An array of Docker CLI arguments that should be used when running the container.
19 | "runArgs": ["--name", "webmention-client-ruby"],
20 |
21 | // Use 'postCreateCommand' to run commands after the container is created.
22 | "postCreateCommand": ".devcontainer/setup.sh",
23 |
24 | // Configure tool-specific properties.
25 | "customizations": {
26 | "vscode": {
27 | "extensions": [
28 | "EditorConfig.EditorConfig"
29 | ]
30 | }
31 | }
32 |
33 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
34 | // "remoteUser": "root"
35 | }
36 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | ci:
9 | name: CI
10 | uses: ./.github/workflows/ci.yml
11 | publish-to-rubygems:
12 | name: Publish to RubyGems
13 | permissions:
14 | contents: write
15 | id-token: write
16 | needs: ci
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/checkout@v6
20 | - uses: ruby/setup-ruby@v1
21 | with:
22 | bundler-cache: true
23 | - uses: rubygems/release-gem@v1
24 | with:
25 | await-release: false
26 | publish-to-github-packages:
27 | name: Publish to GitHub Packages
28 | permissions:
29 | contents: read
30 | packages: write
31 | needs: ci
32 | runs-on: ubuntu-latest
33 | steps:
34 | - uses: actions/checkout@v6
35 | - uses: ruby/setup-ruby@v1
36 | with:
37 | bundler-cache: true
38 | - run: |
39 | mkdir -p $HOME/.gem
40 | touch $HOME/.gem/credentials
41 | chmod 0600 $HOME/.gem/credentials
42 | printf -- "---\n:github: Bearer ${{ secrets.GITHUB_TOKEN }}\n" > $HOME/.gem/credentials
43 | - run: bundle exec rake release
44 | env:
45 | BUNDLE_GEM__PUSH_KEY: github
46 | RUBYGEMS_HOST: "https://rubygems.pkg.github.com/${{ github.repository_owner }}"
47 |
--------------------------------------------------------------------------------
/lib/webmention/response.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | class Response
5 | extend Forwardable
6 |
7 | # @return [Request]
8 | attr_reader :request
9 |
10 | # @!method
11 | # @return [HTTP::Headers]
12 | def_delegator :@response, :headers
13 |
14 | # @!method
15 | # @return [HTTP::Response::Body]
16 | def_delegator :@response, :body
17 |
18 | # @!method
19 | # @return [Integer]
20 | def_delegator :@response, :code
21 |
22 | # @!method
23 | # @return [String]
24 | def_delegator :@response, :reason
25 |
26 | # !@method
27 | # @return [String]
28 | def_delegator :@response, :mime_type
29 |
30 | # !@method
31 | # @return [HTTP::URI]
32 | def_delegator :@response, :uri
33 |
34 | # Create a new {Response}.
35 | #
36 | # Instances of this class represent completed HTTP requests, the details
37 | # of which may be accessed using the delegated {#code} and {#reason}
38 | # instance methods.
39 | #
40 | # @param response [HTTP::Response]
41 | # @param request [Request]
42 | def initialize(response, request)
43 | @response = response
44 | @request = request
45 | end
46 |
47 | # :nocov:
48 | # @return [String]
49 | def inspect
50 | "#<#{self.class}:#{format("%#0x", object_id)} " \
51 | "code: #{code.inspect}, " \
52 | "reason: #{reason}, " \
53 | "url: #{request.uri}>"
54 | end
55 | # :nocov:
56 |
57 | # @return [Boolean]
58 | def ok?
59 | true
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/spec/lib/webmention_send_webmention_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention, ".send_webmention" do
4 | subject(:response) { described_class.send_webmention(source_url, target_url) }
5 |
6 | let(:source_url) { "https://jgarber.example/foo" }
7 | let(:target_url) { "https://aaronpk.example/bar" }
8 |
9 | context "when target URL is not an absolute URL" do
10 | let(:target_url) { "/foo" }
11 | let(:message) { "unknown scheme: " }
12 |
13 | it { is_expected.to be_a(Webmention::ErrorResponse) }
14 | it { is_expected.to have_attributes(message: message, ok?: false) }
15 | end
16 |
17 | context "when target URL unreachable" do
18 | let(:message) { "Exception from WebMock" }
19 |
20 | before do
21 | stub_request(:get, target_url).to_raise(OpenSSL::SSL::SSLError)
22 | end
23 |
24 | it { is_expected.to be_a(Webmention::ErrorResponse) }
25 | it { is_expected.to have_attributes(message: message, ok?: false) }
26 | end
27 |
28 | context "when target URL does not advertise a Webmention endpoint" do
29 | let(:message) { "No webmention endpoint found for target URL #{target_url}" }
30 |
31 | before do
32 | stub_request(:get, target_url).to_return(body: load_fixture(:sample_post))
33 | end
34 |
35 | it { is_expected.to be_a(Webmention::ErrorResponse) }
36 | it { is_expected.to have_attributes(message: message, ok?: false) }
37 | end
38 |
39 | context "when target URL advertises a Webmention endpoint" do
40 | let(:webmention_endpoint) { "#{target_url}/webmention" }
41 |
42 | before do
43 | stub_request(:get, target_url).to_return(
44 | headers: {
45 | Link: %(<#{webmention_endpoint}>; rel="webmention"),
46 | }
47 | )
48 |
49 | stub_request(:post, webmention_endpoint).to_return(status: 200)
50 | end
51 |
52 | it { is_expected.to be_a(Webmention::Response) }
53 | it { is_expected.to be_ok }
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/webmention/parsers/html_parser.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | module Parsers
5 | # @api private
6 | class HtmlParser < Parser
7 | @mime_types = ["text/html"]
8 |
9 | Client.register_parser(self)
10 |
11 | HTML_ATTRIBUTES_MAP = {
12 | "cite" => ["blockquote", "del", "ins", "q"],
13 | "data" => ["object"],
14 | "href" => ["a", "area"],
15 | "poster" => ["video"],
16 | "src" => ["audio", "embed", "img", "source", "track", "video"],
17 | "srcset" => ["img", "source"],
18 | }.freeze
19 |
20 | CSS_SELECTORS_ARRAY = HTML_ATTRIBUTES_MAP
21 | .flat_map { |attribute, names| names.map { |name| "#{name}[#{attribute}]" } }.freeze
22 |
23 | ROOT_NODE_SELECTORS_ARRAY = [".h-entry .e-content", ".h-entry", "body"].freeze
24 |
25 | private_constant :HTML_ATTRIBUTES_MAP
26 | private_constant :CSS_SELECTORS_ARRAY
27 | private_constant :ROOT_NODE_SELECTORS_ARRAY
28 |
29 | # @return [Array] An array of absolute URLs.
30 | def results
31 | @results ||=
32 | extract_urls_from(*url_attributes)
33 | .map { |url| response_uri.join(url).to_s }
34 | .grep(Parser::URI_REGEXP)
35 | end
36 |
37 | private
38 |
39 | # @return [Nokogiri::HTML5::Document]
40 | def doc
41 | Nokogiri.HTML5(response_body)
42 | end
43 |
44 | # @param attributes [Array]
45 | #
46 | # @return [Array]
47 | def extract_urls_from(*attributes)
48 | attributes.flat_map do |attribute|
49 | if attribute.name == "srcset"
50 | attribute.value.split(",").map { |value| value.strip.match(/^\S+/).to_s }
51 | else
52 | attribute.value
53 | end
54 | end
55 | end
56 |
57 | # @return [Nokogiri::XML::Element]
58 | def root_node
59 | doc.at_css(*ROOT_NODE_SELECTORS_ARRAY)
60 | end
61 |
62 | # @return [Array]
63 | def url_attributes
64 | url_nodes.flat_map(&:attribute_nodes).select { |attribute| HTML_ATTRIBUTES_MAP.key?(attribute.name) }
65 | end
66 |
67 | # @return [Nokogiri::XML::NodeSet]
68 | def url_nodes
69 | root_node.css(*CSS_SELECTORS_ARRAY)
70 | end
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/spec/lib/webmention_mentioned_urls_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention, ".mentioned_urls" do
4 | subject(:mentioned_urls) { described_class.mentioned_urls(source_url) }
5 |
6 | let(:source_url) { "https://jgarber.example/foo" }
7 |
8 | context "when response is an ErrorResponse" do
9 | before do
10 | stub_request(:get, source_url).to_raise(OpenSSL::SSL::SSLError)
11 | end
12 |
13 | it "raises a NoMethodError" do
14 | expect { mentioned_urls }.to raise_error(NoMethodError)
15 | end
16 | end
17 |
18 | context "when response is of unsupported MIME type" do
19 | before do
20 | stub_request(:get, source_url).to_return(
21 | headers: {
22 | "Content-Type": "foo/bar",
23 | }
24 | )
25 | end
26 |
27 | it "raises a NoMethodError" do
28 | expect { mentioned_urls }.to raise_error(NoMethodError)
29 | end
30 | end
31 |
32 | context "when no mentioned URLs found" do
33 | before do
34 | stub_request(:get, source_url).to_return(
35 | body: load_fixture(:sample_post_no_links),
36 | headers: {
37 | "Content-Type": "text/html",
38 | }
39 | )
40 | end
41 |
42 | it { is_expected.to eq([]) }
43 | end
44 |
45 | context "when mentioned URLs found" do
46 | let(:extracted_urls) do
47 | [
48 | "https://aaronpk.example/image-1x.jpg",
49 | "https://aaronpk.example/image-2x.jpg",
50 | "https://aaronpk.example/image.jpg",
51 | "https://aaronpk.example/post/1",
52 | "https://aaronpk.example/post/2",
53 | "https://jgarber.example",
54 | ]
55 | end
56 |
57 | before do
58 | stub_request(:get, source_url).to_return(
59 | body: load_fixture(:sample_post),
60 | headers: {
61 | "Content-Type": "text/html",
62 | }
63 | )
64 | end
65 |
66 | it { is_expected.to eq(extracted_urls) }
67 | end
68 |
69 | context "when mentioned URLs include links to source URL" do
70 | let(:extracted_urls) do
71 | [
72 | "https://aaronpk.example/post/1",
73 | "https://aaronpk.example/post/2",
74 | "https://jgarber.example/post/1",
75 | ]
76 | end
77 |
78 | before do
79 | stub_request(:get, source_url).to_return(
80 | body: load_fixture(:sample_post_anchors_only),
81 | headers: {
82 | "Content-Type": "text/html",
83 | }
84 | )
85 | end
86 |
87 | it { is_expected.to eq(extracted_urls) }
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/lib/webmention/verification.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | class Verification
5 | # Create a new {Verification}.
6 | #
7 | # @param source_url [Url]
8 | # An {URL} representing a source document.
9 | # @param target_url [Url]
10 | # An {URL} representing a target document.
11 | # @param vouch_url [Url]
12 | # An {URL} representing a document vouching for the source document.
13 | # See https://indieweb.org/Vouch for additional details.
14 | def initialize(source_url, target_url, vouch_url: nil)
15 | @source_url = source_url
16 | @target_url = target_url
17 | @vouch_url = vouch_url
18 | end
19 |
20 | # :nocov:
21 | # @return [String]
22 | def inspect
23 | "#<#{self.class}:#{format("%#0x", object_id)} " \
24 | "source_url: #{source_url} " \
25 | "target_url: #{target_url} " \
26 | "vouch_url: #{vouch_url}>"
27 | end
28 | # :nocov:
29 |
30 | # @return [Boolean]
31 | def source_mentions_target?
32 | @source_mentions_target ||= mentioned_urls(source_url.response).any?(target_url.to_s)
33 | end
34 |
35 | # @return [Boolean]
36 | def verified?
37 | return source_mentions_target? unless verify_vouch?
38 |
39 | source_mentions_target? && vouch_mentions_source?
40 | end
41 |
42 | # @return [Boolean]
43 | def verify_vouch?
44 | !vouch_url.nil? && !vouch_url.to_s.strip.empty?
45 | end
46 |
47 | # @return [Boolean]
48 | def vouch_mentions_source?
49 | @vouch_mentions_source ||=
50 | verify_vouch? && mentioned_domains(vouch_url.response).any?(source_url.uri.host)
51 | end
52 |
53 | private
54 |
55 | # @return [Url]
56 | attr_reader :source_url
57 |
58 | # @return [Url]
59 | attr_reader :target_url
60 |
61 | # @return [Url]
62 | attr_reader :vouch_url
63 |
64 | # @param response [Response]
65 | #
66 | # @raise (see Client#mentioned_urls)
67 | #
68 | # @return [Array]
69 | def mentioned_domains(response)
70 | Set.new(mentioned_urls(response).map { |url| HTTP::URI.parse(url).host }).to_a
71 | end
72 |
73 | # @param response [Response]
74 | #
75 | # @raise (see Client#mentioned_urls)
76 | #
77 | # @return [Array]
78 | def mentioned_urls(response)
79 | Set.new(
80 | Client.registered_parsers[response.mime_type]
81 | .new(response.body, response.uri)
82 | .results
83 | ).to_a
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to webmention-client-ruby
2 |
3 | There are a couple ways you can help improve webmention-client-ruby:
4 |
5 | 1. Fix an existing [Issue][issues] and submit a [Pull Request][pulls].
6 | 1. Review open [Pull Requests][pulls].
7 | 1. Report a new [Issue][issues]. _Only do this after you've made sure the behavior or problem you're observing isn't already documented in an open issue._
8 |
9 | ## Getting Started
10 |
11 | webmention-client-ruby is developed using Ruby 3.4 and is tested against additional Ruby versions using [GitHub Actions](https://github.com/indieweb/webmention-client-ruby/actions).
12 |
13 | > [!TIP]
14 | > This project is configured with a [Dev Container](https://containers.dev) which includes everything you'd need to contribute to webmention-client-ruby. If you use a supported code editor or IDE, you're encouraged to use the existing Dev Container setup.
15 |
16 | Before making changes to webmention-client-ruby, you'll want to install Ruby 3.4. Using a Ruby version managment tool like [rbenv](https://github.com/rbenv/rbenv), [chruby](https://github.com/postmodern/chruby), or [rvm](https://github.com/rvm/rvm) is recommended. Once you've installed Ruby 3.4 using your method of choice, install the project's gems by running:
17 |
18 | ```sh
19 | bundle install
20 | ```
21 |
22 | ## Making Changes
23 |
24 | 1. Fork and clone the project's repo.
25 | 2. Install development dependencies as outlined above.
26 | 3. Create a feature branch for the code changes you're looking to make: `git checkout -b my-new-feature`.
27 | 4. _Write some code!_
28 | 5. If your changes would benefit from testing, add the necessary tests and verify everything passes by running `bin/rspec`.
29 | 6. Commit your changes: `git commit -am 'Add some new feature or fix some issue'`. _(See [this excellent article](https://cbea.ms/git-commit/) for tips on writing useful Git commit messages.)_
30 | 7. Push the branch to your fork: `git push -u origin my-new-feature`.
31 | 8. Create a new [Pull Request][pulls] and we'll review your changes.
32 |
33 | ## Code Style
34 |
35 | Code formatting conventions are defined in the `.editorconfig` file which uses the [EditorConfig](https://editorconfig.org) syntax. There are [plugins for a variety of editors](https://editorconfig.org/#download) that utilize the settings in the `.editorconfig` file. We recommended installing the EditorConfig plugin for your editor of choice.
36 |
37 | Your bug fix or feature addition won't be rejected if it runs afoul of any (or all) of these guidelines, but following the guidelines will definitely make everyone's lives a little easier.
38 |
39 | [issues]: https://github.com/indieweb/webmention-client-ruby/issues
40 | [pulls]: https://github.com/indieweb/webmention-client-ruby/pulls
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # webmention-client-ruby
2 |
3 | **A Ruby gem for sending and verifying [Webmention](https://indieweb.org/Webmention) notifications.**
4 |
5 | [](https://rubygems.org/gems/webmention)
6 | [](https://rubygems.org/gems/webmention)
7 | [](https://github.com/indieweb/webmention-client-ruby/actions/workflows/ci.yml)
8 |
9 | ## Key Features
10 |
11 | - Crawl a URL for mentioned URLs.
12 | - Perform [endpoint discovery](https://www.w3.org/TR/webmention/#sender-discovers-receiver-webmention-endpoint) on mentioned URLs.
13 | - Send webmentions to one or more mentioned URLs (and optionally include a [vouch](https://indieweb.org/Vouch) URL).
14 | - Verify that a received webmention's source URL links to a target URL (and optionally verify that a vouch URL mentions the source URL's domain).
15 | - Supports Ruby 2.7 and newer.
16 |
17 | ## Getting Started
18 |
19 | Before installing and using webmention-client-ruby, you'll want to have [Ruby](https://www.ruby-lang.org) 2.7 (or newer) installed. Using a Ruby version managment tool like [rbenv](https://github.com/rbenv/rbenv), [chruby](https://github.com/postmodern/chruby), or [rvm](https://github.com/rvm/rvm) is recommended.
20 |
21 | webmention-client-ruby is developed using Ruby 3.4 and is tested against additional Ruby versions using [GitHub Actions](https://github.com/indieweb/webmention-client-ruby/actions).
22 |
23 | ## Installation
24 |
25 | Add webmention-client-ruby to your project's `Gemfile` and run `bundle install`:
26 |
27 | ```ruby
28 | source "https://rubygems.org"
29 |
30 | gem "webmention"
31 | ```
32 |
33 | ## Usage
34 |
35 | See [USAGE.md](USAGE.md) for documentation of webmention-client-ruby's features.
36 |
37 | ## Contributing
38 |
39 | See [CONTRIBUTING.md](https://github.com/indieweb/webmention-client-ruby/blob/main/CONTRIBUTING.md) for more on how to contribute to webmention-client-ruby. Your help is greatly appreciated!
40 |
41 | By contributing to and participating in the development of webmention-client-ruby, you acknowledge that you have read and agree to the [IndieWeb Code of Conduct](https://indieweb.org/code-of-conduct).
42 |
43 | ## Acknowledgments
44 |
45 | webmention-client-ruby is written and maintained by [Jason Garber](https://sixtwothree.org) ([@jgarber623](https://github.com/jgarber623)) with help from [these additional contributors](https://github.com/indieweb/webmention-client-ruby/graphs/contributors). Prior to 2018, webmention-client-ruby was written and maintained by [Aaron Parecki](https://aaronparecki.com) ([@aaronpk](https://github.com/aaronpk)) and [Nat Welch](https://natwelch.com) ([@icco](https://github.com/icco)).
46 |
47 | To learn more about Webmention, see [indieweb.org/Webmention](https://indieweb.org/Webmention) and [webmention.net](https://webmention.net).
48 |
49 | ## License
50 |
51 | webmention-client-ruby is freely available under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html). See [LICENSE](https://github.com/indieweb/webmention-client-ruby/blob/main/LICENSE) for more details.
52 |
--------------------------------------------------------------------------------
/lib/webmention/request.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | class Request
5 | # Set defaults derived from Webmention specification examples.
6 | #
7 | # @see https://www.w3.org/TR/webmention/#limits-on-get-requests
8 | # W3C Webmention Recommendation § 4.2 Limits on GET requests
9 | HTTP_CLIENT_OPTS = {
10 | follow: {
11 | max_hops: 20,
12 | },
13 | headers: {
14 | accept: "*/*",
15 | user_agent: "Webmention Client (https://rubygems.org/gems/webmention)",
16 | },
17 | timeout_options: {
18 | connect_timeout: 5,
19 | read_timeout: 5,
20 | },
21 | }.freeze
22 |
23 | private_constant :HTTP_CLIENT_OPTS
24 |
25 | # @return [Symbol]
26 | attr_reader :method
27 |
28 | # @return [HTTP::URI]
29 | attr_reader :uri
30 |
31 | # @return [Hash]
32 | attr_reader :options
33 |
34 | # Send an HTTP GET request to the supplied URL.
35 | #
36 | # @example
37 | # Webmention::Request.get("https://jgarber.example/posts/100")
38 | #
39 | # @param url [String]
40 | #
41 | # @return [Response, ErrorResponse]
42 | def self.get(url)
43 | new(:get, url).perform
44 | end
45 |
46 | # Send an HTTP POST request with form-encoded data to the supplied URL.
47 | #
48 | # @example
49 | # Webmention::Request.post(
50 | # "https://aaronpk.example/webmention",
51 | # source: "https://jgarber.examples/posts/100",
52 | # target: "https://aaronpk.example/notes/1",
53 | # vouch: "https://tantek.example/notes/1"
54 | # )
55 | #
56 | # @param url [String]
57 | # @param options [Hash{Symbol => String}]
58 | # @option options [String] :source
59 | # An absolute URL representing a source document.
60 | # @option options [String] :target
61 | # An absolute URL representing a target document.
62 | # @option options [String] :vouch
63 | # An absolute URL representing a document vouching for the source document.
64 | # See https://indieweb.org/Vouch for additional details.
65 | #
66 | # @return [Response, ErrorResponse]
67 | def self.post(url, **options)
68 | new(:post, url, form: options.slice(:source, :target, :vouch)).perform
69 | end
70 |
71 | # Create a new {Request}.
72 | #
73 | # @param method [Symbol]
74 | # @param url [String]
75 | # @param options [Hash{Symbol => String}]
76 | def initialize(method, url, **options)
77 | @method = method.to_sym
78 | @uri = HTTP::URI.parse(url.to_s)
79 | @options = options
80 | end
81 |
82 | # :nocov:
83 | # @return [String]
84 | def inspect
85 | "#<#{self.class}:#{format("%#0x", object_id)} " \
86 | "method: #{method.upcase}, " \
87 | "url: #{uri}>"
88 | end
89 | # :nocov:
90 |
91 | # Submit the {Request}.
92 | #
93 | # @return [Response, ErrorResponse]
94 | def perform
95 | Response.new(client.request(method, uri, options), self)
96 | rescue HTTP::Error, OpenSSL::SSL::SSLError => e
97 | ErrorResponse.new(e.message, self)
98 | end
99 |
100 | private
101 |
102 | # @return [HTTP::Client]
103 | def client
104 | @client ||= HTTP::Client.new(HTTP_CLIENT_OPTS)
105 | end
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/spec/lib/webmention_verify_webmention_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Webmention, ".verify_webmention" do
4 | subject(:verification) { described_class.verify_webmention(source_url, target_url) }
5 |
6 | let(:source_url) { "https://jgarber.example/post/1" }
7 | let(:target_url) { "https://aaronpk.example/post/2" }
8 | let(:vouch_url) { "https://tantek.example/post/1" }
9 |
10 | context "when source URL does not link to target URL" do
11 | let(:verification_attributes) do
12 | {
13 | source_mentions_target?: false,
14 | verified?: false,
15 | verify_vouch?: false,
16 | }
17 | end
18 |
19 | before do
20 | stub_request(:get, source_url).to_return(
21 | body: load_fixture(:sample_post_no_links),
22 | headers: {
23 | "Content-Type": "text/html",
24 | }
25 | )
26 | end
27 |
28 | it { is_expected.to be_a(Webmention::Verification) }
29 | it { is_expected.to have_attributes(verification_attributes) }
30 | end
31 |
32 | context "when source URL links to target URL" do
33 | let(:verification_attributes) do
34 | {
35 | source_mentions_target?: true,
36 | verified?: true,
37 | verify_vouch?: false,
38 | }
39 | end
40 |
41 | before do
42 | stub_request(:get, source_url).to_return(
43 | body: %({"url":"#{target_url}"}),
44 | headers: {
45 | "Content-Type": "application/json",
46 | }
47 | )
48 | end
49 |
50 | it { is_expected.to be_a(Webmention::Verification) }
51 | it { is_expected.to have_attributes(verification_attributes) }
52 | end
53 |
54 | context "when vouch URL does not link to source URL domain" do
55 | subject(:verification) { described_class.verify_webmention(source_url, target_url, vouch: vouch_url) }
56 |
57 | let(:verification_attributes) do
58 | {
59 | source_mentions_target?: true,
60 | verified?: false,
61 | verify_vouch?: true,
62 | vouch_mentions_source?: false,
63 | }
64 | end
65 |
66 | before do
67 | stub_request(:get, source_url).to_return(
68 | body: load_fixture(:sample_post_anchors_only),
69 | headers: {
70 | "Content-Type": "text/html",
71 | }
72 | )
73 |
74 | stub_request(:get, vouch_url).to_return(
75 | body: load_fixture(:sample_post_no_links),
76 | headers: {
77 | "Content-Type": "text/html",
78 | }
79 | )
80 | end
81 |
82 | it { is_expected.to be_a(Webmention::Verification) }
83 | it { is_expected.to have_attributes(verification_attributes) }
84 | end
85 |
86 | context "when vouch URL links to source URL domain" do
87 | subject(:verification) { described_class.verify_webmention(source_url, target_url, vouch: vouch_url) }
88 |
89 | let(:verification_attributes) do
90 | {
91 | source_mentions_target?: true,
92 | verified?: true,
93 | verify_vouch?: true,
94 | vouch_mentions_source?: true,
95 | }
96 | end
97 |
98 | before do
99 | stub_request(:get, source_url).to_return(
100 | body: load_fixture(:sample_post_anchors_only),
101 | headers: {
102 | "Content-Type": "text/html",
103 | }
104 | )
105 |
106 | stub_request(:get, vouch_url).to_return(
107 | body: "I vouch for:\n\nhttps://jgarber.example\nhttps://adactio.example",
108 | headers: {
109 | "Content-Type": "text/plain",
110 | }
111 | )
112 | end
113 |
114 | it { is_expected.to be_a(Webmention::Verification) }
115 | it { is_expected.to have_attributes(verification_attributes) }
116 | end
117 | end
118 |
--------------------------------------------------------------------------------
/lib/webmention.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "json"
4 |
5 | require "http"
6 | require "indieweb/endpoints"
7 | require "nokogiri"
8 |
9 | require_relative "webmention/client"
10 | require_relative "webmention/url"
11 | require_relative "webmention/request"
12 | require_relative "webmention/response"
13 | require_relative "webmention/error_response"
14 | require_relative "webmention/verification"
15 |
16 | require_relative "webmention/parser"
17 | require_relative "webmention/parsers/html_parser"
18 | require_relative "webmention/parsers/json_parser"
19 | require_relative "webmention/parsers/plaintext_parser"
20 |
21 | module Webmention
22 | # Retrieve unique URLs mentioned by the provided URL.
23 | #
24 | # @example
25 | # Webmention.mentioned_urls("https://jgarber.example/posts/100")
26 | #
27 | # @param url [String, HTTP::URI, #to_s] An absolute URL.
28 | #
29 | # @raise [NoMethodError]
30 | # Raised when response is an {ErrorResponse} or response is of an
31 | # unsupported MIME type.
32 | #
33 | # @return [Array]
34 | def self.mentioned_urls(url)
35 | Client.new(url).mentioned_urls
36 | end
37 |
38 | # Send a webmention from a source URL to a target URL.
39 | #
40 | # @example Send a webmention
41 | # source = "https://jgarber.example/posts/100"
42 | # target = "https://aaronpk.example/notes/1"
43 | # Webmention.send_webmention(source, target)
44 | #
45 | # @example Send a webmention with a vouch URL
46 | # source = "https://jgarber.example/posts/100"
47 | # target = "https://aaronpk.example/notes/1"
48 | # Webmention.send_webmention(source, target, vouch: "https://tantek.example/notes/1")
49 | #
50 | # @param source [String, HTTP::URI, #to_s]
51 | # An absolute URL representing a source document.
52 | # @param target [String, HTTP::URI, #to_s]
53 | # An absolute URL representing a target document.
54 | # @param vouch [String, HTTP::URI, #to_s]
55 | # An absolute URL representing a document vouching for the source document.
56 | # See https://indieweb.org/Vouch for additional details.
57 | #
58 | # @return [Response, ErrorResponse]
59 | def self.send_webmention(source, target, vouch: nil)
60 | Client.new(source, vouch: vouch).send_webmention(target)
61 | end
62 |
63 | # Send webmentions from a source URL to multiple target URLs.
64 | #
65 | # @example Send multiple webmentions
66 | # source = "https://jgarber.example/posts/100"
67 | # targets = ["https://aaronpk.example/notes/1", "https://adactio.example/notes/1"]
68 | # Webmention.send_webmentions(source, targets)
69 | #
70 | # @example Send multiple webmentions with a vouch URL
71 | # source = "https://jgarber.example/posts/100"
72 | # targets = ["https://aaronpk.example/notes/1", "https://adactio.example/notes/1"]
73 | # Webmention.send_webmentions(source, targets, vouch: "https://tantek.example/notes/1")
74 | #
75 | # @param source [String, HTTP::URI, #to_s]
76 | # An absolute URL representing a source document.
77 | # @param targets [Array]
78 | # An array of absolute URLs representing multiple target documents.
79 | # @param vouch [String, HTTP::URI, #to_s]
80 | # An absolute URL representing a document vouching for the source document.
81 | # See https://indieweb.org/Vouch for additional details.
82 | #
83 | # @return [Array]
84 | def self.send_webmentions(source, *targets, vouch: nil)
85 | Client.new(source, vouch: vouch).send_webmentions(*targets)
86 | end
87 |
88 | # Verify that a source URL links to a target URL.
89 | #
90 | # @param (see Webmention.send_webmention)
91 | #
92 | # @raise (see Client#mentioned_urls)
93 | #
94 | # @return [Boolean]
95 | def self.verify_webmention(source, target, vouch: nil)
96 | Client.new(source, vouch: vouch).verify_webmention(target)
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | > [!NOTE]
4 | > From v8.0.0, changes are documented using [GitHub Releases](https://github.com/indieweb/webmention-client-ruby/releases). For a given release, metadata on RubyGems.org will link to that version's Release page.
5 |
6 | ## 7.0.0 / 2022-11-09
7 |
8 | - Refactor HTML and JSON parser classes (ec58206 and 6818c05)
9 | - Update indieweb-endpoints dependency constraint (4cd742f)
10 | - Relax nokogiri dependency constraint (e86f3bc)
11 | - **Breaking change:** Update development Ruby to 2.7.6 and minimum Ruby to 2.7 (5bee7dd)
12 |
13 | ## 6.0.0 / 2022-05-13
14 |
15 | ### New Features
16 |
17 | - Top-level module methods:
18 | - `Webmention.send_webmention(source, target)`
19 | - `Webmention.send_webmentions(source, *targets)`
20 | - `Webmention.mentioned_urls(url)`
21 | - `Webmention.verify_webmention(source, target)`
22 | - New JSON and plaintext parsers
23 | - [Vouch](https://indieweb.org/Vouch) URL support (9829269)
24 | - Webmention verification support (5fe5f58 and 100644)
25 | - Fewer exceptions! HTTP response handling updated to return similar objects (`Webmention::Response` and `Webmention::ErrorResponse`).
26 | - Fewer runtime dependencies!
27 |
28 | ### Breaking Changes
29 |
30 | - `Webmention.send_mention` renamed to `Webmention.send_webmention`
31 | - `Webmention.client` method removed
32 | - `Webmention::Client#send_all_mentions` removed in favor of `Webmention.mentioned_urls` and `Webmention.send_webmentions`
33 | - Response objects from `Webmention.send_webmention` and `Webmention.send_webmentions` have changed from instances of `HTTP::Response` to instances of `Webmention::Response` or `Webmention::ErrorResponse`
34 | - Remove Absolutely and Addressable dependencies
35 | - Add support for Ruby 3 (a31aae6)
36 | - Update minimum supported Ruby version to 2.6 (e4fed8e)
37 |
38 | ### Development Changes
39 |
40 | - Remove Reek development dependency (806bbc7)
41 | - Update development Ruby version to 2.6.10 (7e52ec9)
42 | - Migrate test suite to RSpec (79ac684)
43 | - Migrate to GitHub Actions (f5a3d7a)
44 |
45 | ## 5.0.0 / 2020-12-13
46 |
47 | - Update absolutely and indieweb-endpoints gems to v5.0 (89f4ea8)
48 |
49 | ## 4.0.0 / 2020-08-23
50 |
51 | - **Breaking change:** Update minimum supported Ruby version to 2.5 (b2bc62f)
52 | - Update indieweb-endpoints to 4.0 (e61588f)
53 | - Update project Ruby version to 2.5.8 (2a626a6)
54 |
55 | ## 3.0.0 / 2020-05-19
56 |
57 | - Reject "internal" URLs when sending webmentions (#24) (ccc82c8)
58 | - Select only HTTP/HTTPS URLs when sending webmentions (#22) (39e5852)
59 |
60 | ## 2.2.0 / 2020-05-18
61 |
62 | - Update absolutely and indieweb-endpoints gems (350d2ed)
63 | - Add pry-byebug and `bin/console` script (d2c5e03)
64 | - Move development dependencies to Gemfile per current Bundler conventions (3a2fc21)
65 | - Update development Ruby version to 2.4.10 (4c7d1f7)
66 |
67 | ## 2.1.0 / 2020-04-06
68 |
69 | - Refactor `BaseParser` class and remove `Registerable` module (b706229)
70 | - Refactor `HttpRequest` and `NodeParser` classes into Service Objects (f29c073 and 7456bf1)
71 |
72 | ## 2.0.0 / 2020-01-25
73 |
74 | - Add Ruby 2.7 to list of supported Ruby versions (c67ed14)
75 | - Update absolutely, addressable, http, and indieweb-endpoints version constaints (986d326 and 6ba054f)
76 | - Update development dependencies (74ac982)
77 | - Update project Ruby version to 2.4.9 and update documentation (fd61ddf)
78 |
79 | ## 1.0.2 / 2019-08-31
80 |
81 | - Update Addressable and WebMock gems (0b98981)
82 | - Update project development Ruby to 2.4.7 and update documentation (882d4d3)
83 |
84 | ## 1.0.1 / 2019-07-17
85 |
86 | - Update indieweb-endpoints (cfe6287) and rubocop (c2b7047)
87 |
88 | ## 1.0.0 / 2019-07-03
89 |
90 | ### Breaking Changes
91 |
92 | For an instance of the `Webmention::Client` class:
93 |
94 | - The `send_mention` no longer accepts the `full_response` argument. When a Webmention endpoint is found, the method returns an `HTTP::Response` object. Otherwise, the method returns `nil`.
95 | - The `send_mentions` method is renamed to `send_all_mentions` and now returns a Hash whose keys are URLs and values are `HTTP::Response` objects (or `nil` when no Webmention endpoint is found at the given URL).
96 | - The `mentioned_url` method returns an Array of URLs mentioned within given URL's first `.h-entry` (if one exists). Otherwise, it returns a list of all URLs within the given URL's ``.
97 |
98 | ### Development Changes
99 |
100 | - Removes [Bundler](https://bundler.io) as a dependency (5e1662d)
101 | - Updates project Ruby to 2.4.6 (the latest 2.4.x release at this time) (b53a400)
102 | - Add the [Reek](https://github.com/troessner/reek) code smell detector (eb314dc)
103 | - Adds binstubs for more easily running common development tools (8899a22)
104 |
--------------------------------------------------------------------------------
/lib/webmention/client.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Webmention
4 | class Client
5 | @registered_parsers = {}
6 |
7 | class << self
8 | # @api private
9 | attr_reader :registered_parsers
10 | end
11 |
12 | # @return [Url]
13 | attr_reader :source_url
14 |
15 | # @return [Url]
16 | attr_reader :vouch_url
17 |
18 | # @api private
19 | def self.register_parser(klass)
20 | klass.mime_types.each { |mime_type| @registered_parsers[mime_type] = klass }
21 | end
22 |
23 | # Create a new {Client}.
24 | #
25 | # @example
26 | # Webmention::Client.new("https://jgarber.example/posts/100")
27 | #
28 | # @example
29 | # Webmention::Client.new("https://jgarber.example/posts/100", vouch: "https://tantek.example/notes/1")
30 | #
31 | # @param source [String, HTTP::URI, #to_s]
32 | # An absolute URL representing a source document.
33 | # @param vouch [String, HTTP::URI, #to_s]
34 | # An absolute URL representing a document vouching for the source document.
35 | # See https://indieweb.org/Vouch for additional details.
36 | def initialize(source, vouch: nil)
37 | @source_url = Url.new(source)
38 | @vouch_url = Url.new(vouch)
39 | end
40 |
41 | # :nocov:
42 | # @return [String]
43 | def inspect
44 | "#<#{self.class}:#{format("%#0x", object_id)} " \
45 | "source_url: #{source_url} " \
46 | "vouch_url: #{vouch_url}>"
47 | end
48 | # :nocov:
49 |
50 | # Retrieve unique URLs mentioned by this client's source URL.
51 | #
52 | # @example
53 | # client = Webmention::Client.new("https://jgarber.example/posts/100")
54 | # client.mentioned_urls
55 | #
56 | # @raise [NoMethodError]
57 | # Raised when response is an {ErrorResponse} or response is of an
58 | # unsupported MIME type.
59 | #
60 | # @return [Array]
61 | def mentioned_urls
62 | response = source_url.response
63 |
64 | urls = Set.new(
65 | self.class
66 | .registered_parsers[response.mime_type]
67 | .new(response.body, response.uri)
68 | .results
69 | )
70 |
71 | urls.reject! { |url| url.match(/^#{response.uri}(?:#.*)?$/) }
72 |
73 | urls.to_a.sort
74 | end
75 |
76 | # Send a webmention from this client's source URL to a target URL.
77 | #
78 | # @example
79 | # client = Webmention::Client.new("https://jgarber.example/posts/100")
80 | # client.send_webmention("https://aaronpk.example/notes/1")
81 | #
82 | # @param target [String, HTTP::URI, #to_s]
83 | # An absolute URL representing a target document.
84 | #
85 | # @return [Response, ErrorResponse]
86 | def send_webmention(target)
87 | target_url = Url.new(target)
88 |
89 | # A Webmention endpoint exists. Send the request and return the response.
90 | if target_url.webmention_endpoint?
91 | return Request.post(target_url.webmention_endpoint, **request_options_for(target))
92 | end
93 |
94 | # An error was encountered fetching the target URL. Return the response.
95 | return target_url.response unless target_url.response.ok?
96 |
97 | # No Webmention endpoint exists. Return a new ErrorResponse.
98 | ErrorResponse.new("No webmention endpoint found for target URL #{target}", target_url.response.request)
99 | end
100 |
101 | # Send webmentions from this client's source URL to multiple target URLs.
102 | #
103 | # @example
104 | # client = Webmention::Client.new("https://jgarber.example/posts/100")
105 | # targets = ["https://aaronpk.example/notes/1", "https://adactio.example/notes/1"]
106 | # client.send_webmentions(targets)
107 | #
108 | # @param targets [Array]
109 | # An array of absolute URLs representing multiple target documents.
110 | #
111 | # @return [Array]
112 | def send_webmentions(*targets)
113 | targets.map { |target| send_webmention(target) }
114 | end
115 |
116 | # Verify that this client's source URL links to a target URL.
117 | #
118 | # @param target [String, HTTP::URI, #to_s]
119 | # An absolute URL representing a target document.
120 | #
121 | # @raise (see Client#mentioned_urls)
122 | #
123 | # @return [Verification]
124 | def verify_webmention(target)
125 | Verification.new(source_url, Url.new(target), vouch_url: vouch_url)
126 | end
127 |
128 | private
129 |
130 | # @param target [String, HTTP::URI, #to_s]
131 | #
132 | # @return [Hash{Symbol => String}]
133 | def request_options_for(target)
134 | opts = {
135 | source: source_url,
136 | target: target,
137 | vouch: vouch_url,
138 | }
139 |
140 | opts.transform_values! { |value| value.to_s.strip }
141 | opts.reject! { |_, value| value.empty? }
142 |
143 | opts
144 | end
145 | end
146 | end
147 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file was generated by the `rspec --init` command. Conventionally, all
4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
5 | # The generated `.rspec` file contains `--require spec_helper` which will cause
6 | # this file to always be loaded, without a need to explicitly require it in any
7 | # files.
8 | #
9 | # Given that it is always loaded, you are encouraged to keep this file as
10 | # light-weight as possible. Requiring heavyweight dependencies from this file
11 | # will add to the boot time of your test suite on EVERY test run, even for an
12 | # individual file that may not need all of that loaded. Instead, consider making
13 | # a separate helper file that requires the additional dependencies and performs
14 | # the additional setup, and require it from the spec files that actually need
15 | # it.
16 | #
17 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
18 |
19 | require "simplecov"
20 | require "webmock/rspec"
21 |
22 | require "webmention"
23 |
24 | Dir[File.join(__dir__, "support/**/*.rb")].sort.each { |f| require_relative f }
25 |
26 | RSpec.configure do |config|
27 | config.include FixturesHelper
28 |
29 | # rspec-expectations config goes here. You can use an alternate
30 | # assertion/expectation library such as wrong or the stdlib/minitest
31 | # assertions if you prefer.
32 | config.expect_with :rspec do |expectations|
33 | # This option will default to `true` in RSpec 4. It makes the `description`
34 | # and `failure_message` of custom matchers include text for helper methods
35 | # defined using `chain`, e.g.:
36 | # be_bigger_than(2).and_smaller_than(4).description
37 | # # => "be bigger than 2 and smaller than 4"
38 | # ...rather than:
39 | # # => "be bigger than 2"
40 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true
41 | end
42 |
43 | # rspec-mocks config goes here. You can use an alternate test double
44 | # library (such as bogus or mocha) by changing the `mock_with` option here.
45 | config.mock_with :rspec do |mocks|
46 | # Prevents you from mocking or stubbing a method that does not exist on
47 | # a real object. This is generally recommended, and will default to
48 | # `true` in RSpec 4.
49 | mocks.verify_partial_doubles = true
50 | end
51 |
52 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
53 | # have no way to turn it off -- the option exists only for backwards
54 | # compatibility in RSpec 3). It causes shared context metadata to be
55 | # inherited by the metadata hash of host groups and examples, rather than
56 | # triggering implicit auto-inclusion in groups with matching metadata.
57 | config.shared_context_metadata_behavior = :apply_to_host_groups
58 |
59 | # The settings below are suggested to provide a good initial experience
60 | # with RSpec, but feel free to customize to your heart's content.
61 |
62 | # This allows you to limit a spec run to individual examples or groups
63 | # you care about by tagging them with `:focus` metadata. When nothing
64 | # is tagged with `:focus`, all examples get run. RSpec also provides
65 | # aliases for `it`, `describe`, and `context` that include `:focus`
66 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
67 | config.filter_run_when_matching :focus
68 |
69 | # Allows RSpec to persist some state between runs in order to support
70 | # the `--only-failures` and `--next-failure` CLI options. We recommend
71 | # you configure your source control system to ignore this file.
72 | # config.example_status_persistence_file_path = 'spec/examples.txt'
73 |
74 | # Limits the available syntax to the non-monkey patched syntax that is
75 | # recommended. For more details, see:
76 | # https://relishapp.com/rspec/rspec-core/docs/configuration/zero-monkey-patching-mode
77 | config.disable_monkey_patching!
78 |
79 | # This setting enables warnings. It's recommended, but in some cases may
80 | # be too noisy due to issues in dependencies.
81 | config.warnings = true
82 |
83 | # Many RSpec users commonly either run the entire suite or an individual
84 | # file, and it's useful to allow more verbose output when running an
85 | # individual spec file.
86 | if config.files_to_run.one?
87 | # Use the documentation formatter for detailed output,
88 | # unless a formatter has already been configured
89 | # (e.g. via a command-line flag).
90 | config.default_formatter = "doc"
91 | end
92 |
93 | # Print the 10 slowest examples and example groups at the
94 | # end of the spec run, to help surface which specs are running
95 | # particularly slow.
96 | # config.profile_examples = 10
97 |
98 | # Run specs in random order to surface order dependencies. If you find an
99 | # order dependency and want to debug it, you can fix the order by providing
100 | # the seed, which is printed after each run.
101 | # --seed 1234
102 | config.order = :random
103 |
104 | # Seed global randomization in this process using the `--seed` CLI option.
105 | # Setting this allows you to use `--seed` to deterministically reproduce
106 | # test failures related to randomization by passing the same `--seed` value
107 | # as the one that triggered the failure.
108 | Kernel.srand config.seed
109 | end
110 |
--------------------------------------------------------------------------------
/USAGE.md:
--------------------------------------------------------------------------------
1 | # Using webmention-client-ruby
2 |
3 | > [!NOTE]
4 | > Before using webmention-client-ruby, please read the [Getting Started](README.md#getting-started) and [Installation](README.md#installation) sections of the project's [README.md](README.md).
5 |
6 | ## Sending a webmention
7 |
8 | With webmention-client-ruby installed, you may send a webmention from a source URL to a target URL:
9 |
10 | ```ruby
11 | require "webmention"
12 |
13 | source = "https://jgarber.example/post/100" # A post on your website
14 | target = "https://aaronpk.example/post/100" # A post on someone else's website
15 |
16 | response = Webmention.send_webmention(source, target)
17 | ```
18 |
19 | `Webmention.send_webmention` will return either a `Webmention::Response` or a `Webmention::ErrorResponse`. Instances of both classes respond to `ok?`. Building on the examples above, a `Webmention::ErrorResponse` may be returned when:
20 |
21 | 1. The target URL does not advertise a Webmention endpoint.
22 | 2. The request to the target URL raises an `HTTP::Error` or an `OpenSSL::SSL::SSLError`.
23 |
24 | ```ruby
25 | response.ok?
26 | #=> false
27 |
28 | response.class
29 | #=> Webmention::ErrorResponse
30 |
31 | response.message
32 | #=> "No webmention endpoint found for target URL https://aaronpk.example/post/100"
33 | ```
34 |
35 | A `Webmention::Response` will be returned in all other cases.
36 |
37 | ```ruby
38 | response.ok?
39 | #=> true
40 |
41 | response.class
42 | #=> Webmention::Response
43 | ```
44 |
45 | Instances of `Webmention::Response` include useful methods delegated to the underlying `HTTP::Response` object:
46 |
47 | ```ruby
48 | response.headers #=> HTTP::Headers
49 | response.body #=> HTTP::Response::Body
50 | response.code #=> Integer
51 | response.reason #=> String
52 | response.mime_type #=> String
53 | response.uri #=> HTTP::URI
54 | ```
55 |
56 | > [!NOTE]
57 | > `Webmention::Response` objects may return a variety of status codes that will vary depending on the endpoint's capabilities and the success or failure of the request. See [the Webmention spec](https://www.w3.org/TR/webmention/) for more on status codes on their implications. A `Webmention::Response` responding affirmatively to `ok?` _may_ also have a non-successful HTTP status code (e.g. `404 Not Found`).
58 |
59 | ## Sending multiple webmentions
60 |
61 | To send webmentions to multiple target URLs mentioned by a source URL:
62 |
63 | ```ruby
64 | source = "https://jgarber.example/post/100"
65 | targets = ["https://aaronpk.example/notes/1", "https://adactio.example/notes/1"]
66 |
67 | responses = Webmention.send_webmentions(source, targets)
68 | ```
69 |
70 | `Webmention.send_webmentions` will return an array of `Webmention::Response` and `Webmention::ErrorResponse` objects.
71 |
72 | ## Including a vouch URL
73 |
74 | webmention-client-ruby supports submitting a [vouch](https://indieweb.org/Vouch) URL when sending webmentions:
75 |
76 | ```ruby
77 | # Send a webmention with a vouch URL to a target URL
78 | Webmention.send_webmention(source, target, vouch: "https://tantek.example/notes/1")
79 |
80 | # Send webmentions with a vouch URL to multiple target URLs
81 | Webmention.send_webmentions(source, targets, vouch: "https://tantek.example/notes/1")
82 | ```
83 |
84 | ## Discovering mentioned URLs
85 |
86 | To retrieve unique URLs mentioned by a URL:
87 |
88 | ```ruby
89 | urls = Webmention.mentioned_urls("https://jgarber.example/post/100")
90 | ```
91 |
92 | `Webmention.mentioned_urls` will crawl the provided URL, parse the response body, and return a sorted list of unique URLs. Response bodies are parsed using MIME type-specific rules as noted in the [Verifying a webmention](#verifying-a-webmention) section below.
93 |
94 | When parsing HTML documents, webmention-client-ruby will find the first [h-entry](https://microformats.org/wiki/h-entry) and search its markup for URLs. If no h-entry is found, the parser will search the document's ``.
95 |
96 | > [!NOTE]
97 | > Links pointing to the supplied URL (or those with internal fragment identifiers) will be rejected. You may wish to additionally filter the results returned by `Webmention.mentioned_urls` before sending webmentions.
98 |
99 | ## Verifying a webmention
100 |
101 | webmention-client-ruby verifies [HTML](https://www.w3.org/TR/html/), [JSON](https://json.org), and plaintext files in accordance with [Section 3.2.2](https://www.w3.org/TR/webmention/#webmention-verification) of [the W3C's Webmention Recommendation](https://www.w3.org/TR/webmention/):
102 |
103 | > The receiver **should** use per-media-type rules to determine whether the source document mentions the target URL.
104 |
105 | In plaintext documents, webmention-client-ruby will search the source URL for exact matches of the target URL. If the source URL is a JSON document, key/value pairs whose value equals the target URL are matched.
106 |
107 | HTML documents are searched for a variety of elements and attributes whose values may be (or include) URLs:
108 |
109 | | Element | Attributes |
110 | |:-------------|:----------------|
111 | | `a` | `href` |
112 | | `area` | `href` |
113 | | `audio` | `src` |
114 | | `blockquote` | `cite` |
115 | | `del` | `cite` |
116 | | `embed` | `src` |
117 | | `img` | `src`, `srcset` |
118 | | `ins` | `cite` |
119 | | `object` | `data` |
120 | | `q` | `cite` |
121 | | `source` | `src`, `srcset` |
122 | | `track` | `src` |
123 | | `video` | `src` |
124 |
125 | To verify a received webmention:
126 |
127 | ```ruby
128 | # Verify that a source URL links to a target URL
129 | verification = Webmention.verify_webmention(source, target)
130 |
131 | # Verify that a source URL links to a target URL and that the vouch URL mentions
132 | # the source URL's domain
133 | verification = Webmention.verify_webmention(source, target, vouch: "https://tantek.example/notes/1")
134 | ```
135 |
136 | `Webmention.verify_webmention` returns an instance of `Webmention::Verification` which includes the following methods (each returns either `true` or `false`):
137 |
138 | ```ruby
139 | verification.source_mentions_target?
140 | verification.verified?
141 | verification.verify_vouch?
142 | verification.vouch_mentions_source?
143 | ```
144 |
145 | > [!NOTE]
146 | > `Webmention.verify_webmention` parses HTML documents using the same rules outlined in [Discovering mentioned URLs](#discovering-mentioned-urls).
147 |
148 | ## Exception Handling
149 |
150 | webmention-client-ruby avoids raising exceptions when making HTTP requests. As noted above, a `Webmention::ErrorResponse` should be returned in cases where an HTTP request triggers an exception.
151 |
152 | When crawling the supplied URL, `Webmention.mentioned_urls` _may_ raise a `NoMethodError` if a `Webmention::ErrorResponse` is returned, or the response is of an unsupported MIME type.
153 |
--------------------------------------------------------------------------------