jekyll-standard-site 0.1.0 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 432d493831fe450e29f8cea035a23d7ec753b87f55b92c53e521afbc361abbee
4
- data.tar.gz: '09ffc806be505b95521bdb62698615790d2967fa331d262d93b66bdb22d71494'
3
+ metadata.gz: 26ecdb29d4af51a0e59c0edc928d346ab5b999800755e2d461efb442dd77ba69
4
+ data.tar.gz: 2c1bda0b284aa9e3293cdb8acf1faf3b41bf9dd7699f1d092af0695a3a484462
5
5
  SHA512:
6
- metadata.gz: 3567bd39945886a4230422d6496e74e4172f022a2dffa32a9520633c07b5eba829efbf2308658f454aecfb3aecfc49dd9013cce1b902becc46b8c22b15cf81b0
7
- data.tar.gz: 3a1c605dd484f9342964dc8f7ae974abc08a81245369bc285d636df6bf0874d9ae1856f3b745c92de70cc97bcb3196c6a4e0bf0335aade744f930f7d8e3ea0eb
6
+ metadata.gz: b0b1cccd4f5f28ab56c90957330cb9c3f8eb9de5707e6c54f001769c0793770ea9a604c770f5ac9b3074ffa5d1097f54dbff90704a7654d9d9a9c3e92c264147
7
+ data.tar.gz: ebc0a6af25b249a9e694862566ea940c6ef4c9c5b95f755a096a1683268560ccdcb00484aaf579a906c78c2f1cd7f5e3d0126f794accb9d3d7ccd830f8f47732
data/README.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  A Jekyll plugin that emits the verification artifacts required by [standard.site](https://standard.site), the AT Protocol lexicons for long-form publishing.
4
4
 
5
- The plugin does not create AT Protocol records. Those live on your PDS and are created separately (via the standard.site dashboard, `goat`, `@atproto/api`, etc.). Once the records exist, this plugin handles the static-site side: a `.well-known` endpoint for your publication and `<link>` tags on each post that point at the corresponding document record.
5
+ It does two things:
6
6
 
7
- ## Installation
7
+ 1. **At build time**, emits the `.well-known` endpoint and the `<link>` tags that prove your site owns its publication and document records.
8
+ 2. **At publish time**, optionally creates `site.standard.document` records on your PDS and writes the returned AT-URIs back into each post's front matter, so the next build emits the document verification link.
8
9
 
9
- Add to your Gemfile:
10
+ ## Installation
10
11
 
11
12
  ```ruby
12
13
  group :jekyll_plugins do
@@ -14,7 +15,7 @@ group :jekyll_plugins do
14
15
  end
15
16
  ```
16
17
 
17
- And to `_config.yml`:
18
+ In `_config.yml`:
18
19
 
19
20
  ```yaml
20
21
  plugins:
@@ -24,13 +25,24 @@ standard_site:
24
25
  publication: "at://did:plc:abc123/site.standard.publication/3lwafzkjqm25s"
25
26
  ```
26
27
 
27
- ## Usage
28
+ ## Bootstrap: create the publication record
29
+
30
+ This gem does **not** create the publication record. That's a one-time step you do out-of-band, then paste the resulting AT-URI into `_config.yml`.
31
+
32
+ Pick whichever flow you prefer:
28
33
 
29
- ### Publication verification
34
+ - The [standard.site](https://standard.site) dashboard
35
+ - [`goat`](https://github.com/bluesky-social/indigo/tree/main/cmd/goat) — `goat record create --collection site.standard.publication --record '{"url":"https://example.com","name":"Example"}'`
36
+ - [`sequoia-cli`](https://sequoia.pub)
37
+ - A direct `com.atproto.repo.createRecord` call
30
38
 
31
- The plugin writes the publication AT-URI to `/.well-known/site.standard.publication`. No further setup needed.
39
+ Once the record exists, set `standard_site.publication` and the well-known endpoint plus the publication discovery hint start working immediately on the next build.
32
40
 
33
- If the publication is not at the domain root, set `publication_path`:
41
+ ## Build-time behaviour
42
+
43
+ ### `.well-known/site.standard.publication`
44
+
45
+ Written automatically from `standard_site.publication`. If your publication is not at the domain root, set `publication_path`:
34
46
 
35
47
  ```yaml
36
48
  standard_site:
@@ -38,26 +50,94 @@ standard_site:
38
50
  publication_path: "/blog"
39
51
  ```
40
52
 
41
- This writes to `/.well-known/site.standard.publication/blog` per the [verification spec](https://standard.site/docs/verification).
53
+ This writes to `/.well-known/site.standard.publication/blog`.
42
54
 
43
- ### Document verification
55
+ ### Verification link tags
44
56
 
45
- Add the document's AT-URI to each post's front matter:
57
+ Drop the Liquid tag into your `<head>` layout:
46
58
 
47
- ```yaml
48
- ---
49
- title: My First Post
50
- at_uri: "at://did:plc:abc123/site.standard.document/3mek5jhkri72r"
51
- ---
59
+ ```liquid
60
+ {% standard_site_links %}
52
61
  ```
53
62
 
54
- Then drop the Liquid tag into your `<head>` layout:
63
+ It emits a `site.standard.publication` discovery hint on every page, and a `site.standard.document` link tag on any page whose front matter includes `at_uri`.
55
64
 
56
- ```liquid
57
- {% standard_site_links %}
65
+ ## Publishing documents
66
+
67
+ To verify individual posts and have them discoverable via standard.site readers, each post needs a `site.standard.document` record on your PDS, plus its AT-URI in the post's front matter as `at_uri`.
68
+
69
+ This gem ships a Rake task that handles both. Add to your `Rakefile`:
70
+
71
+ ```ruby
72
+ require "jekyll-standard-site/tasks"
73
+ ```
74
+
75
+ Then:
76
+
77
+ ```
78
+ BSKY_HANDLE=you.bsky.social BSKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx bundle exec rake standard_site:publish
79
+ ```
80
+
81
+ The task:
82
+
83
+ 1. Logs in via `com.atproto.server.createSession` using the credentials in env vars.
84
+ 2. Reads `_posts` and finds posts whose front matter has no `at_uri`.
85
+ 3. For each, calls `com.atproto.repo.createRecord` with a `site.standard.document` payload built from the post's `title`, `description`, `tags`, `date`, and URL.
86
+ 4. Patches the returned AT-URI back into the post file as `at_uri: "..."`.
87
+
88
+ It's idempotent. Re-running skips any post that already has `at_uri`.
89
+
90
+ Env vars:
91
+
92
+ - `BSKY_HANDLE` — your handle (e.g. `you.bsky.social`).
93
+ - `BSKY_APP_PASSWORD` — generate at <https://bsky.app/settings/app-passwords>. Use a dedicated one.
94
+ - `BSKY_PDS` — defaults to `https://bsky.social`. Override if you self-host.
95
+
96
+ ### GitHub Actions
97
+
98
+ A workflow that publishes documents whenever new posts land on `master`:
99
+
100
+ ```yaml
101
+ name: Publish documents
102
+
103
+ on:
104
+ push:
105
+ branches: ["master"]
106
+ paths: ["_posts/**"]
107
+
108
+ permissions: {}
109
+
110
+ concurrency:
111
+ group: standard-site-publish
112
+ cancel-in-progress: false
113
+
114
+ jobs:
115
+ publish:
116
+ if: github.actor != 'github-actions[bot]'
117
+ runs-on: ubuntu-latest
118
+ permissions:
119
+ contents: write
120
+ steps:
121
+ - uses: actions/checkout@v6
122
+ - uses: ruby/setup-ruby@v1
123
+ with:
124
+ bundler-cache: true
125
+ - run: bundle exec rake standard_site:publish
126
+ env:
127
+ BSKY_HANDLE: ${{ secrets.BSKY_HANDLE }}
128
+ BSKY_APP_PASSWORD: ${{ secrets.BSKY_APP_PASSWORD }}
129
+ - name: Commit patched front matter
130
+ run: |
131
+ if [[ -n "$(git status --porcelain _posts)" ]]; then
132
+ git config user.name "github-actions[bot]"
133
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
134
+ git add _posts
135
+ git commit -m "Add at_uri to new posts [skip ci]"
136
+ git push
137
+ fi
58
138
  ```
59
139
 
60
- This emits a `site.standard.publication` discovery hint on every page and a `site.standard.document` link tag on any page whose front matter includes `at_uri`.
140
+ The `[skip ci]` plus the `github.actor` guard stop the bot's own commits from re-triggering the workflow.
61
141
 
62
142
  ## License
63
143
 
@@ -0,0 +1,123 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+ require "yaml"
5
+
6
+ module Jekyll
7
+ module StandardSite
8
+ class PublisherError < StandardError; end
9
+
10
+ class HttpClient
11
+ def post_json(url, body, headers = {})
12
+ uri = URI(url)
13
+ req = Net::HTTP::Post.new(uri)
14
+ req["Content-Type"] = "application/json"
15
+ headers.each { |k, v| req[k] = v }
16
+ req.body = JSON.dump(body)
17
+
18
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
19
+ http.request(req)
20
+ end
21
+
22
+ parsed = res.body && !res.body.empty? ? JSON.parse(res.body) : {}
23
+ [res.code.to_i, parsed]
24
+ end
25
+ end
26
+
27
+ class Publisher
28
+ DEFAULT_PDS = "https://bsky.social".freeze
29
+
30
+ def initialize(site:, handle:, password:, pds: DEFAULT_PDS, http: HttpClient.new, logger: Jekyll.logger)
31
+ @site = site
32
+ @handle = handle
33
+ @password = password
34
+ @pds = pds.sub(%r{/\z}, "")
35
+ @http = http
36
+ @logger = logger
37
+ end
38
+
39
+ def publish_all
40
+ publication = StandardSite.publication_uri(@site)
41
+ raise PublisherError, "standard_site.publication is not set in _config.yml" unless publication
42
+
43
+ session = login
44
+ published = []
45
+
46
+ unpublished_posts.each do |post|
47
+ uri = create_document_record(session, publication, post)
48
+ patch_front_matter(post.path, uri)
49
+ @logger.info "Standard.site:", "published #{post.relative_path} -> #{uri}"
50
+ published << [post.relative_path, uri]
51
+ end
52
+
53
+ published
54
+ end
55
+
56
+ def unpublished_posts
57
+ @site.read if @site.posts.docs.empty?
58
+ @site.posts.docs.reject { |p| p.data["at_uri"] }
59
+ end
60
+
61
+ def login
62
+ code, body = @http.post_json(
63
+ "#{@pds}/xrpc/com.atproto.server.createSession",
64
+ { "identifier" => @handle, "password" => @password }
65
+ )
66
+ raise PublisherError, "login failed (#{code}): #{body["message"] || body}" unless code == 200
67
+ body
68
+ end
69
+
70
+ def create_document_record(session, publication, post)
71
+ record = build_record(publication, post)
72
+ code, body = @http.post_json(
73
+ "#{@pds}/xrpc/com.atproto.repo.createRecord",
74
+ {
75
+ "repo" => session["did"],
76
+ "collection" => "site.standard.document",
77
+ "record" => record
78
+ },
79
+ "Authorization" => "Bearer #{session["accessJwt"]}"
80
+ )
81
+ raise PublisherError, "createRecord failed for #{post.relative_path} (#{code}): #{body["message"] || body}" unless code == 200
82
+ body["uri"]
83
+ end
84
+
85
+ def build_record(publication, post)
86
+ data = post.data
87
+ record = {
88
+ "$type" => "site.standard.document",
89
+ "site" => publication,
90
+ "title" => data["title"].to_s,
91
+ "publishedAt" => normalize_time(post.date)
92
+ }
93
+ record["path"] = post.url if post.url
94
+ record["description"] = data["description"] if data["description"]
95
+ record["tags"] = Array(data["tags"]) if data["tags"] && !Array(data["tags"]).empty?
96
+ record["updatedAt"] = normalize_time(data["updated"]) if data["updated"]
97
+ record
98
+ end
99
+
100
+ def normalize_time(value)
101
+ case value
102
+ when Time then value.utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
103
+ when String then value
104
+ else raise PublisherError, "unrecognised time value: #{value.inspect}"
105
+ end
106
+ end
107
+
108
+ def patch_front_matter(path, at_uri)
109
+ content = File.read(path)
110
+ unless content.start_with?("---\n") || content.start_with?("---\r\n")
111
+ raise PublisherError, "no front matter in #{path}"
112
+ end
113
+
114
+ patched = content.sub(/\n---\r?\n/, %(\nat_uri: "#{at_uri}"\n---\n))
115
+ if patched == content
116
+ raise PublisherError, "could not locate closing front-matter delimiter in #{path}"
117
+ end
118
+
119
+ File.write(path, patched)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,30 @@
1
+ require "rake"
2
+ require "jekyll"
3
+ require_relative "../jekyll-standard-site"
4
+ require_relative "publisher"
5
+
6
+ namespace :standard_site do
7
+ desc "Create site.standard.document records for posts missing at_uri and patch front matter"
8
+ task :publish do
9
+ handle = ENV["BSKY_HANDLE"] or abort("BSKY_HANDLE is not set")
10
+ password = ENV["BSKY_APP_PASSWORD"] or abort("BSKY_APP_PASSWORD is not set")
11
+ pds = ENV["BSKY_PDS"] || Jekyll::StandardSite::Publisher::DEFAULT_PDS
12
+
13
+ site = Jekyll::Site.new(Jekyll.configuration({}))
14
+ site.read
15
+
16
+ publisher = Jekyll::StandardSite::Publisher.new(
17
+ site: site,
18
+ handle: handle,
19
+ password: password,
20
+ pds: pds
21
+ )
22
+
23
+ published = publisher.publish_all
24
+ if published.empty?
25
+ puts "Standard.site: no posts to publish"
26
+ else
27
+ puts "Standard.site: published #{published.size} document(s)"
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  module Jekyll
2
2
  module StandardSite
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-standard-site
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -85,6 +85,8 @@ files:
85
85
  - LICENSE.txt
86
86
  - README.md
87
87
  - lib/jekyll-standard-site.rb
88
+ - lib/jekyll-standard-site/publisher.rb
89
+ - lib/jekyll-standard-site/tasks.rb
88
90
  - lib/jekyll-standard-site/version.rb
89
91
  homepage: https://github.com/andrew/jekyll-standard-site
90
92
  licenses: