chunky_bacon 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 79373a42eb29eb28f1fdf188d7837fd3c97e5cebc5a2419cf0edbfe93535acfc
4
+ data.tar.gz: c13f00849c79d15e17094dfdd316c8ca00aa50e8d9673c0c71b381ce59d56edc
5
+ SHA512:
6
+ metadata.gz: 45360a618e747dbfb441d77fc29f5505354537947bfda4b3783d1dfcc27488e452fbfc102bf1a4874d9586f4a434b99ad70e73db3dc6ac1eb78137b27722e6b3
7
+ data.tar.gz: 873db5d5a4e07063229ff494b9d97f336c5fd4eeb3fe42d36104571037039c2edcf3f5e51a175b428fed11082a56abb33598409f01cb9b0798d7265406b79e2b
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - The course's helpers on a computer: `install_gem`, `show_image`,
6
+ `show_pdf`, `download_file`, `show_browser` (with a small built-in server),
7
+ `mock_get`, `show_irb`, `show_files`, `run_tests`; `show_three` and
8
+ `show_shoes` explain what to do instead.
9
+ - `chunkybacon` shows Chunky; `chunkybacon run [FILE]` runs a program with
10
+ the helpers loaded.
11
+ - In the course page (ruby.wasm) the page's own helpers stay.
12
+ - Code MIT; the fox drawing (`lib/chunky_bacon/fox.txt`) CC BY-SA 4.0.
13
+ - Also installable as `chunkybacon` and `chunky-bacon` (alias gems).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andi Idogawa
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-ASSETS ADDED
@@ -0,0 +1,13 @@
1
+ The assets of chunky_bacon - the drawing of Chunky, the fox, in
2
+ lib/chunky_bacon/fox.txt - are licensed under the Creative Commons
3
+ Attribution-ShareAlike 4.0 International License (CC BY-SA 4.0):
4
+ https://creativecommons.org/licenses/by-sa/4.0/
5
+
6
+ Copyright (c) 2026 Andi Idogawa. Chunky is the mascot of the Ruby course
7
+ "Ruby lernen mit Chunky Bacon" (https://github.com/Largo/chunkybacon).
8
+
9
+ You may share and adapt the drawing, also commercially, as long as you give
10
+ credit (name the course and Andi Idogawa, link the license) and share
11
+ adaptations under the same license.
12
+
13
+ The code of chunky_bacon is licensed under the MIT License (LICENSE).
data/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # chunky_bacon
2
+
3
+ The companion gem of **[Ruby lernen mit Chunky Bacon](https://github.com/Largo/chunkybacon)**,
4
+ an interactive Ruby course that runs in the browser.
5
+
6
+ In the course, notebook cells and the workshop have helpers like `show_image`,
7
+ `show_browser` and `download_file`. This gem gives your own computer the same
8
+ helpers, so a program you wrote in the course runs unchanged with plain Ruby.
9
+
10
+ ```sh
11
+ gem install chunky_bacon # or: gem install chunkybacon - the same gem
12
+ chunkybacon # Chunky shouts
13
+ chunkybacon run # runs main.rb with the helpers loaded
14
+ chunkybacon run spiel.rb # ... or another file
15
+ ```
16
+
17
+ In your own code:
18
+
19
+ ```ruby
20
+ require "chunky_bacon"
21
+
22
+ require "chunky_png"
23
+ image = ChunkyPNG::Image.new(64, 64, ChunkyPNG::Color.rgb(232, 114, 42))
24
+ show_image image # saves chunky-image-1.png and opens it
25
+ ```
26
+
27
+ ## The helpers
28
+
29
+ What the course page showed below the cell becomes a file in the program's
30
+ folder, opened in your system's viewer. Status lines go to stderr, so they
31
+ never mix with your program's output.
32
+
33
+ | Helper | On your computer |
34
+ |---|---|
35
+ | `install_gem "name"` | installs the gem unless it is there, and activates it |
36
+ | `show_image image` | saves `chunky-image-N.png` (ChunkyPNG image, PNG bytes or a path) and opens it |
37
+ | `show_pdf pdf` | saves `chunky-document-N.pdf` (Prawn/HexaPDF document, bytes or a path) and opens it |
38
+ | `download_file data, "name"` | saves the data as `name` in the program's folder |
39
+ | `show_browser App, "/path"` | starts the Rack app (Sinatra, Roda, ...) on 127.0.0.1 and opens it; after the program's last line the server runs on until Ctrl+C |
40
+ | `mock_get App, "/path"` | `[status, body]` without any server |
41
+ | `show_irb` | an IRB session; `exit` returns to the program |
42
+ | `show_files` | lists the files in the program's folder |
43
+ | `run_tests` | runs the Minitest tests defined so far |
44
+ | `show_three`, `show_shoes` | explain how to do it on a computer (they need the course page for now) |
45
+
46
+ `CHUNKYBACON_OPEN=0` stops the viewer and the browser from opening (CI, a
47
+ machine without a desktop); the files are saved either way. Inside the course
48
+ page the gem keeps the page's own helpers and only adds the fox.
49
+
50
+ ## License
51
+
52
+ The code is MIT ([LICENSE](LICENSE)). The drawing of Chunky, the fox
53
+ (`lib/chunky_bacon/fox.txt`), is CC BY-SA 4.0 like the course's other content
54
+ ([LICENSE-ASSETS](LICENSE-ASSETS)). The course's lesson texts are not part of
55
+ this gem.
data/exe/chunkybacon ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/chunky_bacon"
5
+ require_relative "../lib/chunky_bacon/cli"
6
+
7
+ begin
8
+ exit ChunkyBacon::CLI.new.call(ARGV)
9
+ rescue Interrupt
10
+ exit 130
11
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module ChunkyBacon
6
+ # The `chunkybacon` command.
7
+ class CLI
8
+ HELP = <<~TEXT
9
+ Usage: chunkybacon [command]
10
+
11
+ (no command) Chunky shouts
12
+ run [FILE] [ARGS] runs FILE (main.rb if not given) with the course's
13
+ helpers loaded - show_image, show_browser,
14
+ download_file, ... - like the workshop's Run button
15
+ version the version of chunky_bacon
16
+ help this text
17
+
18
+ The course: https://github.com/Largo/chunkybacon
19
+ TEXT
20
+
21
+ def initialize(out: $stdout, err: $stderr)
22
+ @out = out
23
+ @err = err
24
+ end
25
+
26
+ # the exit status
27
+ def call(args)
28
+ command, *rest = args
29
+ case command
30
+ when nil
31
+ ChunkyBacon.shout(@out)
32
+ 0
33
+ when "run"
34
+ run(rest)
35
+ when "version", "--version", "-v"
36
+ @out.puts VERSION
37
+ 0
38
+ when "help", "--help", "-h"
39
+ @out.puts HELP
40
+ 0
41
+ else
42
+ @err.puts "chunkybacon: unknown command #{command.inspect}\n\n#{HELP}"
43
+ 1
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # In a child Ruby, so the program has the terminal - input for gets,
50
+ # Ctrl+C - and its exit status is the command's.
51
+ def run(args)
52
+ file = args.first || "main.rb"
53
+ unless File.file?(file)
54
+ others = Dir["*.rb"].sort
55
+ hint = others.empty? ? "" : " - there is #{others.join(", ")}: chunkybacon run #{others.first}"
56
+ @err.puts "chunkybacon: no #{file} here#{hint}"
57
+ return 1
58
+ end
59
+ lib = File.expand_path("..", __dir__)
60
+ system(RbConfig.ruby, "-I", lib, "-r", "chunky_bacon", file, *args.drop(1))
61
+ $?.exitstatus || 1
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChunkyBacon
4
+ # Chunky, the course's fox, holding up a strip of bacon and shouting - the
5
+ # same original character as the site's mascot (html/assets/chunky.svg),
6
+ # in plain ASCII so any terminal shows it. The drawing is fox.txt next to
7
+ # this file; like the course's other content it is licensed CC BY-SA 4.0
8
+ # (LICENSE-ASSETS), while this code is MIT.
9
+ FOX = File.read(File.expand_path("fox.txt", __dir__), encoding: "UTF-8").gsub("\r\n", "\n").freeze
10
+
11
+ # Puts the fox on +io+ (the terminal unless told otherwise).
12
+ def self.shout(io = $stdout)
13
+ io.puts FOX
14
+ nil
15
+ end
16
+ end
@@ -0,0 +1,10 @@
1
+ /\ /\
2
+ / \________/ \ \ | /
3
+ | ^ ^ | CHUNKY BACON!
4
+ | ____ | / | \
5
+ .~~~~. \ \__/ /
6
+ |~~~~|--\____________/
7
+ '~~~~' | |\__
8
+ | | \
9
+ |__________| ~~'
10
+ | |
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChunkyBacon
4
+ class << self
5
+ # Servers started by show_browser keep the program alive after its last
6
+ # line, until Ctrl+C - as the mini browser stayed below the cell. Tests
7
+ # switch this off.
8
+ attr_writer :keep_serving
9
+
10
+ def keep_serving
11
+ @keep_serving.nil? ? true : @keep_serving
12
+ end
13
+
14
+ def servers
15
+ @servers ||= []
16
+ end
17
+
18
+ # Status lines (where a file went, which address a server has) go to
19
+ # stderr, so they never mix with what the program itself puts.
20
+ def say(message)
21
+ $stderr.puts "[chunky] #{message}"
22
+ end
23
+
24
+ # "chunky-image-1.png", "chunky-image-2.png", ... per run of the program
25
+ def next_file(stem, extension)
26
+ @counters ||= Hash.new(0)
27
+ @counters[stem] += 1
28
+ File.join(output_dir, "#{stem}-#{@counters[stem]}.#{extension}")
29
+ end
30
+
31
+ def save(path, bytes)
32
+ File.binwrite(path, bytes)
33
+ say "saved #{relative(path)} (#{bytes.bytesize} B)"
34
+ path
35
+ end
36
+
37
+ def relative(path)
38
+ path = File.expand_path(path)
39
+ base = File.expand_path(output_dir) + File::SEPARATOR
40
+ path.start_with?(base) ? path.delete_prefix(base) : path
41
+ end
42
+
43
+ def serve_until_interrupt
44
+ return if servers.empty? || !keep_serving
45
+
46
+ say "the server keeps running - Ctrl+C stops it"
47
+ servers.each(&:join)
48
+ rescue Interrupt
49
+ $stderr.puts
50
+ end
51
+ end
52
+
53
+ # The course's notebook helpers, for a program on a computer. Same names,
54
+ # arguments and defaults as on the course page (html/main.rb); what showed
55
+ # below the cell there is a file in the program's folder here, opened in
56
+ # the system's viewer.
57
+ module Helpers
58
+ private
59
+
60
+ # Installs a gem now, unless it is installed already, and activates it -
61
+ # afterwards `require` finds it. true when it is there.
62
+ def install_gem(name)
63
+ name = name.to_s
64
+ begin
65
+ gem name
66
+ return true
67
+ rescue Gem::LoadError
68
+ nil # not installed yet
69
+ end
70
+ if defined?(Bundler) && ENV["BUNDLE_GEMFILE"]
71
+ ChunkyBacon.say "this program runs under Bundler: add gem \"#{name}\" to its Gemfile and run bundle install"
72
+ return false
73
+ end
74
+ ChunkyBacon.say "installing #{name} ..."
75
+ Gem.install(name)
76
+ Gem::Specification.reset
77
+ gem name
78
+ true
79
+ rescue Gem::Exception, SystemCallError => e
80
+ ChunkyBacon.say "#{name} could not be installed: #{e.message}"
81
+ false
82
+ end
83
+
84
+ # A picture: a ChunkyPNG image (anything with to_blob or to_data_url),
85
+ # PNG bytes, or the path of an image file.
86
+ def show_image(image)
87
+ # a path: text without the PNG signature or NUL bytes, naming a file
88
+ if image.is_a?(String) && !image.b.start_with?("\x89PNG".b) && !image.include?("\0") && File.file?(image)
89
+ ChunkyBacon::Opener.open(File.expand_path(image))
90
+ return nil
91
+ end
92
+ bytes = if image.respond_to?(:to_blob)
93
+ image.to_blob
94
+ elsif image.respond_to?(:to_data_url)
95
+ image.to_data_url.split(",", 2).last.unpack1("m0")
96
+ else
97
+ image.to_s.b
98
+ end
99
+ path = ChunkyBacon.save(ChunkyBacon.next_file("chunky-image", "png"), bytes)
100
+ ChunkyBacon::Opener.open(path)
101
+ nil
102
+ end
103
+
104
+ # A PDF: a file's path, a Prawn::Document or HexaPDF::Document/Composer,
105
+ # or the PDF itself as a String.
106
+ def show_pdf(pdf)
107
+ pdf = pdf.document if defined?(HexaPDF::Composer) && pdf.is_a?(HexaPDF::Composer)
108
+ if pdf.is_a?(String) && !pdf.b.start_with?("%PDF")
109
+ ChunkyBacon::Opener.open(File.expand_path(pdf))
110
+ return nil
111
+ end
112
+ bytes = if pdf.is_a?(String)
113
+ pdf
114
+ elsif pdf.respond_to?(:render)
115
+ pdf.render
116
+ else
117
+ StringIO.new("".b).tap { |io| pdf.write(io) }.string
118
+ end
119
+ path = ChunkyBacon.save(ChunkyBacon.next_file("chunky-document", "pdf"), bytes)
120
+ ChunkyBacon::Opener.open(path)
121
+ nil
122
+ end
123
+
124
+ # What the page offered as a download: the data saved as +name+ in the
125
+ # program's folder. download_file "notizen.txt" names a file that is
126
+ # there already.
127
+ def download_file(data, name = nil)
128
+ if name.nil?
129
+ path = File.expand_path(data.to_s, ChunkyBacon.output_dir)
130
+ raise Errno::ENOENT, data.to_s unless File.file?(path)
131
+
132
+ ChunkyBacon.say "#{ChunkyBacon.relative(path)} is ready (#{File.size(path)} B)"
133
+ return nil
134
+ end
135
+ data = data.to_blob if data.respond_to?(:to_blob)
136
+ ChunkyBacon.save(File.expand_path(name.to_s, ChunkyBacon.output_dir), data.to_s.b)
137
+ nil
138
+ end
139
+
140
+ # Starts the Rack app (a Sinatra/Roda class or anything with #call) on
141
+ # this computer and opens it in the browser. The program goes on; once
142
+ # it has finished, the server keeps running until Ctrl+C.
143
+ def show_browser(app, path = "/")
144
+ server = ChunkyBacon::Server.new(app).start
145
+ if ChunkyBacon.servers.empty? && ChunkyBacon.keep_serving
146
+ at_exit { ChunkyBacon.serve_until_interrupt }
147
+ end
148
+ ChunkyBacon.servers << server
149
+ ChunkyBacon.say "#{app.respond_to?(:name) && app.name ? app.name : "the app"} runs at #{server.url(path)}"
150
+ ChunkyBacon::Opener.open(server.url(path))
151
+ nil
152
+ end
153
+
154
+ # A GET against a Rack app without any server, for experiments and
155
+ # checks: status, body = mock_get(MyApp, "/hello")
156
+ def mock_get(app, path)
157
+ status, _headers, body = ChunkyBacon::RackEnv.call(app, ChunkyBacon::RackEnv.for(path))
158
+ [status, body]
159
+ end
160
+
161
+ # An IRB session, here and now (at the top level, like the page's fresh
162
+ # one). `exit` ends it, and the program carries on.
163
+ def show_irb
164
+ require "irb"
165
+ TOPLEVEL_BINDING.irb
166
+ nil
167
+ end
168
+
169
+ # The files in the program's folder, as the page's file explorer showed
170
+ # the simulated ones.
171
+ def show_files
172
+ base = ChunkyBacon.output_dir
173
+ puts "#{File.basename(File.expand_path(base))}/"
174
+ entries = Dir.glob("**/*", base: base).reject { |p| p.split("/").any? { |part| part.start_with?(".") } }.sort
175
+ entries.first(200).each do |entry|
176
+ depth = entry.count("/")
177
+ full = File.join(base, entry)
178
+ name = File.basename(entry)
179
+ puts File.directory?(full) ? "#{" " * (depth + 1)}#{name}/" : "#{" " * (depth + 1)}#{name} (#{File.size(full)} B)"
180
+ end
181
+ puts " ... #{entries.size - 200} more" if entries.size > 200
182
+ nil
183
+ end
184
+
185
+ # Runs the Minitest tests defined so far and clears them, like the page.
186
+ # (In a file of its own, `require "minitest/autorun"` does the same.)
187
+ def run_tests
188
+ require "minitest"
189
+ result = Minitest.run([])
190
+ Minitest::Runnable.runnables.clear
191
+ result
192
+ end
193
+
194
+ def show_three(*)
195
+ raise ChunkyBacon::NotHere, "show_three needs the course page for now - three-rb draws with three.js " \
196
+ "in the browser. On your computer, three-rb's own examples show the way."
197
+ end
198
+
199
+ def show_shoes(**, &)
200
+ raise ChunkyBacon::NotHere, "On your computer, Shoes apps run with Scarpe: gem install scarpe, put " \
201
+ "Shoes.app do ... end in a file and start it with: scarpe app.rb"
202
+ end
203
+ end
204
+ end
205
+
206
+ module Kernel
207
+ include ChunkyBacon::Helpers
208
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChunkyBacon
4
+ # Opens a file or a URL with whatever the system uses for it - the image
5
+ # viewer, the PDF reader, the browser. CHUNKYBACON_OPEN=0 turns it off
6
+ # (tests, CI, a machine without a desktop); the file is saved either way.
7
+ module Opener
8
+ def self.enabled?
9
+ ENV["CHUNKYBACON_OPEN"] != "0"
10
+ end
11
+
12
+ # true when a viewer was started
13
+ def self.open(target)
14
+ return false unless enabled?
15
+
16
+ pid = Process.spawn(*command(target.to_s), out: File::NULL, err: File::NULL)
17
+ Process.detach(pid)
18
+ true
19
+ rescue SystemCallError
20
+ false
21
+ end
22
+
23
+ def self.command(target)
24
+ if Gem.win_platform?
25
+ # `start` is a cmd built-in; its first quoted argument is a window
26
+ # title. cmd reads & | < > ^ as operators unless escaped - Ruby only
27
+ # quotes an argument that has a space, and inside quotes the escape
28
+ # would be taken literally.
29
+ target = target.gsub(/[&|<>^]/) { |c| "^#{c}" } unless target.include?(" ")
30
+ ["cmd", "/c", "start", "", target]
31
+ elsif RUBY_PLATFORM.include?("darwin")
32
+ ["open", target]
33
+ else
34
+ ["xdg-open", target]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module ChunkyBacon
6
+ # Rack environments without a Rack server - what mock_get hands an app,
7
+ # and what ChunkyBacon::Server builds from a real HTTP request. Mirrors the
8
+ # course page's RackPlayground (html/rack_playground.rb), so an app answers
9
+ # here as it does in the lesson.
10
+ module RackEnv
11
+ # "speisekarte", "/a?b=1", "http://localhost/x" -> "/speisekarte" etc.
12
+ def self.normalize(path)
13
+ "/" + path.to_s.sub(%r{\Ahttps?://[^/]*}, "").sub(%r{\A/+}, "")
14
+ end
15
+
16
+ def self.for(path, method: "GET", headers: {}, body: "", host: "localhost", port: 80)
17
+ path, query = normalize(path).split("?", 2)
18
+ env = {
19
+ "REQUEST_METHOD" => method.to_s.upcase,
20
+ "SCRIPT_NAME" => "",
21
+ "PATH_INFO" => path,
22
+ "QUERY_STRING" => query.to_s,
23
+ "SERVER_NAME" => host,
24
+ "SERVER_PORT" => port.to_s,
25
+ "HTTP_HOST" => port.to_i == 80 ? host : "#{host}:#{port}",
26
+ "SERVER_PROTOCOL" => "HTTP/1.1",
27
+ "rack.url_scheme" => "http",
28
+ "rack.input" => StringIO.new(body.to_s.b),
29
+ "rack.errors" => $stderr
30
+ }
31
+ headers.each do |name, value|
32
+ key = name.to_s.upcase.tr("-", "_")
33
+ case key
34
+ when "CONTENT_TYPE", "CONTENT_LENGTH" then env[key] = value.to_s
35
+ else env["HTTP_#{key}"] = value.to_s
36
+ end
37
+ end
38
+ env
39
+ end
40
+
41
+ # Calls the app; returns [status, headers, body as one String].
42
+ def self.call(app, env)
43
+ status, headers, body = app.call(env)
44
+ chunks = []
45
+ body.each { |chunk| chunks << chunk.to_s }
46
+ body.close if body.respond_to?(:close)
47
+ [status.to_i, headers.respond_to?(:to_h) ? headers.to_h : headers, chunks.join]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module ChunkyBacon
6
+ # The smallest web server that lets a browser click through a Rack app -
7
+ # what show_browser starts on a computer, where the lesson page had its
8
+ # mini browser. One app, 127.0.0.1 only (nothing outside this computer
9
+ # reaches it), one request at a time, no keep-alive. For a real app, use a
10
+ # real server: rackup with puma, falcon, webrick.
11
+ class Server
12
+ REASONS = { 200 => "OK", 201 => "Created", 204 => "No Content", 301 => "Moved Permanently",
13
+ 302 => "Found", 303 => "See Other", 304 => "Not Modified", 400 => "Bad Request",
14
+ 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found",
15
+ 405 => "Method Not Allowed", 422 => "Unprocessable Content",
16
+ 500 => "Internal Server Error" }.freeze
17
+
18
+ attr_reader :app, :port
19
+
20
+ # port 0: whichever port is free
21
+ def initialize(app, port: 0)
22
+ @app = app
23
+ @tcp = TCPServer.new("127.0.0.1", port)
24
+ @port = @tcp.addr[1]
25
+ end
26
+
27
+ def url(path = "/")
28
+ "http://127.0.0.1:#{port}#{RackEnv.normalize(path)}"
29
+ end
30
+
31
+ def start
32
+ @thread = Thread.new do
33
+ loop { handle(@tcp.accept) }
34
+ rescue IOError, Errno::EBADF
35
+ nil # stopped
36
+ end
37
+ self
38
+ end
39
+
40
+ def join
41
+ @thread&.join
42
+ end
43
+
44
+ def stop
45
+ @tcp.close unless @tcp.closed?
46
+ @thread&.join(1)
47
+ nil
48
+ end
49
+
50
+ private
51
+
52
+ def handle(socket)
53
+ line = socket.gets
54
+ return if line.nil?
55
+
56
+ method, target = line.split(" ", 3)
57
+ headers = {}
58
+ while (header = socket.gets) && !header.strip.empty?
59
+ name, value = header.split(":", 2)
60
+ headers[name.strip.downcase] = value.to_s.strip
61
+ end
62
+ body = headers["content-length"] ? socket.read(headers["content-length"].to_i).to_s : ""
63
+ env = RackEnv.for(target, method: method, headers: headers, body: body, host: "127.0.0.1", port: port)
64
+ status, response_headers, response_body = respond(env)
65
+ write(socket, status, response_headers, response_body)
66
+ rescue IOError, SystemCallError
67
+ nil # the browser went away
68
+ ensure
69
+ socket.close unless socket.closed?
70
+ end
71
+
72
+ # The app's answer - an error in the app becomes a 500 page that names
73
+ # it, like the error below a cell.
74
+ def respond(env)
75
+ RackEnv.call(app, env)
76
+ rescue StandardError => e
77
+ warn "#{e.class}: #{e.message}"
78
+ [500, { "content-type" => "text/plain; charset=utf-8" }, "#{e.class}: #{e.message}\n"]
79
+ end
80
+
81
+ def write(socket, status, headers, body)
82
+ socket.write("HTTP/1.1 #{status} #{REASONS.fetch(status, "Status")}\r\n")
83
+ headers.each do |name, value|
84
+ next if %w[connection content-length transfer-encoding].include?(name.to_s.downcase)
85
+
86
+ Array(value).each { |v| v.to_s.split("\n").each { |part| socket.write("#{name}: #{part}\r\n") } }
87
+ end
88
+ socket.write("content-length: #{body.bytesize}\r\nconnection: close\r\n\r\n")
89
+ socket.write(body)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChunkyBacon
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # chunky_bacon: the companion gem of "Ruby lernen mit Chunky Bacon", the Ruby
4
+ # course that runs in the browser (https://github.com/Largo/chunkybacon).
5
+ #
6
+ # On your own computer, `require "chunky_bacon"` gives a program the helpers
7
+ # the course's notebook cells and workshop have - show_image, show_browser,
8
+ # download_file and friends - so a program written in the browser runs
9
+ # unchanged with plain Ruby (or with `chunkybacon run`, which loads them for
10
+ # you). In the browser the page brings its own helpers; there the gem only
11
+ # adds the fox.
12
+ require_relative "chunky_bacon/version"
13
+ require_relative "chunky_bacon/fox"
14
+
15
+ module ChunkyBacon
16
+ # A helper that needs the browser (for now), called on a computer.
17
+ class NotHere < StandardError; end
18
+
19
+ class << self
20
+ # true inside the course's page (ruby.wasm), false on a computer
21
+ def browser?
22
+ RUBY_PLATFORM.start_with?("wasm")
23
+ end
24
+
25
+ # where show_image, show_pdf and download_file put their files: the
26
+ # folder the program runs in, like the downloads below a cell
27
+ attr_writer :output_dir
28
+
29
+ def output_dir
30
+ @output_dir || Dir.pwd
31
+ end
32
+ end
33
+ end
34
+
35
+ unless ChunkyBacon.browser?
36
+ require_relative "chunky_bacon/opener"
37
+ require_relative "chunky_bacon/rack_env"
38
+ require_relative "chunky_bacon/server"
39
+ require_relative "chunky_bacon/helpers"
40
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chunky_bacon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andi Idogawa
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: The companion gem of "Ruby lernen mit Chunky Bacon", an interactive Ruby
13
+ course that runs in the browser. require "chunky_bacon" gives a program the course's
14
+ helpers - show_image, show_pdf, show_browser, download_file, mock_get, show_irb,
15
+ show_files, run_tests, install_gem - so code written in the course runs unchanged
16
+ with plain Ruby; `chunkybacon run` starts a program with them loaded. Pure Ruby,
17
+ no dependencies.
18
+ email:
19
+ - web@idogawa.com
20
+ executables:
21
+ - chunkybacon
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - CHANGELOG.md
26
+ - LICENSE
27
+ - LICENSE-ASSETS
28
+ - README.md
29
+ - exe/chunkybacon
30
+ - lib/chunky_bacon.rb
31
+ - lib/chunky_bacon/cli.rb
32
+ - lib/chunky_bacon/fox.rb
33
+ - lib/chunky_bacon/fox.txt
34
+ - lib/chunky_bacon/helpers.rb
35
+ - lib/chunky_bacon/opener.rb
36
+ - lib/chunky_bacon/rack_env.rb
37
+ - lib/chunky_bacon/server.rb
38
+ - lib/chunky_bacon/version.rb
39
+ homepage: https://github.com/Largo/chunkybacon
40
+ licenses:
41
+ - MIT
42
+ - CC-BY-SA-4.0
43
+ metadata:
44
+ homepage_uri: https://github.com/Largo/chunkybacon
45
+ source_code_uri: https://github.com/Largo/chunkybacon/tree/main/gem/chunky_bacon
46
+ changelog_uri: https://github.com/Largo/chunkybacon/blob/main/gem/chunky_bacon/CHANGELOG.md
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.1'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 4.0.20
63
+ specification_version: 4
64
+ summary: The Chunky Bacon Ruby course's helpers on your own computer - and a fox that
65
+ shouts.
66
+ test_files: []