jekyll-client-search 0.3.4 → 0.4.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 +4 -4
- data/CHANGELOG.md +109 -1
- data/LICENSE +654 -15
- data/README.developer.md +37 -15
- data/README.md +195 -30
- data/assets/includes/related-articles.html +4 -2
- data/assets/query-embedders/ollama-api.js +1 -1
- data/lib/jekyll/client_search/configuration.rb +11 -10
- data/lib/jekyll/client_search/configuration_accessors.rb +13 -0
- data/lib/jekyll/client_search/dropdown_tag.rb +24 -41
- data/lib/jekyll/client_search/embedding_configuration.rb +51 -15
- data/lib/jekyll/client_search/generator.rb +66 -19
- data/lib/jekyll/client_search/index_cache.rb +3 -2
- data/lib/jekyll/client_search/ollama_embedding_adapter.rb +47 -1
- data/lib/jekyll/client_search/query_embedder_configuration.rb +3 -0
- data/lib/jekyll/client_search/related_analyzer.rb +4 -1
- data/lib/jekyll/client_search/related_configuration.rb +15 -0
- data/lib/jekyll/client_search/related_tag.rb +23 -17
- data/lib/jekyll/client_search/script_tags.rb +41 -0
- data/lib/jekyll/client_search/search_tag.rb +20 -34
- data/lib/jekyll/client_search/semantic_configuration.rb +46 -0
- data/lib/jekyll/client_search/version.rb +1 -1
- data/lib/jekyll/client_search.rb +2 -0
- metadata +4 -2
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cgi"
|
|
4
|
-
|
|
5
3
|
module Jekyll
|
|
6
4
|
module ClientSearch
|
|
7
5
|
# Liquid tag that renders a compact live-search dropdown suitable for
|
|
@@ -15,27 +13,22 @@ module Jekyll
|
|
|
15
13
|
#
|
|
16
14
|
# When client_search is disabled the tag renders nothing.
|
|
17
15
|
class DropdownTag < Liquid::Tag
|
|
16
|
+
include ScriptTags
|
|
17
|
+
|
|
18
18
|
SYNTAX = /\A(max:(\d+))?\s*(scripts_only|no_scripts)?\z/
|
|
19
19
|
|
|
20
20
|
def initialize(tag_name, markup, tokens)
|
|
21
21
|
super
|
|
22
|
+
# No validation here: `{% comment %}` still parses nested tags, so
|
|
23
|
+
# `initialize` runs on commented-out tags. Validate in `render` —
|
|
24
|
+
# a bad tag inside a comment must not break the build.
|
|
22
25
|
@markup = markup.to_s.strip
|
|
23
|
-
unless (match = @markup.match(SYNTAX))
|
|
24
|
-
raise Liquid::SyntaxError,
|
|
25
|
-
"search_dropdown: invalid syntax. Use {% search_dropdown %}, " \
|
|
26
|
-
"{% search_dropdown max:10 %}, {% search_dropdown scripts_only %}, " \
|
|
27
|
-
"or {% search_dropdown no_scripts %}"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
@max_items = match[2].to_i if match[2]
|
|
31
|
-
raise Liquid::SyntaxError, "search_dropdown: max must be greater than zero" if @max_items && @max_items < 1
|
|
32
|
-
|
|
33
|
-
@mode = match[3] || "full"
|
|
34
26
|
@input_id = "cs-dropdown-input-#{object_id}"
|
|
35
27
|
@results_id = "cs-dropdown-results-#{object_id}"
|
|
36
28
|
end
|
|
37
29
|
|
|
38
30
|
def render(context)
|
|
31
|
+
validate_markup!
|
|
39
32
|
site = context.registers[:site]
|
|
40
33
|
return "" unless enabled?(site)
|
|
41
34
|
|
|
@@ -47,6 +40,23 @@ module Jekyll
|
|
|
47
40
|
|
|
48
41
|
private
|
|
49
42
|
|
|
43
|
+
def validate_markup!
|
|
44
|
+
return if @validated
|
|
45
|
+
|
|
46
|
+
@validated = true
|
|
47
|
+
unless (match = @markup.match(SYNTAX))
|
|
48
|
+
raise Liquid::SyntaxError,
|
|
49
|
+
"search_dropdown: invalid syntax. Use {% search_dropdown %}, " \
|
|
50
|
+
"{% search_dropdown max:10 %}, {% search_dropdown scripts_only %}, " \
|
|
51
|
+
"or {% search_dropdown no_scripts %}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@max_items = match[2].to_i if match[2]
|
|
55
|
+
raise Liquid::SyntaxError, "search_dropdown: max must be greater than zero" if @max_items && @max_items < 1
|
|
56
|
+
|
|
57
|
+
@mode = match[3] || "full"
|
|
58
|
+
end
|
|
59
|
+
|
|
50
60
|
def enabled?(site)
|
|
51
61
|
return false if site.nil?
|
|
52
62
|
|
|
@@ -62,7 +72,7 @@ module Jekyll
|
|
|
62
72
|
form_html = build_form(max)
|
|
63
73
|
return form_html if @mode == "no_scripts"
|
|
64
74
|
|
|
65
|
-
scripts =
|
|
75
|
+
scripts = script_tags(configuration, prefix, "client-search-dropdown.js")
|
|
66
76
|
return scripts if @mode == "scripts_only"
|
|
67
77
|
|
|
68
78
|
"#{form_html}\n#{scripts}"
|
|
@@ -87,33 +97,6 @@ module Jekyll
|
|
|
87
97
|
</div>
|
|
88
98
|
HTML
|
|
89
99
|
end
|
|
90
|
-
|
|
91
|
-
def build_scripts(configuration, prefix)
|
|
92
|
-
scripts = []
|
|
93
|
-
engine = engine_script(configuration)
|
|
94
|
-
scripts << engine if engine
|
|
95
|
-
scripts << "<script src=\"#{prefix}/assets/search-runtime-config.js\"></script>"
|
|
96
|
-
scripts << "<script src=\"#{prefix}/assets/client-search-shared.js\"></script>"
|
|
97
|
-
scripts << "<script src=\"#{prefix}/assets/client-search-dropdown.js\"></script>"
|
|
98
|
-
scripts << "<script src=\"#{prefix}/assets/adapters/#{configuration.engine}.js\"></script>"
|
|
99
|
-
scripts.map { |script| " #{script}" }.join("\n")
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def engine_script(configuration)
|
|
103
|
-
url = configuration.engine_url
|
|
104
|
-
return nil unless url
|
|
105
|
-
|
|
106
|
-
attrs = ["src=\"#{CGI.escapeHTML(url)}\""]
|
|
107
|
-
if configuration.engine_crossorigin
|
|
108
|
-
crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
|
|
109
|
-
attrs << "crossorigin=\"#{crossorigin}\""
|
|
110
|
-
end
|
|
111
|
-
if configuration.engine_sri
|
|
112
|
-
integrity = CGI.escapeHTML(configuration.engine_sri)
|
|
113
|
-
attrs << "integrity=\"#{integrity}\""
|
|
114
|
-
end
|
|
115
|
-
"<script #{attrs.join(' ')}></script>"
|
|
116
|
-
end
|
|
117
100
|
end
|
|
118
101
|
end
|
|
119
102
|
end
|
|
@@ -9,9 +9,25 @@ module Jekyll
|
|
|
9
9
|
"document" => "title: none | text: ",
|
|
10
10
|
"query" => "task: search result | query: "
|
|
11
11
|
},
|
|
12
|
+
"embeddinggemma-2" => {
|
|
13
|
+
"document" => "title: none | text: ",
|
|
14
|
+
"query" => "task: search result | query: "
|
|
15
|
+
},
|
|
12
16
|
"nomic-embed-text" => {
|
|
13
17
|
"document" => "search_document: ",
|
|
14
18
|
"query" => "search_query: "
|
|
19
|
+
},
|
|
20
|
+
"voyage-4-nano" => {
|
|
21
|
+
"document" => "Represent the document for retrieval: ",
|
|
22
|
+
"query" => "Represent the query for retrieving supporting documents: "
|
|
23
|
+
},
|
|
24
|
+
"qwen3-embedding" => {
|
|
25
|
+
"document" => "",
|
|
26
|
+
"query" => "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: "
|
|
27
|
+
},
|
|
28
|
+
"bge-large" => {
|
|
29
|
+
"document" => "",
|
|
30
|
+
"query" => "Represent this sentence for searching relevant passages: "
|
|
15
31
|
}
|
|
16
32
|
}.freeze
|
|
17
33
|
|
|
@@ -44,43 +60,63 @@ module Jekyll
|
|
|
44
60
|
configured.nil? ? engine == "semantic" : configured == true
|
|
45
61
|
end
|
|
46
62
|
|
|
63
|
+
# Document prefix for the index model (semantic.index_model).
|
|
47
64
|
def embedding_document_prefix
|
|
48
|
-
configured_prefix("
|
|
65
|
+
configured_prefix("document", model: embedding_index_model, override: @semantic.document_prefix)
|
|
49
66
|
end
|
|
50
67
|
|
|
68
|
+
# Query prefix for the index model — must match the browser query
|
|
69
|
+
# embedder's vector space.
|
|
51
70
|
def embedding_query_prefix
|
|
52
|
-
configured_prefix("
|
|
71
|
+
configured_prefix("query", model: embedding_index_model, override: @semantic.query_prefix)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Document prefix for the related-analysis model (related.index_model).
|
|
75
|
+
def embedding_related_document_prefix
|
|
76
|
+
configured_prefix("document", model: embedding_related_model)
|
|
53
77
|
end
|
|
54
78
|
|
|
55
79
|
def embedding_identity
|
|
80
|
+
embedding_identity_for(model: embedding_index_model,
|
|
81
|
+
document_prefix: embedding_document_prefix,
|
|
82
|
+
query_prefix: embedding_query_prefix)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def embedding_related_identity
|
|
86
|
+
embedding_identity_for(model: embedding_related_model,
|
|
87
|
+
document_prefix: embedding_related_document_prefix,
|
|
88
|
+
query_prefix: configured_prefix("query", model: embedding_related_model))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def embedding_identity_for(model:, document_prefix:, query_prefix:)
|
|
56
94
|
{
|
|
57
95
|
"provider" => "ollama",
|
|
58
|
-
"model" =>
|
|
96
|
+
"model" => model,
|
|
59
97
|
"base_url" => embedding_base_url,
|
|
60
|
-
"document_prefix" =>
|
|
61
|
-
"query_prefix" =>
|
|
98
|
+
"document_prefix" => document_prefix,
|
|
99
|
+
"query_prefix" => query_prefix,
|
|
62
100
|
"schema" => 2
|
|
63
101
|
}
|
|
64
102
|
end
|
|
65
103
|
|
|
66
|
-
|
|
104
|
+
def configured_prefix(role, model:, override: nil)
|
|
105
|
+
return override.to_s if override
|
|
67
106
|
|
|
68
|
-
def configured_prefix(config_key, role)
|
|
69
107
|
embedding = @values.fetch("embedding")
|
|
70
|
-
|
|
108
|
+
config_key = "#{role}_prefix"
|
|
109
|
+
return embedding[config_key].to_s if model == embedding_model && embedding.key?(config_key)
|
|
71
110
|
|
|
72
|
-
|
|
73
|
-
PREFIXES.fetch(model_name, {}).fetch(role, "")
|
|
111
|
+
PREFIXES.fetch(model.split(":").first, {}).fetch(role, "")
|
|
74
112
|
end
|
|
75
113
|
|
|
76
114
|
def positive_timeout(key)
|
|
77
115
|
value = @values.fetch("embedding").fetch(key).to_f
|
|
78
|
-
|
|
79
|
-
raise Jekyll::Errors::FatalException,
|
|
80
|
-
"embedding #{key} must be a finite value greater than zero"
|
|
81
|
-
end
|
|
116
|
+
return value if value.positive? && value.finite?
|
|
82
117
|
|
|
83
|
-
|
|
118
|
+
raise Jekyll::Errors::FatalException,
|
|
119
|
+
"embedding #{key} must be a finite value greater than zero"
|
|
84
120
|
end
|
|
85
121
|
|
|
86
122
|
def validate_embedding!
|
|
@@ -25,6 +25,7 @@ module Jekyll
|
|
|
25
25
|
|
|
26
26
|
def add_generated_pages(site, documents, configuration)
|
|
27
27
|
add_related_page(site, documents, configuration) if configuration.related_enabled?
|
|
28
|
+
documents.each { |document| document.delete("related_embedding") }
|
|
28
29
|
remove_embeddings(documents) unless configuration.embedding_include_in_index?
|
|
29
30
|
site.pages << SearchIndexPage.new(site, configuration.output, documents)
|
|
30
31
|
site.pages << RuntimeConfigPage.new(site, configuration) if configuration.copy_runtime?
|
|
@@ -39,18 +40,60 @@ module Jekyll
|
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def enhance_with_embeddings(site, documents, configuration)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
embedding_jobs(configuration).each do |job|
|
|
44
|
+
cache = IndexCache.new(site.source, embedding_identity: job[:identity],
|
|
45
|
+
file_name: job[:cache_file])
|
|
46
|
+
adapter = build_embedding_adapter(configuration, model: job[:model])
|
|
47
|
+
|
|
48
|
+
documents.each { |entry| embed_document(entry, cache, adapter, configuration, job) }
|
|
49
|
+
cache.prune(documents.map { |doc| doc["id"] })
|
|
50
|
+
cache.save
|
|
51
|
+
end
|
|
48
52
|
documents
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
# Each job embeds every document once per model into a target field.
|
|
56
|
+
# The index model writes "embedding" (kept when include_in_index); a
|
|
57
|
+
# divergent related model writes "related_embedding" (stripped before
|
|
58
|
+
# the index is emitted). Identical models share a single pass.
|
|
59
|
+
def embedding_jobs(configuration)
|
|
60
|
+
jobs = []
|
|
61
|
+
jobs << index_embedding_job(configuration) if configuration.embedding_include_in_index?
|
|
62
|
+
jobs.concat(related_embedding_jobs(configuration)) if configuration.related_enabled?
|
|
63
|
+
jobs
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def index_embedding_job(configuration)
|
|
67
|
+
{
|
|
68
|
+
model: configuration.embedding_index_model,
|
|
69
|
+
field: "embedding",
|
|
70
|
+
document_prefix: configuration.embedding_document_prefix,
|
|
71
|
+
identity: configuration.embedding_identity,
|
|
72
|
+
cache_file: IndexCache::CACHE_FILE
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def related_embedding_jobs(configuration)
|
|
77
|
+
return [related_embedding_job("embedding", IndexCache::CACHE_FILE, configuration)] unless
|
|
78
|
+
configuration.embedding_include_in_index?
|
|
79
|
+
return [] if configuration.embedding_related_model == configuration.embedding_index_model
|
|
80
|
+
|
|
81
|
+
[related_embedding_job("related_embedding", IndexCache::RELATED_CACHE_FILE, configuration)]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def related_embedding_job(field, cache_file, configuration)
|
|
85
|
+
{
|
|
86
|
+
model: configuration.embedding_related_model,
|
|
87
|
+
field: field,
|
|
88
|
+
document_prefix: configuration.embedding_related_document_prefix,
|
|
89
|
+
identity: configuration.embedding_related_identity,
|
|
90
|
+
cache_file: cache_file
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_embedding_adapter(configuration, model: configuration.embedding_model)
|
|
52
95
|
OllamaEmbeddingAdapter.new(
|
|
53
|
-
model:
|
|
96
|
+
model: model,
|
|
54
97
|
base_url: configuration.embedding_base_url,
|
|
55
98
|
connect_timeout: configuration.embedding_connect_timeout,
|
|
56
99
|
read_timeout: configuration.embedding_read_timeout
|
|
@@ -66,17 +109,13 @@ module Jekyll
|
|
|
66
109
|
documents.each { |document| document.delete("embedding") }
|
|
67
110
|
end
|
|
68
111
|
|
|
69
|
-
def embed_document(document, cache, adapter, configuration)
|
|
70
|
-
hash = IndexCache.content_hash(document)
|
|
71
|
-
|
|
72
|
-
if cached && cached["embedding"]
|
|
73
|
-
document["embedding"] = cached["embedding"]
|
|
74
|
-
return
|
|
75
|
-
end
|
|
112
|
+
def embed_document(document, cache, adapter, configuration, job)
|
|
113
|
+
hash = IndexCache.content_hash(document.except("embedding", "related_embedding"))
|
|
114
|
+
return if embedding_restored?(document, cache, hash, job[:field])
|
|
76
115
|
|
|
77
|
-
embedding = adapter.embed(embedding_text(document,
|
|
116
|
+
embedding = adapter.embed(embedding_text(document, job[:document_prefix]))
|
|
78
117
|
if embedding
|
|
79
|
-
document[
|
|
118
|
+
document[job[:field]] = embedding
|
|
80
119
|
cache.store(document["id"], hash, embedding)
|
|
81
120
|
elsif configuration.embedding_fail_on_error?
|
|
82
121
|
raise Jekyll::Errors::FatalException,
|
|
@@ -84,9 +123,17 @@ module Jekyll
|
|
|
84
123
|
end
|
|
85
124
|
end
|
|
86
125
|
|
|
87
|
-
def
|
|
126
|
+
def embedding_restored?(document, cache, hash, field)
|
|
127
|
+
cached = cache.lookup(document["id"], hash)
|
|
128
|
+
return false unless cached && cached["embedding"]
|
|
129
|
+
|
|
130
|
+
document[field] = cached["embedding"]
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def embedding_text(document, document_prefix)
|
|
88
135
|
text = [document["title"], document["excerpt"], document["content"]].compact.join(" ")
|
|
89
|
-
"#{
|
|
136
|
+
"#{document_prefix}#{text}"
|
|
90
137
|
end
|
|
91
138
|
|
|
92
139
|
def collection_documents(site, configuration, builder)
|
|
@@ -12,11 +12,12 @@ module Jekyll
|
|
|
12
12
|
# should be git-ignored.
|
|
13
13
|
class IndexCache
|
|
14
14
|
CACHE_FILE = ".jekyll-client-search-cache.json"
|
|
15
|
+
RELATED_CACHE_FILE = ".jekyll-client-search-related-cache.json"
|
|
15
16
|
|
|
16
17
|
attr_reader :path
|
|
17
18
|
|
|
18
|
-
def initialize(site_source, embedding_identity: nil)
|
|
19
|
-
@path = File.join(site_source,
|
|
19
|
+
def initialize(site_source, embedding_identity: nil, file_name: CACHE_FILE)
|
|
20
|
+
@path = File.join(site_source, file_name)
|
|
20
21
|
@embedding_identity = embedding_identity
|
|
21
22
|
@entries = load
|
|
22
23
|
end
|
|
@@ -11,6 +11,11 @@ module Jekyll
|
|
|
11
11
|
# enabled: true
|
|
12
12
|
# model: all-minilm # or nomic-embed-text, bge-m3, etc.
|
|
13
13
|
# base_url: http://localhost:11434
|
|
14
|
+
#
|
|
15
|
+
# A +localhost+ base_url is resolved once to the loopback literal
|
|
16
|
+
# (127.0.0.1 or ::1) that accepts a TCP connection, because Excon
|
|
17
|
+
# does not fall back across address families when the DNS name maps
|
|
18
|
+
# to a family the server is not listening on.
|
|
14
19
|
class OllamaEmbeddingAdapter
|
|
15
20
|
attr_reader :model, :base_url, :connect_timeout, :read_timeout
|
|
16
21
|
|
|
@@ -48,12 +53,53 @@ module Jekyll
|
|
|
48
53
|
@client ||= begin
|
|
49
54
|
require "ollama"
|
|
50
55
|
Ollama::Client.new(
|
|
51
|
-
base_url:
|
|
56
|
+
base_url: resolved_base_url,
|
|
52
57
|
connect_timeout: @connect_timeout,
|
|
53
58
|
read_timeout: @read_timeout
|
|
54
59
|
)
|
|
55
60
|
end
|
|
56
61
|
end
|
|
62
|
+
|
|
63
|
+
# "localhost" may resolve to ::1 while the server only listens on
|
|
64
|
+
# 127.0.0.1, or the reverse. Excon does not fall back across
|
|
65
|
+
# address families, so probe both loopback literals once and use
|
|
66
|
+
# the one that accepts a TCP connection. Any other host name is
|
|
67
|
+
# used exactly as configured.
|
|
68
|
+
def resolved_base_url
|
|
69
|
+
@resolved_base_url ||= resolve_localhost(@base_url)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resolve_localhost(base_url)
|
|
73
|
+
require "uri"
|
|
74
|
+
uri = URI.parse(base_url)
|
|
75
|
+
return base_url unless uri.host.to_s.chomp(".").casecmp("localhost").zero?
|
|
76
|
+
|
|
77
|
+
resolved = loopback_base_url(uri)
|
|
78
|
+
return base_url unless resolved
|
|
79
|
+
|
|
80
|
+
Jekyll.logger.info "ClientSearch:", "resolved localhost to #{resolved}"
|
|
81
|
+
resolved
|
|
82
|
+
rescue URI::InvalidURIError
|
|
83
|
+
base_url
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def loopback_base_url(uri)
|
|
87
|
+
%w[127.0.0.1 ::1].each do |address|
|
|
88
|
+
next unless tcp_reachable?(address, uri.port)
|
|
89
|
+
|
|
90
|
+
resolved = uri.dup
|
|
91
|
+
resolved.host = address.include?(":") ? "[#{address}]" : address
|
|
92
|
+
return resolved.to_s
|
|
93
|
+
end
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def tcp_reachable?(address, port)
|
|
98
|
+
require "socket"
|
|
99
|
+
Socket.tcp(address, port, connect_timeout: @connect_timeout) { true }
|
|
100
|
+
rescue SystemCallError, SocketError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
57
103
|
end
|
|
58
104
|
end
|
|
59
105
|
end
|
|
@@ -8,6 +8,9 @@ module Jekyll
|
|
|
8
8
|
|
|
9
9
|
MODEL_MAP = {
|
|
10
10
|
"embeddinggemma:300m" => "onnx-community/embeddinggemma-300m-ONNX",
|
|
11
|
+
"embeddinggemma-2" => "onnx-community/embeddinggemma-2-ONNX",
|
|
12
|
+
"embeddinggemma-2:270m" => "onnx-community/embeddinggemma-2-ONNX",
|
|
13
|
+
"embeddinggemma-2:570m" => "onnx-community/embeddinggemma-2-ONNX",
|
|
11
14
|
"all-minilm" => "Xenova/all-MiniLM-L6-v2"
|
|
12
15
|
}.freeze
|
|
13
16
|
|
|
@@ -29,7 +29,10 @@ module Jekyll
|
|
|
29
29
|
|
|
30
30
|
def build_relation(source, target)
|
|
31
31
|
metadata = metadata_relation(source, target)
|
|
32
|
-
semantic_similarity = similarity(
|
|
32
|
+
semantic_similarity = similarity(
|
|
33
|
+
source["related_embedding"] || source["embedding"],
|
|
34
|
+
target["related_embedding"] || target["embedding"]
|
|
35
|
+
)
|
|
33
36
|
return unless metadata || semantic_match?(semantic_similarity)
|
|
34
37
|
|
|
35
38
|
relation = base_relation(target, metadata, semantic_similarity)
|
|
@@ -62,12 +62,27 @@ module Jekyll
|
|
|
62
62
|
@values.fetch("max_items")
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Ollama model used for related-article similarity analysis. nil means
|
|
66
|
+
# the site uses the general embedding.model.
|
|
67
|
+
def index_model
|
|
68
|
+
value = @values["index_model"]
|
|
69
|
+
value&.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
65
72
|
private
|
|
66
73
|
|
|
67
74
|
def validate!
|
|
68
75
|
validate_booleans!
|
|
69
76
|
validate_similarity!
|
|
70
77
|
validate_max_items!
|
|
78
|
+
validate_index_model!
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_index_model!
|
|
82
|
+
return if index_model.nil? || !index_model.empty?
|
|
83
|
+
|
|
84
|
+
raise Jekyll::Errors::FatalException,
|
|
85
|
+
"client_search related index_model must not be empty"
|
|
71
86
|
end
|
|
72
87
|
|
|
73
88
|
def validate_booleans!
|
|
@@ -18,11 +18,34 @@ module Jekyll
|
|
|
18
18
|
|
|
19
19
|
def initialize(tag_name, markup, tokens)
|
|
20
20
|
super
|
|
21
|
+
# No validation here: `{% comment %}` still parses nested tags, so
|
|
22
|
+
# `initialize` runs on commented-out tags. Validate in `render` —
|
|
23
|
+
# a bad tag inside a comment must not break the build.
|
|
21
24
|
@markup = markup.to_s.strip
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render(context)
|
|
22
28
|
parse_tokens(@markup)
|
|
29
|
+
site = context.registers[:site]
|
|
30
|
+
return "" if site.nil?
|
|
31
|
+
|
|
32
|
+
config = site.config.fetch("client_search", {})
|
|
33
|
+
return "" if config == false
|
|
34
|
+
return "" unless related_enabled?(config)
|
|
35
|
+
|
|
36
|
+
asset_prefix = asset_prefix(site)
|
|
37
|
+
sort_attr = @sort ? " data-related-sort=\"#{@sort}\"" : ""
|
|
38
|
+
max_attr = resolve_max_attr(config)
|
|
39
|
+
scripts = build_scripts(asset_prefix)
|
|
40
|
+
build_html(sort_attr, max_attr, scripts)
|
|
23
41
|
end
|
|
24
42
|
|
|
43
|
+
private
|
|
44
|
+
|
|
25
45
|
def parse_tokens(markup)
|
|
46
|
+
return if @parsed
|
|
47
|
+
|
|
48
|
+
@parsed = true
|
|
26
49
|
@sort = nil
|
|
27
50
|
@max_items = nil
|
|
28
51
|
@include_scripts = true
|
|
@@ -49,23 +72,6 @@ module Jekyll
|
|
|
49
72
|
"or {% related_articles no_scripts %}"
|
|
50
73
|
end
|
|
51
74
|
|
|
52
|
-
def render(context)
|
|
53
|
-
site = context.registers[:site]
|
|
54
|
-
return "" if site.nil?
|
|
55
|
-
|
|
56
|
-
config = site.config.fetch("client_search", {})
|
|
57
|
-
return "" if config == false
|
|
58
|
-
return "" unless related_enabled?(config)
|
|
59
|
-
|
|
60
|
-
asset_prefix = asset_prefix(site)
|
|
61
|
-
sort_attr = @sort ? " data-related-sort=\"#{@sort}\"" : ""
|
|
62
|
-
max_attr = resolve_max_attr(config)
|
|
63
|
-
scripts = build_scripts(asset_prefix)
|
|
64
|
-
build_html(sort_attr, max_attr, scripts)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
private
|
|
68
|
-
|
|
69
75
|
def related_enabled?(config)
|
|
70
76
|
related = config.fetch("related", {})
|
|
71
77
|
related["enabled"] == true
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module ClientSearch
|
|
7
|
+
# Shared <script> tag assembly for the client-search Liquid tags so each
|
|
8
|
+
# tag only declares its own asset file.
|
|
9
|
+
module ScriptTags
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def script_tags(configuration, prefix, tag_asset, extra: [])
|
|
13
|
+
scripts = []
|
|
14
|
+
engine = engine_script(configuration)
|
|
15
|
+
scripts << engine if engine
|
|
16
|
+
scripts << "<script src=\"#{prefix}/assets/search-runtime-config.js\"></script>"
|
|
17
|
+
scripts.concat(extra)
|
|
18
|
+
scripts << "<script src=\"#{prefix}/assets/client-search-shared.js\"></script>"
|
|
19
|
+
scripts << "<script src=\"#{prefix}/assets/#{tag_asset}\"></script>"
|
|
20
|
+
scripts << "<script src=\"#{prefix}/assets/adapters/#{configuration.engine}.js\"></script>"
|
|
21
|
+
scripts.map { |script| " #{script}" }.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def engine_script(configuration)
|
|
25
|
+
url = configuration.engine_url
|
|
26
|
+
return nil unless url
|
|
27
|
+
|
|
28
|
+
attrs = ["src=\"#{CGI.escapeHTML(url)}\""]
|
|
29
|
+
if configuration.engine_crossorigin
|
|
30
|
+
crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
|
|
31
|
+
attrs << "crossorigin=\"#{crossorigin}\""
|
|
32
|
+
end
|
|
33
|
+
if configuration.engine_sri
|
|
34
|
+
integrity = CGI.escapeHTML(configuration.engine_sri)
|
|
35
|
+
attrs << "integrity=\"#{integrity}\""
|
|
36
|
+
end
|
|
37
|
+
"<script #{attrs.join(' ')}></script>"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cgi"
|
|
4
|
-
|
|
5
3
|
module Jekyll
|
|
6
4
|
module ClientSearch
|
|
7
5
|
# Liquid tag that renders the search form, status/results containers,
|
|
@@ -14,21 +12,20 @@ module Jekyll
|
|
|
14
12
|
#
|
|
15
13
|
# When client_search is disabled the tag renders nothing.
|
|
16
14
|
class SearchTag < Liquid::Tag
|
|
15
|
+
include ScriptTags
|
|
16
|
+
|
|
17
17
|
SYNTAX = /\A(scripts_only|no_scripts)?\z/
|
|
18
18
|
|
|
19
19
|
def initialize(tag_name, markup, tokens)
|
|
20
20
|
super
|
|
21
|
+
# No validation here: `{% comment %}` still parses nested tags, so
|
|
22
|
+
# `initialize` runs on commented-out tags. Validate in `render` —
|
|
23
|
+
# a bad tag inside a comment must not break the build.
|
|
21
24
|
@markup = markup.to_s.strip
|
|
22
|
-
unless (match = @markup.match(SYNTAX))
|
|
23
|
-
raise Liquid::SyntaxError,
|
|
24
|
-
"search_form: invalid syntax. Use {% search_form %}, " \
|
|
25
|
-
"{% search_form scripts_only %}, or {% search_form no_scripts %}"
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
@mode = match[1] || "full"
|
|
29
25
|
end
|
|
30
26
|
|
|
31
27
|
def render(context)
|
|
28
|
+
validate_markup!
|
|
32
29
|
site = context.registers[:site]
|
|
33
30
|
return "" if site.nil?
|
|
34
31
|
|
|
@@ -51,6 +48,18 @@ module Jekyll
|
|
|
51
48
|
|
|
52
49
|
private
|
|
53
50
|
|
|
51
|
+
def validate_markup!
|
|
52
|
+
return if @mode
|
|
53
|
+
|
|
54
|
+
unless (match = @markup.match(SYNTAX))
|
|
55
|
+
raise Liquid::SyntaxError,
|
|
56
|
+
"search_form: invalid syntax. Use {% search_form %}, " \
|
|
57
|
+
"{% search_form scripts_only %}, or {% search_form no_scripts %}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@mode = match[1] || "full"
|
|
61
|
+
end
|
|
62
|
+
|
|
54
63
|
def build_form
|
|
55
64
|
<<~HTML
|
|
56
65
|
<form id="search-form" role="search">
|
|
@@ -64,31 +73,8 @@ module Jekyll
|
|
|
64
73
|
end
|
|
65
74
|
|
|
66
75
|
def build_scripts(configuration, prefix)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
scripts << engine if engine
|
|
70
|
-
scripts << "<script src=\"#{prefix}/assets/search-runtime-config.js\"></script>"
|
|
71
|
-
scripts.concat(embedder_scripts(configuration, prefix))
|
|
72
|
-
scripts << "<script src=\"#{prefix}/assets/client-search-shared.js\"></script>"
|
|
73
|
-
scripts << "<script src=\"#{prefix}/assets/client-search-base.js\"></script>"
|
|
74
|
-
scripts << "<script src=\"#{prefix}/assets/adapters/#{configuration.engine}.js\"></script>"
|
|
75
|
-
scripts.map { |script| " #{script}" }.join("\n")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def engine_script(configuration)
|
|
79
|
-
url = configuration.engine_url
|
|
80
|
-
return nil unless url
|
|
81
|
-
|
|
82
|
-
attrs = ["src=\"#{CGI.escapeHTML(url)}\""]
|
|
83
|
-
if configuration.engine_crossorigin
|
|
84
|
-
crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
|
|
85
|
-
attrs << "crossorigin=\"#{crossorigin}\""
|
|
86
|
-
end
|
|
87
|
-
if configuration.engine_sri
|
|
88
|
-
integrity = CGI.escapeHTML(configuration.engine_sri)
|
|
89
|
-
attrs << "integrity=\"#{integrity}\""
|
|
90
|
-
end
|
|
91
|
-
"<script #{attrs.join(' ')}></script>"
|
|
76
|
+
script_tags(configuration, prefix, "client-search-base.js",
|
|
77
|
+
extra: embedder_scripts(configuration, prefix))
|
|
92
78
|
end
|
|
93
79
|
|
|
94
80
|
def embedder_scripts(configuration, prefix)
|