slidict 0.1.9 → 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 +4 -4
- data/AGENTS.md +41 -0
- data/README.md +70 -6
- data/lib/slidict/cli/app.rb +316 -0
- data/lib/slidict/cli/serve.rb +113 -0
- data/lib/slidict/cli/slides.rb +271 -0
- data/lib/slidict/external/slidict_io/auth.rb +80 -0
- data/lib/slidict/external/slidict_io/client.rb +126 -0
- data/lib/slidict/external/slidict_io/credentials.rb +43 -0
- data/lib/slidict/llm/client.rb +146 -0
- data/lib/slidict/output/format.rb +44 -0
- data/lib/slidict/output/renderer.rb +36 -0
- data/lib/slidict/version.rb +1 -1
- data/lib/slidict.rb +9 -3
- metadata +54 -5
- data/lib/slidict/cli.rb +0 -162
- data/lib/slidict/llm_client.rb +0 -144
- data/lib/slidict/markdown_renderer.rb +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a58f7f50da42dd170f4f479e90d30f750bfa7e09edc7175b567c34f0637e9d37
|
|
4
|
+
data.tar.gz: f61880e7f032400e945e5174abae17e15077b0439b352b90af8df2937cf6df9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 30f98a358a929f7cd6048f3862183bf8a20c901ea6b6c86a3492865b4abbbdf64de140939c6c754168d4645b66e074788fdbf7a669c1176c7d5e9445b2e4edcd
|
|
7
|
+
data.tar.gz: d89395639ce333ec905175788aa44da292951560d230529d34f0c1be01b2091a9fc827e4986dff8f02cb07535c0d36562bdc9b05704f13cf6da623f16e532b5c
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Guidance for AI coding agents working in this repository.
|
|
4
|
+
|
|
5
|
+
## Project overview
|
|
6
|
+
|
|
7
|
+
Slidict is a Ruby CLI gem that turns conversational input into presentation
|
|
8
|
+
source files (Slidev, Marp, Asciidoctor Reveal.js, etc.) via an
|
|
9
|
+
OpenAI-compatible chat API.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
- Ruby 3.1+
|
|
14
|
+
- Install dependencies: `bundle install`
|
|
15
|
+
|
|
16
|
+
## Development workflow
|
|
17
|
+
|
|
18
|
+
- Run tests: `bundle exec rspec`
|
|
19
|
+
- Run lint: `bundle exec rubocop`
|
|
20
|
+
- Run the CLI locally: `bin/slidict`
|
|
21
|
+
|
|
22
|
+
Run both tests and lint before considering a change complete.
|
|
23
|
+
|
|
24
|
+
When you add or change a CLI command (or its options), update the "Commands" section
|
|
25
|
+
of `README.md` to document it.
|
|
26
|
+
|
|
27
|
+
## Commit conventions
|
|
28
|
+
|
|
29
|
+
Use Conventional Commits for every commit message: `<type>: <summary>`.
|
|
30
|
+
|
|
31
|
+
Common types used in this repo:
|
|
32
|
+
|
|
33
|
+
- `feat:` new functionality
|
|
34
|
+
- `fix:` bug fixes
|
|
35
|
+
- `chore:` maintenance, version bumps, dependency updates
|
|
36
|
+
- `ci:` CI/workflow changes
|
|
37
|
+
- `docs:` documentation only changes
|
|
38
|
+
- `refactor:` code change that neither fixes a bug nor adds a feature
|
|
39
|
+
- `test:` adding or correcting tests
|
|
40
|
+
|
|
41
|
+
Keep the summary short and in the imperative mood (e.g. `fix: handle empty topic input`).
|
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
|
|
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
|
|
42
|
+
Created public/001.md
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
You can also provide answers non-interactively:
|
|
@@ -53,16 +54,79 @@ bin/slidict \
|
|
|
53
54
|
--output slides.adoc
|
|
54
55
|
```
|
|
55
56
|
|
|
57
|
+
Add `--publish` to also save the generated slides to slidict.io as a draft (requires
|
|
58
|
+
`slidict auth` first). Pass `--slide-id` to edit an existing draft instead of creating a
|
|
59
|
+
new one:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Create a new draft on slidict.io from the generated slides
|
|
63
|
+
bin/slidict --topic "PDF Difference Monitoring Service" --duration "5 minutes" \
|
|
64
|
+
--audience "Engineering managers" --goal "Approve an MVP pilot" --publish
|
|
65
|
+
|
|
66
|
+
# Edit an existing draft (slide #42) instead of creating a new one
|
|
67
|
+
bin/slidict --topic "PDF Difference Monitoring Service" --duration "5 minutes" \
|
|
68
|
+
--audience "Engineering managers" --goal "Approve an MVP pilot" --slide-id 42
|
|
69
|
+
```
|
|
70
|
+
|
|
56
71
|
## Output files
|
|
57
72
|
|
|
58
|
-
Choose the framework and output path that match the presentation tool you want to use. If you omit `--output`, Slidict
|
|
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
|
+
```
|
|
59
79
|
|
|
60
80
|
```text
|
|
61
|
-
Slidev ->
|
|
62
|
-
Marp ->
|
|
63
|
-
Asciidoctor Reveal.js ->
|
|
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, ...
|
|
64
84
|
```
|
|
65
85
|
|
|
86
|
+
## Commands
|
|
87
|
+
|
|
88
|
+
### `slidict auth`
|
|
89
|
+
|
|
90
|
+
Authenticates the CLI with your GitHub account via the device code flow and saves a
|
|
91
|
+
CLI access token to `~/.config/slidict/credentials.json`.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
bin/slidict auth
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `slidict slides`
|
|
98
|
+
|
|
99
|
+
Manage your slides on slidict.io using the CLI access token saved by `slidict auth`.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
bin/slidict slides list [--page N]
|
|
103
|
+
bin/slidict slides show <id>
|
|
104
|
+
bin/slidict slides create [--title TEXT] [--body TEXT | --file PATH] [--body-format asciidoc|markdown] [--visibility public|unlisted|group_only]
|
|
105
|
+
bin/slidict slides edit <id> [--title TEXT] [--body TEXT | --file PATH] [--body-format asciidoc|markdown] [--visibility public|unlisted|group_only]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `create` and `edit` always save the slide as a draft. Publishing requires going through
|
|
109
|
+
the moderation flow on the Web UI; the CLI cannot publish a slide.
|
|
110
|
+
- `edit` only works on slides that are still drafts; editing an already-published slide
|
|
111
|
+
must be done from the Web UI.
|
|
112
|
+
- `create`/`edit` are rate limited to once per minute per user.
|
|
113
|
+
|
|
114
|
+
Run `bin/slidict slides -h` for the full list of options.
|
|
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
|
+
|
|
127
|
+
`bin/slidict --publish` and `--slide-id` (see [Usage](#usage)) wrap this same `create`/`edit`
|
|
128
|
+
behavior so you can save the slides you just generated straight to slidict.io.
|
|
129
|
+
|
|
66
130
|
## Configuration
|
|
67
131
|
|
|
68
132
|
Slidict generates slides with an LLM through any OpenAI Compatible API. Configure the
|
|
@@ -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
|