kozeki 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +5 -0
- data/Rakefile +8 -0
- data/bin/kozeki +5 -0
- data/lib/kozeki/build.rb +260 -0
- data/lib/kozeki/cli.rb +57 -0
- data/lib/kozeki/client.rb +48 -0
- data/lib/kozeki/collection.rb +136 -0
- data/lib/kozeki/collection_list.rb +27 -0
- data/lib/kozeki/config.rb +91 -0
- data/lib/kozeki/dsl.rb +65 -0
- data/lib/kozeki/filesystem.rb +46 -0
- data/lib/kozeki/item.rb +44 -0
- data/lib/kozeki/loader_chain.rb +20 -0
- data/lib/kozeki/local_filesystem.rb +83 -0
- data/lib/kozeki/markdown_loader.rb +52 -0
- data/lib/kozeki/queued_filesystem.rb +65 -0
- data/lib/kozeki/record.rb +48 -0
- data/lib/kozeki/source.rb +60 -0
- data/lib/kozeki/state.rb +326 -0
- data/lib/kozeki/version.rb +5 -0
- data/lib/kozeki.rb +8 -0
- data/sig/kozeki.rbs +4 -0
- metadata +140 -0
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
|
data/lib/kozeki/item.rb
ADDED
@@ -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
|