kozeki 0.1.0

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.
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