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 +4 -4
- data/README.md +23 -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 -7
- metadata +53 -9
- data/lib/slidict/auth_client.rb +0 -75
- data/lib/slidict/cli.rb +0 -266
- data/lib/slidict/credentials.rb +0 -39
- data/lib/slidict/llm_client.rb +0 -144
- data/lib/slidict/markdown_renderer.rb +0 -39
- data/lib/slidict/slides_client.rb +0 -120
- data/lib/slidict/slides_command.rb +0 -253
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/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:
|
|
@@ -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
|
|
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 ->
|
|
76
|
-
Marp ->
|
|
77
|
-
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, ...
|
|
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
|