kozeki 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/kozeki/dsl.rb ADDED
@@ -0,0 +1,65 @@
1
+ module Kozeki
2
+ class Dsl
3
+ def initialize(options)
4
+ @options = options
5
+ end
6
+
7
+ attr_reader :options
8
+
9
+ def self.load_file(path, options: {})
10
+ d = self.new(options)
11
+ d.base_directory(File.dirname(path))
12
+ d.instance_eval(File.read(path), path, 1)
13
+ d
14
+ end
15
+
16
+ def self.eval(options: {}, &block)
17
+ d = self.new(options)
18
+ d.base_directory('.')
19
+ d.instance_eval(&block)
20
+ d
21
+ end
22
+
23
+ def base_directory(path)
24
+ @options[:base_directory] = path
25
+ end
26
+
27
+ def source_directory(path)
28
+ @options[:source_directory] = path
29
+ end
30
+
31
+ def destination_directory(path)
32
+ @options[:destination_directory] = path
33
+ end
34
+
35
+ def cache_directory(path)
36
+ @options[:cache_directory] = path
37
+ end
38
+
39
+ def collection_list_included_prefix(*prefixes)
40
+ (@options[:collection_list_included_prefix] ||= []).concat prefixes.flatten.map(&:to_s)
41
+ end
42
+
43
+ def collection_options(prefix:, **options)
44
+ @options[:collection_options] ||= []
45
+ # FIXME: recursive dependency :<
46
+ @options[:collection_options].push(Config::CollectionOptions.new(prefix:, **options))
47
+ end
48
+
49
+ def metadata_decorator(&block)
50
+ (@options[:metadata_decorators] ||= []).push(block)
51
+ end
52
+
53
+ def source_filesystem(x)
54
+ @options[:source_filesystem] = x
55
+ end
56
+
57
+ def destination_filesystem(x)
58
+ @options[:destination_filesystem] = x
59
+ end
60
+
61
+ def on_after_build(&block)
62
+ (@options[:after_build_callbacks] ||= []).push(block)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,46 @@
1
+ module Kozeki
2
+ module Filesystem
3
+ Entry = Data.define(:path, :mtime)
4
+ Event = Data.define(:op, :path, :time)
5
+
6
+ class NotFound < StandardError; end
7
+
8
+ def read(path)
9
+ read_with_mtime(path)[0]
10
+ end
11
+
12
+ def read_with_mtime(path)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def write(path, string)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def delete(path)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def list
25
+ list_entries.map(&:path)
26
+ end
27
+
28
+ def list_entries
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def watch
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def retain_only(files)
37
+ to_remove = list() - files
38
+ to_remove.each do |path|
39
+ delete(path)
40
+ end
41
+ end
42
+
43
+ def flush
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,44 @@
1
+ require 'time'
2
+
3
+ module Kozeki
4
+ # Item represents a built item from Source.
5
+ class Item
6
+ class ParseError < StandardError; end
7
+
8
+ def self.load_from_json(json_string)
9
+ j = JSON.parse(json_string, symbolize_names: true)
10
+ raise ParseError, ".kind must be `item`" unless j[:kind] == 'item'
11
+ id = j.fetch(:id)
12
+ data = j.fetch(:data)
13
+ meta = j.fetch(:meta, {})
14
+ build = j.fetch(:kozeki_build, {})
15
+ Item.new(id:, data:, meta:, build:)
16
+ end
17
+
18
+ def initialize(id:, data:, meta: nil, build: nil)
19
+ @id = id
20
+ @data = data
21
+ @meta = meta
22
+ @build = build
23
+ end
24
+
25
+ attr_reader :id, :data, :meta, :build
26
+
27
+ def as_json
28
+ {
29
+ kind: 'item',
30
+ id: id,
31
+ meta: meta.transform_values do |v|
32
+ case v
33
+ when Time
34
+ v.xmlschema
35
+ else
36
+ v
37
+ end
38
+ end,
39
+ data:,
40
+ kozeki_build: build,
41
+ }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,20 @@
1
+ module Kozeki
2
+ class LoaderChain
3
+ def initialize(loaders:, decorators:)
4
+ @loaders = loaders
5
+ @decorators = decorators
6
+ end
7
+
8
+ def try_read(...)
9
+ @loaders.each do |loader|
10
+ source = loader.try_read(...)
11
+ next unless source
12
+ @decorators.each do |decorator|
13
+ decorator.call(source.meta, source)
14
+ end
15
+ return source
16
+ end
17
+ nil
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+ require 'kozeki/filesystem'
3
+ require 'fileutils'
4
+
5
+ module Kozeki
6
+ class LocalFilesystem
7
+ include Filesystem
8
+
9
+ def initialize(base_directory)
10
+ @base = base_directory
11
+ end
12
+
13
+ # @param path [Array]
14
+ def read(path)
15
+ File.read(File.join(@base, *path))
16
+ rescue Errno::ENOENT
17
+ raise Filesystem::NotFound
18
+ end
19
+
20
+ # @param path [Array]
21
+ def read_with_mtime(path)
22
+ [
23
+ read(path),
24
+ File.mtime(File.join(@base, *path)),
25
+ ]
26
+ end
27
+
28
+ # @param path [Array]
29
+ def write(path, string)
30
+ path = File.join(@base, *path)
31
+ dirname = File.dirname(path)
32
+ FileUtils.mkdir_p(dirname)
33
+ File.write(path, string)
34
+ end
35
+
36
+ # @param path [Array]
37
+ def delete(path)
38
+ File.unlink(File.join(@base, *path))
39
+ rescue Errno::ENOENT
40
+ end
41
+
42
+ def list_entries
43
+ range = File.join(@base, 'x')[0..-2].size .. -1
44
+ Dir[File.join(@base, '**', '*')].filter_map do |fspath|
45
+ path = fspath[range].split(File::SEPARATOR)
46
+ next if File.directory?(fspath) rescue nil
47
+ Filesystem::Entry.new(
48
+ path:,
49
+ mtime: File.mtime(fspath),
50
+ )
51
+ end
52
+ end
53
+
54
+ def watch(&block)
55
+ require 'listen'
56
+ base = File.expand_path(@base)
57
+ l = Listen.to(@base) do |modified, added, removed|
58
+ yield [
59
+ *(modified + added).map do |path|
60
+ Filesystem::Event.new(
61
+ op: :update,
62
+ path: convert_absolute_to_path(base, path),
63
+ time: nil,
64
+ )
65
+ end,
66
+ *removed.map do |path|
67
+ Filesystem::Event.new(
68
+ op: :delete,
69
+ path: convert_absolute_to_path(base, path),
70
+ time: nil,
71
+ )
72
+ end,
73
+ ]
74
+ end
75
+ l.start
76
+ -> { l.stop }
77
+ end
78
+
79
+ private def convert_absolute_to_path(base, path)
80
+ Pathname.new(path).relative_path_from(base).to_s.split(File::SEPARATOR)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require 'yaml'
3
+ require 'commonmarker'
4
+ require 'kozeki/source'
5
+
6
+ module Kozeki
7
+ module MarkdownLoader
8
+ def self.try_read(path:, filesystem:)
9
+ basename = path.last
10
+ return nil unless basename.end_with?('.md') || basename.end_with?('.mkd') || basename.end_with?('.markdown')
11
+ content, mtime = filesystem.read_with_mtime(path)
12
+ m = content.match(/\A\s*^---$(.+?)^---$/m)
13
+ meta = if m
14
+ YAML.safe_load(m[1], symbolize_names: true)
15
+ else
16
+ {}
17
+ end
18
+ Source.new(
19
+ path:,
20
+ meta:,
21
+ mtime:,
22
+ content:,
23
+ loader: self,
24
+ )
25
+ end
26
+
27
+ def self.build(source)
28
+ html = Commonmarker.to_html(source.content, options: {
29
+ render: {
30
+ unsafe: true,
31
+ },
32
+ extension: {
33
+ strikethrough: true,
34
+ tagfilter: false,
35
+ table: true,
36
+ autolink: true,
37
+ tasklist: true,
38
+ superscript: true,
39
+ header_ids: "#{source.id}--",
40
+ footnotes: true,
41
+ description_lists: true,
42
+ front_matter_delimiter: '---',
43
+ shortcodes: true,
44
+ },
45
+ })
46
+
47
+ {
48
+ html: html,
49
+ }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,65 @@
1
+ require 'kozeki/filesystem'
2
+ require 'thread'
3
+
4
+ module Kozeki
5
+ class QueuedFilesystem
6
+ include Kozeki::Filesystem
7
+ Operation = Data.define(:method, :args)
8
+
9
+ def initialize(filesystem:, threads: 6)
10
+ @backend = filesystem
11
+ @lock = Mutex.new
12
+ @threads_num = threads
13
+ @threads = nil
14
+ @queue = nil
15
+ start
16
+ end
17
+
18
+ def start
19
+ @lock.synchronize do
20
+ queue = @queue = Queue.new
21
+ @threads = @threads_num.times.map { Thread.new(queue, &method(:do_worker)) }
22
+ end
23
+ end
24
+
25
+ def flush
26
+ return unless @threads
27
+ ths, q = @lock.synchronize do
28
+ [@threads, @queue]
29
+ end
30
+ start
31
+ q.close
32
+ ths.each(&:value)
33
+ end
34
+
35
+ def write(path, string)
36
+ @queue.push Operation.new(method: :write, args: [path, string])
37
+ nil
38
+ end
39
+
40
+ def delete(path)
41
+ @queue.push Operation.new(method: :delete, args: [path])
42
+ nil
43
+ end
44
+
45
+ private def do_worker(q)
46
+ while op = q.pop
47
+ case op.method
48
+ when :write
49
+ @backend.write(*op.args)
50
+ when :delete
51
+ @backend.delete(*op.args)
52
+ else
53
+ raise "unknown operation #{op.inspect}"
54
+ end
55
+ end
56
+ end
57
+
58
+ def read(...) = @backend.read(...)
59
+ def read_with_mtime(...) = @backend.read_with_mtime(...)
60
+ def list(...) = @backend.list(...)
61
+ def list_entries(...) = @backend.list_entries(...)
62
+ def watch(...) = @backend.watch(...)
63
+ def retain_only(...) = @backend.retain_only(...)
64
+ end
65
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozeki
4
+ # Represents cached metadata of Item and Source in a State
5
+ Record = Data.define(:id, :path, :timestamp, :mtime, :meta, :build, :pending_build_action, :id_was) do
6
+ def self.from_source(s)
7
+ new(
8
+ path: s.path,
9
+ id: s.id,
10
+ timestamp: s.timestamp,
11
+ mtime: s.mtime,
12
+ meta: s.meta,
13
+ build: nil,
14
+ pending_build_action: nil,
15
+ id_was: nil,
16
+ )
17
+ end
18
+
19
+ def self.from_row(h)
20
+ new(
21
+ path: h.fetch('path').split('/'),
22
+ id: h.fetch('id'),
23
+ timestamp: Time.at(h.fetch('timestamp')),
24
+ mtime: Time.at(h.fetch('mtime')/1000.0),
25
+ meta: JSON.parse(h.fetch('meta'), symbolize_names: true),
26
+ build: h['build']&.then { JSON.parse(_1, symbolize_names: true) },
27
+ pending_build_action: h.fetch('pending_build_action', 'none')&.to_sym&.then { _1 == :none ? nil : _1 },
28
+ id_was: h.fetch('id_was', nil),
29
+ )
30
+ end
31
+
32
+ def path_row
33
+ path.join('/')
34
+ end
35
+
36
+ def to_row
37
+ {
38
+ path: path_row,
39
+ id:,
40
+ timestamp: timestamp.to_i,
41
+ mtime: (mtime.floor(4).to_f * 1000).truncate,
42
+ meta: JSON.generate(meta),
43
+ build: build && JSON.generate(build),
44
+ pending_build_action: pending_build_action&.to_s || 'none',
45
+ }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ require 'digest/sha2'
3
+
4
+ require 'kozeki/record'
5
+ require 'kozeki/item'
6
+
7
+ module Kozeki
8
+ # Source represents a source text file.
9
+ class Source
10
+ # @param path [Array<String>]
11
+ def initialize(path:, meta:, mtime:, content:, loader:)
12
+ raise ArgumentError, "path fragment cannot include /" if path.any? { _1.include?('/') }
13
+ @path = path
14
+ @meta = meta
15
+ @mtime = mtime
16
+ @content = content
17
+
18
+ @loader = loader
19
+
20
+ raise ArgumentError, "path fragment cannot include /" if path.any? { _1.include?('/') }
21
+ raise ArgumentError, "id cannot include /" if id.include?('/')
22
+ end
23
+
24
+ attr_reader :path, :mtime, :content, :loader
25
+ attr_accessor :meta
26
+
27
+ def id
28
+ meta.fetch(:id) do
29
+ "ao_#{Digest::SHA256.hexdigest(path.join('/'))}"
30
+ end.to_s
31
+ end
32
+
33
+ def timestamp
34
+ meta[:timestamp]&.then { Time.xmlschema(_1) }
35
+ end
36
+
37
+ def collections
38
+ meta[:collections] || []
39
+ end
40
+
41
+ # Relative file path of built Item.
42
+ def item_path
43
+ ['items', "#{id}.json"]
44
+ end
45
+
46
+ def to_record
47
+ Record.from_source(self)
48
+ end
49
+
50
+ def build_item
51
+ data = loader.build(self)
52
+ Item.new(
53
+ id:,
54
+ data:,
55
+ meta:,
56
+ build: {},
57
+ )
58
+ end
59
+ end
60
+ end