slidict 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Output
5
+ # Single source of truth for everything that varies per output framework:
6
+ # the output file extension, the body_format sent to the slidict.io API,
7
+ # and (for markdown-based frameworks) the frontmatter block to render.
8
+ # Add a new framework by adding one entry to REGISTRY.
9
+ class Format
10
+ Definition = Struct.new(:name, :extension, :body_format, :frontmatter, keyword_init: true)
11
+
12
+ DEFAULT_NAME = "slidev"
13
+
14
+ REGISTRY = {
15
+ "slidev" => Definition.new(
16
+ name: "slidev",
17
+ extension: ".md",
18
+ body_format: "markdown",
19
+ frontmatter: "theme: default\nclass: text-center"
20
+ ),
21
+ "marp" => Definition.new(
22
+ name: "marp",
23
+ extension: ".md",
24
+ body_format: "markdown",
25
+ frontmatter: "marp: true\ntheme: default"
26
+ ),
27
+ "asciidoctor-revealjs" => Definition.new(
28
+ name: "asciidoctor-revealjs",
29
+ extension: ".adoc",
30
+ body_format: "asciidoc",
31
+ frontmatter: nil
32
+ )
33
+ }.freeze
34
+
35
+ def self.fetch(name)
36
+ REGISTRY.fetch(name.to_s.downcase, REGISTRY.fetch(DEFAULT_NAME))
37
+ end
38
+
39
+ def self.names
40
+ REGISTRY.keys
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Output
5
+ class Renderer
6
+ def render(deck)
7
+ return render_asciidoctor_revealjs(deck) if Format.fetch(deck.framework).body_format == "asciidoc"
8
+
9
+ [frontmatter(deck.framework), deck.slides.map { |slide| render_slide(slide) }.join("\n---\n\n")].join("\n")
10
+ end
11
+
12
+ private
13
+
14
+ def frontmatter(framework)
15
+ body = Format.fetch(framework).frontmatter
16
+ "---\n#{body}\ngenerated: #{Time.now.utc.iso8601}\n---\n"
17
+ end
18
+
19
+ def render_slide(slide)
20
+ lines = ["# #{slide.title}", ""]
21
+ lines.concat(slide.bullets.map { |bullet| "- #{bullet}" })
22
+ lines.join("\n")
23
+ end
24
+
25
+ def render_asciidoctor_revealjs(deck)
26
+ lines = ["= #{deck.topic}", ":revealjs_theme: white", ":slidict_generated: #{Time.now.utc.iso8601}", ""]
27
+ lines.concat(deck.slides.flat_map { |slide| render_asciidoctor_slide(slide) })
28
+ lines.join("\n")
29
+ end
30
+
31
+ def render_asciidoctor_slide(slide)
32
+ ["== #{slide.title}", "", *slide.bullets.map { |bullet| "* #{bullet}" }, ""]
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slidict
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/slidict.rb CHANGED
@@ -2,15 +2,17 @@
2
2
 
3
3
  require "time"
4
4
 
5
- require_relative "slidict/auth_client"
6
- require_relative "slidict/cli"
5
+ require_relative "slidict/cli/app"
6
+ require_relative "slidict/cli/serve"
7
+ require_relative "slidict/cli/slides"
7
8
  require_relative "slidict/config"
8
- require_relative "slidict/credentials"
9
9
  require_relative "slidict/deck"
10
- require_relative "slidict/llm_client"
11
- require_relative "slidict/markdown_renderer"
12
- require_relative "slidict/slides_client"
13
- require_relative "slidict/slides_command"
10
+ require_relative "slidict/external/slidict_io/auth"
11
+ require_relative "slidict/external/slidict_io/client"
12
+ require_relative "slidict/external/slidict_io/credentials"
13
+ require_relative "slidict/llm/client"
14
+ require_relative "slidict/output/format"
15
+ require_relative "slidict/output/renderer"
14
16
  require_relative "slidict/version"
15
17
 
16
18
  module Slidict
metadata CHANGED
@@ -1,14 +1,56 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slidict
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Abe
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: puma
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rackup
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sinatra
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '4.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '4.0'
12
54
  description: Slidict is a Ruby CLI for turning rough ideas into presentation-ready
13
55
  Markdown slides.
14
56
  email:
@@ -31,15 +73,17 @@ files:
31
73
  - README.md
32
74
  - Rakefile
33
75
  - lib/slidict.rb
34
- - lib/slidict/auth_client.rb
35
- - lib/slidict/cli.rb
76
+ - lib/slidict/cli/app.rb
77
+ - lib/slidict/cli/serve.rb
78
+ - lib/slidict/cli/slides.rb
36
79
  - lib/slidict/config.rb
37
- - lib/slidict/credentials.rb
38
80
  - lib/slidict/deck.rb
39
- - lib/slidict/llm_client.rb
40
- - lib/slidict/markdown_renderer.rb
41
- - lib/slidict/slides_client.rb
42
- - lib/slidict/slides_command.rb
81
+ - lib/slidict/external/slidict_io/auth.rb
82
+ - lib/slidict/external/slidict_io/client.rb
83
+ - lib/slidict/external/slidict_io/credentials.rb
84
+ - lib/slidict/llm/client.rb
85
+ - lib/slidict/output/format.rb
86
+ - lib/slidict/output/renderer.rb
43
87
  - lib/slidict/version.rb
44
88
  homepage: https://labs.slidict.io/slidict/
45
89
  licenses:
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "net/http"
5
- require "uri"
6
-
7
- module Slidict
8
- class AuthClient
9
- Error = Class.new(StandardError)
10
- Pending = Class.new(StandardError)
11
-
12
- DEFAULT_BASE_URL = "https://slidict.io"
13
-
14
- attr_reader :base_url
15
-
16
- def initialize(base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
17
- @base_url = base_url
18
- end
19
-
20
- def request_device_code
21
- response = post_json("/api/cli/device/code", { provider: "github" })
22
- {
23
- device_code: fetch!(response, "device_code"),
24
- user_code: fetch!(response, "user_code"),
25
- verification_uri: response["verification_uri"] || "#{base_url}/cli/activate",
26
- interval: response.fetch("interval", 5).to_i,
27
- expires_in: response.fetch("expires_in", 600).to_i
28
- }
29
- end
30
-
31
- def poll_token(device_code:)
32
- response = post_json(
33
- "/api/cli/device/token",
34
- { device_code: device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" },
35
- raise_on_http_error: false
36
- )
37
- return response if response["access_token"]
38
-
39
- error = response["error"].to_s
40
- raise Pending if %w[authorization_pending slow_down].include?(error)
41
-
42
- raise Error, response["error_description"] || error unless error.empty?
43
-
44
- raise Error, "token response did not include access_token"
45
- end
46
-
47
- private
48
-
49
- def post_json(path, payload, raise_on_http_error: true)
50
- uri = URI.join(base_url, path)
51
- request = Net::HTTP::Post.new(uri)
52
- request["Accept"] = "application/json"
53
- request["Content-Type"] = "application/json"
54
- request.body = JSON.generate(payload)
55
-
56
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
57
- http.request(request)
58
- end
59
- body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
60
- return body if response.is_a?(Net::HTTPSuccess) || !raise_on_http_error
61
-
62
- raise Error, body["error_description"] || body["error"] || "HTTP #{response.code}"
63
- rescue JSON::ParserError => e
64
- raise Error, "invalid JSON response: #{e.message}"
65
- rescue SystemCallError, Timeout::Error => e
66
- raise Error, e.message
67
- end
68
-
69
- def fetch!(hash, key)
70
- hash.fetch(key)
71
- rescue KeyError
72
- raise Error, "device code response did not include #{key}"
73
- end
74
- end
75
- end
data/lib/slidict/cli.rb DELETED
@@ -1,266 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Slidict
4
- class CLI
5
- DEFAULT_OUTPUT = "slides.md"
6
- DEFAULT_OUTPUT_BY_FRAMEWORK = {
7
- "slidev" => "slides.md",
8
- "marp" => "slides.md",
9
- "asciidoctor-revealjs" => "slides.adoc"
10
- }.freeze
11
-
12
- def initialize(input: $stdin, output: $stdout, renderer: MarkdownRenderer.new, auth_client: nil,
13
- credentials: nil, sleeper: Kernel, slides_command: nil)
14
- @input = input
15
- @output = output
16
- @renderer = renderer
17
- @auth_client = auth_client
18
- @credentials = credentials
19
- @sleeper = sleeper
20
- @slides_command = slides_command
21
- end
22
-
23
- def run(argv = [])
24
- options = parse(argv)
25
- return print_help if options[:help]
26
- return auth if options[:command] == "auth"
27
- return slides(options[:args]) if options[:command] == "slides"
28
-
29
- config = build_config(options)
30
- client = llm_client_for(config)
31
- return 1 if client && !verify_connection(client)
32
-
33
- deck = Deck.new(
34
- topic: ask("What would you like to talk about?", options[:topic]),
35
- duration: ask("How long is the presentation?", options[:duration]),
36
- audience: ask("Who is the audience?", options[:audience]),
37
- goal: ask("What should the audience remember or do?", options[:goal]),
38
- framework: options[:framework]
39
- )
40
-
41
- if client
42
- begin
43
- slides = client.generate_slides(deck)
44
- rescue LLMClient::Error => e
45
- @output.puts "Error: LLM request failed (#{e.message})"
46
- return 1
47
- end
48
- deck = Deck.new(
49
- topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
50
- framework: deck.framework, slides: slides
51
- )
52
- end
53
-
54
- path = options[:output]
55
- content = @renderer.render(deck)
56
- File.write(path, content)
57
- @output.puts "Created #{path}"
58
-
59
- return publish_to_slidict(deck, content, options) if options[:publish] || options[:slide_id]
60
-
61
- 0
62
- rescue ArgumentError => e
63
- @output.puts "Error: #{e.message}"
64
- @output.puts
65
- print_help
66
- 1
67
- end
68
-
69
- private
70
-
71
- def parse(argv)
72
- options = { framework: "slidev" }
73
- args = argv.dup
74
-
75
- if args.first == "auth"
76
- args.shift
77
- raise ArgumentError, "auth does not accept options" unless args.empty?
78
-
79
- options[:command] = "auth"
80
- return options
81
- end
82
-
83
- if args.first == "slides"
84
- args.shift
85
- options[:command] = "slides"
86
- options[:args] = args
87
- return options
88
- end
89
-
90
- until args.empty?
91
- case (arg = args.shift)
92
- when "-h", "--help"
93
- options[:help] = true
94
- when "-o", "--output"
95
- options[:output] = fetch_value!(args, arg)
96
- when "--topic"
97
- options[:topic] = fetch_value!(args, arg)
98
- when "--duration"
99
- options[:duration] = fetch_value!(args, arg)
100
- when "--audience"
101
- options[:audience] = fetch_value!(args, arg)
102
- when "--goal"
103
- options[:goal] = fetch_value!(args, arg)
104
- when "--framework"
105
- options[:framework] = fetch_value!(args, arg)
106
- when "--llm-base-url"
107
- options[:llm_base_url] = fetch_value!(args, arg)
108
- when "--llm-api-key"
109
- options[:llm_api_key] = fetch_value!(args, arg)
110
- when "--llm-model"
111
- options[:llm_model] = fetch_value!(args, arg)
112
- when "--no-llm"
113
- options[:no_llm] = true
114
- when "--publish"
115
- options[:publish] = true
116
- when "--slide-id"
117
- options[:slide_id] = fetch_value!(args, arg)
118
- when "--slide-title"
119
- options[:slide_title] = fetch_value!(args, arg)
120
- when "--visibility"
121
- options[:visibility] = fetch_value!(args, arg)
122
- else
123
- raise ArgumentError, "unknown option #{arg}"
124
- end
125
- end
126
-
127
- options[:output] ||= default_output_for(options[:framework])
128
- options
129
- end
130
-
131
- def build_config(options)
132
- Config.from_env.merge(
133
- base_url: options[:llm_base_url],
134
- api_key: options[:llm_api_key],
135
- model: options[:llm_model],
136
- enabled: options[:no_llm] ? false : nil
137
- )
138
- end
139
-
140
- def llm_client_for(config)
141
- return nil unless config.llm_enabled?
142
-
143
- LLMClient.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
144
- end
145
-
146
- def verify_connection(client)
147
- client.verify_connection!
148
- true
149
- rescue LLMClient::Error => e
150
- @output.puts "Error: LLM request failed (#{e.message})"
151
- false
152
- end
153
-
154
- def auth
155
- client = @auth_client || AuthClient.new
156
- credentials = @credentials || Credentials.new
157
-
158
- device = client.request_device_code
159
- @output.puts "1. Open #{device[:verification_uri]} in your browser"
160
- @output.puts "2. Enter code: #{device[:user_code]}"
161
- @output.puts "3. Log in with GitHub"
162
- @output.puts "Waiting for GitHub authentication..."
163
-
164
- deadline = Time.now + device[:expires_in]
165
- loop do
166
- token = client.poll_token(device_code: device[:device_code])
167
- path = credentials.write_cli_token!(
168
- access_token: token.fetch("access_token"),
169
- token_type: token.fetch("token_type", "Bearer"),
170
- provider: token.fetch("provider", "github")
171
- )
172
- @output.puts "4. Saved CLI access token to #{path}"
173
- return 0
174
- rescue AuthClient::Pending
175
- return login_expired if Time.now >= deadline
176
-
177
- @sleeper.sleep(device[:interval])
178
- end
179
- rescue AuthClient::Error, KeyError => e
180
- @output.puts "Error: GitHub auth failed (#{e.message})"
181
- 1
182
- end
183
-
184
- def slides(args)
185
- slides_command.run(args)
186
- end
187
-
188
- def publish_to_slidict(deck, content, options)
189
- slides_command.publish(
190
- id: options[:slide_id],
191
- title: options[:slide_title] || deck.topic,
192
- body: content,
193
- body_format: body_format_for(deck.framework),
194
- visibility: options[:visibility]
195
- )
196
- end
197
-
198
- def slides_command
199
- @slides_command ||= SlidesCommand.new(output: @output, credentials: @credentials, reauthenticate: method(:auth))
200
- end
201
-
202
- def body_format_for(framework)
203
- framework.to_s.downcase == "asciidoctor-revealjs" ? "asciidoc" : "markdown"
204
- end
205
-
206
- def login_expired
207
- @output.puts "Error: GitHub auth timed out. Run `slidict auth` and try again."
208
- 1
209
- end
210
-
211
- def fetch_value!(args, option)
212
- value = args.shift
213
- raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
214
-
215
- value
216
- end
217
-
218
- def ask(question, provided)
219
- return provided unless provided.nil? || provided.strip.empty?
220
-
221
- @output.puts question
222
- @output.print "> "
223
- @input.gets&.chomp.to_s
224
- end
225
-
226
- def print_help
227
- @output.puts <<~HELP
228
- Usage: slidict [options]
229
- Usage: slidict auth
230
- Usage: slidict slides <list|show|create|edit> [options]
231
-
232
- Generate presentation source files from a short conversation.
233
-
234
- Commands:
235
- auth Authenticate the CLI with GitHub and save a CLI access token
236
- slides Manage your slides on slidict.io (run `slidict slides -h` for details)
237
-
238
- Options:
239
- --topic TEXT Presentation topic
240
- --duration TEXT Presentation length, for example "5 minutes"
241
- --audience TEXT Target audience
242
- --goal TEXT Desired audience takeaway or action
243
- --framework NAME slidev, marp, or asciidoctor-revealjs (default: slidev)
244
- --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
245
- When omitted, the built-in slide template is used instead.
246
- --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
247
- --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
248
- --no-llm Skip the LLM call and use the built-in slide template
249
- --publish Publish the generated slides to slidict.io as a draft
250
- (requires `slidict auth`; creates a new slide, or edits
251
- an existing one when --slide-id is given)
252
- --slide-id ID Edit this existing draft instead of creating a new one
253
- (implies --publish)
254
- --slide-title TEXT Title for the published slide (default: --topic)
255
- --visibility VIS public, unlisted, or group_only (default: public)
256
- -o, --output PATH Output file (default depends on --framework)
257
- -h, --help Show this help
258
- HELP
259
- 0
260
- end
261
-
262
- def default_output_for(framework)
263
- DEFAULT_OUTPUT_BY_FRAMEWORK.fetch(framework.to_s.downcase, DEFAULT_OUTPUT)
264
- end
265
- end
266
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "json"
5
-
6
- module Slidict
7
- class Credentials
8
- DEFAULT_PATH = File.expand_path("~/.config/slidict/credentials.json")
9
-
10
- def initialize(path: DEFAULT_PATH)
11
- @path = path
12
- end
13
-
14
- def write_cli_token!(access_token:, token_type: "Bearer", provider: "github")
15
- FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
16
- json = JSON.pretty_generate(
17
- "access_token" => access_token,
18
- "token_type" => token_type,
19
- "provider" => provider,
20
- "kind" => "cli_access_token"
21
- )
22
- File.write(@path, "#{json}\n", mode: "w", perm: 0o600)
23
- File.chmod(0o600, @path)
24
- @path
25
- end
26
-
27
- # Returns { access_token:, token_type: } or nil if no CLI access token is saved.
28
- def read_cli_token
29
- return nil unless File.exist?(@path)
30
-
31
- data = JSON.parse(File.read(@path))
32
- return nil unless data["kind"] == "cli_access_token" && data["access_token"]
33
-
34
- { access_token: data["access_token"], token_type: data.fetch("token_type", "Bearer") }
35
- rescue JSON::ParserError
36
- nil
37
- end
38
- end
39
- end