impression 0.1 → 0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,8 @@ require 'kramdown'
4
4
  require 'rouge'
5
5
  require 'kramdown-parser-gfm'
6
6
  require 'yaml'
7
+ require 'rb-inotify'
8
+ require 'papercraft'
7
9
 
8
10
  require_relative './errors'
9
11
 
@@ -18,8 +20,53 @@ module Impression
18
20
  def initialize(base_path, opts = {})
19
21
  @base_path = base_path
20
22
  @opts = opts
23
+ @opts[:pages] = self
21
24
 
22
25
  load
26
+
27
+ if opts[:auto_reload]
28
+ start_automatic_reloader
29
+ end
30
+ end
31
+
32
+ def start_automatic_reloader
33
+ notifier = INotify::Notifier.new
34
+ watched = {}
35
+ @map.each_value do |entry|
36
+ path = entry[:full_path]
37
+ next if watched[path]
38
+
39
+ notifier.watch(path, :modify, :delete_self) { |e| handle_changed_file(path) }
40
+ watched[path] = true
41
+ end
42
+ notifier.watch(@base_path, :moved_to, :create) do |event|
43
+ path = event.absolute_name
44
+ if File.file?(path)
45
+ notifier.watch(path, :modify, :delete_self) { |e| handle_changed_file(path) }
46
+ end
47
+ handle_changed_file(path)
48
+ end
49
+ @reloader = spin do
50
+ notify_io = notifier.to_io
51
+ loop do
52
+ notify_io.wait_readable
53
+ notifier.process
54
+ end
55
+ end
56
+ end
57
+
58
+ def handle_changed_file(full_path)
59
+ p handle_changed_file: full_path
60
+ if !File.file?(full_path)
61
+ @map.reject! { |k, v| v[:full_path] == full_path }
62
+ return
63
+ end
64
+
65
+ path = File.basename(full_path)
66
+ page = Page.new(path, full_path, @opts)
67
+ permalink = page.permalink
68
+ @map[permalink] = { page: page, full_path: full_path }
69
+ @map['/'] = @map[permalink] if permalink == '/index'
23
70
  end
24
71
 
25
72
  def load
@@ -31,87 +78,159 @@ module Impression
31
78
  next unless File.file?(full_path)
32
79
 
33
80
  page = Page.new(path, full_path, @opts)
34
- @map[page.relative_permalink] = page
81
+ @map[page.permalink] = { page: page, full_path: full_path }
35
82
  end
36
83
  @map['/'] = @map['/index']
37
84
  end
38
85
  alias_method :reload, :load
39
86
 
87
+ def prev_page(page)
88
+ keys = @map.keys
89
+ case idx = keys.index(page.permalink)
90
+ when 0, nil
91
+ nil
92
+ else
93
+ @map[keys[idx - 1]][:page]
94
+ end
95
+ end
96
+
97
+ def next_page(page)
98
+ keys = @map.keys
99
+ case idx = keys.index(page.permalink)
100
+ when keys.size - 1, nil
101
+ nil
102
+ else
103
+ @map[keys[idx + 1]][:page]
104
+ end
105
+ end
106
+
40
107
  def load_file(path)
41
108
  content = IO.read(path)
42
109
  end
43
110
 
44
111
  def serve(req)
45
- page = @map[req.routing_path]
46
- raise NotFoundError unless page
112
+ entry = @map[req.route_relative_path]
113
+ raise NotFoundError unless entry
47
114
 
48
- # return req.respond('Hello world')
115
+ body = render_page(entry[:page])
116
+ req.respond(body, 'Content-Type' => 'text/html')
117
+ rescue NotFoundError => e
118
+ req.respond('Not found.', ':status' => e.http_status)
119
+ end
120
+ alias_method :call, :serve
49
121
 
50
-
51
- req.respond(page.to_html, 'Content-Type' => 'text/html')
122
+ def render_page(page)
123
+ layout_proc(page.layout).().render(pages: self, page: page)
52
124
  end
53
125
 
54
- class Page
55
- def initialize(path, full_path, opts = {})
56
- @path = path
57
- @full_path = full_path
58
- @opts = opts
59
- read_page
60
- end
61
-
62
- PAGE_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze
63
-
64
- def read_page
65
- data = IO.read(@full_path) || ''
66
- if data =~ PAGE_REGEXP
67
- front_matter = Regexp.last_match(1)
68
- @attributes = YAML.load(front_matter)
69
- @content = Regexp.last_match.post_match
70
- else
71
- @attributes = {}
72
- @content = data
73
- end
74
- end
126
+ def layout_proc(layout)
127
+ full_path = File.expand_path("../_layouts/#{layout}.rb", @base_path)
128
+ instance_eval("->(&block) do; #{IO.read(full_path)}; end", full_path)
129
+ end
75
130
 
76
- def relative_permalink
77
- @relative_permalink = @attributes[:permalink] || path_without_extension
131
+ def select(selector)
132
+ @map.inject([]) do |array, (permalink, entry)|
133
+ array << entry[:page] if permalink =~ selector
134
+ array
78
135
  end
136
+ end
137
+ end
79
138
 
80
- def path_without_extension
81
- "/#{File.basename(@path, File.extname(@path))}"
82
- end
83
-
84
- def title
85
- @attributes['title'] || title_from_content
86
- end
87
-
88
- TITLE_REGEXP = /^#\s+([^\n]+)/.freeze
89
-
90
- def title_from_content
91
- (@content =~ TITLE_REGEXP) && Regexp.last_match(1)
92
- end
93
-
94
- def status
95
- @attributes['status'] || Qeweney::Status::OK
96
- end
97
-
98
- def to_html
99
- @html ||= render_html
100
- end
139
+ class Page
140
+ attr_reader :attributes
141
+
142
+ def initialize(path, full_path, opts = {})
143
+ @path = path
144
+ @full_path = full_path
145
+ @opts = opts
146
+ @kind = detect_page_kind(full_path)
147
+ read_page
148
+ end
101
149
 
102
- def kramdown_options
103
- {
104
- entity_output: :numeric,
105
- syntax_highlighter: :rouge,
106
- input: 'GFM'
107
- }
150
+ EXTNAME_REGEXP = /^\.(.+)$/.freeze
151
+
152
+ def detect_page_kind(path)
153
+ File.extname(path).match(EXTNAME_REGEXP)[1].to_sym
154
+ end
155
+
156
+ PAGE_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze
157
+
158
+ def read_page
159
+ data = IO.read(@full_path) || ''
160
+ if data =~ PAGE_REGEXP
161
+ front_matter = Regexp.last_match(1)
162
+ @content_start_line = front_matter.lines.size + 2
163
+ @attributes = YAML.load(front_matter)
164
+ @content = Regexp.last_match.post_match
165
+ else
166
+ @attributes = {}
167
+ @content_start_line = 1
168
+ @content = data
108
169
  end
170
+ end
171
+
172
+ def permalink
173
+ @permalink = @attributes[:permalink] || path_without_extension
174
+ end
175
+
176
+ def path_without_extension
177
+ "/#{@path.delete_suffix(File.extname(@path))}"
178
+ end
179
+
180
+ def title
181
+ @attributes['title'] || title_from_content
182
+ end
183
+
184
+ def prev_page
185
+ @opts[:pages].prev_page(self)
186
+ end
187
+
188
+ def next_page
189
+ @opts[:pages].next_page(self)
190
+ end
191
+
192
+ TITLE_REGEXP = /^#\s+([^\n]+)/.freeze
193
+
194
+ def title_from_content
195
+ (@content =~ TITLE_REGEXP) && Regexp.last_match(1)
196
+ end
197
+
198
+ def status
199
+ @attributes['status'] || Qeweney::Status::OK
200
+ end
109
201
 
110
- def render_html
111
- inner = Kramdown::Document.new(@content, **kramdown_options).to_html
112
- "<!doctype html><html><body>#{inner}</body></html>"
202
+ def layout
203
+ layout = @attributes['layout'] || 'default'
204
+ end
205
+
206
+ def render
207
+ case @kind
208
+ when :md
209
+ render_markdown
210
+ when :rb
211
+ render_papercraft
212
+ else
213
+ raise "Invalid page kind #{kind.inspect}"
113
214
  end
114
215
  end
216
+
217
+ def render_markdown
218
+ Kramdown::Document.new(@content, **kramdown_options).to_html
219
+ end
220
+
221
+ def kramdown_options
222
+ {
223
+ entity_output: :numeric,
224
+ syntax_highlighter: :rouge,
225
+ input: 'GFM',
226
+ hard_wrap: false
227
+ }
228
+ end
229
+
230
+ def render_papercraft
231
+ proc = instance_eval("->(&block) do; #{@content}; end", @full_path, @content_start_line)
232
+ proc.().render(page: self, pages: @opts[:pages])
233
+ end
115
234
  end
116
235
  end
117
236
 
@@ -2,8 +2,12 @@
2
2
 
3
3
  require 'qeweney'
4
4
 
5
- require_relative './pages'
5
+ # require_relative './pages'
6
+ require_relative './request_routing'
6
7
 
8
+ # Extensions to `Qeweney::Request`
7
9
  class Qeweney::Request
8
- include Impression::Pages::RequestMethods
10
+
11
+ # include Impression::Pages::RequestMethods
12
+ include Impression::RequestRouting
9
13
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Impression
6
+
7
+ # Request routing extensions for `Qeweney::Request`
8
+ module RequestRouting
9
+
10
+ # Matches a route regexp against the relative request path. The relative
11
+ # request path is a separate string (stored in `@resource_relative_path`)
12
+ # that is updated as routes are matched against it. The `route_regexp`
13
+ # should be either `nil` for root routes (`/`) or a Regexp of the form
14
+ # `/^#{route}(\/.*)?$/`. See also `Resource#initialize`.
15
+ #
16
+ # @param route_regexp [Regexp, nil] Route regexp to match against
17
+ # @return [String, nil] The remainder of the path (relative to the route)
18
+ def match_resource_path?(route_regexp)
19
+ @resource_relative_path ||= path.dup
20
+
21
+ return @resource_relative_path unless route_regexp
22
+
23
+ # Simplified logic: no match returns nil, otherwise we set the relative path for
24
+ @resource_relative_path = match_resource_relative_path(
25
+ @resource_relative_path, route_regexp
26
+ )
27
+ end
28
+
29
+ # Returns the relative_path for the latest matched resource
30
+ #
31
+ # @return [String]
32
+ def resource_relative_path
33
+ @resource_relative_path ||= path.dup
34
+ end
35
+
36
+ private
37
+
38
+ # Matches the given path against the given route regexp. If the path matches
39
+ # the regexp, the relative path for the given route is returned. Otherwise,
40
+ # this method returns `nil`.
41
+ #
42
+ # @param path [String] path to match
43
+ # @param route_regexp [Regexp] route regexp to match against
44
+ # @return [String, nil] the relative path for the given route, or nil if no match.
45
+ def match_resource_relative_path(path, route_regexp)
46
+ match = path.match(route_regexp)
47
+ match && (match[1] || '/')
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'qeweney'
5
+
6
+ module Impression
7
+
8
+ # The `Resource` class represents an abstract web resource. Resources are
9
+ # organized in tree like structure, with the structure normally corresponding
10
+ # to the URL path hierarchy. Other ways of organising resources, according to
11
+ # other taxonomies, can be implemented as long as the resources implement the
12
+ # same interface as the `Resource` class, which includes the following
13
+ # methods:
14
+ #
15
+ # - `Resource#route` - returns the resource which should respond to the
16
+ # request.
17
+ # - `Resource#respond` - responds to the request.
18
+ #
19
+ class Resource
20
+ # Reference to the parent resource
21
+ attr_reader :parent
22
+
23
+ # The resource's path relative to its parent
24
+ attr_reader :path
25
+
26
+ # A hash mapping relative paths to child resources
27
+ attr_reader :children
28
+
29
+ # Initalizes a new resource instance.
30
+ #
31
+ # @param parent [Impression::Resource, nil] the parent resource (or nil)
32
+ # @param path [String] the resource's relative path
33
+ # @return [void]
34
+ def initialize(parent: nil, path:)
35
+ @parent = parent
36
+ @path = normalize_route_path(path)
37
+ @route_regexp = @path == '/' ? nil : /^#{@path}(\/.*)?$/.freeze
38
+ @children = {}
39
+
40
+ @parent&.add_child(self)
41
+ end
42
+
43
+ # Returns the resource's absolute path, according to its location in the
44
+ # resource hierarchy.
45
+ #
46
+ # @return [String] absolute path
47
+ def absolute_path
48
+ @absoulte_path ||= File.join(@parent ? @parent.absolute_path : '/', @path)
49
+ end
50
+
51
+ # Iterates over the resource and any of its sub-resources, passing each to
52
+ # the given block.
53
+ #
54
+ # @return [Impression::Resource] self
55
+ def each(&block)
56
+ block.(self)
57
+ @children.values.each { |c| c.each(&block) }
58
+ self
59
+ end
60
+
61
+ # Adds a child reference to the children map.
62
+ #
63
+ # @param child [Impression::Resource] child resource
64
+ # @return [Impression::Resource] self
65
+ def add_child(child)
66
+ @children[child.path] = child
67
+ self
68
+ end
69
+
70
+ # Responds to the given request by rendering a 404 Not found response.
71
+ #
72
+ # @param req [Qeweney::Request] request
73
+ # @return [void]
74
+ def respond(req)
75
+ req.respond(nil, ':status' => Qeweney::Status::NOT_FOUND)
76
+ end
77
+
78
+ FIRST_PATH_SEGMENT_REGEXP = /^(\/[^\/]+)\//.freeze
79
+
80
+ # Routes the request by matching self and any children against the request
81
+ # path, returning the target resource, or nil if there's no match.
82
+ #
83
+ # @param req [Qeweney::Request] request
84
+ # @return [Impression::Resource, nil] target resource
85
+ def route(req)
86
+ case (relative_path = req.match_resource_path?(@route_regexp))
87
+ when nil
88
+ return nil
89
+ when '/'
90
+ return self
91
+ else
92
+ # naive case
93
+ child = @children[relative_path]
94
+ return child.route(req) if child
95
+
96
+ if (m = relative_path.match(FIRST_PATH_SEGMENT_REGEXP))
97
+ child = @children[m[1]]
98
+ return child.route(req) if child
99
+ end
100
+
101
+ return self
102
+ end
103
+ end
104
+
105
+ # Renders the resource and all of its sub-resources to static files.
106
+ #
107
+ # @param base_path [String] base path of target directory
108
+ # @return [Impression::Resource] self
109
+ def render_tree_to_static_files(base_path)
110
+ each do |r|
111
+ path = File.join(base_path, r.relative_static_file_path)
112
+ dir = File.dirname(path)
113
+ FileUtils.mkdir_p(dir) if !File.directory?(dir)
114
+ File.open(path, 'w') { |f| r.render_to_file(f) }
115
+ end
116
+ self
117
+ end
118
+
119
+ # Converts the resource to a Proc, for use as a Qeweney app.
120
+ #
121
+ # @return [Proc] web app proc
122
+ def to_proc
123
+ ->(req) do
124
+ resource = route(req) || self
125
+ resource.respond(req)
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ SLASH_PREFIXED_PATH_REGEXP = /^\//.freeze
132
+
133
+ # Normalizes the given path by ensuring it starts with a slash.
134
+ #
135
+ # @param path [String] path to normalize
136
+ # @return [String] normalized path
137
+ def normalize_route_path(path)
138
+ path =~ SLASH_PREFIXED_PATH_REGEXP ? path : "/#{path}"
139
+ end
140
+ end
141
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Impression
4
- VERSION = '0.1'
4
+ VERSION = '0.2'
5
5
  end
data/lib/impression.rb CHANGED
@@ -1,3 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'polyphony'
4
+
3
5
  require_relative './impression/request_extensions'
6
+ # require_relative './impression/file_watcher'
7
+
8
+ require_relative './impression/resource'
9
+ require_relative './impression/file_tree'
10
+ require_relative './impression/app'
data/test/helper.rb ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require_relative './coverage' if ENV['COVERAGE']
5
+ require 'minitest/autorun'
6
+ require 'impression'
7
+ require 'qeweney/test_adapter'
8
+
9
+ module Kernel
10
+ def mock_req(**args)
11
+ Qeweney::TestAdapter.mock(**args)
12
+ end
13
+
14
+ def capture_exception
15
+ yield
16
+ rescue Exception => e
17
+ e
18
+ end
19
+
20
+ def trace(*args)
21
+ STDOUT.orig_write(format_trace(args))
22
+ end
23
+
24
+ def format_trace(args)
25
+ if args.first.is_a?(String)
26
+ if args.size > 1
27
+ format("%s: %p\n", args.shift, args)
28
+ else
29
+ format("%s\n", args.first)
30
+ end
31
+ else
32
+ format("%p\n", args.size == 1 ? args.first : args)
33
+ end
34
+ end
35
+ end
36
+
37
+ module Minitest::Assertions
38
+ def assert_in_range exp_range, act
39
+ msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" }
40
+ assert exp_range.include?(act), msg
41
+ end
42
+
43
+ def assert_response exp_body, exp_content_type, req
44
+ msg = message(msg) { "Expected response body #{mu_pp(act)} to equal #{mu_pp(exp_body)}" }
45
+ assert_equal exp_body, req.response_body, msg
46
+
47
+ return unless exp_content_type
48
+
49
+ if Symbol === exp_content_type
50
+ exp_content_type = Qeweney::MimeTypes[exp_content_type]
51
+ end
52
+ msg = message(msg) { "Expected response content type #{mu_pp(act)} to equal #{mu_pp(exp_body)}" }
53
+ assert_equal exp_content_type, req.response_content_type, msg
54
+ end
55
+ end
56
+
57
+ class Impression::Resource
58
+ def route_and_respond(req)
59
+ route(req).respond(req)
60
+ end
61
+ end
62
+
63
+ class PathRenderingResource < Impression::Resource
64
+ def respond(req)
65
+ req.respond(absolute_path)
66
+ end
67
+ end
68
+
69
+ class CompletePathInfoRenderingResource < Impression::Resource
70
+ def respond(req)
71
+ req.respond("#{absolute_path} #{req.resource_relative_path}")
72
+ end
73
+ end
74
+
75
+ # Extensions to be used in conjunction with `Qeweney::TestAdapter`
76
+ class Qeweney::Request
77
+ def response_headers
78
+ adapter.headers
79
+ end
80
+
81
+ def response_body
82
+ adapter.body
83
+ end
84
+
85
+ def response_status
86
+ adapter.status
87
+ end
88
+
89
+ def response_content_type
90
+ response_headers['Content-Type']
91
+ end
92
+ end
93
+
94
+ puts "Polyphony backend: #{Thread.current.backend.kind}"
data/test/run.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob("#{__dir__}/test_*.rb").each do |path|
4
+ require(path)
5
+ end
@@ -0,0 +1 @@
1
+ <h1>BarIndex</h1>
@@ -0,0 +1 @@
1
+ <h1>Foo</h1>
@@ -0,0 +1 @@
1
+ <h1>Index</h1>
@@ -0,0 +1 @@
1
+ console.log(42);
data/test/test_app.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class AppTest < MiniTest::Test
6
+ def test_empty_app
7
+ app = Impression::App.new(path: '/')
8
+ req = mock_req(':method' => 'GET', ':path' => '/')
9
+
10
+ app.respond(req)
11
+ assert_equal Qeweney::Status::NOT_FOUND, req.adapter.status
12
+ end
13
+
14
+ def test_app_each
15
+ app = Impression::App.new(path: '/')
16
+
17
+ buffer = []
18
+ app.each { |r| buffer << r }
19
+ assert_equal [app], buffer
20
+
21
+ foo = PathRenderingResource.new(parent: app, path: 'foo')
22
+ bar = PathRenderingResource.new(parent: app, path: 'bar')
23
+
24
+ buffer = []
25
+ app.each { |r| buffer << r }
26
+ assert_equal [app, foo, bar], buffer
27
+ end
28
+
29
+ def test_app_to_proc
30
+ app = Impression::App.new(path: '/')
31
+ app_proc = app.to_proc
32
+
33
+ foo = PathRenderingResource.new(parent: app, path: 'foo')
34
+ bar = PathRenderingResource.new(parent: app, path: 'bar')
35
+
36
+ # req = mock_req(':method' => 'GET', ':path' => '/')
37
+ # app_proc.(req)
38
+ # assert_equal Qeweney::Status::NOT_FOUND, req.adapter.status
39
+
40
+ req = mock_req(':method' => 'GET', ':path' => '/foo')
41
+ app_proc.(req)
42
+ assert_equal '/foo', req.adapter.body
43
+
44
+ req = mock_req(':method' => 'GET', ':path' => '/bar')
45
+ app_proc.(req)
46
+ assert_equal '/bar', req.adapter.body
47
+
48
+ req = mock_req(':method' => 'GET', ':path' => '/baz')
49
+ app_proc.(req)
50
+ assert_equal Qeweney::Status::NOT_FOUND, req.adapter.status
51
+ end
52
+ end