jekyll-client-search 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/CHANGELOG.md +121 -0
- data/LICENSE +22 -0
- data/NOTICE +49 -0
- data/README.developer.md +204 -0
- data/README.md +948 -0
- data/assets/adapters/elasticlunr.js +59 -0
- data/assets/adapters/minisearch.js +57 -0
- data/assets/adapters/semantic.js +154 -0
- data/assets/client-search-base.js +294 -0
- data/assets/client-search-related.js +176 -0
- data/assets/includes/related-articles.html +36 -0
- data/assets/layouts/post-with-related.html +54 -0
- data/assets/query-embedders/ollama-api.js +63 -0
- data/assets/query-embedders/transformers-worker.js +130 -0
- data/assets/query-embedders/transformers.js +223 -0
- data/docs/assets/icon-256.png +0 -0
- data/docs/assets/icon.svg +133 -0
- data/lib/jekyll/client_search/configuration.rb +152 -0
- data/lib/jekyll/client_search/configuration_accessors.rb +48 -0
- data/lib/jekyll/client_search/document_builder.rb +66 -0
- data/lib/jekyll/client_search/embedder_config_page.rb +14 -0
- data/lib/jekyll/client_search/embedding_configuration.rb +95 -0
- data/lib/jekyll/client_search/generator.rb +134 -0
- data/lib/jekyll/client_search/index_cache.rb +82 -0
- data/lib/jekyll/client_search/live_search_configuration.rb +70 -0
- data/lib/jekyll/client_search/ollama_embedding_adapter.rb +59 -0
- data/lib/jekyll/client_search/query_embedder_configuration.rb +127 -0
- data/lib/jekyll/client_search/related_analyzer.rb +152 -0
- data/lib/jekyll/client_search/related_configuration.rb +103 -0
- data/lib/jekyll/client_search/related_page.rb +14 -0
- data/lib/jekyll/client_search/related_tag.rb +76 -0
- data/lib/jekyll/client_search/runtime_config_page.rb +30 -0
- data/lib/jekyll/client_search/search_index_page.rb +15 -0
- data/lib/jekyll/client_search/search_tag.rb +100 -0
- data/lib/jekyll/client_search/tasks.rb +137 -0
- data/lib/jekyll/client_search/version.rb +7 -0
- data/lib/jekyll/client_search.rb +25 -0
- data/lib/jekyll-client-search.rb +3 -0
- metadata +104 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Jekyll
|
|
8
|
+
module ClientSearch
|
|
9
|
+
# Persists content hashes and cached embeddings across Jekyll builds so
|
|
10
|
+
# that unchanged documents are not re-embedded. The cache file lives in
|
|
11
|
+
# the site source directory as +.jekyll-client-search-cache.json+ and
|
|
12
|
+
# should be git-ignored.
|
|
13
|
+
class IndexCache
|
|
14
|
+
CACHE_FILE = ".jekyll-client-search-cache.json"
|
|
15
|
+
|
|
16
|
+
attr_reader :path
|
|
17
|
+
|
|
18
|
+
def initialize(site_source, embedding_identity: nil)
|
|
19
|
+
@path = File.join(site_source, CACHE_FILE)
|
|
20
|
+
@embedding_identity = embedding_identity
|
|
21
|
+
@entries = load
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the cached entry for +id+ if the content hash and embedding
|
|
25
|
+
# identity match, nil otherwise (or if not cached).
|
|
26
|
+
def lookup(id, content_hash)
|
|
27
|
+
entry = @entries[id]
|
|
28
|
+
return nil unless entry
|
|
29
|
+
return nil unless entry["content_hash"] == content_hash
|
|
30
|
+
return nil if @embedding_identity && entry["embedding_identity"] != @embedding_identity
|
|
31
|
+
|
|
32
|
+
entry
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Stores or updates a cache entry for +id+.
|
|
36
|
+
def store(id, content_hash, embedding = nil)
|
|
37
|
+
@entries[id] = {
|
|
38
|
+
"content_hash" => content_hash,
|
|
39
|
+
"embedding" => embedding,
|
|
40
|
+
"embedding_identity" => @embedding_identity
|
|
41
|
+
}.compact
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Removes entries for IDs that are no longer present.
|
|
45
|
+
def prune(known_ids)
|
|
46
|
+
@entries.select! { |id, _| known_ids.include?(id) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Writes the cache to disk if any entries have changed.
|
|
50
|
+
def save
|
|
51
|
+
return unless dirty?
|
|
52
|
+
|
|
53
|
+
temporary_path = "#{@path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
|
|
54
|
+
File.write(temporary_path, JSON.pretty_generate(@entries))
|
|
55
|
+
File.rename(temporary_path, @path)
|
|
56
|
+
ensure
|
|
57
|
+
FileUtils.rm_f(temporary_path) if temporary_path && File.exist?(temporary_path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.content_hash(document)
|
|
61
|
+
Digest::SHA256.hexdigest(document.to_json)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def load
|
|
67
|
+
return {} unless File.exist?(@path)
|
|
68
|
+
|
|
69
|
+
data = JSON.parse(File.read(@path))
|
|
70
|
+
data.is_a?(Hash) ? data : {}
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
{}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dirty?
|
|
76
|
+
return true unless File.exist?(@path)
|
|
77
|
+
|
|
78
|
+
File.read(@path) != JSON.pretty_generate(@entries)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Normalizes optional browser live-search behavior.
|
|
6
|
+
class LiveSearchConfiguration
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
"min_chars" => 2,
|
|
9
|
+
"debounce_ms" => 150,
|
|
10
|
+
"semantic_debounce_ms" => 500,
|
|
11
|
+
"update_url" => true
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(config, engine: "minisearch")
|
|
15
|
+
unless config.is_a?(Hash)
|
|
16
|
+
raise Jekyll::Errors::FatalException,
|
|
17
|
+
"client_search live_search configuration must be a mapping"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@engine = engine
|
|
21
|
+
@values = DEFAULTS.merge(config)
|
|
22
|
+
@values["enabled"] = default_enabled?(engine) unless config.key?("enabled")
|
|
23
|
+
validate!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h(engine:)
|
|
27
|
+
{
|
|
28
|
+
"enabled" => @values.fetch("enabled"),
|
|
29
|
+
"minChars" => @values.fetch("min_chars"),
|
|
30
|
+
"debounceMs" => debounce_ms(engine),
|
|
31
|
+
"updateUrl" => @values.fetch("update_url")
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def default_enabled?(engine)
|
|
38
|
+
engine != "semantic"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def debounce_ms(engine)
|
|
42
|
+
key = engine == "semantic" ? "semantic_debounce_ms" : "debounce_ms"
|
|
43
|
+
@values.fetch(key)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate!
|
|
47
|
+
validate_boolean!("enabled")
|
|
48
|
+
validate_boolean!("update_url")
|
|
49
|
+
validate_integer!("min_chars", minimum: 0)
|
|
50
|
+
validate_integer!("debounce_ms", minimum: 0)
|
|
51
|
+
validate_integer!("semantic_debounce_ms", minimum: 0)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_boolean!(key)
|
|
55
|
+
return if [true, false].include?(@values[key])
|
|
56
|
+
|
|
57
|
+
raise Jekyll::Errors::FatalException,
|
|
58
|
+
"client_search live_search.#{key} must be true or false"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_integer!(key, minimum:)
|
|
62
|
+
value = @values.fetch(key)
|
|
63
|
+
return if value.is_a?(Integer) && value >= minimum
|
|
64
|
+
|
|
65
|
+
raise Jekyll::Errors::FatalException,
|
|
66
|
+
"client_search live_search.#{key} must be an integer greater than or equal to #{minimum}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Generates embeddings via a local Ollama server using the +ollama-ruby+
|
|
6
|
+
# gem. The gem is required lazily so that users who do not enable
|
|
7
|
+
# embeddings never need to install it.
|
|
8
|
+
#
|
|
9
|
+
# Configuration:
|
|
10
|
+
# embedding:
|
|
11
|
+
# enabled: true
|
|
12
|
+
# model: all-minilm # or nomic-embed-text, bge-m3, etc.
|
|
13
|
+
# base_url: http://localhost:11434
|
|
14
|
+
class OllamaEmbeddingAdapter
|
|
15
|
+
attr_reader :model, :base_url, :connect_timeout, :read_timeout
|
|
16
|
+
|
|
17
|
+
def initialize(model:, base_url: "http://localhost:11434", connect_timeout: 5, read_timeout: 120)
|
|
18
|
+
@model = model
|
|
19
|
+
@base_url = base_url
|
|
20
|
+
@connect_timeout = connect_timeout
|
|
21
|
+
@read_timeout = read_timeout
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns a float vector for the given text. Raises a clear error if
|
|
25
|
+
# the ollama-ruby gem is not installed or the server is unreachable.
|
|
26
|
+
def embed(text)
|
|
27
|
+
embedding = client.embed(model: @model, input: text).embeddings&.first
|
|
28
|
+
return embedding if valid_embedding?(embedding)
|
|
29
|
+
|
|
30
|
+
Jekyll.logger.warn "ClientSearch:", "embedding response was empty or invalid"
|
|
31
|
+
nil
|
|
32
|
+
rescue LoadError, NameError
|
|
33
|
+
raise Jekyll::Errors::FatalException,
|
|
34
|
+
"Add gem \"ollama-ruby\" to your Gemfile to use embedding features"
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
Jekyll.logger.warn "ClientSearch:", "embedding failed for text: #{e.message}"
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def valid_embedding?(embedding)
|
|
43
|
+
embedding.is_a?(Array) && !embedding.empty? &&
|
|
44
|
+
embedding.all? { |value| value.is_a?(Numeric) && value.finite? }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def client
|
|
48
|
+
@client ||= begin
|
|
49
|
+
require "ollama"
|
|
50
|
+
Ollama::Client.new(
|
|
51
|
+
base_url: @base_url,
|
|
52
|
+
connect_timeout: @connect_timeout,
|
|
53
|
+
read_timeout: @read_timeout
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Normalizes browser query-embedder settings.
|
|
6
|
+
class QueryEmbedderConfiguration
|
|
7
|
+
TYPES = %w[transformers ollama_api none].freeze
|
|
8
|
+
|
|
9
|
+
MODEL_MAP = {
|
|
10
|
+
"embeddinggemma:300m" => "onnx-community/embeddinggemma-300m-ONNX",
|
|
11
|
+
"all-minilm" => "Xenova/all-MiniLM-L6-v2"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_LIBRARY_URL = "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1"
|
|
15
|
+
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
"type" => "transformers",
|
|
18
|
+
"library_url" => DEFAULT_LIBRARY_URL,
|
|
19
|
+
"dtype" => "q8",
|
|
20
|
+
"worker" => true,
|
|
21
|
+
"timeout_ms" => 300_000,
|
|
22
|
+
"retry_attempts" => 1,
|
|
23
|
+
"max_tokens" => 512
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
JSON_KEYS = {
|
|
27
|
+
"library_url" => "libraryUrl",
|
|
28
|
+
"model_base_url" => "modelBaseUrl",
|
|
29
|
+
"wasm_base_url" => "wasmBaseUrl",
|
|
30
|
+
"worker_url" => "workerUrl",
|
|
31
|
+
"device" => "device",
|
|
32
|
+
"dtype" => "dtype",
|
|
33
|
+
"worker" => "worker",
|
|
34
|
+
"timeout_ms" => "timeoutMs",
|
|
35
|
+
"retry_attempts" => "retryAttempts",
|
|
36
|
+
"max_tokens" => "maxTokens"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
def initialize(config, build_model:, build_base_url:, query_prefix:)
|
|
40
|
+
unless config.is_a?(Hash)
|
|
41
|
+
raise Jekyll::Errors::FatalException,
|
|
42
|
+
"client_search embedding.query_embedder configuration must be a mapping"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@values = DEFAULTS.merge(config)
|
|
46
|
+
@values["timeout_ms"] = 30_000 if @values["type"] == "ollama_api" && !config.key?("timeout_ms")
|
|
47
|
+
@build_model = build_model
|
|
48
|
+
@build_base_url = build_base_url
|
|
49
|
+
@query_prefix = query_prefix
|
|
50
|
+
validate!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def type
|
|
54
|
+
@values.fetch("type")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def model
|
|
58
|
+
return @values.fetch("model") if @values.key?("model")
|
|
59
|
+
return @build_model unless type == "transformers"
|
|
60
|
+
return MODEL_MAP.fetch(@build_model) if MODEL_MAP.key?(@build_model)
|
|
61
|
+
|
|
62
|
+
raise Jekyll::Errors::FatalException,
|
|
63
|
+
"embedding query_embedder.model is required for #{@build_model.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def api_url
|
|
67
|
+
@values.fetch("api_url", "#{@build_base_url}/api/embed")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def assets
|
|
71
|
+
case type
|
|
72
|
+
when "transformers"
|
|
73
|
+
files = ["assets/query-embedders/transformers.js"]
|
|
74
|
+
files << "assets/query-embedders/transformers-worker.js" if @values["worker"]
|
|
75
|
+
files
|
|
76
|
+
when "ollama_api"
|
|
77
|
+
["assets/query-embedders/ollama-api.js"]
|
|
78
|
+
else
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def asset
|
|
84
|
+
assets.first
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_json(*)
|
|
88
|
+
config = {
|
|
89
|
+
"type" => type,
|
|
90
|
+
"model" => model,
|
|
91
|
+
"apiUrl" => api_url,
|
|
92
|
+
"buildModel" => @build_model,
|
|
93
|
+
"queryPrefix" => @query_prefix
|
|
94
|
+
}
|
|
95
|
+
JSON_KEYS.each do |source, target|
|
|
96
|
+
config[target] = @values[source] if @values.key?(source)
|
|
97
|
+
end
|
|
98
|
+
config.to_json
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def validate!
|
|
104
|
+
unless TYPES.include?(type)
|
|
105
|
+
valid = TYPES.join(", ")
|
|
106
|
+
raise Jekyll::Errors::FatalException,
|
|
107
|
+
"embedding query_embedder type must be one of #{valid} (got #{type.inspect})"
|
|
108
|
+
end
|
|
109
|
+
validate_integer!("timeout_ms", minimum: 1)
|
|
110
|
+
validate_integer!("retry_attempts", minimum: 0)
|
|
111
|
+
validate_integer!("max_tokens", minimum: 1)
|
|
112
|
+
return if [true, false].include?(@values["worker"])
|
|
113
|
+
|
|
114
|
+
raise Jekyll::Errors::FatalException,
|
|
115
|
+
"embedding query_embedder.worker must be true or false"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_integer!(key, minimum:)
|
|
119
|
+
value = @values.fetch(key)
|
|
120
|
+
return if value.is_a?(Integer) && value >= minimum
|
|
121
|
+
|
|
122
|
+
raise Jekyll::Errors::FatalException,
|
|
123
|
+
"embedding query_embedder.#{key} must be an integer greater than or equal to #{minimum}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Builds deterministic related-article records from metadata and vectors.
|
|
6
|
+
class RelatedAnalyzer
|
|
7
|
+
def initialize(configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def analyze(documents)
|
|
12
|
+
relations = documents.each_with_index.to_h do |source, index|
|
|
13
|
+
candidates = documents.each_with_index.filter_map do |target, target_index|
|
|
14
|
+
next if index == target_index
|
|
15
|
+
|
|
16
|
+
build_relation(source, target)
|
|
17
|
+
end
|
|
18
|
+
[source.fetch("id"), sort_relations(candidates)]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
"version" => 1,
|
|
23
|
+
"minimum_similarity" => @configuration.minimum_similarity,
|
|
24
|
+
"relations" => relations
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_relation(source, target)
|
|
31
|
+
metadata = metadata_relation(source, target)
|
|
32
|
+
semantic_similarity = similarity(source["embedding"], target["embedding"])
|
|
33
|
+
return unless metadata || semantic_match?(semantic_similarity)
|
|
34
|
+
|
|
35
|
+
relation = base_relation(target, metadata, semantic_similarity)
|
|
36
|
+
add_semantic_relation(relation, semantic_similarity) if semantic_match?(semantic_similarity)
|
|
37
|
+
add_metadata_relation(relation, metadata)
|
|
38
|
+
relation
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def semantic_match?(similarity)
|
|
42
|
+
@configuration.semantic? && similarity && similarity >= @configuration.minimum_similarity
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def base_relation(target, metadata, semantic_similarity)
|
|
46
|
+
metadata_score = metadata ? metadata.fetch(:score) : 0.0
|
|
47
|
+
score = [metadata_score, semantic_similarity || 0.0].max
|
|
48
|
+
reasons = metadata ? metadata.fetch(:reasons).dup : []
|
|
49
|
+
reasons << "semantic-similarity" if semantic_match?(semantic_similarity)
|
|
50
|
+
{
|
|
51
|
+
"id" => target.fetch("id"),
|
|
52
|
+
"title" => target.fetch("title"),
|
|
53
|
+
"url" => target.fetch("url"),
|
|
54
|
+
"date" => target["date"],
|
|
55
|
+
"date_timestamp" => target["date_timestamp"],
|
|
56
|
+
"score" => score.round(6),
|
|
57
|
+
"reasons" => reasons.uniq.sort
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_semantic_relation(relation, similarity)
|
|
62
|
+
relation["semantic_similarity"] = similarity.round(6)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add_metadata_relation(relation, metadata)
|
|
66
|
+
return unless metadata
|
|
67
|
+
|
|
68
|
+
relation["shared_tags"] = metadata.fetch(:tags) if metadata.fetch(:tags).any?
|
|
69
|
+
relation["shared_categories"] = metadata.fetch(:categories) if metadata.fetch(:categories).any?
|
|
70
|
+
relation["shared_domains"] = metadata.fetch(:domains) if metadata.fetch(:domains).any?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def metadata_relation(source, target)
|
|
74
|
+
shared_tags = configured_intersection(source["tags"], target["tags"], @configuration.shared_tags?)
|
|
75
|
+
shared_categories = configured_intersection(
|
|
76
|
+
source["categories"], target["categories"], @configuration.same_category?
|
|
77
|
+
)
|
|
78
|
+
shared_domains = configured_intersection(
|
|
79
|
+
domain_paths(source), domain_paths(target), @configuration.include_parent_domains?
|
|
80
|
+
) - shared_categories
|
|
81
|
+
return if shared_tags.empty? && shared_categories.empty? && shared_domains.empty?
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
score: metadata_score(source, target, shared_tags, shared_categories, shared_domains),
|
|
85
|
+
tags: shared_tags,
|
|
86
|
+
categories: shared_categories,
|
|
87
|
+
domains: shared_domains,
|
|
88
|
+
reasons: metadata_reasons(shared_tags, shared_categories, shared_domains)
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def configured_intersection(first, second, enabled)
|
|
93
|
+
enabled ? intersection(first, second) : []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def metadata_score(source, target, tags, categories, domains)
|
|
97
|
+
tag_score = tags.empty? ? 0.0 : 0.25 * tags.length.to_f / union(source["tags"], target["tags"]).length
|
|
98
|
+
[tag_score + (categories.any? ? 0.5 : 0.0) + (domains.any? ? 0.25 : 0.0), 0.01].max
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def metadata_reasons(tags, categories, domains)
|
|
102
|
+
tags.map { |tag| "shared-tag: #{tag}" } +
|
|
103
|
+
categories.map { |category| "shared-category: #{category}" } +
|
|
104
|
+
domains.map { |domain| "shared-domain: #{domain}" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sort_relations(relations)
|
|
108
|
+
sorted = relations.sort_by do |relation|
|
|
109
|
+
[-relation.fetch("score"), relation.fetch("title"), relation.fetch("id")]
|
|
110
|
+
end
|
|
111
|
+
maximum = @configuration.max_items
|
|
112
|
+
maximum ? sorted.first(maximum) : sorted
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def similarity(first, second)
|
|
116
|
+
return unless valid_vector?(first)
|
|
117
|
+
return unless valid_vector?(second)
|
|
118
|
+
return unless first.length == second.length
|
|
119
|
+
|
|
120
|
+
dot = first.zip(second).sum { |a, b| a * b }
|
|
121
|
+
denominator = vector_norm(first) * vector_norm(second)
|
|
122
|
+
denominator.zero? ? 0.0 : dot / denominator
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def vector_norm(vector)
|
|
126
|
+
Math.sqrt(vector.sum { |value| value * value })
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def valid_vector?(vector)
|
|
130
|
+
return false unless vector.is_a?(Array)
|
|
131
|
+
return false if vector.empty?
|
|
132
|
+
|
|
133
|
+
vector.all? { |value| value.is_a?(Numeric) && value.finite? }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def intersection(first, second)
|
|
137
|
+
(Array(first).map(&:to_s) & Array(second).map(&:to_s)).sort
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def union(first, second)
|
|
141
|
+
(Array(first).map(&:to_s) | Array(second).map(&:to_s))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def domain_paths(document)
|
|
145
|
+
Array(document["categories"]).flat_map do |category|
|
|
146
|
+
parts = category.to_s.split("/").reject(&:empty?)
|
|
147
|
+
parts.each_index.map { |index| parts.first(index + 1).join("/") }
|
|
148
|
+
end.uniq
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Validates related-article output and matching rules.
|
|
6
|
+
class RelatedConfiguration
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
"enabled" => false,
|
|
9
|
+
"output" => "search-relations.json",
|
|
10
|
+
"same_category" => true,
|
|
11
|
+
"shared_tags" => true,
|
|
12
|
+
"include_parent_domains" => true,
|
|
13
|
+
"semantic" => true,
|
|
14
|
+
"minimum_similarity" => 0.55,
|
|
15
|
+
"max_items" => nil
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(config)
|
|
19
|
+
unless config.is_a?(Hash)
|
|
20
|
+
raise Jekyll::Errors::FatalException,
|
|
21
|
+
"client_search related configuration must be a mapping"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@values = DEFAULTS.merge(config)
|
|
25
|
+
validate!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def enabled?
|
|
29
|
+
@values.fetch("enabled") == true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def output
|
|
33
|
+
value = @values.fetch("output").to_s.sub(%r{\A/+}, "")
|
|
34
|
+
normalized = Pathname.new(value).cleanpath.to_s
|
|
35
|
+
return normalized unless value.empty? || normalized == "." || normalized.start_with?("../")
|
|
36
|
+
|
|
37
|
+
raise Jekyll::Errors::FatalException,
|
|
38
|
+
"client_search related output must be a relative file path inside the destination"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def same_category?
|
|
42
|
+
@values.fetch("same_category") == true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def shared_tags?
|
|
46
|
+
@values.fetch("shared_tags") == true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def include_parent_domains?
|
|
50
|
+
@values.fetch("include_parent_domains") == true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def semantic?
|
|
54
|
+
@values.fetch("semantic") == true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def minimum_similarity
|
|
58
|
+
@values.fetch("minimum_similarity").to_f
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def max_items
|
|
62
|
+
@values.fetch("max_items")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def validate!
|
|
68
|
+
validate_booleans!
|
|
69
|
+
validate_similarity!
|
|
70
|
+
validate_max_items!
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_booleans!
|
|
74
|
+
%w[enabled same_category shared_tags include_parent_domains semantic].each do |key|
|
|
75
|
+
validate_boolean!(key)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_similarity!
|
|
80
|
+
similarity = @values.fetch("minimum_similarity")
|
|
81
|
+
return if similarity.is_a?(Numeric) && similarity.finite? && similarity.between?(-1, 1)
|
|
82
|
+
|
|
83
|
+
raise Jekyll::Errors::FatalException,
|
|
84
|
+
"client_search related minimum_similarity must be finite and between -1 and 1"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_max_items!
|
|
88
|
+
maximum = max_items
|
|
89
|
+
return if maximum.nil? || (maximum.is_a?(Integer) && maximum.positive?)
|
|
90
|
+
|
|
91
|
+
raise Jekyll::Errors::FatalException,
|
|
92
|
+
"client_search related max_items must be a positive integer or null"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_boolean!(key)
|
|
96
|
+
return if [true, false].include?(@values[key])
|
|
97
|
+
|
|
98
|
+
raise Jekyll::Errors::FatalException,
|
|
99
|
+
"client_search related #{key} must be true or false"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Writes build-time related-article data as a standalone JSON page.
|
|
6
|
+
class RelatedPage < Jekyll::PageWithoutAFile
|
|
7
|
+
def initialize(site, output, relation_data)
|
|
8
|
+
super(site, site.source, File.dirname(output), File.basename(output))
|
|
9
|
+
self.data = { "layout" => nil, "sitemap" => false }
|
|
10
|
+
self.content = JSON.generate(relation_data)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Liquid tag that renders the related-articles container, sort control,
|
|
6
|
+
# and runtime scripts in one line. Drop into any post layout:
|
|
7
|
+
#
|
|
8
|
+
# {% related_articles %}
|
|
9
|
+
# {% related_articles sort:date %}
|
|
10
|
+
# {% related_articles no_scripts %}
|
|
11
|
+
#
|
|
12
|
+
# When +related.enabled+ is false the tag renders nothing, so it is safe
|
|
13
|
+
# to leave in a layout even when the feature is off.
|
|
14
|
+
class RelatedTag < Liquid::Tag
|
|
15
|
+
SYNTAX = /\A\s*(sort:(\w+))?\s*(no_scripts)?\s*\z/
|
|
16
|
+
|
|
17
|
+
def initialize(tag_name, markup, tokens)
|
|
18
|
+
super
|
|
19
|
+
@markup = markup.to_s
|
|
20
|
+
unless (match = @markup.match(SYNTAX))
|
|
21
|
+
raise Liquid::SyntaxError,
|
|
22
|
+
"related_articles: invalid syntax. Use {% related_articles %}, " \
|
|
23
|
+
"{% related_articles sort:date %}, or {% related_articles no_scripts %}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@sort = match[2] if match[2]
|
|
27
|
+
@include_scripts = match[3].nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def render(context)
|
|
31
|
+
site = context.registers[:site]
|
|
32
|
+
config = site&.config&.fetch("client_search", {})
|
|
33
|
+
return "" unless related_enabled?(config)
|
|
34
|
+
|
|
35
|
+
asset_prefix = asset_prefix(site)
|
|
36
|
+
sort_attr = @sort ? " data-related-sort=\"#{@sort}\"" : ""
|
|
37
|
+
scripts = build_scripts(asset_prefix)
|
|
38
|
+
build_html(sort_attr, scripts)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def related_enabled?(config)
|
|
44
|
+
related = config.fetch("related", {})
|
|
45
|
+
related["enabled"] == true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def asset_prefix(site)
|
|
49
|
+
baseurl = site.config["baseurl"].to_s.gsub(%r{\A/+|/+$}, "")
|
|
50
|
+
baseurl.empty? ? "" : "/#{baseurl}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_scripts(prefix)
|
|
54
|
+
return "" unless @include_scripts
|
|
55
|
+
|
|
56
|
+
"\n<script src=\"#{prefix}/assets/search-runtime-config.js\"></script>\n" \
|
|
57
|
+
"<script src=\"#{prefix}/assets/client-search-related.js\"></script>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_html(sort_attr, scripts)
|
|
61
|
+
<<~HTML
|
|
62
|
+
<section class="related-articles-section">
|
|
63
|
+
<label for="related-sort">Sort related articles</label>
|
|
64
|
+
<select id="related-sort">
|
|
65
|
+
<option value="relevance">Most related</option>
|
|
66
|
+
<option value="date">Newest</option>
|
|
67
|
+
</select>
|
|
68
|
+
<div id="related-articles"#{sort_attr}></div>
|
|
69
|
+
</section>#{scripts}
|
|
70
|
+
HTML
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Liquid::Template.register_tag("related_articles", Jekyll::ClientSearch::RelatedTag)
|