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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc62a4a9def18173cd4fa3c36a07b8113cbd952f3ecef69f25d31bdd8a0c2c29
4
- data.tar.gz: b559163e5add314725911941fece8829b47cd79f801369a4735a5b62fb6aadfb
3
+ metadata.gz: a58f7f50da42dd170f4f479e90d30f750bfa7e09edc7175b567c34f0637e9d37
4
+ data.tar.gz: f61880e7f032400e945e5174abae17e15077b0439b352b90af8df2937cf6df9e
5
5
  SHA512:
6
- metadata.gz: 7482973532b58361216b54dc6a45e81bee4bc39e65e9a26958c37c271cdcb97f5c915649ee26d88973f4e34f26a9a7eb2b56a0ad7224488bd87a739087201d25
7
- data.tar.gz: 946075256cf69b70511aae347f2d6a15164f87aeb4bd0678ac329af8a367773c515aec5a09bc4cab52a42b475e4aff7ec26995cbb7fce8f191ee3c12accea668
6
+ metadata.gz: 30f98a358a929f7cd6048f3862183bf8a20c901ea6b6c86a3492865b4abbbdf64de140939c6c754168d4645b66e074788fdbf7a669c1176c7d5e9445b2e4edcd
7
+ data.tar.gz: d89395639ce333ec905175788aa44da292951560d230529d34f0c1be01b2091a9fc827e4986dff8f02cb07535c0d36562bdc9b05704f13cf6da623f16e532b5c
data/README.md CHANGED
@@ -11,6 +11,7 @@ Unlike traditional slide generators, Slidict focuses on communication before sli
11
11
  - Interactive CLI conversation
12
12
  - Generate slides for Slidev, Marp, Asciidoctor Reveal.js, and other OSS presentation frameworks
13
13
  - Local-first MVP implemented in Ruby
14
+ - Built-in Sinatra server for browsing generated slides from `public/`
14
15
  - OpenAI Compatible API support, so you can point Slidict at OpenAI, Ollama, LM Studio, vLLM, or any other server implementing the same `/chat/completions` endpoint
15
16
 
16
17
  ## Requirements
@@ -28,7 +29,7 @@ bin/slidict
28
29
  Slidict asks a few questions and generates presentation source files. For example, this creates a Marp Markdown deck:
29
30
 
30
31
  ```bash
31
- $ bin/slidict --framework marp --output slides.md
32
+ $ bin/slidict --framework marp
32
33
 
33
34
  What would you like to talk about?
34
35
  > PDF Difference Monitoring Service
@@ -38,7 +39,7 @@ Who is the audience?
38
39
  > Engineering managers
39
40
  What should the audience remember or do?
40
41
  > Approve an MVP pilot
41
- Created slides.md
42
+ Created public/001.md
42
43
  ```
43
44
 
44
45
  You can also provide answers non-interactively:
@@ -69,12 +70,17 @@ bin/slidict --topic "PDF Difference Monitoring Service" --duration "5 minutes" \
69
70
 
70
71
  ## Output files
71
72
 
72
- Choose the framework and output path that match the presentation tool you want to use. If you omit `--output`, Slidict chooses a framework-specific default:
73
+ Choose the framework and output path that match the presentation tool you want to use. If you omit `--output`, Slidict writes under `public/` with the next sequential file name. Use `--filename` to choose the relative file name under `public/`; Slidict appends the framework extension when the name has no extension.
74
+
75
+ ```bash
76
+ # Choose a served file name under public/
77
+ bin/slidict --filename product-demo/slides --topic "Product Demo"
78
+ ```
73
79
 
74
80
  ```text
75
- Slidev -> slides.md
76
- Marp -> slides.md
77
- Asciidoctor Reveal.js -> slides.adoc
81
+ Slidev -> public/001.md, public/002.md, ...
82
+ Marp -> public/001.md, public/002.md, ...
83
+ Asciidoctor Reveal.js -> public/001.adoc, public/002.adoc, ...
78
84
  ```
79
85
 
80
86
  ## Commands
@@ -107,6 +113,17 @@ bin/slidict slides edit <id> [--title TEXT] [--body TEXT | --file PATH] [--body-
107
113
 
108
114
  Run `bin/slidict slides -h` for the full list of options.
109
115
 
116
+ ### `slidict serve`
117
+
118
+ Serve generated slide files from the local `public/` directory with Sinatra. The top
119
+ page lists Markdown and Asciidoc slide files below `public/`, so you can organize
120
+ decks in subdirectories such as `public/product-demo/slides.md`. Any arguments
121
+ after `serve` are passed through to Sinatra.
122
+
123
+ ```bash
124
+ bin/slidict serve -p 4567 -o 0.0.0.0
125
+ ```
126
+
110
127
  `bin/slidict --publish` and `--slide-id` (see [Usage](#usage)) wrap this same `create`/`edit`
111
128
  behavior so you can save the slides you just generated straight to slidict.io.
112
129
 
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Slidict
7
+ module Cli
8
+ class App
9
+ def initialize(input: $stdin, output: $stdout, renderer: Output::Renderer.new, auth_client: nil,
10
+ credentials: nil, sleeper: Kernel, slides_command: nil, server: nil)
11
+ @input = input
12
+ @output = output
13
+ @renderer = renderer
14
+ @auth_client = auth_client
15
+ @credentials = credentials
16
+ @sleeper = sleeper
17
+ @slides_command = slides_command
18
+ @server = server
19
+ end
20
+
21
+ def run(argv = [])
22
+ options = parse(argv)
23
+ return print_help if options[:help]
24
+ return auth if options[:command] == "auth"
25
+ return slides(options[:args]) if options[:command] == "slides"
26
+ return serve(options[:args]) if options[:command] == "serve"
27
+
28
+ config = build_config(options)
29
+ client = llm_client_for(config)
30
+ return 1 if client && !verify_connection(client)
31
+
32
+ deck = Deck.new(
33
+ topic: ask("What would you like to talk about?", options[:topic]),
34
+ duration: ask("How long is the presentation?", options[:duration]),
35
+ audience: ask("Who is the audience?", options[:audience]),
36
+ goal: ask("What should the audience remember or do?", options[:goal]),
37
+ framework: options[:framework]
38
+ )
39
+
40
+ if client
41
+ begin
42
+ slides = client.generate_slides(deck)
43
+ rescue Llm::Client::Error => e
44
+ @output.puts "Error: LLM request failed (#{e.message})"
45
+ return 1
46
+ end
47
+ deck = Deck.new(
48
+ topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
49
+ framework: deck.framework, slides: slides
50
+ )
51
+ end
52
+
53
+ path = options[:output]
54
+ content = @renderer.render(deck)
55
+ FileUtils.mkdir_p(File.dirname(path))
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
+ if args.first == "serve"
91
+ args.shift
92
+ options[:command] = "serve"
93
+ options[:args] = args
94
+ return options
95
+ end
96
+
97
+ until args.empty?
98
+ case (arg = args.shift)
99
+ when "-h", "--help"
100
+ options[:help] = true
101
+ when "-o", "--output"
102
+ options[:output] = fetch_value!(args, arg)
103
+ when "--filename"
104
+ options[:filename] = fetch_value!(args, arg)
105
+ when "--topic"
106
+ options[:topic] = fetch_value!(args, arg)
107
+ when "--duration"
108
+ options[:duration] = fetch_value!(args, arg)
109
+ when "--audience"
110
+ options[:audience] = fetch_value!(args, arg)
111
+ when "--goal"
112
+ options[:goal] = fetch_value!(args, arg)
113
+ when "--framework"
114
+ options[:framework] = fetch_value!(args, arg)
115
+ when "--llm-base-url"
116
+ options[:llm_base_url] = fetch_value!(args, arg)
117
+ when "--llm-api-key"
118
+ options[:llm_api_key] = fetch_value!(args, arg)
119
+ when "--llm-model"
120
+ options[:llm_model] = fetch_value!(args, arg)
121
+ when "--no-llm"
122
+ options[:no_llm] = true
123
+ when "--publish"
124
+ options[:publish] = true
125
+ when "--slide-id"
126
+ options[:slide_id] = fetch_value!(args, arg)
127
+ when "--slide-title"
128
+ options[:slide_title] = fetch_value!(args, arg)
129
+ when "--visibility"
130
+ options[:visibility] = fetch_value!(args, arg)
131
+ else
132
+ raise ArgumentError, "unknown option #{arg}"
133
+ end
134
+ end
135
+
136
+ options[:output] ||= output_path_for(options[:framework], options[:filename])
137
+ options
138
+ end
139
+
140
+ def build_config(options)
141
+ Config.from_env.merge(
142
+ base_url: options[:llm_base_url],
143
+ api_key: options[:llm_api_key],
144
+ model: options[:llm_model],
145
+ enabled: options[:no_llm] ? false : nil
146
+ )
147
+ end
148
+
149
+ def llm_client_for(config)
150
+ return nil unless config.llm_enabled?
151
+
152
+ Llm::Client.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
153
+ end
154
+
155
+ def verify_connection(client)
156
+ client.verify_connection!
157
+ true
158
+ rescue Llm::Client::Error => e
159
+ @output.puts "Error: LLM request failed (#{e.message})"
160
+ false
161
+ end
162
+
163
+ def auth
164
+ client = @auth_client || External::SlidictIo::Auth.new
165
+ credentials = @credentials || External::SlidictIo::Credentials.new
166
+
167
+ device = client.request_device_code
168
+ @output.puts "1. Open #{device[:verification_uri]} in your browser"
169
+ @output.puts "2. Enter code: #{device[:user_code]}"
170
+ @output.puts "3. Log in with GitHub"
171
+ @output.puts "Waiting for GitHub authentication..."
172
+
173
+ deadline = Time.now + device[:expires_in]
174
+ loop do
175
+ token = client.poll_token(device_code: device[:device_code])
176
+ path = credentials.write_cli_token!(
177
+ access_token: token.fetch("access_token"),
178
+ token_type: token.fetch("token_type", "Bearer"),
179
+ provider: token.fetch("provider", "github")
180
+ )
181
+ @output.puts "4. Saved CLI access token to #{path}"
182
+ return 0
183
+ rescue External::SlidictIo::Auth::Pending
184
+ return login_expired if Time.now >= deadline
185
+
186
+ @sleeper.sleep(device[:interval])
187
+ end
188
+ rescue External::SlidictIo::Auth::Error, KeyError => e
189
+ @output.puts "Error: GitHub auth failed (#{e.message})"
190
+ 1
191
+ end
192
+
193
+ def slides(args)
194
+ slides_command.run(args)
195
+ end
196
+
197
+ def serve(args)
198
+ server.run(args)
199
+ end
200
+
201
+ def publish_to_slidict(deck, content, options)
202
+ slides_command.publish(
203
+ id: options[:slide_id],
204
+ title: options[:slide_title] || deck.topic,
205
+ body: content,
206
+ body_format: body_format_for(deck.framework),
207
+ visibility: options[:visibility]
208
+ )
209
+ end
210
+
211
+ def slides_command
212
+ @slides_command ||= Slides.new(output: @output, credentials: @credentials, reauthenticate: method(:auth))
213
+ end
214
+
215
+ def server
216
+ @server ||= Serve.new(output: @output)
217
+ end
218
+
219
+ def body_format_for(framework)
220
+ Output::Format.fetch(framework).body_format
221
+ end
222
+
223
+ def login_expired
224
+ @output.puts "Error: GitHub auth timed out. Run `slidict auth` and try again."
225
+ 1
226
+ end
227
+
228
+ def fetch_value!(args, option)
229
+ value = args.shift
230
+ raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
231
+
232
+ value
233
+ end
234
+
235
+ def ask(question, provided)
236
+ return provided unless provided.nil? || provided.strip.empty?
237
+
238
+ @output.puts question
239
+ @output.print "> "
240
+ @input.gets&.chomp.to_s
241
+ end
242
+
243
+ def print_help
244
+ @output.puts <<~HELP
245
+ Usage: slidict [options]
246
+ Usage: slidict auth
247
+ Usage: slidict slides <list|show|create|edit> [options]
248
+ Usage: slidict serve [sinatra options]
249
+
250
+ Generate presentation source files from a short conversation.
251
+
252
+ Commands:
253
+ auth Authenticate the CLI with GitHub and save a CLI access token
254
+ slides Manage your slides on slidict.io (run `slidict slides -h` for details)
255
+ serve Serve slide files from ./public with Sinatra
256
+
257
+ Options:
258
+ --topic TEXT Presentation topic
259
+ --duration TEXT Presentation length, for example "5 minutes"
260
+ --audience TEXT Target audience
261
+ --goal TEXT Desired audience takeaway or action
262
+ --framework NAME #{Output::Format.names.join(", ")} (default: slidev)
263
+ --filename NAME File name under public/ (default: next sequential file)
264
+ --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
265
+ When omitted, the built-in slide template is used instead.
266
+ --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
267
+ --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
268
+ --no-llm Skip the LLM call and use the built-in slide template
269
+ --publish Publish the generated slides to slidict.io as a draft
270
+ (requires `slidict auth`; creates a new slide, or edits
271
+ an existing one when --slide-id is given)
272
+ --slide-id ID Edit this existing draft instead of creating a new one
273
+ (implies --publish)
274
+ --slide-title TEXT Title for the published slide (default: --topic)
275
+ --visibility VIS public, unlisted, or group_only (default: public)
276
+ -o, --output PATH Output file (overrides --filename and the public/ default)
277
+ -h, --help Show this help
278
+ HELP
279
+ 0
280
+ end
281
+
282
+ def output_path_for(framework, filename)
283
+ return File.join("public", normalize_filename(filename, framework)) if filename
284
+
285
+ next_sequential_output_for(framework)
286
+ end
287
+
288
+ def normalize_filename(filename, framework)
289
+ path = filename.to_s.strip
290
+ raise ArgumentError, "--filename requires a relative path under public" if path.empty?
291
+ raise ArgumentError, "--filename must be relative" if Pathname.new(path).absolute?
292
+ raise ArgumentError, "--filename cannot include .." if Pathname.new(path).each_filename.any?("..")
293
+
294
+ # --filename is already relative to public/, so drop a redundant leading
295
+ # "public/" instead of nesting it twice (public/public/...).
296
+ path = path.delete_prefix("public/")
297
+ File.extname(path).empty? ? "#{path}#{default_extension_for(framework)}" : path
298
+ end
299
+
300
+ def next_sequential_output_for(framework)
301
+ extension = default_extension_for(framework)
302
+ number = 1
303
+ loop do
304
+ path = File.join("public", format("%03d%s", number, extension))
305
+ return path unless File.exist?(path)
306
+
307
+ number += 1
308
+ end
309
+ end
310
+
311
+ def default_extension_for(framework)
312
+ Output::Format.fetch(framework).extension
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+ require "pathname"
6
+
7
+ module Slidict
8
+ module Cli
9
+ class Serve
10
+ DEFAULT_PUBLIC_DIR = "public"
11
+
12
+ def initialize(public_dir: DEFAULT_PUBLIC_DIR, output: $stdout)
13
+ @public_dir = File.expand_path(public_dir)
14
+ @output = output
15
+ end
16
+
17
+ def run(args = [])
18
+ app = build_app
19
+ original_argv = ARGV.dup
20
+ ARGV.replace(args)
21
+ @output.puts "Serving slides from #{@public_dir}"
22
+ app.run!
23
+ 0
24
+ ensure
25
+ ARGV.replace(original_argv) if original_argv
26
+ end
27
+
28
+ private
29
+
30
+ def build_app
31
+ require "sinatra/base"
32
+
33
+ public_dir = @public_dir
34
+
35
+ Class.new(Sinatra::Base) do
36
+ set :public_folder, public_dir
37
+ set :static, true
38
+ set :show_exceptions, false
39
+
40
+ get "/" do
41
+ @public_dir = public_dir
42
+ @entries = slide_entries(public_dir)
43
+ erb INDEX_TEMPLATE
44
+ end
45
+
46
+ helpers do
47
+ def slide_entries(public_dir)
48
+ return [] unless Dir.exist?(public_dir)
49
+
50
+ Dir.glob(File.join(public_dir, "**", "*")).filter_map do |path|
51
+ next unless File.file?(path)
52
+
53
+ relative = Pathname.new(path).relative_path_from(Pathname.new(public_dir)).to_s
54
+ next unless slide_file?(relative)
55
+
56
+ { title: title_for(relative), href: "/#{escape_path(relative)}", path: relative }
57
+ end.sort_by { |entry| entry[:path] }
58
+ end
59
+
60
+ def slide_file?(path)
61
+ File.extname(path).match?(/\A\.(?:adoc|md|markdown)\z/i)
62
+ end
63
+
64
+ def title_for(path)
65
+ dirname = File.dirname(path)
66
+ basename = File.basename(path, File.extname(path))
67
+ dirname == "." ? basename : dirname
68
+ end
69
+
70
+ def escape_path(path)
71
+ # CGI.escape turns spaces into "+", which Rack's static file
72
+ # routing does not unescape back to a literal space. Percent-encode
73
+ # each segment instead so hrefs match the file on disk.
74
+ path.split("/").map { |segment| ERB::Util.url_encode(segment) }.join("/")
75
+ end
76
+
77
+ def h(text)
78
+ CGI.escapeHTML(text.to_s)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ INDEX_TEMPLATE = <<~ERB.freeze
85
+ <!doctype html>
86
+ <html lang="en">
87
+ <head>
88
+ <meta charset="utf-8">
89
+ <meta name="viewport" content="width=device-width, initial-scale=1">
90
+ <title>Slidict slides</title>
91
+ <style>
92
+ body { font-family: system-ui, sans-serif; margin: 2rem; }
93
+ li { margin: 0.5rem 0; }
94
+ code { color: #555; }
95
+ </style>
96
+ </head>
97
+ <body>
98
+ <h1>Slidict slides</h1>
99
+ <% if @entries.empty? %>
100
+ <p>No slides found in <code><%= h(@public_dir) %></code>.</p>
101
+ <% else %>
102
+ <ul>
103
+ <% @entries.each do |entry| %>
104
+ <li><a href="<%= entry[:href] %>"><%= h(entry[:title]) %></a> <code><%= h(entry[:path]) %></code></li>
105
+ <% end %>
106
+ </ul>
107
+ <% end %>
108
+ </body>
109
+ </html>
110
+ ERB
111
+ end
112
+ end
113
+ end