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.
@@ -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.1.9"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/slidict.rb CHANGED
@@ -2,11 +2,17 @@
2
2
 
3
3
  require "time"
4
4
 
5
- require_relative "slidict/cli"
5
+ require_relative "slidict/cli/app"
6
+ require_relative "slidict/cli/serve"
7
+ require_relative "slidict/cli/slides"
6
8
  require_relative "slidict/config"
7
9
  require_relative "slidict/deck"
8
- require_relative "slidict/llm_client"
9
- require_relative "slidict/markdown_renderer"
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"
10
16
  require_relative "slidict/version"
11
17
 
12
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.1.9
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:
@@ -23,6 +65,7 @@ files:
23
65
  - ".github/workflows/changelog.yml"
24
66
  - ".github/workflows/gem-push.yml"
25
67
  - ".github/workflows/test.yml"
68
+ - AGENTS.md
26
69
  - CHANGELOG.md
27
70
  - CODE_OF_CONDUCT.md
28
71
  - LICENSE
@@ -30,11 +73,17 @@ files:
30
73
  - README.md
31
74
  - Rakefile
32
75
  - lib/slidict.rb
33
- - lib/slidict/cli.rb
76
+ - lib/slidict/cli/app.rb
77
+ - lib/slidict/cli/serve.rb
78
+ - lib/slidict/cli/slides.rb
34
79
  - lib/slidict/config.rb
35
80
  - lib/slidict/deck.rb
36
- - lib/slidict/llm_client.rb
37
- - lib/slidict/markdown_renderer.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
38
87
  - lib/slidict/version.rb
39
88
  homepage: https://labs.slidict.io/slidict/
40
89
  licenses:
data/lib/slidict/cli.rb DELETED
@@ -1,162 +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)
13
- @input = input
14
- @output = output
15
- @renderer = renderer
16
- end
17
-
18
- def run(argv = [])
19
- options = parse(argv)
20
- return print_help if options[:help]
21
-
22
- config = build_config(options)
23
- client = llm_client_for(config)
24
- return 1 if client && !verify_connection(client)
25
-
26
- deck = Deck.new(
27
- topic: ask("What would you like to talk about?", options[:topic]),
28
- duration: ask("How long is the presentation?", options[:duration]),
29
- audience: ask("Who is the audience?", options[:audience]),
30
- goal: ask("What should the audience remember or do?", options[:goal]),
31
- framework: options[:framework]
32
- )
33
-
34
- if client
35
- begin
36
- slides = client.generate_slides(deck)
37
- rescue LLMClient::Error => error
38
- @output.puts "Error: LLM request failed (#{error.message})"
39
- return 1
40
- end
41
- deck = Deck.new(
42
- topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
43
- framework: deck.framework, slides: slides
44
- )
45
- end
46
-
47
- path = options[:output]
48
- File.write(path, @renderer.render(deck))
49
- @output.puts "Created #{path}"
50
- 0
51
- rescue ArgumentError => error
52
- @output.puts "Error: #{error.message}"
53
- @output.puts
54
- print_help
55
- 1
56
- end
57
-
58
- private
59
-
60
- def parse(argv)
61
- options = { framework: "slidev" }
62
- args = argv.dup
63
-
64
- until args.empty?
65
- case (arg = args.shift)
66
- when "-h", "--help"
67
- options[:help] = true
68
- when "-o", "--output"
69
- options[:output] = fetch_value!(args, arg)
70
- when "--topic"
71
- options[:topic] = fetch_value!(args, arg)
72
- when "--duration"
73
- options[:duration] = fetch_value!(args, arg)
74
- when "--audience"
75
- options[:audience] = fetch_value!(args, arg)
76
- when "--goal"
77
- options[:goal] = fetch_value!(args, arg)
78
- when "--framework"
79
- options[:framework] = fetch_value!(args, arg)
80
- when "--llm-base-url"
81
- options[:llm_base_url] = fetch_value!(args, arg)
82
- when "--llm-api-key"
83
- options[:llm_api_key] = fetch_value!(args, arg)
84
- when "--llm-model"
85
- options[:llm_model] = fetch_value!(args, arg)
86
- when "--no-llm"
87
- options[:no_llm] = true
88
- else
89
- raise ArgumentError, "unknown option #{arg}"
90
- end
91
- end
92
-
93
- options[:output] ||= default_output_for(options[:framework])
94
- options
95
- end
96
-
97
- def build_config(options)
98
- Config.from_env.merge(
99
- base_url: options[:llm_base_url],
100
- api_key: options[:llm_api_key],
101
- model: options[:llm_model],
102
- enabled: options[:no_llm] ? false : nil
103
- )
104
- end
105
-
106
- def llm_client_for(config)
107
- return nil unless config.llm_enabled?
108
-
109
- LLMClient.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
110
- end
111
-
112
- def verify_connection(client)
113
- client.verify_connection!
114
- true
115
- rescue LLMClient::Error => error
116
- @output.puts "Error: LLM request failed (#{error.message})"
117
- false
118
- end
119
-
120
- def fetch_value!(args, option)
121
- value = args.shift
122
- raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
123
-
124
- value
125
- end
126
-
127
- def ask(question, provided)
128
- return provided unless provided.nil? || provided.strip.empty?
129
-
130
- @output.puts question
131
- @output.print "> "
132
- @input.gets&.chomp.to_s
133
- end
134
-
135
- def print_help
136
- @output.puts <<~HELP
137
- Usage: slidict [options]
138
-
139
- Generate presentation source files from a short conversation.
140
-
141
- Options:
142
- --topic TEXT Presentation topic
143
- --duration TEXT Presentation length, for example "5 minutes"
144
- --audience TEXT Target audience
145
- --goal TEXT Desired audience takeaway or action
146
- --framework NAME slidev, marp, or asciidoctor-revealjs (default: slidev)
147
- --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
148
- When omitted, the built-in slide template is used instead.
149
- --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
150
- --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
151
- --no-llm Skip the LLM call and use the built-in slide template
152
- -o, --output PATH Output file (default depends on --framework)
153
- -h, --help Show this help
154
- HELP
155
- 0
156
- end
157
-
158
- def default_output_for(framework)
159
- DEFAULT_OUTPUT_BY_FRAMEWORK.fetch(framework.to_s.downcase, DEFAULT_OUTPUT)
160
- end
161
- end
162
- end
@@ -1,144 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
-
7
- module Slidict
8
- # Talks to any OpenAI Compatible API (OpenAI, Ollama, LM Studio, vLLM, etc.)
9
- # via the standard /chat/completions endpoint. Configure the target with
10
- # Slidict::Config (base_url, api_key, model).
11
- class LLMClient
12
- class Error < StandardError; end
13
-
14
- def initialize(base_url:, api_key:, model:)
15
- @base_url = base_url
16
- @api_key = api_key
17
- @model = model
18
- end
19
-
20
- # Checks that the endpoint is reachable before the (slower, more
21
- # expensive) chat completion request is made. Raises Error on failure.
22
- def verify_connection!
23
- uri = endpoint_uri("models")
24
- request = Net::HTTP::Get.new(uri)
25
- request["Authorization"] = "Bearer #{@api_key}"
26
- response = perform_request(uri, request)
27
-
28
- raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
29
- end
30
-
31
- def generate_slides(deck)
32
- content = chat_completion(prompt_for(deck))
33
- slides_from(content)
34
- end
35
-
36
- private
37
-
38
- def prompt_for(deck)
39
- <<~PROMPT
40
- You are an assistant that designs presentation slide outlines.
41
- Topic: #{deck.topic}
42
- Duration: #{deck.duration}
43
- Audience: #{deck.audience}
44
- Goal: #{deck.goal}
45
-
46
- Return exactly 5 slides as a JSON array. Each item must be an object with
47
- a "title" string and a "bullets" array of 2-4 short strings.
48
- Respond with the JSON array only: no commentary, no markdown code fences,
49
- and no reasoning or thinking content before or after it.
50
- PROMPT
51
- end
52
-
53
- def chat_completion(prompt)
54
- response = JSON.parse(post_chat_completion(prompt))
55
- content = response.dig("choices", 0, "message", "content")
56
- raise Error, "empty response from model" if content.to_s.strip.empty?
57
-
58
- content
59
- rescue JSON::ParserError => e
60
- raise Error, "could not parse model response: #{e.message}"
61
- end
62
-
63
- def post_chat_completion(prompt)
64
- uri = endpoint_uri("chat/completions")
65
- request = build_request(uri, prompt)
66
- response = perform_request(uri, request)
67
-
68
- raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
69
-
70
- response.body
71
- end
72
-
73
- def perform_request(uri, request)
74
- Net::HTTP.start(uri.host, uri.port,
75
- use_ssl: uri.scheme == "https",
76
- open_timeout: 5,
77
- read_timeout: 30,
78
- write_timeout: 30) do |http|
79
- http.request(request)
80
- end
81
- rescue StandardError => e
82
- raise Error, e.message
83
- end
84
-
85
- def endpoint_uri(path)
86
- base = @base_url.to_s.sub(%r{/+\z}, "")
87
- URI.join("#{base}/", path)
88
- end
89
-
90
- def build_request(uri, prompt)
91
- request = Net::HTTP::Post.new(uri)
92
- request["Content-Type"] = "application/json"
93
- request["Authorization"] = "Bearer #{@api_key}"
94
- request.body = JSON.generate(
95
- model: @model,
96
- messages: [{ role: "user", content: prompt }],
97
- temperature: 0.7
98
- )
99
- request
100
- end
101
-
102
- def slides_from(content)
103
- parsed = JSON.parse(extract_json_array(content))
104
- raise Error, "expected a JSON array of slides" unless parsed.is_a?(Array)
105
-
106
- parsed.map do |item|
107
- Slide.new(title: item.fetch("title"), bullets: Array(item.fetch("bullets")))
108
- end
109
- rescue JSON::ParserError, KeyError => e
110
- raise Error, "could not parse model response: #{e.message}"
111
- end
112
-
113
- # Some models (especially reasoning models served through LM Studio or
114
- # Ollama) prepend or append thinking/reasoning text around the JSON
115
- # answer instead of returning it verbatim, so the array is extracted from
116
- # within the raw content rather than parsed as-is.
117
- def extract_json_array(content)
118
- content.enum_for(:scan, /\[/).each do
119
- start = Regexp.last_match.begin(0)
120
- candidate = json_array_from(content, start)
121
- return candidate if candidate
122
- end
123
-
124
- raise Error, "no JSON array found in model response"
125
- end
126
-
127
- def json_array_from(content, start)
128
- finish = start
129
- while (finish = content.index("]", finish))
130
- candidate = content[start..finish]
131
- return candidate if parses_to_array?(candidate)
132
-
133
- finish += 1
134
- end
135
- nil
136
- end
137
-
138
- def parses_to_array?(candidate)
139
- JSON.parse(candidate).is_a?(Array)
140
- rescue JSON::ParserError
141
- false
142
- end
143
- end
144
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Slidict
4
- class MarkdownRenderer
5
- FRONTMATTER_BY_FRAMEWORK = {
6
- "slidev" => "theme: default\nclass: text-center",
7
- "marp" => "marp: true\ntheme: default"
8
- }.freeze
9
-
10
- def render(deck)
11
- return render_asciidoctor_revealjs(deck) if deck.framework == "asciidoctor-revealjs"
12
-
13
- [frontmatter(deck.framework), deck.slides.map { |slide| render_slide(slide) }.join("\n---\n\n")].join("\n")
14
- end
15
-
16
- private
17
-
18
- def frontmatter(framework)
19
- body = FRONTMATTER_BY_FRAMEWORK.fetch(framework, FRONTMATTER_BY_FRAMEWORK["slidev"])
20
- "---\n#{body}\ngenerated: #{Time.now.utc.iso8601}\n---\n"
21
- end
22
-
23
- def render_slide(slide)
24
- lines = ["# #{slide.title}", ""]
25
- lines.concat(slide.bullets.map { |bullet| "- #{bullet}" })
26
- lines.join("\n")
27
- end
28
-
29
- def render_asciidoctor_revealjs(deck)
30
- lines = ["= #{deck.topic}", ":revealjs_theme: white", ":slidict_generated: #{Time.now.utc.iso8601}", ""]
31
- lines.concat(deck.slides.flat_map { |slide| render_asciidoctor_slide(slide) })
32
- lines.join("\n")
33
- end
34
-
35
- def render_asciidoctor_slide(slide)
36
- ["== #{slide.title}", "", *slide.bullets.map { |bullet| "* #{bullet}" }, ""]
37
- end
38
- end
39
- end