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
|
@@ -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
|
data/lib/slidict/version.rb
CHANGED
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/
|
|
9
|
-
require_relative "slidict/
|
|
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
|
|
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/
|
|
37
|
-
- lib/slidict/
|
|
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
|
data/lib/slidict/llm_client.rb
DELETED
|
@@ -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
|