slidict 0.1.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: be7eb12f72a7017dada85767aef5a2723e0c7ebd28a7d3b8dd5725098903e0c2
4
+ data.tar.gz: c7be11bc74153be8ea5fd0ae52e62bcb62fdfed5ec6a57fce05642cc5b341432
5
+ SHA512:
6
+ metadata.gz: f597120d84f08d011fdae68253d950efae06633fb8e5a15166f86a2e4f995c7b75e0004925b0fa9c385e7514e5b0d421d4eb68edb487e637d7016b9c713d8e3d
7
+ data.tar.gz: 26792e2c9d343f849790d4fc83b1bf2cefa7f4a638487eac3ea25c71795713fb960d8cceac0bf8d51390f8d409506906089639e76c70c55bebe58d645651bc37
@@ -0,0 +1,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "bundler" # See documentation for possible values
4
+ directory: "/" # Location of package manifests
5
+ schedule:
6
+ interval: "weekly"
@@ -0,0 +1,23 @@
1
+ name-template: 'v$NEXT_PATCH_VERSION'
2
+ tag-template: 'v$NEXT_PATCH_VERSION'
3
+ template: |
4
+ ## Changes
5
+
6
+ $CHANGES
7
+
8
+ ## Contributors
9
+
10
+ $CONTRIBUTORS
11
+
12
+ categories:
13
+ - title: '🚀 Features'
14
+ labels:
15
+ - 'feat'
16
+ - title: '🐛 Fixes'
17
+ labels:
18
+ - 'fix'
19
+ - title: '🧰 Maintenance'
20
+ labels:
21
+ - 'chore'
22
+ change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
23
+ no-changes-template: '- No changes'
@@ -0,0 +1,50 @@
1
+ name: Changelog
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ changelog:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout Code
14
+ uses: actions/checkout@v3
15
+
16
+ - name: Extract version
17
+ id: version
18
+ run: |
19
+ version=$(grep -Eo "[0-9]+\.[0-9]+\.[0-9]+" lib/prspec/ruby/version.rb | head -n 1)
20
+ echo "version=v$version" >> $GITHUB_ENV
21
+ echo "VERSION_TAG=v$version" >> $GITHUB_ENV
22
+
23
+ - name: Check if version tag exists
24
+ id: check_tag
25
+ run: |
26
+ if gh release view "$VERSION_TAG" --json tagName > /dev/null 2>&1; then
27
+ echo "exists=true" >> $GITHUB_OUTPUT
28
+ else
29
+ echo "exists=false" >> $GITHUB_OUTPUT
30
+ fi
31
+ env:
32
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33
+
34
+ - name: Generate CHANGELOG with requarks/changelog-action
35
+ if: steps.check_tag.outputs.exists == 'true'
36
+ id: changelog
37
+ uses: requarks/changelog-action@v1
38
+ with:
39
+ token: ${{ secrets.GITHUB_TOKEN }}
40
+ tag: ${{ env.VERSION_TAG }}
41
+ writeToFile: true
42
+ changelogFilePath: CHANGELOG.md
43
+
44
+ - name: Draft release with release-drafter
45
+ if: steps.check_tag.outputs.exists == 'false'
46
+ uses: release-drafter/release-drafter@v5
47
+ with:
48
+ config-name: release-drafter.yml
49
+ env:
50
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,92 @@
1
+ name: Ruby Gem
2
+
3
+ on:
4
+ push:
5
+ tags: [ 'v*' ]
6
+
7
+ jobs:
8
+ build:
9
+ name: Build + Publish
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ packages: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v3
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - name: Set up Ruby 3.4
21
+ uses: ruby/setup-ruby@v1
22
+ with:
23
+ ruby-version: 3.4.3
24
+
25
+ - name: Extract version
26
+ id: version
27
+ run: |
28
+ version=$(echo "${GITHUB_REF#refs/tags/}")
29
+ echo "version=$version" >> $GITHUB_ENV
30
+
31
+ - name: Check if a previous tag exists
32
+ id: previous_tag
33
+ run: |
34
+ if [ "$(git tag --list 'v*' | wc -l)" -gt 1 ]; then
35
+ echo "exists=true" >> $GITHUB_OUTPUT
36
+ else
37
+ echo "exists=false" >> $GITHUB_OUTPUT
38
+ fi
39
+
40
+ - name: Generate CHANGELOG
41
+ if: steps.previous_tag.outputs.exists == 'true'
42
+ id: changelog
43
+ uses: requarks/changelog-action@v1
44
+ with:
45
+ token: ${{ secrets.GITHUB_TOKEN }}
46
+ tag: ${{ env.version }}
47
+ writeToFile: true
48
+ changelogFilePath: CHANGELOG.md
49
+ includeRefIssues: true
50
+ useGitmojis: true
51
+
52
+ - name: Commit updated CHANGELOG.md
53
+ if: steps.previous_tag.outputs.exists == 'true'
54
+ run: |
55
+ git config user.name "github-actions"
56
+ git config user.email "github-actions@github.com"
57
+ git add CHANGELOG.md
58
+ git commit -m "docs: update CHANGELOG for ${{ env.version }}" || echo "No changes to commit"
59
+ git push origin HEAD:main
60
+ continue-on-error: true
61
+
62
+ - name: Update GitHub Release
63
+ uses: ncipollo/release-action@v1
64
+ with:
65
+ allowUpdates: true
66
+ tag: ${{ env.version }}
67
+ name: ${{ env.version }}
68
+ body: ${{ steps.changelog.outputs.changes }}
69
+ token: ${{ secrets.GITHUB_TOKEN }}
70
+
71
+ - name: Publish to GPR
72
+ run: |
73
+ mkdir -p $HOME/.gem
74
+ touch $HOME/.gem/credentials
75
+ chmod 0600 $HOME/.gem/credentials
76
+ printf -- "---\n:github: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
77
+ gem build *.gemspec
78
+ gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} *.gem
79
+ env:
80
+ GEM_HOST_API_KEY: "Bearer ${{ secrets.GITHUB_TOKEN }}"
81
+ OWNER: ${{ github.repository_owner }}
82
+
83
+ - name: Publish to RubyGems
84
+ run: |
85
+ mkdir -p $HOME/.gem
86
+ touch $HOME/.gem/credentials
87
+ chmod 0600 $HOME/.gem/credentials
88
+ printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
89
+ gem build *.gemspec
90
+ gem push *.gem
91
+ env:
92
+ GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_AUTH_TOKEN }}"
@@ -0,0 +1,34 @@
1
+ name: Test
2
+
3
+ on:
4
+ pull_request:
5
+
6
+ jobs:
7
+ ruby:
8
+ name: Ruby tests
9
+ runs-on: ubuntu-latest
10
+
11
+ strategy:
12
+ matrix:
13
+ ruby-version: ["3.2", "3.3", "3.4", "4.0"]
14
+
15
+ steps:
16
+ - name: Check out repository
17
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
18
+ with:
19
+ persist-credentials: false
20
+
21
+ - name: Set up Ruby
22
+ uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
23
+ with:
24
+ ruby-version: ${{ matrix.ruby-version }}
25
+ bundler-cache: true
26
+
27
+ - name: Run test suite
28
+ run: bundle exec rake spec
29
+
30
+ - name: Smoke test CLI
31
+ run: |
32
+ printf 'PDF Difference Monitoring Service\n5 minutes\nEngineering managers\nApprove an MVP pilot\n' | bin/slidict --output /tmp/slidict-smoke.md
33
+ test -s /tmp/slidict-smoke.md
34
+ grep -q '# PDF Difference Monitoring Service' /tmp/slidict-smoke.md
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ ## [v0.1.2] - 2026-06-21
2
+ ### :wrench: Chores
3
+ - [`12bd765`](https://github.com/slidict/slidict/commit/12bd765c172267431349cf028b011db73f921721) - bump version 0.1.2 *(commit by [@abechan1](https://github.com/abechan1))*
4
+ - [`c46fff3`](https://github.com/slidict/slidict/commit/c46fff3fef785e661b2e9cd41fc2f01ae725c984) - stop tracking Gemfile.lock *(commit by [@abechan1](https://github.com/abechan1))*
5
+
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-06-21
10
+
11
+ - Initial release
12
+ [v0.1.2]: https://github.com/slidict/slidict/compare/v0.1.1...v0.1.2
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "slidict" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["255824173+abechan1@users.noreply.github.com"](mailto:"255824173+abechan1@users.noreply.github.com").
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 slidict
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yusuke Abe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # Slidict
2
+
3
+ Generate presentation-ready slides from a simple conversation.
4
+
5
+ Slidict is a CLI tool that helps you turn rough ideas into presentations through AI-guided conversations.
6
+
7
+ Unlike traditional slide generators, Slidict focuses on communication before slide creation.
8
+
9
+ ## Features
10
+
11
+ - Interactive CLI conversation
12
+ - Generate Markdown slides for Slidev, Marp, Asciidoctor Reveal.js, and other OSS presentation frameworks
13
+ - Local-first MVP implemented in Ruby
14
+ - 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
+ ## Requirements
17
+
18
+ - Ruby 3.1 or later
19
+
20
+ ## Usage
21
+
22
+ Run the executable directly from this repository:
23
+
24
+ ```bash
25
+ bin/slidict
26
+ ```
27
+
28
+ Slidict asks a few questions and writes `slides.md`:
29
+
30
+ ```bash
31
+ $ bin/slidict
32
+
33
+ What would you like to talk about?
34
+ > PDF Difference Monitoring Service
35
+ How long is the presentation?
36
+ > 5 minutes
37
+ Who is the audience?
38
+ > Engineering managers
39
+ What should the audience remember or do?
40
+ > Approve an MVP pilot
41
+ Created slides.md
42
+ ```
43
+
44
+ You can also provide answers non-interactively:
45
+
46
+ ```bash
47
+ bin/slidict \
48
+ --topic "PDF Difference Monitoring Service" \
49
+ --duration "5 minutes" \
50
+ --audience "Engineering managers" \
51
+ --goal "Approve an MVP pilot" \
52
+ --framework slidev \
53
+ --output slides.md
54
+ ```
55
+
56
+ Output:
57
+
58
+ ```text
59
+ slides.md
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Slidict generates slides with an LLM through any OpenAI Compatible API. Configure the
65
+ target endpoint with environment variables or CLI flags (flags take precedence):
66
+
67
+ | Environment variable | CLI flag | Default |
68
+ | ------------------------ | ---------------- | -------------- |
69
+ | `SLIDICT_LLM_BASE_URL` | `--llm-base-url` | _(none)_ |
70
+ | `SLIDICT_LLM_API_KEY` | `--llm-api-key` | _(none)_ |
71
+ | `SLIDICT_LLM_MODEL` | `--llm-model` | `gpt-4o-mini` |
72
+
73
+ If no `llm-base-url` is configured, Slidict uses its built-in slide template and never
74
+ calls an LLM. Once a `llm-base-url` is set, Slidict always calls that endpoint; if the
75
+ request fails, Slidict reports the error and exits without writing a file (no fallback).
76
+ You can force the template even when a base URL is configured with `--no-llm`.
77
+
78
+ Examples:
79
+
80
+ ```bash
81
+ # OpenAI
82
+ export SLIDICT_LLM_BASE_URL=https://api.openai.com/v1
83
+ export SLIDICT_LLM_API_KEY=sk-...
84
+ bin/slidict --topic "PDF Difference Monitoring Service" --duration "5 minutes" \
85
+ --audience "Engineering managers" --goal "Approve an MVP pilot"
86
+
87
+ # Ollama (running locally, OpenAI Compatible API)
88
+ bin/slidict --llm-base-url http://localhost:11434/v1 --llm-api-key ollama --llm-model llama3
89
+
90
+ # LM Studio (running locally, OpenAI Compatible API)
91
+ bin/slidict --llm-base-url http://localhost:1234/v1 --llm-api-key lm-studio --llm-model local-model
92
+ ```
93
+
94
+ ## Philosophy
95
+
96
+ Slidict helps you communicate ideas, not just create slides.
97
+
98
+ Many presentation tools focus on layouts, themes, and visual design.
99
+
100
+ Slidict focuses on the message.
101
+
102
+ Before generating slides, Slidict helps you:
103
+
104
+ - Clarify your message
105
+ - Build a compelling narrative
106
+ - Focus on what matters
107
+ - Create presentations people remember
108
+
109
+ ```text
110
+ Idea
111
+
112
+ Conversation
113
+
114
+ Story
115
+
116
+ Slides
117
+ ```
118
+
119
+ We optimize for communication, not decoration.
120
+
121
+ ## Roadmap
122
+
123
+ - [x] Interactive CLI
124
+ - [x] Slide generation
125
+ - [x] OpenAI Compatible API support (configurable base URL, so Ollama, LM Studio, and other compatible servers work out of the box)
126
+
127
+ ## License
128
+
129
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ class CLI
5
+ DEFAULT_OUTPUT = "slides.md"
6
+
7
+ def initialize(input: $stdin, output: $stdout, renderer: MarkdownRenderer.new)
8
+ @input = input
9
+ @output = output
10
+ @renderer = renderer
11
+ end
12
+
13
+ def run(argv = [])
14
+ options = parse(argv)
15
+ return print_help if options[:help]
16
+
17
+ config = build_config(options)
18
+ client = llm_client_for(config)
19
+ return 1 if client && !verify_connection(client)
20
+
21
+ deck = Deck.new(
22
+ topic: ask("What would you like to talk about?", options[:topic]),
23
+ duration: ask("How long is the presentation?", options[:duration]),
24
+ audience: ask("Who is the audience?", options[:audience]),
25
+ goal: ask("What should the audience remember or do?", options[:goal]),
26
+ framework: options[:framework]
27
+ )
28
+
29
+ if client
30
+ begin
31
+ slides = client.generate_slides(deck)
32
+ rescue LLMClient::Error => error
33
+ @output.puts "Error: LLM request failed (#{error.message})"
34
+ return 1
35
+ end
36
+ deck = Deck.new(
37
+ topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
38
+ framework: deck.framework, slides: slides
39
+ )
40
+ end
41
+
42
+ path = options[:output]
43
+ File.write(path, @renderer.render(deck))
44
+ @output.puts "Created #{path}"
45
+ 0
46
+ rescue ArgumentError => error
47
+ @output.puts "Error: #{error.message}"
48
+ @output.puts
49
+ print_help
50
+ 1
51
+ end
52
+
53
+ private
54
+
55
+ def parse(argv)
56
+ options = { output: DEFAULT_OUTPUT, framework: "slidev" }
57
+ args = argv.dup
58
+
59
+ until args.empty?
60
+ case (arg = args.shift)
61
+ when "-h", "--help"
62
+ options[:help] = true
63
+ when "-o", "--output"
64
+ options[:output] = fetch_value!(args, arg)
65
+ when "--topic"
66
+ options[:topic] = fetch_value!(args, arg)
67
+ when "--duration"
68
+ options[:duration] = fetch_value!(args, arg)
69
+ when "--audience"
70
+ options[:audience] = fetch_value!(args, arg)
71
+ when "--goal"
72
+ options[:goal] = fetch_value!(args, arg)
73
+ when "--framework"
74
+ options[:framework] = fetch_value!(args, arg)
75
+ when "--llm-base-url"
76
+ options[:llm_base_url] = fetch_value!(args, arg)
77
+ when "--llm-api-key"
78
+ options[:llm_api_key] = fetch_value!(args, arg)
79
+ when "--llm-model"
80
+ options[:llm_model] = fetch_value!(args, arg)
81
+ when "--no-llm"
82
+ options[:no_llm] = true
83
+ else
84
+ raise ArgumentError, "unknown option #{arg}"
85
+ end
86
+ end
87
+
88
+ options
89
+ end
90
+
91
+ def build_config(options)
92
+ Config.from_env.merge(
93
+ base_url: options[:llm_base_url],
94
+ api_key: options[:llm_api_key],
95
+ model: options[:llm_model],
96
+ enabled: options[:no_llm] ? false : nil
97
+ )
98
+ end
99
+
100
+ def llm_client_for(config)
101
+ return nil unless config.llm_enabled?
102
+
103
+ LLMClient.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
104
+ end
105
+
106
+ def verify_connection(client)
107
+ client.verify_connection!
108
+ true
109
+ rescue LLMClient::Error => error
110
+ @output.puts "Error: LLM request failed (#{error.message})"
111
+ false
112
+ end
113
+
114
+ def fetch_value!(args, option)
115
+ value = args.shift
116
+ raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
117
+
118
+ value
119
+ end
120
+
121
+ def ask(question, provided)
122
+ return provided unless provided.nil? || provided.strip.empty?
123
+
124
+ @output.puts question
125
+ @output.print "> "
126
+ @input.gets&.chomp.to_s
127
+ end
128
+
129
+ def print_help
130
+ @output.puts <<~HELP
131
+ Usage: slidict [options]
132
+
133
+ Generate presentation-ready Markdown slides from a short conversation.
134
+
135
+ Options:
136
+ --topic TEXT Presentation topic
137
+ --duration TEXT Presentation length, for example "5 minutes"
138
+ --audience TEXT Target audience
139
+ --goal TEXT Desired audience takeaway or action
140
+ --framework NAME slidev, marp, or asciidoctor-revealjs (default: slidev)
141
+ --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
142
+ When omitted, the built-in slide template is used instead.
143
+ --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
144
+ --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
145
+ --no-llm Skip the LLM call and use the built-in slide template
146
+ -o, --output PATH Output file (default: slides.md)
147
+ -h, --help Show this help
148
+ HELP
149
+ 0
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ class Config
5
+ DEFAULT_MODEL = "gpt-4o-mini"
6
+
7
+ attr_reader :base_url, :api_key, :model
8
+
9
+ def initialize(base_url: nil, api_key: nil, model: DEFAULT_MODEL, enabled: true)
10
+ @base_url = base_url
11
+ @api_key = api_key
12
+ @model = model
13
+ @enabled = enabled
14
+ end
15
+
16
+ def self.from_env(env = ENV)
17
+ new(
18
+ base_url: env["SLIDICT_LLM_BASE_URL"],
19
+ api_key: env["SLIDICT_LLM_API_KEY"],
20
+ model: env["SLIDICT_LLM_MODEL"] || DEFAULT_MODEL
21
+ )
22
+ end
23
+
24
+ def merge(base_url: nil, api_key: nil, model: nil, enabled: nil)
25
+ self.class.new(
26
+ base_url: base_url || @base_url,
27
+ api_key: api_key || @api_key,
28
+ model: model || @model,
29
+ enabled: enabled.nil? ? @enabled : enabled
30
+ )
31
+ end
32
+
33
+ # An llm-base-url is required to enable the LLM call; otherwise the
34
+ # built-in slide template is used.
35
+ def llm_enabled?
36
+ @enabled && !base_url.to_s.strip.empty?
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ Slide = Struct.new(:title, :bullets, keyword_init: true)
5
+
6
+ class Deck
7
+ attr_reader :topic, :duration, :audience, :goal, :framework
8
+
9
+ def initialize(topic:, duration:, audience:, goal:, framework: "slidev", slides: nil)
10
+ @topic = normalize(topic, fallback: "Untitled presentation")
11
+ @duration = normalize(duration, fallback: "5 minutes")
12
+ @audience = normalize(audience, fallback: "general audience")
13
+ @goal = normalize(goal, fallback: "understand the key message")
14
+ @framework = normalize(framework, fallback: "slidev").downcase
15
+ @slides = slides
16
+ end
17
+
18
+ def slides
19
+ @slides || default_slides
20
+ end
21
+
22
+ private
23
+
24
+ def default_slides
25
+ [
26
+ Slide.new(title: topic, bullets: ["For #{audience}", "Goal: #{goal}", "Length: #{duration}"]),
27
+ Slide.new(title: "Why this matters", bullets: ["Clarifies the problem before discussing solutions", "Keeps the story focused on audience value", "Sets up a memorable takeaway"]),
28
+ Slide.new(title: "Core message", bullets: ["#{topic} should be easy to explain", "Every slide should support: #{goal}", "Details are included only when they help the audience decide or act"]),
29
+ Slide.new(title: "Suggested narrative", bullets: ["Start with the current pain or opportunity", "Show what changes when #{topic} works well", "Close with the next step you want the audience to take"]),
30
+ Slide.new(title: "Next steps", bullets: ["Review the generated outline", "Replace generic bullets with concrete examples", "Rehearse and refine for #{duration}"])
31
+ ]
32
+ end
33
+
34
+ def normalize(value, fallback:)
35
+ text = value.to_s.strip
36
+ text.empty? ? fallback : text
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,144 @@
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
@@ -0,0 +1,28 @@
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
+ "asciidoctor-revealjs" => "revealjs_theme: white"
9
+ }.freeze
10
+
11
+ def render(deck)
12
+ [frontmatter(deck.framework), deck.slides.map { |slide| render_slide(slide) }.join("\n---\n\n")].join("\n")
13
+ end
14
+
15
+ private
16
+
17
+ def frontmatter(framework)
18
+ body = FRONTMATTER_BY_FRAMEWORK.fetch(framework, FRONTMATTER_BY_FRAMEWORK["slidev"])
19
+ "---\n#{body}\ngenerated: #{Time.now.utc.iso8601}\n---\n"
20
+ end
21
+
22
+ def render_slide(slide)
23
+ lines = ["# #{slide.title}", ""]
24
+ lines.concat(slide.bullets.map { |bullet| "- #{bullet}" })
25
+ lines.join("\n")
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ VERSION = "0.1.2"
5
+ end
data/lib/slidict.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require_relative "slidict/cli"
6
+ require_relative "slidict/config"
7
+ require_relative "slidict/deck"
8
+ require_relative "slidict/llm_client"
9
+ require_relative "slidict/markdown_renderer"
10
+ require_relative "slidict/version"
11
+
12
+ module Slidict
13
+ class Error < StandardError; end
14
+ # Your code goes here...
15
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: slidict
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Yusuke Abe
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Slidict is a Ruby CLI for turning rough ideas into presentation-ready
13
+ Markdown slides.
14
+ email:
15
+ - 255824173+abechan1@users.noreply.github.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".github/dependabot.yml"
21
+ - ".github/release-drafter.yml"
22
+ - ".github/workflows/changelog.yml"
23
+ - ".github/workflows/gem-push.yml"
24
+ - ".github/workflows/test.yml"
25
+ - CHANGELOG.md
26
+ - CODE_OF_CONDUCT.md
27
+ - LICENSE
28
+ - LICENSE.txt
29
+ - README.md
30
+ - Rakefile
31
+ - lib/slidict.rb
32
+ - lib/slidict/cli.rb
33
+ - lib/slidict/config.rb
34
+ - lib/slidict/deck.rb
35
+ - lib/slidict/llm_client.rb
36
+ - lib/slidict/markdown_renderer.rb
37
+ - lib/slidict/version.rb
38
+ homepage: https://labs.slidict.io/slidict/
39
+ licenses:
40
+ - MIT
41
+ metadata:
42
+ homepage_uri: https://labs.slidict.io/slidict/
43
+ source_code_uri: https://github.com/slidict/slidict
44
+ changelog_uri: https://github.com/slidict/slidict/releases
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 3.2.0
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ requirements: []
59
+ rubygems_version: 3.6.7
60
+ specification_version: 4
61
+ summary: Generate presentation-ready slides from a simple conversation.
62
+ test_files: []