slidict 0.2.0 → 0.4.0

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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Slidict
8
+ module External
9
+ module SlidictIo
10
+ # Talks to the slidict.io CLI slides API using a CLI access token
11
+ # (obtained via Slidict::External::SlidictIo::Auth / `slidict auth`).
12
+ class Client
13
+ class Error < StandardError; end
14
+ class Unauthorized < Error; end
15
+ class Forbidden < Error; end
16
+ class NotFound < Error; end
17
+ class NotEditable < Error; end
18
+ class RateLimited < Error; end
19
+
20
+ # Raised for 422 responses other than "not_editable", carrying the API's validation errors.
21
+ class Unprocessable < Error
22
+ attr_reader :errors
23
+
24
+ def initialize(message, errors: [])
25
+ super(message)
26
+ @errors = errors
27
+ end
28
+ end
29
+
30
+ DEFAULT_BASE_URL = "https://slidict.io"
31
+
32
+ attr_reader :base_url
33
+
34
+ def initialize(access_token:, token_type: "Bearer",
35
+ base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
36
+ @access_token = access_token
37
+ @token_type = token_type
38
+ @base_url = base_url
39
+ end
40
+
41
+ def list(page: nil)
42
+ get_json("/api/cli/slides", query: page ? { page: page } : {})
43
+ end
44
+
45
+ def show(id)
46
+ get_json("/api/cli/slides/#{id}")
47
+ end
48
+
49
+ def create(body:, title: nil, body_format: nil, visibility: nil)
50
+ post_json("/api/cli/slides",
51
+ slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
52
+ end
53
+
54
+ def update(id, body: nil, title: nil, body_format: nil, visibility: nil)
55
+ patch_json("/api/cli/slides/#{id}",
56
+ slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
57
+ end
58
+
59
+ private
60
+
61
+ def slide_payload(title:, body:, body_format:, visibility:)
62
+ { title: title, body: body, body_format: body_format, visibility: visibility }.compact
63
+ end
64
+
65
+ def get_json(path, query: {})
66
+ uri = URI.join(base_url, path)
67
+ uri.query = URI.encode_www_form(query) unless query.empty?
68
+ perform(uri, Net::HTTP::Get.new(uri))
69
+ end
70
+
71
+ def post_json(path, payload)
72
+ uri = URI.join(base_url, path)
73
+ perform(uri, build_request(Net::HTTP::Post.new(uri), payload))
74
+ end
75
+
76
+ def patch_json(path, payload)
77
+ uri = URI.join(base_url, path)
78
+ perform(uri, build_request(Net::HTTP::Patch.new(uri), payload))
79
+ end
80
+
81
+ def build_request(request, payload)
82
+ request["Content-Type"] = "application/json"
83
+ request.body = JSON.generate(payload)
84
+ request
85
+ end
86
+
87
+ def perform(uri, request)
88
+ request["Accept"] = "application/json"
89
+ request["Authorization"] = "#{@token_type} #{@access_token}"
90
+
91
+ response = Net::HTTP.start(uri.hostname, uri.port,
92
+ use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
93
+ http.request(request)
94
+ end
95
+ body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
96
+ handle_response(response, body)
97
+ rescue JSON::ParserError => e
98
+ raise Error, "invalid JSON response: #{e.message}"
99
+ rescue SystemCallError, Timeout::Error => e
100
+ raise Error, e.message
101
+ end
102
+
103
+ ERROR_CLASSES_BY_STATUS = {
104
+ "401" => Unauthorized,
105
+ "403" => Forbidden,
106
+ "404" => NotFound,
107
+ "429" => RateLimited
108
+ }.freeze
109
+
110
+ def handle_response(response, body)
111
+ return body if response.is_a?(Net::HTTPSuccess)
112
+ return raise_unprocessable(body) if response.code == "422"
113
+
114
+ error_class = ERROR_CLASSES_BY_STATUS.fetch(response.code, Error)
115
+ raise error_class, body["error_description"] || body["error"] || "HTTP #{response.code}"
116
+ end
117
+
118
+ def raise_unprocessable(body)
119
+ raise NotEditable, "not_editable" if body["error"] == "not_editable"
120
+
121
+ raise Unprocessable.new(body["error"] || "unprocessable", errors: Array(body["errors"]))
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Slidict
7
+ module External
8
+ module SlidictIo
9
+ class Credentials
10
+ DEFAULT_PATH = File.expand_path("~/.config/slidict/credentials.json")
11
+
12
+ def initialize(path: DEFAULT_PATH)
13
+ @path = path
14
+ end
15
+
16
+ def write_cli_token!(access_token:, token_type: "Bearer", provider: "github")
17
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
18
+ json = JSON.pretty_generate(
19
+ "access_token" => access_token,
20
+ "token_type" => token_type,
21
+ "provider" => provider,
22
+ "kind" => "cli_access_token"
23
+ )
24
+ File.write(@path, "#{json}\n", mode: "w", perm: 0o600)
25
+ File.chmod(0o600, @path)
26
+ @path
27
+ end
28
+
29
+ # Returns { access_token:, token_type: } or nil if no CLI access token is saved.
30
+ def read_cli_token
31
+ return nil unless File.exist?(@path)
32
+
33
+ data = JSON.parse(File.read(@path))
34
+ return nil unless data["kind"] == "cli_access_token" && data["access_token"]
35
+
36
+ { access_token: data["access_token"], token_type: data.fetch("token_type", "Bearer") }
37
+ rescue JSON::ParserError
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Lint
5
+ # A single diagnostic produced by the linter: which slide it concerns,
6
+ # how severe it is ("warning" blocks understanding, "info" is a
7
+ # suggestion), and the human-readable message to show the user.
8
+ Finding = Struct.new(:slide, :severity, :message, keyword_init: true)
9
+ end
10
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Lint
5
+ # Orchestrates a lint run: splits the raw deck source into slides and
6
+ # asks the LLM client to diagnose the presentation's structure.
7
+ class Linter
8
+ class Error < StandardError; end
9
+
10
+ def initialize(client:)
11
+ @client = client
12
+ end
13
+
14
+ def lint(content, format: "markdown")
15
+ slides = SlideParser.parse(content, format: format)
16
+ raise Error, "no slides found in the given file" if slides.empty?
17
+
18
+ @client.lint_slides(slides)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Lint
5
+ # Formats findings as one line per finding:
6
+ # [warning] Slide 3: this slide's main point is unclear
7
+ class Renderer
8
+ def render(findings)
9
+ findings.sort_by(&:slide).map { |finding| line_for(finding) }.join("\n")
10
+ end
11
+
12
+ private
13
+
14
+ def line_for(finding)
15
+ "[#{finding.severity}] Slide #{finding.slide}: #{finding.message}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Lint
5
+ # Splits raw Markdown/Asciidoc slide source into one text block per
6
+ # slide, so the linter can refer to slides by number. Frontmatter
7
+ # (Markdown) and the document title/preamble (Asciidoc) are dropped
8
+ # since they are not slides a reader sees.
9
+ class SlideParser
10
+ MARKDOWN_SEPARATOR = /\A-{3,}\s*\z/
11
+ ASCIIDOC_SLIDE_HEADING = /\A==\s+/
12
+ MARKDOWN_HEADING = /\A#\s+/
13
+
14
+ def self.parse(content, format: "markdown")
15
+ format == "asciidoc" ? parse_asciidoc(content) : parse_markdown(content)
16
+ end
17
+
18
+ def self.parse_markdown(content)
19
+ blocks = split_lines(content, MARKDOWN_SEPARATOR)
20
+ blocks = blocks.drop(2) if content.lines.first&.match?(MARKDOWN_SEPARATOR)
21
+
22
+ if blocks.size <= 1
23
+ by_heading = split_lines(content, MARKDOWN_HEADING, keep_separator: true, drop_preamble: true)
24
+ blocks = by_heading unless by_heading.empty?
25
+ end
26
+
27
+ clean(blocks)
28
+ end
29
+ private_class_method :parse_markdown
30
+
31
+ def self.parse_asciidoc(content)
32
+ clean(split_lines(content, ASCIIDOC_SLIDE_HEADING, keep_separator: true, drop_preamble: true))
33
+ end
34
+ private_class_method :parse_asciidoc
35
+
36
+ # Splits `content` into blocks every time a line matches `separator`.
37
+ # With keep_separator: true the matching line starts a new block
38
+ # instead of being discarded (used for heading-based splitting).
39
+ # With drop_preamble: true, any lines before the first match are
40
+ # discarded rather than kept as a leading block.
41
+ def self.split_lines(content, separator, keep_separator: false, drop_preamble: false)
42
+ blocks = []
43
+ current = drop_preamble ? nil : []
44
+ content.each_line do |line|
45
+ current = append_line(blocks, current, line, separator, keep_separator)
46
+ end
47
+ blocks << current.join if current
48
+ blocks
49
+ end
50
+ private_class_method :split_lines
51
+
52
+ def self.append_line(blocks, current, line, separator, keep_separator)
53
+ return current << line if current && !line.match?(separator)
54
+
55
+ blocks << current.join if current
56
+ return current unless line.match?(separator)
57
+
58
+ keep_separator ? [line] : []
59
+ end
60
+ private_class_method :append_line
61
+
62
+ def self.clean(blocks)
63
+ blocks.map(&:strip).reject(&:empty?)
64
+ end
65
+ private_class_method :clean
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Slidict
8
+ module Llm
9
+ # Talks to any OpenAI Compatible API (OpenAI, Ollama, LM Studio, vLLM, etc.)
10
+ # via the standard /chat/completions endpoint. Configure the target with
11
+ # Slidict::Config (base_url, api_key, model).
12
+ class Client
13
+ class Error < StandardError; end
14
+
15
+ def initialize(base_url:, api_key:, model:)
16
+ @base_url = base_url
17
+ @api_key = api_key
18
+ @model = model
19
+ end
20
+
21
+ # Checks that the endpoint is reachable before the (slower, more
22
+ # expensive) chat completion request is made. Raises Error on failure.
23
+ def verify_connection!
24
+ uri = endpoint_uri("models")
25
+ request = Net::HTTP::Get.new(uri)
26
+ request["Authorization"] = "Bearer #{@api_key}"
27
+ response = perform_request(uri, request)
28
+
29
+ raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
30
+ end
31
+
32
+ def generate_slides(deck)
33
+ content = chat_completion(prompt_for(deck))
34
+ slides_from(content)
35
+ end
36
+
37
+ # slide_texts is an array of slide bodies (1-indexed by position) as
38
+ # produced by Slidict::Lint::SlideParser. Returns an array of
39
+ # Slidict::Lint::Finding.
40
+ def lint_slides(slide_texts)
41
+ content = chat_completion(lint_prompt_for(slide_texts))
42
+ findings_from(content)
43
+ end
44
+
45
+ private
46
+
47
+ def prompt_for(deck)
48
+ <<~PROMPT
49
+ You are an assistant that designs presentation slide outlines.
50
+ Topic: #{deck.topic}
51
+ Duration: #{deck.duration}
52
+ Audience: #{deck.audience}
53
+ Goal: #{deck.goal}
54
+
55
+ Return exactly 5 slides as a JSON array. Each item must be an object with
56
+ a "title" string and a "bullets" array of 2-4 short strings.
57
+ Respond with the JSON array only: no commentary, no markdown code fences,
58
+ and no reasoning or thinking content before or after it.
59
+ PROMPT
60
+ end
61
+
62
+ LINT_PROMPT_TEMPLATE = <<~PROMPT
63
+ You are a presentation structure linter for tech talks and lightning talks.
64
+ Your goal is not to judge how the slides look, but to diagnose whether the
65
+ structure will actually land with an audience.
66
+
67
+ Evaluate the deck as a whole against these six checks:
68
+
69
+ 1. Is the audience clear (who is this talk for)?
70
+ 2. Can the overall point be stated in one sentence?
71
+ 3. Does the deck flow background -> problem -> solution -> result/learning
72
+ (call out where the flow breaks down)?
73
+ 4. Is any single slide overloaded with information?
74
+ 5. Is jargon used without first explaining it?
75
+ 6. Does the closing slide give the audience one concrete takeaway?
76
+
77
+ For each finding, name the single slide it relates to most. For deck-wide
78
+ findings (unclear audience, unclear thesis, etc.), point to the slide where
79
+ the problem is most visible, or slide 1 if you cannot tell.
80
+
81
+ Decide severity using these rules:
82
+ - warning: likely to block audience understanding (checks 1-5)
83
+ - info: a suggestion that would make things better (check 6, or minor polish)
84
+
85
+ Here is the slide content ("--- Slide N ---" marks each slide boundary):
86
+
87
+ %<numbered>s
88
+
89
+ Respond with a JSON array of findings only. Each item must be an object of
90
+ the form {"slide": <integer>, "severity": "warning" or "info", "message": "<one sentence>"}.
91
+
92
+ Do not include any commentary, markdown code fences, or text other than the
93
+ JSON array. Return an empty array [] if you find no issues.
94
+ PROMPT
95
+
96
+ def lint_prompt_for(slide_texts)
97
+ numbered = slide_texts.each_with_index.map { |text, i| "--- Slide #{i + 1} ---\n#{text}" }.join("\n\n")
98
+ format(LINT_PROMPT_TEMPLATE, numbered: numbered)
99
+ end
100
+
101
+ def findings_from(content)
102
+ parsed = JSON.parse(extract_json_array(content))
103
+ raise Error, "expected a JSON array of findings" unless parsed.is_a?(Array)
104
+
105
+ parsed.map { |item| finding_from(item) }
106
+ rescue JSON::ParserError, KeyError, ArgumentError, TypeError => e
107
+ raise Error, "could not parse model response: #{e.message}"
108
+ end
109
+
110
+ def finding_from(item)
111
+ Lint::Finding.new(
112
+ slide: Integer(item.fetch("slide")),
113
+ severity: normalize_severity(item["severity"]),
114
+ message: item.fetch("message")
115
+ )
116
+ end
117
+
118
+ def normalize_severity(value)
119
+ %w[warning info].include?(value) ? value : "info"
120
+ end
121
+
122
+ def chat_completion(prompt)
123
+ response = JSON.parse(post_chat_completion(prompt))
124
+ content = response.dig("choices", 0, "message", "content")
125
+ raise Error, "empty response from model" if content.to_s.strip.empty?
126
+
127
+ content
128
+ rescue JSON::ParserError => e
129
+ raise Error, "could not parse model response: #{e.message}"
130
+ end
131
+
132
+ def post_chat_completion(prompt)
133
+ uri = endpoint_uri("chat/completions")
134
+ request = build_request(uri, prompt)
135
+ response = perform_request(uri, request)
136
+
137
+ raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
138
+
139
+ response.body
140
+ end
141
+
142
+ def perform_request(uri, request)
143
+ Net::HTTP.start(uri.host, uri.port,
144
+ use_ssl: uri.scheme == "https",
145
+ open_timeout: 5,
146
+ read_timeout: 30,
147
+ write_timeout: 30) do |http|
148
+ http.request(request)
149
+ end
150
+ rescue StandardError => e
151
+ raise Error, e.message
152
+ end
153
+
154
+ def endpoint_uri(path)
155
+ base = @base_url.to_s.sub(%r{/+\z}, "")
156
+ URI.join("#{base}/", path)
157
+ end
158
+
159
+ def build_request(uri, prompt)
160
+ request = Net::HTTP::Post.new(uri)
161
+ request["Content-Type"] = "application/json"
162
+ request["Authorization"] = "Bearer #{@api_key}"
163
+ request.body = JSON.generate(
164
+ model: @model,
165
+ messages: [{ role: "user", content: prompt }],
166
+ temperature: 0.7
167
+ )
168
+ request
169
+ end
170
+
171
+ def slides_from(content)
172
+ parsed = JSON.parse(extract_json_array(content))
173
+ raise Error, "expected a JSON array of slides" unless parsed.is_a?(Array)
174
+
175
+ parsed.map do |item|
176
+ Slide.new(title: item.fetch("title"), bullets: Array(item.fetch("bullets")))
177
+ end
178
+ rescue JSON::ParserError, KeyError => e
179
+ raise Error, "could not parse model response: #{e.message}"
180
+ end
181
+
182
+ # Some models (especially reasoning models served through LM Studio or
183
+ # Ollama) prepend or append thinking/reasoning text around the JSON
184
+ # answer instead of returning it verbatim, so the array is extracted from
185
+ # within the raw content rather than parsed as-is.
186
+ def extract_json_array(content)
187
+ content.enum_for(:scan, /\[/).each do
188
+ start = Regexp.last_match.begin(0)
189
+ candidate = json_array_from(content, start)
190
+ return candidate if candidate
191
+ end
192
+
193
+ raise Error, "no JSON array found in model response"
194
+ end
195
+
196
+ def json_array_from(content, start)
197
+ finish = start
198
+ while (finish = content.index("]", finish))
199
+ candidate = content[start..finish]
200
+ return candidate if parses_to_array?(candidate)
201
+
202
+ finish += 1
203
+ end
204
+ nil
205
+ end
206
+
207
+ def parses_to_array?(candidate)
208
+ JSON.parse(candidate).is_a?(Array)
209
+ rescue JSON::ParserError
210
+ false
211
+ end
212
+ end
213
+ end
214
+ end
@@ -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.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/slidict.rb CHANGED
@@ -2,15 +2,22 @@
2
2
 
3
3
  require "time"
4
4
 
5
- require_relative "slidict/auth_client"
6
- require_relative "slidict/cli"
5
+ require_relative "slidict/cli/app"
6
+ require_relative "slidict/cli/lint"
7
+ require_relative "slidict/cli/serve"
8
+ require_relative "slidict/cli/slides"
7
9
  require_relative "slidict/config"
8
- require_relative "slidict/credentials"
9
10
  require_relative "slidict/deck"
10
- require_relative "slidict/llm_client"
11
- require_relative "slidict/markdown_renderer"
12
- require_relative "slidict/slides_client"
13
- require_relative "slidict/slides_command"
11
+ require_relative "slidict/external/slidict_io/auth"
12
+ require_relative "slidict/external/slidict_io/client"
13
+ require_relative "slidict/external/slidict_io/credentials"
14
+ require_relative "slidict/lint/finding"
15
+ require_relative "slidict/lint/linter"
16
+ require_relative "slidict/lint/renderer"
17
+ require_relative "slidict/lint/slide_parser"
18
+ require_relative "slidict/llm/client"
19
+ require_relative "slidict/output/format"
20
+ require_relative "slidict/output/renderer"
14
21
  require_relative "slidict/version"
15
22
 
16
23
  module Slidict