wani 0.1.8

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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +262 -0
  4. data/assets/bootstrap.rb +9 -0
  5. data/assets/build.json +6 -0
  6. data/assets/fonts/IBMPlexSansJP-Bold.woff2 +0 -0
  7. data/assets/fonts/IBMPlexSansJP-Regular.woff2 +0 -0
  8. data/assets/highlight.css +117 -0
  9. data/assets/index.html +39 -0
  10. data/assets/rabbit.svg +1 -0
  11. data/assets/rouge.LICENSE +187 -0
  12. data/assets/runtime.LICENSE +21 -0
  13. data/assets/runtime.NOTICE +1292 -0
  14. data/assets/runtime.pr672.js +3143 -0
  15. data/assets/shark.svg +1 -0
  16. data/assets/speaker.html +2 -0
  17. data/assets/style.css +103 -0
  18. data/assets/time-expired.png +0 -0
  19. data/assets/wani.wasm +4 -0
  20. data/exe/wani +3 -0
  21. data/lib/wani/browser/application.rb +189 -0
  22. data/lib/wani/browser/bridge_progress.rb +19 -0
  23. data/lib/wani/browser/fonts.rb +60 -0
  24. data/lib/wani/browser/layout.rb +107 -0
  25. data/lib/wani/browser/presentation_timer.rb +66 -0
  26. data/lib/wani/browser/renderer.rb +80 -0
  27. data/lib/wani/cli.rb +134 -0
  28. data/lib/wani/core/fonts.rb +49 -0
  29. data/lib/wani/core/parser.rb +127 -0
  30. data/lib/wani/core/presentation.rb +15 -0
  31. data/lib/wani/core/safe_html.rb +74 -0
  32. data/lib/wani/core/validator.rb +31 -0
  33. data/lib/wani/fonts/http.rb +45 -0
  34. data/lib/wani/fonts/manager.rb +190 -0
  35. data/lib/wani/server/application.rb +92 -0
  36. data/lib/wani/server/http_server.rb +34 -0
  37. data/lib/wani/version.rb +3 -0
  38. data/lib/wani.rb +4 -0
  39. data/templates/images/workflow.svg +36 -0
  40. data/templates/slides.md +182 -0
  41. data/third_party/gems/kramdown-LICENSE +30 -0
  42. data/third_party/gems/kramdown-parser-gfm-LICENSE +21 -0
  43. data/third_party/gems/rexml-LICENSE +22 -0
  44. data/third_party/ibm-plex-sans-jp/LICENSE.txt +93 -0
  45. data/third_party/ibm-plex-sans-jp/README.md +39 -0
  46. data/third_party/ibm-plex-sans-jp/provenance.json +20 -0
  47. data/third_party/ruby-wasm-loader/LICENSE +21 -0
  48. data/third_party/ruby-wasm-loader/NOTICE +1292 -0
  49. data/third_party/ruby-wasm-loader/README.md +35 -0
  50. data/third_party/ruby-wasm-loader/browser.script.iife.js +3143 -0
  51. data/third_party/ruby-wasm-loader/provenance.json +10 -0
  52. metadata +159 -0
data/lib/wani/cli.rb ADDED
@@ -0,0 +1,134 @@
1
+ require "optparse"
2
+ require "fileutils"
3
+ require_relative "../wani"
4
+
5
+ module Wani
6
+ class CLI
7
+ def initialize(out: $stdout, err: $stderr, font_manager: nil)
8
+ @out, @err = out, err
9
+ @font_manager = font_manager
10
+ end
11
+
12
+ def run(arguments)
13
+ args = arguments.dup
14
+ command = args.shift
15
+ case command
16
+ when "new" then create(args)
17
+ when "font" then font(args)
18
+ when "serve" then serve(args)
19
+ when "check" then check(args)
20
+ when "--version", "-v" then @out.puts(VERSION); 0
21
+ when nil, "--help", "-h", "help" then @out.puts("Usage: wani new DIRECTORY | serve [FILE] [--bind ADDRESS] [--port PORT] | check [FILE] | font add SOURCE | font list | font remove ID"); 0
22
+ else raise ArgumentError, "Unknown command: #{command}"
23
+ end
24
+ rescue ArgumentError, OptionParser::ParseError, SystemCallError => error
25
+ @err.puts "wani: #{error.message}"
26
+ 1
27
+ end
28
+
29
+ private
30
+
31
+ def font(args)
32
+ require_relative "fonts/manager"
33
+ manager = @font_manager || Fonts::Manager.new
34
+ action = args.shift
35
+ case action
36
+ when "add"
37
+ raise ArgumentError, "Usage: wani font add SOURCE" unless args.length == 1
38
+ entry = with_font_progress { manager.add(args.first) }
39
+ @out.puts "Installed #{entry['family']} (#{entry['version']}); set font in Markdown to apply it."
40
+ when "list"
41
+ raise ArgumentError, "Usage: wani font list" unless args.empty?
42
+ @out.puts "ID\tFamily\tVersion\tVariable"
43
+ manager.list.each { |id, entry| @out.puts [id, entry['family'], entry['version'], entry['variable']].join("\t") }
44
+ when "remove"
45
+ raise ArgumentError, "Usage: wani font remove ID" unless args.length == 1
46
+ manager.remove(args.first, confirm: ->(files) {
47
+ @err.puts "Warning: font is used by #{files.join(', ')}"
48
+ next false unless $stdin.tty?
49
+ @err.print "Remove this font? [y/N] "
50
+ $stdin.gets&.strip&.downcase == "y"
51
+ })
52
+ @out.puts "Removed #{args.first}"
53
+ else
54
+ raise ArgumentError, "Usage: wani font add SOURCE | list | remove ID"
55
+ end
56
+ 0
57
+ end
58
+
59
+ def with_font_progress
60
+ message = "Downloading font"
61
+ if @err.tty?
62
+ @err.print "\r#{message}. "
63
+ @err.flush
64
+ animation = Thread.new do
65
+ dots = 1
66
+ loop do
67
+ sleep 0.3
68
+ dots = dots % 3 + 1
69
+ @err.print "\r#{message}#{('.' * dots).ljust(3)}"
70
+ @err.flush
71
+ end
72
+ end
73
+ else
74
+ @err.puts "#{message}..."
75
+ @err.flush
76
+ end
77
+ yield
78
+ ensure
79
+ if animation
80
+ animation.kill
81
+ animation.join
82
+ @err.print "\r#{' ' * (message.length + 3)}\r"
83
+ @err.flush
84
+ end
85
+ end
86
+
87
+ def create(args)
88
+ raise ArgumentError, "Usage: wani new DIRECTORY" unless args.length == 1
89
+ destination = File.expand_path(args.first)
90
+ Dir.mkdir(destination) # Atomic refusal, including existing directories and symlinks.
91
+ FileUtils.cp_r(File.join(ROOT, "templates/images"), File.join(destination, "images"))
92
+ FileUtils.cp(File.join(ROOT, "templates/slides.md"), File.join(destination, "slides.md"))
93
+ @out.puts "Created #{args.first}. Run: cd #{args.first} && wani serve"
94
+ 0
95
+ end
96
+
97
+ def serve(args)
98
+ options = { bind: "127.0.0.1", port: 8000, out: @out }
99
+ parser = OptionParser.new do |opts|
100
+ opts.banner = "Usage: wani serve [FILE] [--bind ADDRESS] [--port PORT]"
101
+ opts.on("-b", "--bind ADDRESS") { |value| options[:bind] = value }
102
+ opts.on("-p", "--port PORT", Integer) { |value| options[:port] = value }
103
+ end
104
+ parser.parse!(args)
105
+ raise ArgumentError, parser.banner if args.length > 1
106
+ raise ArgumentError, "Port must be between 0 and 65535" unless (0..65535).cover?(options[:port])
107
+ raise ArgumentError, "Browser assets missing; run bundle exec rake build:wasm before gem build" unless File.file?(File.join(ROOT, "assets/wani.wasm"))
108
+ require_relative "server/http_server"
109
+ Server::HTTPServer.new(args.first || "slides.md", **options).start
110
+ 0
111
+ end
112
+
113
+ def check(args)
114
+ require_relative "core/parser"
115
+ require_relative "core/validator"
116
+ raise ArgumentError, "Usage: wani check [FILE]" if args.length > 1
117
+ file = File.expand_path(args.first || "slides.md")
118
+ presentation = Core::Parser.new.parse(File.read(file, encoding: "UTF-8"))
119
+ issues = Core::Validator.new.validate(presentation, base_dir: File.dirname(file))
120
+ if (name = presentation.fonts["default"])
121
+ require_relative "fonts/manager"
122
+ unless Core::Fonts.resolve(Fonts::Manager.new(File.dirname(file)).list, name)
123
+ issues << Core::Diagnostic.new(severity: :warning, message: "Unregistered font: #{name}; using fallback")
124
+ end
125
+ end
126
+ issues.each { |issue| @out.puts "#{issue.severity}: #{issue.slide ? "slide #{issue.slide}: " : ""}#{issue.message}" }
127
+ @out.puts "#{presentation.slides.length} slides; #{issues.length} diagnostics"
128
+ issues.any? { |issue| issue.severity == :error } ? 1 : 0
129
+ rescue Core::Parser::Error => error
130
+ @err.puts "error: #{error.message}"
131
+ 1
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,49 @@
1
+ require "yaml"
2
+
3
+ module Wani
4
+ module Core
5
+ module Fonts
6
+ ID = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
7
+ module_function
8
+
9
+ def settings(value, config = {})
10
+ settings = {}
11
+ unless value.nil?
12
+ raise ArgumentError, "font must be a non-empty font name or ID" unless value.is_a?(String) && !value.strip.empty? && !value.match?(/[\x00-\x1f]/)
13
+ settings["default"] = value.strip
14
+ end
15
+ %w[title-weight body-weight].each do |key|
16
+ next unless config.key?(key)
17
+ weight = config[key]
18
+ raise ArgumentError, "#{key} must be an integer from 1 to 1000" unless weight.is_a?(Integer) && (1..1000).cover?(weight)
19
+ settings[key] = weight
20
+ end
21
+ settings
22
+ end
23
+
24
+ def registry(source)
25
+ data = YAML.safe_load(source, aliases: false) || {}
26
+ raise ArgumentError, "Invalid font registry" unless data.is_a?(Hash)
27
+ fonts = data.fetch("fonts", {})
28
+ raise ArgumentError, "Invalid font registry" unless fonts.is_a?(Hash)
29
+ fonts.each do |id, entry|
30
+ raise ArgumentError, "Invalid font registration" unless id.is_a?(String) && ID.match?(id) && entry.is_a?(Hash)
31
+ %w[family css_family version].each do |key|
32
+ raise ArgumentError, "Invalid font #{key}" unless entry[key].is_a?(String) && !entry[key].empty?
33
+ end
34
+ raise ArgumentError, "Invalid managed files" unless entry["files"].is_a?(Array) && entry["files"].all? { |path| path.is_a?(String) }
35
+ paths = [entry["stylesheet"], entry["license"], *entry.fetch("files", [])]
36
+ raise ArgumentError, "Invalid font files" unless paths.all? { |path| path.is_a?(String) && path.match?(%r{\Afonts/#{Regexp.escape(id)}/(?:font\.css|LICENSE|files/[a-zA-Z0-9_-]+\.woff2)\z}) }
37
+ raise ArgumentError, "Missing managed font files" unless paths.first(2).all? { |path| entry.fetch("files", []).include?(path) }
38
+ end
39
+ fonts
40
+ rescue Psych::Exception, NoMethodError, TypeError
41
+ raise ArgumentError, "Invalid font registry"
42
+ end
43
+
44
+ def resolve(fonts, name)
45
+ fonts[name] || fonts.values.find { |entry| entry["family"].casecmp?(name) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,127 @@
1
+ require "yaml"
2
+ require "kramdown"
3
+ require "kramdown-parser-gfm"
4
+ require_relative "presentation"
5
+ require_relative "fonts"
6
+ require_relative "safe_html"
7
+
8
+ module Wani
9
+ module Core
10
+ class Parser
11
+ class Error < StandardError; end
12
+
13
+ def parse(source)
14
+ raise Error, "Markdown must be valid UTF-8" unless source.valid_encoding?
15
+ lines = source.delete_prefix("\uFEFF").gsub("\r\n", "\n").lines
16
+ config, lines = front_matter(lines)
17
+ diagnostics = []
18
+ chunks = split_slides(lines, diagnostics)
19
+ slides = chunks.each_with_index.map do |chunk, index|
20
+ number = index + 1
21
+ warn = ->(message) { diagnostics << Diagnostic.new(severity: :warning, message: message, slide: number) }
22
+ warn.call("Empty slide between separators") if chunk.strip.empty? && chunks.length > 1
23
+ document = markdown(chunk)
24
+ metadata = { "notes" => [], "page-footnote" => [] }
25
+ extract_metadata(document.root, metadata)
26
+ notes = metadata.fetch("notes")
27
+ page_footnotes = metadata.fetch("page-footnote")
28
+ warn.call("Multiple notes blocks combined") if notes.length > 1
29
+ warn.call("Multiple page-footnote blocks: the last value is used") if page_footnotes.length > 1
30
+ headings = walk(document.root).select { |node| node.type == :header }
31
+ warn.call("Additional heading: only the first heading is the slide title") if headings.length > 1
32
+ title = headings.first
33
+ raise Error, "Slide #{number}: title is empty" if title && plain(title).strip.empty?
34
+ remove(document.root, title) if title
35
+ renderer = SafeHTML.new(&warn)
36
+ note_roots = notes.map { |text| markdown(text).root }
37
+ Slide.new(number: number, title: title ? plain(title) : "", html: renderer.render(document.root),
38
+ page_footnote: page_footnotes.empty? ? "" : page_footnotes.last.strip,
39
+ notes_html: note_roots.map { |root| renderer.render(root) }.join,
40
+ images: ([document.root] + note_roots).flat_map { |root| walk(root).select { |node| node.type == :img && SafeHTML.safe_url?(node.attr['src'], image: true) }.map { |node| node.attr['src'] } }.uniq)
41
+ end
42
+ Presentation.new(fonts: Fonts.settings(config["font"], config), slides: slides, time_limit: config.fetch("time_limit", 5), diagnostics: diagnostics.uniq { |item| item.to_h })
43
+ rescue Encoding::CompatibilityError, ArgumentError => error
44
+ raise Error, "Invalid Markdown: #{error.message}"
45
+ end
46
+
47
+ private
48
+
49
+ def front_matter(lines)
50
+ return [{}, lines] unless lines.first&.strip == "---"
51
+ ending = (1...lines.length).find { |index| lines[index].strip == "---" }
52
+ raise Error, "Unclosed Front Matter" unless ending
53
+ config = YAML.safe_load(lines[1...ending].join, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
54
+ raise Error, "Front Matter must be a mapping" unless config.is_a?(Hash)
55
+ if config.key?("time_limit")
56
+ limit = config["time_limit"]
57
+ raise Error, "time_limit must be a finite non-negative number of minutes" unless limit.is_a?(Numeric) && limit.finite? && limit >= 0 && (limit * 60_000).finite?
58
+ end
59
+ [config, lines[(ending + 1)..]]
60
+ rescue Psych::Exception
61
+ raise Error, "Invalid or unsafe YAML in Front Matter"
62
+ end
63
+
64
+ def split_slides(lines, diagnostics)
65
+ chunks = [+""]
66
+ fence = nil
67
+ comment = false
68
+ lines.each do |line|
69
+ if comment
70
+ comment = false if line.include?("-->")
71
+ elsif fence
72
+ fence = nil if line.match?(/\A {0,3}#{Regexp.escape(fence[0])}{#{fence.length},}[ \t]*\n?\z/)
73
+ elsif (match = line.match(/\A {0,3}(`{3,}|~{3,})/))
74
+ fence = match[1]
75
+ elsif line.match?(/\A {0,3}<!--/)
76
+ comment = !line.include?("-->")
77
+ elsif line.match?(/\A {0,3}---[ \t]*\n?\z/)
78
+ chunks << +""
79
+ next
80
+ elsif line.match?(/\A {0,3}[#]{1,6}[ \t]*\n?\z/)
81
+ raise Error, "Slide #{chunks.length}: title is empty"
82
+ end
83
+ chunks.last << line
84
+ end
85
+ raise Error, "Unclosed comment or notes block" if comment
86
+ diagnostics << Diagnostic.new(severity: :warning, message: "Unclosed code fence", slide: chunks.length) if fence
87
+ chunks
88
+ end
89
+
90
+ def markdown(text)
91
+ Kramdown::Document.new(text, input: "GFM", syntax_highlighter: nil)
92
+ end
93
+
94
+ def extract_metadata(node, metadata)
95
+ node.children.delete_if do |child|
96
+ if child.type == :xml_comment && child.value.match?(/\A<!--\s*(notes|page-footnote):/)
97
+ match = child.value.match(/\A<!--\s*(notes|page-footnote):\s*(.*?)-->\s*\z/m)
98
+ raise Error, "Invalid slide metadata block" unless match
99
+ metadata.fetch(match[1]) << match[2]
100
+ true
101
+ else
102
+ extract_metadata(child, metadata)
103
+ false
104
+ end
105
+ end
106
+ end
107
+
108
+ def walk(node)
109
+ [node] + node.children.flat_map { |child| walk(child) }
110
+ end
111
+
112
+ def remove(node, target)
113
+ node.children.delete(target)
114
+ node.children.each { |child| remove(child, target) }
115
+ end
116
+
117
+ def plain(node)
118
+ case node.type
119
+ when :text, :codespan then node.value
120
+ when :entity then node.value.char
121
+ when :smart_quote then SafeHTML::QUOTES.fetch(node.value, "")
122
+ else node.children.map { |child| plain(child) }.join
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,15 @@
1
+ module Wani
2
+ module Core
3
+ Diagnostic = Struct.new(:severity, :message, :slide, keyword_init: true) do
4
+ def to_h
5
+ { severity: severity.to_s, message: message, slide: slide }
6
+ end
7
+ end
8
+ Slide = Struct.new(:number, :title, :html, :notes_html, :images, :page_footnote, keyword_init: true)
9
+ Presentation = Struct.new(:slides, :time_limit, :diagnostics, :fonts, keyword_init: true) do
10
+ def to_h
11
+ { slides: slides.map(&:to_h), time_limit: time_limit, diagnostics: diagnostics.map(&:to_h) }.tap { |data| data[:fonts] = fonts unless fonts.nil? || fonts.empty? }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,74 @@
1
+ module Wani
2
+ module Core
3
+ # Emit only known Markdown elements; never forward raw HTML or arbitrary attributes.
4
+ class SafeHTML
5
+ TAGS = { p: "p", em: "em", strong: "strong", ul: "ul", ol: "ol", li: "li", blockquote: "blockquote", header: "h2", table: "table", thead: "thead", tbody: "tbody", tr: "tr", th: "th", td: "td", del: "del" }.freeze
6
+ QUOTES = { lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”" }.freeze
7
+ def self.escape(value)
8
+ value.to_s.gsub(/[&<>"']/) { |character| { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;" }.fetch(character) }
9
+ end
10
+
11
+ def self.safe_url?(value, image: false)
12
+ return false unless value.is_a?(String) && !value.empty?
13
+ return false if value.match?(/[\x00-\x20\x7f\\]/) || value.start_with?("//")
14
+ return false if value.match?(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/i)
15
+ scheme = value[/\A([^\/:?#]+):/, 1]
16
+ !scheme || (image ? %w[http https] : %w[http https mailto]).include?(scheme.downcase)
17
+ end
18
+
19
+ def initialize(&diagnostic)
20
+ @diagnostic = diagnostic || ->(_message) {}
21
+ end
22
+
23
+ def render(node)
24
+ body = node.children.map { |child| render(child) }.join
25
+ tag = TAGS[node.type]
26
+ return "<#{tag}>#{body}</#{tag}>" if tag
27
+ case node.type
28
+ when :root then body
29
+ when :text then self.class.escape(node.value)
30
+ when :codespan then "<code>#{self.class.escape(node.value)}</code>"
31
+ when :codeblock then codeblock(node)
32
+ when :a
33
+ if self.class.safe_url?(node.attr["href"])
34
+ "<a href=\"#{self.class.escape(node.attr['href'])}\" rel=\"noopener noreferrer\">#{body}</a>"
35
+ else
36
+ @diagnostic.call("Unsafe link URL removed")
37
+ body
38
+ end
39
+ when :img
40
+ if self.class.safe_url?(node.attr["src"], image: true)
41
+ "<img src=\"#{self.class.escape(node.attr['src'])}\" alt=\"#{self.class.escape(node.attr['alt'])}\">"
42
+ else
43
+ @diagnostic.call("Unsafe image URL removed")
44
+ self.class.escape(node.attr["alt"])
45
+ end
46
+ when :br then "<br>"
47
+ when :hr then "<hr>"
48
+ when :entity then self.class.escape(node.value.char)
49
+ when :smart_quote then QUOTES.fetch(node.value, "")
50
+ when :html_element, :xml_pi
51
+ @diagnostic.call("Raw HTML removed")
52
+ ""
53
+ when :xml_comment then ""
54
+ else body
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def codeblock(node)
61
+ language = node.options[:lang]
62
+ if language && !language.empty?
63
+ require "rouge"
64
+ lexer = Rouge::Lexer.find(language)
65
+ if lexer
66
+ html = Rouge::Formatters::HTML.new.format(lexer.new.lex(node.value))
67
+ return "<pre class=\"highlight\"><code>#{html}</code></pre>"
68
+ end
69
+ end
70
+ "<pre><code>#{self.class.escape(node.value)}</code></pre>"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,31 @@
1
+ require "uri"
2
+ require_relative "presentation"
3
+ module Wani
4
+ module Core
5
+ class Validator
6
+ EXTENSIONS = %w[.png .jpg .jpeg .webp .svg].freeze
7
+ def validate(presentation, base_dir:)
8
+ diagnostics = presentation.diagnostics.dup
9
+ presentation.slides.each do |slide|
10
+ slide.images.each do |url|
11
+ next if url.match?(/\Ahttps?:/i)
12
+ path = url.split(/[?#]/, 2).first
13
+ decoded = URI::RFC2396_PARSER.unescape(path)
14
+ file = File.expand_path(decoded, base_dir)
15
+ message = if !EXTENSIONS.include?(File.extname(path).downcase)
16
+ "Unsupported image type: #{url}"
17
+ elsif !File.file?(file)
18
+ "Missing image: #{url}"
19
+ elsif !File.realpath(file).start_with?(File.realpath(base_dir) + File::SEPARATOR)
20
+ "Image is outside the public Markdown directory: #{url}"
21
+ end
22
+ diagnostics << Diagnostic.new(severity: :error, message: message, slide: slide.number) if message
23
+ rescue ArgumentError, SystemCallError
24
+ diagnostics << Diagnostic.new(severity: :error, message: "Invalid image path: #{url}", slide: slide.number)
25
+ end
26
+ end
27
+ diagnostics
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "timeout"
4
+
5
+ module Wani
6
+ module Fonts
7
+ class Error < ArgumentError; end
8
+
9
+ class HTTP
10
+ MAX_BYTES = 32 * 1024 * 1024
11
+ def get(url, redirects: 3)
12
+ uri = URI.parse(url)
13
+ valid = uri.scheme == "https" && uri.port == 443 && !uri.userinfo && !uri.fragment && !uri.query
14
+ valid &&= case uri.host
15
+ when "api.fontsource.org" then uri.path.match?(%r{\A/v1/(?:fonts|version)(?:/[a-z0-9-]+)?\z})
16
+ when "cdn.jsdelivr.net" then uri.path.match?(%r{\A/npm/@fontsource(?:-variable)?/[a-z0-9-]+@\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?/(?:package\.json|metadata\.json|LICENSE|[a-z0-9-]+\.css|files/[a-zA-Z0-9_-]+\.woff2)\z})
17
+ else false
18
+ end
19
+ raise Error, "Untrusted Fontsource URL: #{url}" unless valid
20
+ Timeout.timeout(60) do
21
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 20, write_timeout: 20) do |http|
22
+ http.request(Net::HTTP::Get.new(uri.request_uri, "Accept-Encoding" => "identity")) do |response|
23
+ if response.is_a?(Net::HTTPRedirection)
24
+ raise Error, "Too many redirects" if redirects.zero?
25
+ return get(URI.join(url, response.fetch("location")).to_s, redirects: redirects - 1)
26
+ end
27
+ raise Error, "Fontsource HTTP #{response.code}: #{url}" unless response.is_a?(Net::HTTPSuccess)
28
+ expected = response["content-length"]&.to_i
29
+ raise Error, "Download exceeds size limit" if expected && expected > MAX_BYTES
30
+ body = +"".b
31
+ response.read_body do |part|
32
+ body << part
33
+ raise Error, "Download exceeds size limit" if body.bytesize > MAX_BYTES
34
+ end
35
+ raise Error, "Incomplete download: #{url}" if body.empty? || (expected && expected != body.bytesize)
36
+ return body
37
+ end
38
+ end
39
+ end
40
+ rescue URI::InvalidURIError, SocketError, IOError, SystemCallError, Timeout::Error, Net::HTTPError, OpenSSL::SSL::SSLError => error
41
+ raise Error, "Fontsource download failed: #{error.message}"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,190 @@
1
+ require "json"
2
+ require "digest"
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "tempfile"
6
+ require_relative "http"
7
+ require_relative "../core/fonts"
8
+ require_relative "../core/parser"
9
+
10
+ module Wani
11
+ module Fonts
12
+ class Manager
13
+ def initialize(root = Dir.pwd, http: HTTP.new)
14
+ @root, @http = File.realpath(root), http
15
+ end
16
+
17
+ def list
18
+ path = safe_path(".wani/fonts.yml")
19
+ File.exist?(path) ? Core::Fonts.registry(File.read(path)) : {}
20
+ end
21
+
22
+ def add(source)
23
+ locked do
24
+ @bytes = 0
25
+ id = identify(source)
26
+ fonts = list
27
+ raise Error, "Font already installed: #{id}" if fonts.key?(id)
28
+ meta = json("https://api.fontsource.org/v1/fonts/#{id}")
29
+ raise Error, "Font metadata mismatch" unless meta.is_a?(Hash) && meta["id"] == id && meta["family"].is_a?(String)
30
+ raise Error, "Font already installed: #{meta['family']}" if Core::Fonts.resolve(fonts, meta['family'])
31
+ raise Error, "Invalid font styles" unless meta["styles"].is_a?(Array) && !meta["styles"].empty? && (meta["styles"] - %w[normal italic]).empty?
32
+ raise Error, "Invalid font weights" unless meta["weights"].is_a?(Array) && meta["weights"].all? { |weight| weight.is_a?(Numeric) }
33
+ versions = json("https://api.fontsource.org/v1/version/#{id}")
34
+ variable = meta["variable"] == true && versions["latestVariable"].is_a?(String)
35
+ version = versions.fetch(variable ? "latestVariable" : "latest")
36
+ raise Error, "Invalid package version" unless version.is_a?(String) && version.match?(/\A\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?\z/)
37
+ package = "@fontsource#{variable ? '-variable' : ''}/#{id}"
38
+ base = "https://cdn.jsdelivr.net/npm/#{package}@#{version}/"
39
+ manifest = json(base + "package.json")
40
+ raise Error, "Package/version mismatch" unless manifest["name"] == package && manifest["version"] == version
41
+ raise Error, "Package has no license information" unless manifest["license"].is_a?(String) && !manifest["license"].empty?
42
+ weights = [400, 700] & meta.fetch("weights").map(&:to_i)
43
+ raise Error, "Font has neither weight 400 nor 700" if weights.empty?
44
+ css_names = variable ? meta.fetch("styles").map { |style| style == "normal" ? "index.css" : "wght-#{style}.css" } : weights.product(meta.fetch("styles")).map { |weight, style| "#{weight}#{style == 'normal' ? '' : "-#{style}"}.css" }
45
+ css = css_names.map { |name| download(base + name).force_encoding("UTF-8") }.join("\n")
46
+ raise Error, "Invalid font CSS" unless css.valid_encoding? && !css.match?(/@import/i)
47
+ # Retain all faces and unicode ranges, dropping only the WOFF fallback source.
48
+ css = css.gsub(/src\s*:\s*([^;]+);/i) do
49
+ sources = Regexp.last_match(1).scan(/url\(\s*['"]?(\.\/files\/[a-zA-Z0-9_-]+\.woff2)['"]?\s*\)\s*format\(['"]woff2(?:-variations)?['"]\)/).flatten
50
+ raise Error, "CSS face has no supported WOFF2 source" if sources.empty?
51
+ "src: url('#{sources.first}') format('woff2');"
52
+ end
53
+ urls = css.scan(/url\(([^)]+)\)/i).flatten.map { |url| url.strip.delete_prefix("'").delete_suffix("'") }
54
+ raise Error, "Invalid CSS font URLs" if urls.empty? || urls.any? { |url| !url.match?(%r{\A\./files/[a-zA-Z0-9_-]+\.woff2\z}) }
55
+ families = css.scan(/font-family:\s*['"]([^'"]+)['"]\s*;/).flatten.uniq
56
+ raise Error, "Unexpected CSS font family" unless families.length == 1 && [meta['family'], "#{meta['family']} Variable"].include?(families.first)
57
+ destination = safe_path("fonts/#{id}")
58
+ raise Error, "Destination already exists: fonts/#{id}" if File.exist?(destination)
59
+ FileUtils.mkdir_p(safe_path("fonts"))
60
+ Dir.mktmpdir(".wani-font-", @root) do |temporary|
61
+ stage = File.join(temporary, "font")
62
+ FileUtils.mkdir_p("#{stage}/files")
63
+ File.write("#{stage}/font.css", css)
64
+ urls.uniq.each do |url|
65
+ data = download(base + url.delete_prefix("./"))
66
+ raise Error, "Invalid or incomplete WOFF2: #{url}" unless data.bytesize >= 48 && data.start_with?("wOF2") && data.byteslice(8, 4).unpack1("N") == data.bytesize
67
+ File.binwrite(File.join(stage, url), data)
68
+ end
69
+ license = download(base + "LICENSE").force_encoding("UTF-8")
70
+ raise Error, "Invalid license document" unless license.valid_encoding? && license.length >= 100 && !license.match?(/<\s*(?:!doctype|html)/i)
71
+ File.write("#{stage}/LICENSE", license)
72
+ files = Dir.glob("#{stage}/**/*").select { |path| File.file?(path) }.to_h { |path| ["fonts/#{id}/#{path.delete_prefix(stage + '/')}", Digest::SHA256.file(path).hexdigest] }
73
+ entry = { "family" => meta["family"], "css_family" => families.first, "source" => "fontsource", "fontsource_id" => id, "version" => version, "package" => package, "stylesheet" => "fonts/#{id}/font.css", "license" => "fonts/#{id}/LICENSE", "license_type" => manifest["license"], "variable" => variable, "weights" => weights, "styles" => meta["styles"], "subsets" => meta["subsets"], "files" => files.keys, "sha256" => files }
74
+ File.rename(stage, destination)
75
+ begin
76
+ save(fonts.merge(id => entry))
77
+ rescue Exception
78
+ FileUtils.remove_entry(destination)
79
+ raise
80
+ end
81
+ entry
82
+ end
83
+ end
84
+ rescue KeyError, JSON::ParserError => error
85
+ raise Error, "Invalid Fontsource response: #{error.message}"
86
+ end
87
+
88
+ def remove(id, confirm: nil)
89
+ raise Error, "Invalid font ID" unless Core::Fonts::ID.match?(id)
90
+ locked do
91
+ fonts = list
92
+ entry = fonts.fetch(id) { raise Error, "Font not installed: #{id}" }
93
+ used = Dir.glob(File.join(@root, "**/*.{md,markdown}")).select do |file|
94
+ next false if File.symlink?(file) || !File.realpath(file).start_with?(@root + "/")
95
+ name = Core::Parser.new.parse(File.read(file, encoding: "UTF-8")).fonts["default"]
96
+ name && Core::Fonts.resolve(fonts, name) == entry
97
+ rescue Core::Parser::Error
98
+ true # A malformed document cannot establish that removal is safe.
99
+ end
100
+ raise Error, "Font is used by Markdown: #{used.join(', ')}; removal cancelled" if !used.empty? && !(confirm && confirm.call(used))
101
+ paths = entry.fetch("files").map { |relative| [relative, safe_path(relative)] }
102
+ paths.each do |relative, path|
103
+ next unless File.exist?(path)
104
+ raise Error, "Managed file was changed; refusing removal: #{relative}" unless entry.fetch("sha256", {})[relative] == Digest::SHA256.file(path).hexdigest
105
+ end
106
+ # Move only tracked files aside; restore them if committing the registry fails.
107
+ Dir.mktmpdir(".wani-remove-", @root) do |stage|
108
+ moved = []
109
+ begin
110
+ paths.each_with_index do |(_relative, path), index|
111
+ next unless File.exist?(path)
112
+ backup = File.join(stage, index.to_s)
113
+ File.rename(path, backup)
114
+ moved << [path, backup]
115
+ end
116
+ save(fonts.reject { |key, _| key == id })
117
+ rescue Exception
118
+ moved.reverse_each { |path, backup| File.rename(backup, path) }
119
+ raise
120
+ end
121
+ end
122
+ ["fonts/#{id}/files", "fonts/#{id}"].each do |relative|
123
+ path = safe_path(relative)
124
+ Dir.rmdir(path) if File.directory?(path) && Dir.empty?(path)
125
+ end
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def identify(source)
132
+ if source.match?(%r{[:/\\]})
133
+ uri = URI.parse(source)
134
+ raise Error, "Use an official https://fontsource.org/fonts/ID URL" unless uri.scheme == "https" && uri.host == "fontsource.org" && uri.port == 443 && !uri.userinfo && !uri.query && !uri.fragment && uri.path.match?(%r{\A/fonts/[a-z0-9]+(?:-[a-z0-9]+)*/?\z})
135
+ return uri.path.split('/').last
136
+ end
137
+ candidates = json("https://api.fontsource.org/v1/fonts")
138
+ raise Error, "Invalid font catalog" unless candidates.is_a?(Array) && candidates.all? { |item| item.is_a?(Hash) && item['family'].is_a?(String) && item['id'].is_a?(String) }
139
+ matches = candidates.select { |item| item['id'] == source || item.fetch('family').casecmp?(source) }
140
+ matches = candidates.select { |item| item.fetch('family').downcase.include?(source.downcase) } if matches.empty?
141
+ raise Error, "Font not found: #{source}" if matches.empty?
142
+ raise Error, "Ambiguous font; specify an ID: #{matches.map { |item| item['id'] }.join(', ')}" unless matches.length == 1
143
+ id = matches.first.fetch('id')
144
+ raise Error, "Invalid Fontsource ID" unless Core::Fonts::ID.match?(id)
145
+ id
146
+ rescue URI::InvalidURIError
147
+ raise Error, "Invalid Fontsource URL"
148
+ end
149
+
150
+ def download(url)
151
+ body = @http.get(url)
152
+ @bytes += body.bytesize
153
+ raise Error, "Font exceeds 256 MiB installation limit" if @bytes > 256 * 1024 * 1024
154
+ body
155
+ end
156
+
157
+ def json(url)
158
+ JSON.parse(download(url))
159
+ end
160
+
161
+ def safe_path(relative)
162
+ current = @root
163
+ relative.split('/').each do |part|
164
+ raise Error, "Unsafe project path" if part == '..' || part == '.' || part.empty?
165
+ current = File.join(current, part)
166
+ raise Error, "Symlinks are not allowed in managed font paths: #{relative}" if File.symlink?(current)
167
+ end
168
+ current
169
+ end
170
+
171
+ def locked
172
+ FileUtils.mkdir_p(safe_path(".wani"))
173
+ File.open(safe_path(".wani/fonts.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
174
+ lock.flock(File::LOCK_EX)
175
+ yield
176
+ end
177
+ end
178
+
179
+ def save(fonts)
180
+ path = safe_path(".wani/fonts.yml")
181
+ Tempfile.create(["fonts-", ".yml"], safe_path(".wani")) do |temporary|
182
+ temporary.write(YAML.dump("fonts" => fonts))
183
+ temporary.flush
184
+ temporary.fsync
185
+ File.rename(temporary.path, path)
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end