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