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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +262 -0
- data/assets/bootstrap.rb +9 -0
- data/assets/build.json +6 -0
- data/assets/fonts/IBMPlexSansJP-Bold.woff2 +0 -0
- data/assets/fonts/IBMPlexSansJP-Regular.woff2 +0 -0
- data/assets/highlight.css +117 -0
- data/assets/index.html +39 -0
- data/assets/rabbit.svg +1 -0
- data/assets/rouge.LICENSE +187 -0
- data/assets/runtime.LICENSE +21 -0
- data/assets/runtime.NOTICE +1292 -0
- data/assets/runtime.pr672.js +3143 -0
- data/assets/shark.svg +1 -0
- data/assets/speaker.html +2 -0
- data/assets/style.css +103 -0
- data/assets/time-expired.png +0 -0
- data/assets/wani.wasm +4 -0
- data/exe/wani +3 -0
- data/lib/wani/browser/application.rb +189 -0
- data/lib/wani/browser/bridge_progress.rb +19 -0
- data/lib/wani/browser/fonts.rb +60 -0
- data/lib/wani/browser/layout.rb +107 -0
- data/lib/wani/browser/presentation_timer.rb +66 -0
- data/lib/wani/browser/renderer.rb +80 -0
- data/lib/wani/cli.rb +134 -0
- data/lib/wani/core/fonts.rb +49 -0
- data/lib/wani/core/parser.rb +127 -0
- data/lib/wani/core/presentation.rb +15 -0
- data/lib/wani/core/safe_html.rb +74 -0
- data/lib/wani/core/validator.rb +31 -0
- data/lib/wani/fonts/http.rb +45 -0
- data/lib/wani/fonts/manager.rb +190 -0
- data/lib/wani/server/application.rb +92 -0
- data/lib/wani/server/http_server.rb +34 -0
- data/lib/wani/version.rb +3 -0
- data/lib/wani.rb +4 -0
- data/templates/images/workflow.svg +36 -0
- data/templates/slides.md +182 -0
- data/third_party/gems/kramdown-LICENSE +30 -0
- data/third_party/gems/kramdown-parser-gfm-LICENSE +21 -0
- data/third_party/gems/rexml-LICENSE +22 -0
- data/third_party/ibm-plex-sans-jp/LICENSE.txt +93 -0
- data/third_party/ibm-plex-sans-jp/README.md +39 -0
- data/third_party/ibm-plex-sans-jp/provenance.json +20 -0
- data/third_party/ruby-wasm-loader/LICENSE +21 -0
- data/third_party/ruby-wasm-loader/NOTICE +1292 -0
- data/third_party/ruby-wasm-loader/README.md +35 -0
- data/third_party/ruby-wasm-loader/browser.script.iife.js +3143 -0
- data/third_party/ruby-wasm-loader/provenance.json +10 -0
- 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| { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.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
|