pagecord-cli 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a1e156f74ff6f6b5860f2a21cae283aaccf1d3bec27ef02ed5bfc02002a1bdb3
4
+ data.tar.gz: d05ee18c97ee613d5903e76bf3508c20ed6536b8120a3a3f60dc279539048203
5
+ SHA512:
6
+ metadata.gz: f02588d28a65bfd183cd5830f96d88395523960ef7a0ab4584be5e7e386b0f15530a1b5fa8b2c169b629a5cbabac138d77d428cffaeabaada09c2ffbe7a4d105
7
+ data.tar.gz: 5057321b2a894a091ab7f7e76cb8fb0e9724f3576b9c435f83dc06dac38e59cb78f0e240f942297f34fd4479b2caf8a9de3887b4bd1e7593c2f0f89b796b8e65
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pagecord
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,131 @@
1
+ # Pagecord CLI
2
+
3
+ Publish local Markdown and HTML files to [Pagecord](https://pagecord.com).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ gem install pagecord-cli
9
+ ```
10
+
11
+ ## Login
12
+
13
+ Generate an API key from **Settings > API** in Pagecord, then save it locally:
14
+
15
+ ```bash
16
+ pagecord login myblog
17
+ ```
18
+
19
+ The name should be your Pagecord subdomain. The API key is stored in
20
+ `~/.pagecord.yml`.
21
+
22
+ For local testing there is an undocumented `--base-url` option:
23
+
24
+ ```bash
25
+ pagecord login myblog --base-url http://localhost:3000
26
+ ```
27
+
28
+ ## Publishing
29
+
30
+ Publish a file:
31
+
32
+ ```bash
33
+ pagecord publish hello.md
34
+ ```
35
+
36
+ Save or update a draft:
37
+
38
+ ```bash
39
+ pagecord draft notes/idea.md
40
+ ```
41
+
42
+ The first publish creates a post and writes Pagecord metadata back into the
43
+ file. Later publishes update the same post.
44
+
45
+ If you have one blog configured, `publish`, `draft`, and `logout` can omit the
46
+ subdomain. If you have more than one, pass the subdomain as the final argument:
47
+
48
+ ```bash
49
+ pagecord publish hello.md myblog
50
+ ```
51
+
52
+ See configured blogs:
53
+
54
+ ```bash
55
+ pagecord list
56
+ ```
57
+
58
+ Remove a saved blog:
59
+
60
+ ```bash
61
+ pagecord logout myblog
62
+ ```
63
+
64
+ ## Options
65
+
66
+ `publish` and `draft` accept:
67
+
68
+ ```bash
69
+ --title TITLE
70
+ --slug SLUG
71
+ --published-at TIME
72
+ --tags TAGS
73
+ --canonical-url URL
74
+ --hidden
75
+ --locale LOCALE
76
+ ```
77
+
78
+ ## Front Matter
79
+
80
+ Markdown files can include Pagecord-compatible front matter:
81
+
82
+ ```yaml
83
+ ---
84
+ title: My Post
85
+ slug: my-post
86
+ tags:
87
+ - ruby
88
+ - cli
89
+ published_at: 2026-01-02T03:04:05Z
90
+ canonical_url: https://example.com/original
91
+ hidden: false
92
+ locale: en
93
+ ---
94
+ ```
95
+
96
+ If `title` is omitted, the CLI uses the filename. Use `title:` or
97
+ `title: ""` to publish without a title.
98
+
99
+ After publishing, the CLI manages the same front matter fields as the Obsidian
100
+ plugin:
101
+
102
+ ```yaml
103
+ pagecord_token: 65b82933
104
+ pagecord_blog_fingerprint: c92376aeb770
105
+ pagecord_attachments:
106
+ status: published
107
+ ```
108
+
109
+ `pagecord_token` links the file to the remote post. Delete it if you want the
110
+ next publish to create a new post.
111
+
112
+ ## Images
113
+
114
+ Markdown image references to local files are uploaded to Pagecord and sent as
115
+ Action Text attachments:
116
+
117
+ ```markdown
118
+ ![Alt text](photo.jpg)
119
+ ![[photo.jpg]]
120
+ ```
121
+
122
+ Supported local image types are JPEG, PNG, GIF, and WebP. External image URLs
123
+ and HTML `<img>` tags are left alone.
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ rake test
129
+ ruby -Ilib bin/pagecord help
130
+ gem build pagecord-cli.gemspec
131
+ ```
data/bin/pagecord ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pagecord_cli"
5
+
6
+ exit PagecordCLI::CLI.new(ARGV).run
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "optparse"
5
+
6
+ module PagecordCLI
7
+ class CLI
8
+ attr_reader :argv, :config, :input, :output, :error
9
+
10
+ def initialize(argv, config: Config.new, input: $stdin, output: $stdout, error: $stderr)
11
+ @argv = argv.dup
12
+ @config = config
13
+ @input = input
14
+ @output = output
15
+ @error = error
16
+ end
17
+
18
+ def run
19
+ command = argv.shift
20
+
21
+ case command
22
+ when "login" then login
23
+ when "logout" then logout
24
+ when "list" then list
25
+ when "publish" then publish("published")
26
+ when "draft" then publish("draft")
27
+ when "help", nil, "-h", "--help" then help
28
+ else
29
+ fail_with("Unknown command: #{command}")
30
+ end
31
+ rescue Client::Error => e
32
+ fail_with(api_error_message(e))
33
+ rescue Config::Error => e
34
+ fail_with(e.message)
35
+ rescue Errno::ENOENT => e
36
+ fail_with(e.message)
37
+ rescue Interrupt
38
+ error.puts
39
+ fail_with("Cancelled")
40
+ end
41
+
42
+ private
43
+
44
+ def login
45
+ options = { base_url: Config::DEFAULT_BASE_URL }
46
+ parser = OptionParser.new do |opts|
47
+ opts.on("--base-url URL") { |value| options[:base_url] = value }
48
+ end
49
+ parser.parse!(argv)
50
+
51
+ subdomain = argv.shift
52
+ return fail_with("Usage: pagecord login SUBDOMAIN") unless subdomain
53
+
54
+ base_url = Config.normalize_base_url(options[:base_url])
55
+ api_key = prompt_api_key
56
+ client = Client.new(api_key: api_key, base_url: base_url)
57
+ client.verify!
58
+ config.save_blog(subdomain, api_key: api_key, base_url: base_url)
59
+
60
+ output.puts "Saved #{subdomain}"
61
+ 0
62
+ end
63
+
64
+ def logout
65
+ subdomain = resolve_blog(argv.shift)
66
+ return fail_with("No blogs are configured") if config.blogs.empty?
67
+ return fail_with("Please specify a subdomain") unless subdomain
68
+ return fail_with("Unknown blog: #{subdomain}") unless config.blog(subdomain)
69
+
70
+ config.delete_blog(subdomain)
71
+ output.puts "Removed #{subdomain}"
72
+ 0
73
+ end
74
+
75
+ def list
76
+ return fail_with("No blogs are configured") if config.blogs.empty?
77
+
78
+ config.blogs.each do |subdomain, details|
79
+ suffix = details["base_url"] == Config::DEFAULT_BASE_URL ? "" : " (#{details["base_url"]})"
80
+ output.puts "#{subdomain}#{suffix}"
81
+ end
82
+
83
+ 0
84
+ end
85
+
86
+ def publish(status)
87
+ options = publish_options
88
+ parser = OptionParser.new do |opts|
89
+ opts.on("--title TITLE") { |value| options[:title] = value }
90
+ opts.on("--slug SLUG") { |value| options[:slug] = value }
91
+ opts.on("--published-at TIME") { |value| options[:published_at] = value }
92
+ opts.on("--tags TAGS") { |value| options[:tags] = value }
93
+ opts.on("--canonical-url URL") { |value| options[:canonical_url] = value }
94
+ opts.on("--hidden") { options[:hidden] = true }
95
+ opts.on("--locale LOCALE") { |value| options[:locale] = value }
96
+ end
97
+ parser.parse!(argv)
98
+
99
+ file_path = argv.shift
100
+ subdomain = resolve_blog(argv.shift)
101
+
102
+ return fail_with("Usage: pagecord #{status == "draft" ? "draft" : "publish"} FILE [SUBDOMAIN]") unless file_path
103
+ return fail_with("No blogs are configured. Run pagecord login SUBDOMAIN first.") if config.blogs.empty?
104
+ return fail_with("Please specify a subdomain") unless subdomain
105
+ return fail_with("Unknown blog: #{subdomain}") unless config.blog(subdomain)
106
+
107
+ post_file = PostFile.new(file_path)
108
+ return fail_with("Unsupported file type: #{file_path}") unless post_file.supported?
109
+
110
+ blog_config = config.blog(subdomain)
111
+ api_key = blog_config.fetch("api_key")
112
+ return fail_with("This file is linked to another configured blog.") if post_file.wrong_blog?(subdomain, api_key)
113
+
114
+ client = Client.new(api_key: blog_config.fetch("api_key"), base_url: blog_config.fetch("base_url", Config::DEFAULT_BASE_URL))
115
+ params = post_file.params.merge(options.compact).merge(
116
+ content: post_file.content_for(subdomain, client: client),
117
+ status: status
118
+ )
119
+ params[:content_format] = post_file.content_format if post_file.content_format
120
+
121
+ result = if (token = post_file.token_for(subdomain))
122
+ client.update_post(token, params)
123
+ else
124
+ client.create_post(params)
125
+ end
126
+
127
+ post_file.write_token(subdomain, result.fetch("token"), api_key: api_key, status: status)
128
+ output.puts "#{status == "draft" ? "Saved draft" : "Published"} #{file_path} to #{subdomain}"
129
+ 0
130
+ end
131
+
132
+ def publish_options
133
+ {
134
+ title: nil,
135
+ slug: nil,
136
+ published_at: nil,
137
+ tags: nil,
138
+ canonical_url: nil,
139
+ hidden: nil,
140
+ locale: nil
141
+ }
142
+ end
143
+
144
+ def resolve_blog(name)
145
+ return name if name
146
+ return config.blogs.keys.first if config.blogs.size == 1
147
+
148
+ nil
149
+ end
150
+
151
+ def prompt_api_key
152
+ output.print "API key: "
153
+
154
+ if input.tty?
155
+ key = input.noecho(&:gets).to_s.strip
156
+ output.puts
157
+ key
158
+ else
159
+ input.gets.to_s.strip
160
+ end
161
+ end
162
+
163
+ def api_error_message(api_error)
164
+ if api_error.status == 404
165
+ "#{api_error.message}. If this file should create a new post, remove its saved Pagecord token."
166
+ else
167
+ api_error.message
168
+ end
169
+ end
170
+
171
+ def help
172
+ output.puts <<~HELP
173
+ Usage:
174
+ pagecord login SUBDOMAIN
175
+ pagecord logout [SUBDOMAIN]
176
+ pagecord list
177
+ pagecord publish FILE [SUBDOMAIN] [options]
178
+ pagecord draft FILE [SUBDOMAIN] [options]
179
+
180
+ Publish options:
181
+ --title TITLE
182
+ --slug SLUG
183
+ --published-at TIME
184
+ --tags TAGS
185
+ --canonical-url URL
186
+ --hidden
187
+ --locale LOCALE
188
+ HELP
189
+ 0
190
+ end
191
+
192
+ def fail_with(message)
193
+ error.puts message
194
+ 1
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module PagecordCLI
8
+ class Client
9
+ class Error < StandardError
10
+ attr_reader :status
11
+
12
+ def initialize(status, message)
13
+ @status = status
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ attr_reader :api_key, :base_url
19
+ OPEN_TIMEOUT = 10
20
+ READ_TIMEOUT = 30
21
+ WRITE_TIMEOUT = 30
22
+
23
+ def initialize(api_key:, base_url:)
24
+ @api_key = api_key
25
+ @base_url = base_url
26
+ end
27
+
28
+ def verify!
29
+ request(Net::HTTP::Get.new(uri_for("/posts")))
30
+ true
31
+ end
32
+
33
+ def create_post(params)
34
+ request(json_request(Net::HTTP::Post, "/posts", params))
35
+ end
36
+
37
+ def update_post(token, params)
38
+ request(json_request(Net::HTTP::Patch, "/posts/#{token}", params))
39
+ end
40
+
41
+ def upload_attachment(path)
42
+ uri = uri_for("/attachments")
43
+ http_request = Net::HTTP::Post.new(uri)
44
+ http_request["Authorization"] = "Bearer #{api_key}"
45
+ http_request.set_form([
46
+ [
47
+ "file",
48
+ File.open(path, "rb"),
49
+ { filename: File.basename(path), content_type: content_type(path) }
50
+ ]
51
+ ], "multipart/form-data")
52
+
53
+ request(http_request)
54
+ end
55
+
56
+ private
57
+
58
+ def json_request(klass, path, params)
59
+ request = klass.new(uri_for(path))
60
+ request["Content-Type"] = "application/json"
61
+ request.body = JSON.dump(params)
62
+ request
63
+ end
64
+
65
+ def request(http_request)
66
+ http_request["Authorization"] ||= "Bearer #{api_key}"
67
+ response = Net::HTTP.start(http_request.uri.hostname, http_request.uri.port, use_ssl: http_request.uri.scheme == "https") do |http|
68
+ http.open_timeout = OPEN_TIMEOUT
69
+ http.read_timeout = READ_TIMEOUT
70
+ http.write_timeout = WRITE_TIMEOUT if http.respond_to?(:write_timeout=)
71
+ http.request(http_request)
72
+ end
73
+
74
+ handle_response(response)
75
+ ensure
76
+ http_request.body_stream&.close if http_request.respond_to?(:body_stream)
77
+ end
78
+
79
+ def handle_response(response)
80
+ body = response.body.to_s
81
+ parsed = body.empty? ? {} : JSON.parse(body)
82
+
83
+ case response
84
+ when Net::HTTPSuccess
85
+ parsed
86
+ else
87
+ raise Error.new(response.code.to_i, error_message(parsed, response.code))
88
+ end
89
+ rescue JSON::ParserError
90
+ raise Error.new(response.code.to_i, "Unexpected response from Pagecord")
91
+ end
92
+
93
+ def error_message(parsed, status)
94
+ return parsed["error"] if parsed["error"]
95
+ return parsed["errors"].join(", ") if parsed["errors"].is_a?(Array)
96
+
97
+ "Pagecord API returned #{status}"
98
+ end
99
+
100
+ def uri_for(path)
101
+ URI.join(base_url.end_with?("/") ? base_url : "#{base_url}/", path.delete_prefix("/"))
102
+ end
103
+
104
+ def content_type(path)
105
+ case File.extname(path).downcase
106
+ when ".jpg", ".jpeg" then "image/jpeg"
107
+ when ".png" then "image/png"
108
+ when ".gif" then "image/gif"
109
+ when ".webp" then "image/webp"
110
+ when ".mp4" then "video/mp4"
111
+ when ".mov" then "video/quicktime"
112
+ when ".mp3" then "audio/mpeg"
113
+ when ".wav" then "audio/wav"
114
+ else "application/octet-stream"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "date"
5
+ require "uri"
6
+ require "yaml"
7
+
8
+ module PagecordCLI
9
+ class Config
10
+ class Error < StandardError; end
11
+
12
+ DEFAULT_PATH = File.expand_path("~/.pagecord.yml")
13
+ DEFAULT_BASE_URL = "https://api.pagecord.com"
14
+
15
+ attr_reader :path
16
+
17
+ def initialize(path = DEFAULT_PATH)
18
+ @path = path
19
+ end
20
+
21
+ def self.normalize_base_url(url)
22
+ uri = URI(url.match?(%r{\Ahttps?://}) ? url : "https://#{url}")
23
+ return uri.to_s.delete_suffix("/") if api_or_local_host?(uri.host)
24
+
25
+ uri.host = "api.#{uri.host}"
26
+ uri.to_s.delete_suffix("/")
27
+ end
28
+
29
+ def blogs
30
+ data.fetch("blogs", {})
31
+ end
32
+
33
+ def blog(name)
34
+ blogs[name]
35
+ end
36
+
37
+ def save_blog(name, api_key:, base_url: DEFAULT_BASE_URL)
38
+ new_data = data
39
+ new_data["blogs"] ||= {}
40
+ new_data["blogs"][name] = {
41
+ "api_key" => api_key,
42
+ "base_url" => base_url
43
+ }
44
+ write(new_data)
45
+ end
46
+
47
+ def delete_blog(name)
48
+ new_data = data
49
+ new_data.fetch("blogs", {}).delete(name)
50
+ write(new_data)
51
+ end
52
+
53
+ def resolve_blog(name = nil)
54
+ return name if name && blog(name)
55
+ return name if name
56
+
57
+ return blogs.keys.first if blogs.size == 1
58
+
59
+ nil
60
+ end
61
+
62
+ def data
63
+ return { "blogs" => {} } unless File.exist?(path)
64
+
65
+ YAML.safe_load_file(path, permitted_classes: [ Time, Date ], aliases: false) || { "blogs" => {} }
66
+ rescue Psych::Exception => e
67
+ raise Error, "Could not read #{path}: #{e.message}"
68
+ end
69
+
70
+ private
71
+
72
+ def self.api_or_local_host?(host)
73
+ host.start_with?("api.") || %w[localhost 127.0.0.1 ::1].include?(host)
74
+ end
75
+
76
+ def write(new_data)
77
+ FileUtils.mkdir_p(File.dirname(path))
78
+ File.write(path, YAML.dump(new_data))
79
+ File.chmod(0o600, path)
80
+ rescue NotImplementedError
81
+ true
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module PagecordCLI
6
+ class ImageUploads
7
+ MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/
8
+ OBSIDIAN_IMAGE = /!\[\[([^\]]+)\]\]/
9
+ IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp)\z/i
10
+
11
+ attr_reader :content, :file_path, :metadata, :blog, :client
12
+
13
+ def initialize(content, file_path:, metadata:, blog:, client:)
14
+ @content = content
15
+ @file_path = file_path
16
+ @metadata = metadata
17
+ @blog = blog
18
+ @client = client
19
+ end
20
+
21
+ def process
22
+ with_markdown_images = content.gsub(MARKDOWN_IMAGE) do |match|
23
+ path = Regexp.last_match(2)
24
+ local_path?(path) ? attachment_tag_for(path) : match
25
+ end
26
+
27
+ with_markdown_images.gsub(OBSIDIAN_IMAGE) do |match|
28
+ path = Regexp.last_match(1)
29
+ local_path?(path) ? attachment_tag_for(path) : match
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def attachment_tag_for(path)
36
+ sgid = cached_sgid(path) || upload(path)
37
+ %(<action-text-attachment sgid="#{sgid}"></action-text-attachment>)
38
+ end
39
+
40
+ def cached_sgid(path)
41
+ cache = attachment_cache[filename(path)]
42
+ return unless cache
43
+ return unless cache["hash"] == checksum(absolute_path(path))
44
+
45
+ cache["sgid"]
46
+ end
47
+
48
+ def upload(path)
49
+ result = client.upload_attachment(absolute_path(path))
50
+ sgid = result.fetch("attachable_sgid")
51
+
52
+ attachment_cache[filename(path)] = {
53
+ "hash" => checksum(absolute_path(path)),
54
+ "sgid" => sgid
55
+ }
56
+
57
+ sgid
58
+ end
59
+
60
+ def attachment_cache
61
+ metadata["pagecord_attachments"] ||= {}
62
+ end
63
+
64
+ def local_path?(path)
65
+ return false if path.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
66
+ return false if path.start_with?("#", "/")
67
+ return false unless path.match?(IMAGE_EXTENSIONS)
68
+
69
+ File.file?(absolute_path(path))
70
+ end
71
+
72
+ def absolute_path(path)
73
+ File.expand_path(path, File.dirname(file_path))
74
+ end
75
+
76
+ def checksum(path)
77
+ Digest::SHA256.file(path).hexdigest[0, 16]
78
+ end
79
+
80
+ def filename(path)
81
+ File.basename(path)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+ require "digest"
6
+
7
+ module PagecordCLI
8
+ class PostFile
9
+ MARKDOWN_EXTENSIONS = [ ".md", ".markdown" ].freeze
10
+ HTML_EXTENSIONS = [ ".html", ".htm" ].freeze
11
+ FRONT_MATTER = /\A---[ \t]*\n(.*?)\n---[ \t]*\n?/m
12
+ HTML_METADATA = /\A\s*<!--\s*pagecord:\s*\n(.*?)\n-->\s*/m
13
+
14
+ attr_reader :path, :content, :metadata, :body
15
+
16
+ def initialize(path)
17
+ @path = path
18
+ @content = File.read(path)
19
+ @metadata, @body = extract_metadata
20
+ end
21
+
22
+ def markdown?
23
+ MARKDOWN_EXTENSIONS.include?(File.extname(path).downcase)
24
+ end
25
+
26
+ def html?
27
+ HTML_EXTENSIONS.include?(File.extname(path).downcase)
28
+ end
29
+
30
+ def token_for(blog)
31
+ if markdown?
32
+ metadata["pagecord_token"]
33
+ else
34
+ metadata.dig(blog, "token") || metadata[blog]
35
+ end
36
+ end
37
+
38
+ def content_for(blog, client:)
39
+ return body unless markdown?
40
+
41
+ ImageUploads.new(
42
+ body,
43
+ file_path: path,
44
+ metadata: metadata,
45
+ blog: blog,
46
+ client: client
47
+ ).process
48
+ end
49
+
50
+ def write_token(blog, token, api_key: nil, status: nil)
51
+ if markdown?
52
+ metadata["pagecord_token"] = token
53
+ metadata["pagecord_blog_fingerprint"] = self.class.blog_fingerprint(api_key) if api_key
54
+ metadata["status"] = status if status
55
+ write_markdown
56
+ else
57
+ metadata[blog] = token
58
+ write_html
59
+ end
60
+ end
61
+
62
+ def content_format
63
+ markdown? ? "markdown" : nil
64
+ end
65
+
66
+ def params
67
+ publish_params = { title: title_from_metadata }
68
+
69
+ %w[slug published_at canonical_url locale].each do |key|
70
+ value = frontmatter_string(metadata[key])
71
+ publish_params[key.to_sym] = value if value
72
+ end
73
+
74
+ tags = tags_from_metadata
75
+ publish_params[:tags] = tags if tags
76
+
77
+ hidden = frontmatter_boolean(metadata["hidden"])
78
+ publish_params[:hidden] = hidden unless hidden.nil?
79
+
80
+ publish_params
81
+ end
82
+
83
+ def wrong_blog?(blog, api_key)
84
+ return false unless markdown?
85
+ return false unless metadata["pagecord_token"]
86
+ return false unless metadata["pagecord_blog_fingerprint"]
87
+
88
+ metadata["pagecord_blog_fingerprint"].to_s != self.class.blog_fingerprint(api_key)
89
+ end
90
+
91
+ def self.blog_fingerprint(api_key)
92
+ Digest::SHA256.hexdigest(api_key)[0, 12]
93
+ end
94
+
95
+ def default_title
96
+ title = File.basename(path, File.extname(path)).tr("_-", " ").squeeze(" ").strip
97
+ title.empty? ? "" : title[0].upcase + title[1..].to_s
98
+ end
99
+
100
+ def supported?
101
+ markdown? || html?
102
+ end
103
+
104
+ private
105
+
106
+ def extract_metadata
107
+ if markdown?
108
+ extract_markdown_metadata
109
+ elsif html?
110
+ extract_html_metadata
111
+ else
112
+ [ {}, content ]
113
+ end
114
+ end
115
+
116
+ def extract_markdown_metadata
117
+ match = content.match(FRONT_MATTER)
118
+ return [ {}, content ] unless match
119
+
120
+ yaml = YAML.safe_load(match[1], permitted_classes: [ Date, Time ], aliases: false) || {}
121
+ [ stringify_keys(yaml), content[match[0].length..] || "" ]
122
+ rescue Psych::Exception
123
+ [ {}, content ]
124
+ end
125
+
126
+ def extract_html_metadata
127
+ match = content.match(HTML_METADATA)
128
+ return [ {}, content ] unless match
129
+
130
+ data = YAML.safe_load(match[1], aliases: false) || {}
131
+ [ normalize_html_metadata(data), content[match[0].length..] || "" ]
132
+ rescue Psych::Exception
133
+ [ {}, content ]
134
+ end
135
+
136
+ def title_from_metadata
137
+ return default_title unless metadata.key?("title")
138
+ return "" if metadata["title"].nil?
139
+
140
+ frontmatter_string(metadata["title"]).to_s
141
+ end
142
+
143
+ def tags_from_metadata
144
+ tags = metadata["tags"]
145
+ return if tags.nil?
146
+
147
+ if tags.is_a?(Array)
148
+ tags.map { |tag| frontmatter_string(tag).to_s }.join(", ")
149
+ else
150
+ frontmatter_string(tags)
151
+ end
152
+ end
153
+
154
+ def frontmatter_string(value)
155
+ return if value.nil?
156
+
157
+ value = value.to_s
158
+ quoted = value.match(/\A(['"])(.*)\1\z/)
159
+ quoted ? quoted[2] : value
160
+ end
161
+
162
+ def frontmatter_boolean(value)
163
+ return if value.nil?
164
+ return value if value == true || value == false
165
+
166
+ normalized = frontmatter_string(value).to_s.strip.downcase
167
+ return true if normalized == "true"
168
+ return false if normalized == "false"
169
+
170
+ !!value
171
+ end
172
+
173
+ def write_markdown
174
+ File.write(path, "#{YAML.dump(metadata)}---\n#{body}")
175
+ end
176
+
177
+ def write_html
178
+ File.write(path, "<!-- pagecord:\n#{YAML.dump(flat_html_metadata).delete_prefix("---\n")}-->\n#{body}")
179
+ end
180
+
181
+ def flat_html_metadata
182
+ metadata.transform_values { |value| value.is_a?(Hash) ? value["token"] : value }
183
+ end
184
+
185
+ def normalize_html_metadata(data)
186
+ stringify_keys(data).transform_values do |value|
187
+ value.is_a?(Hash) ? value : { "token" => value }
188
+ end
189
+ end
190
+
191
+ def stringify_keys(value)
192
+ case value
193
+ when Hash
194
+ value.each_with_object({}) { |(key, item), hash| hash[key.to_s] = stringify_keys(item) }
195
+ when Array
196
+ value.map { |item| stringify_keys(item) }
197
+ else
198
+ value
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PagecordCLI
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pagecord_cli/client"
4
+ require_relative "pagecord_cli/cli"
5
+ require_relative "pagecord_cli/config"
6
+ require_relative "pagecord_cli/image_uploads"
7
+ require_relative "pagecord_cli/post_file"
8
+ require_relative "pagecord_cli/version"
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pagecord-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Olly Headey
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ email:
13
+ - olly@pagecord.com
14
+ executables:
15
+ - pagecord
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - bin/pagecord
22
+ - lib/pagecord_cli.rb
23
+ - lib/pagecord_cli/cli.rb
24
+ - lib/pagecord_cli/client.rb
25
+ - lib/pagecord_cli/config.rb
26
+ - lib/pagecord_cli/image_uploads.rb
27
+ - lib/pagecord_cli/post_file.rb
28
+ - lib/pagecord_cli/version.rb
29
+ homepage: https://pagecord.com
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ homepage_uri: https://pagecord.com
34
+ source_code_uri: https://github.com/lylo/pagecord-cli
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.2'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 4.0.10
50
+ specification_version: 4
51
+ summary: Publish local files to Pagecord
52
+ test_files: []