impression 0.1 → 0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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