splent-feature-search 0.3.1__tar.gz

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.
Files changed (24) hide show
  1. splent_feature_search-0.3.1/MANIFEST.in +24 -0
  2. splent_feature_search-0.3.1/PKG-INFO +203 -0
  3. splent_feature_search-0.3.1/README.md +196 -0
  4. splent_feature_search-0.3.1/pyproject.toml +61 -0
  5. splent_feature_search-0.3.1/setup.cfg +4 -0
  6. splent_feature_search-0.3.1/src/splent_feature_search.egg-info/PKG-INFO +203 -0
  7. splent_feature_search-0.3.1/src/splent_feature_search.egg-info/SOURCES.txt +22 -0
  8. splent_feature_search-0.3.1/src/splent_feature_search.egg-info/dependency_links.txt +1 -0
  9. splent_feature_search-0.3.1/src/splent_feature_search.egg-info/requires.txt +1 -0
  10. splent_feature_search-0.3.1/src/splent_feature_search.egg-info/top_level.txt +1 -0
  11. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/__init__.py +29 -0
  12. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/assets/css/search.css +417 -0
  13. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/assets/js/search.js +585 -0
  14. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/commands.py +167 -0
  15. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/config.py +72 -0
  16. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/hooks.py +75 -0
  17. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/routes.py +140 -0
  18. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/services.py +657 -0
  19. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/templates/search/_box.html +85 -0
  20. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/templates/search/nav_box.html +31 -0
  21. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/templates/search/results.html +68 -0
  22. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/translations/es/LC_MESSAGES/messages.mo +0 -0
  23. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/translations/es/LC_MESSAGES/messages.po +100 -0
  24. splent_feature_search-0.3.1/src/splent_io/splent_feature_search/translations/messages.pot +97 -0
@@ -0,0 +1,24 @@
1
+ # Frontend assets: the whole tree, not only the compiled bundles.
2
+ #
3
+ # A feature that registers a plain stylesheet with register_asset("css", …)
4
+ # serves it straight from assets/css, and a vendored library lives under
5
+ # assets/vendor. Shipping assets/dist alone put no stylesheet at all in the
6
+ # built package, so every production image came up without its theme, its
7
+ # skin and its editor, while development looked fine because that path
8
+ # installs the checkout in place and reads the files off disk.
9
+ recursive-include src/splent_io/splent_feature_search/assets *.css *.js *.map *.json
10
+ recursive-include src/splent_io/splent_feature_search/assets *.svg *.png *.jpg *.jpeg *.gif *.webp *.ico
11
+ recursive-include src/splent_io/splent_feature_search/assets *.woff *.woff2 *.ttf *.eot
12
+
13
+ # Build inputs, never shipped.
14
+ prune src/splent_io/splent_feature_search/assets/node_modules
15
+
16
+ # Jinja templates (feature views + hook fragments)
17
+ recursive-include src/splent_io/splent_feature_search/templates *.html
18
+
19
+ # Alembic migration config and scripts
20
+ recursive-include src/splent_io/splent_feature_search/migrations *.py *.ini *.mako
21
+
22
+ # Translations. The .mo is what gettext reads at runtime; the .po
23
+ # travels with it so the next translator has something to edit.
24
+ recursive-include src/splent_io/splent_feature_search/translations *.mo *.po *.pot
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_search
3
+ Version: 0.3.1
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
6
+ Requires-Dist: splent_framework>=1.9.0
7
+
8
+ # splent_feature_search
9
+
10
+ Search across a whole product, over material this feature knows nothing about.
11
+
12
+ A SPLENT product is assembled from features, and the material worth searching
13
+ belongs to them: pages to courses, articles to post, people to team. This
14
+ feature owns the search page, the index and the ranking, and owns no content at
15
+ all. A content feature makes itself searchable by registering three callables
16
+ with the framework, and a search written today keeps working with a content
17
+ feature written next year. Neither imports the other.
18
+
19
+ ## The seam
20
+
21
+ `splent_framework.search.search_registry` is the whole contract. A content
22
+ feature calls this once, from `init_feature`:
23
+
24
+ ```python
25
+ from splent_framework.search.search_registry import register_search_source
26
+
27
+ def init_feature(app):
28
+ register_search_source(
29
+ key="courses",
30
+ label="Pages",
31
+ fetch=fetch_pages,
32
+ resolve=resolve_page,
33
+ find=find_pages, # optional
34
+ scopes=course_scopes, # optional
35
+ current_scope=course_of_url, # optional
36
+ order=100,
37
+ )
38
+ ```
39
+
40
+ `fetch()` yields every document, for building an index. Each one is
41
+ `{"id": str, "title": str, "body": str}` and may carry `"url"`, `"scope"` and
42
+ any extra field the feature wants stored.
43
+
44
+ `resolve(doc_id, user)` is the authority. Given one candidate and the reader
45
+ asking, it returns a presentable result, `{"title": ..., "url": ...,
46
+ "snippet": ...}`, or `None` to drop it. It is called once per candidate on
47
+ every search, so it should be a primary key lookup and not a scan.
48
+
49
+ `find(term, user, scope=None)` is optional and is how the feature searches
50
+ itself when no engine is reachable. Same return shape as `resolve`. A source
51
+ that omits it contributes nothing while the engine is down, which is a decision
52
+ the feature gets to make. When a scope arrives it has to be honoured: without an
53
+ index there is nothing else between the reader and the whole site.
54
+
55
+ `order` sorts the groups on the results page. `label` names the group and is
56
+ translated at render time, so pass a plain or a lazy string, never one already
57
+ translated at startup.
58
+
59
+ ## Narrowing
60
+
61
+ A wiki holding fourteen academic years has a page called "Lab 5" in every one of
62
+ them, so "everywhere" is the least useful answer the box can give. Two more
63
+ optional callables turn one box into "search this course", without this feature
64
+ ever learning what a course is.
65
+
66
+ `scopes(user)` returns the places this reader may narrow to, as
67
+ `[{"key": "egc-20252026", "label": "EGC 2025/2026"}, ...]` in display order,
68
+ already filtered to what they may be told exists. The list is rendered in a
69
+ selector, so a course that has not been announced is a course whose name the
70
+ selector would announce.
71
+
72
+ `current_scope()` returns where the reader is standing right now, read out of
73
+ the request, or `None`. It belongs to the feature that owns those URLs. Only
74
+ courses knows that the second segment of `/cursos/egc-20252026/paginas/lab-5`
75
+ names a course, and a search feature taking the URL apart itself would be
76
+ guessing at another feature's routing, would guess wrong the day the product
77
+ serves it under a different word, and would have to guess again for every
78
+ content feature ever written.
79
+
80
+ A document carries `"scope"` with the key of the place it belongs to. It is
81
+ indexed as a keyword, matched whole and never analysed, and a narrowed search
82
+ filters on it. A document with no scope belongs nowhere in particular and does
83
+ not appear in a narrowed search.
84
+
85
+ A scope is applied as it arrives and is never checked against the list first. A
86
+ name nobody offers matches no document and answers nothing, which is what a
87
+ stale bookmark or an edited address deserves; checking it and falling back would
88
+ turn that same request into a search over the whole site, which is the one
89
+ answer nobody asked for. Nothing leaks either way, because every candidate is
90
+ still resolved by the feature that owns it.
91
+
92
+ The box in the header defaults to `current_scope()` and lets the reader switch
93
+ to everything or to another place. On the search page, the scope the page
94
+ searched wins, so the two boxes never disagree. An empty `scope=` means
95
+ everything and is not the same as no `scope=` at all, which means wherever the
96
+ reader is.
97
+
98
+ ## Why the split
99
+
100
+ An index is a cache and is never the authority on visibility.
101
+
102
+ It is a copy taken at some moment in the past, and material is withheld between
103
+ one reindex and the next. If a search answered out of the index, the first
104
+ reader to type "exam" after an exam was pulled would be shown its title, and a
105
+ title gives away as much as a body does. So Elasticsearch is only ever allowed
106
+ to propose candidates: the query asks for ids and switches `_source` off, and
107
+ every id goes back to the feature that owns it through
108
+ `resolve_search_hit(key, doc_id, user)`, at request time, with the reader in
109
+ hand. Nothing stored in the index is ever rendered, not even the title.
110
+
111
+ Deny by default, exactly like the file access registry. A source nobody
112
+ registered, a resolver that raises, a result with no URL: each one drops its
113
+ candidate and leaves the rest of the search alone.
114
+
115
+ The other half of the rule is what never goes into an index. Documents carry no
116
+ visibility, and the registry says so. Recording who may read what would be
117
+ wrong the moment somebody withheld a page, and it would be answering a question
118
+ about a reader who is not there yet and will be somebody else tomorrow.
119
+
120
+ ## What a product gets
121
+
122
+ - `GET /<SEARCH_PATH>` (`search.index`), a server-rendered page. It is a plain
123
+ GET form and it works with JavaScript switched off. A term shorter than
124
+ `SEARCH_MIN_CHARS` renders the empty state, not an error.
125
+ - `GET /<SEARCH_PATH>/results.json` (`search.results`), the same answer as JSON,
126
+ for the script that makes the page feel immediate.
127
+ - A search box in `layout.nav`, unless `SEARCH_NAV` is off. It answers over
128
+ AJAX into a panel under the box, with the arrow keys moving, Enter opening,
129
+ Escape closing, and a plain form submit when there is no script. Nothing in
130
+ that panel is written with `innerHTML`.
131
+ - `SearchService` in the service locator, with `query()`, `scopes()`,
132
+ `reindex()` and `index_report()`.
133
+ - `splent feature:search reindex [--source KEY]` and
134
+ `splent feature:search status`.
135
+
136
+ The JSON answer:
137
+
138
+ ```json
139
+ {
140
+ "query": "grafo",
141
+ "engine": "elasticsearch",
142
+ "scope": "egc-20252026",
143
+ "scopes": [
144
+ {"key": "", "label": "Everything"},
145
+ {"key": "egc-20252026", "label": "EGC 2025/2026"}
146
+ ],
147
+ "total": 12,
148
+ "groups": [
149
+ {"key": "courses", "label": "Pages",
150
+ "results": [{"title": "...", "url": "/cursos/...", "snippet": "..."}]}
151
+ ]
152
+ }
153
+ ```
154
+
155
+ `engine` says how the answer was produced: `elasticsearch` from the index,
156
+ `fallback` from the sources' own `find()`, `none` when nothing produced it. The
157
+ page says so too, in a sentence, rather than letting a thin answer look like a
158
+ site with nothing in it.
159
+
160
+ `scope` is the narrowing that was applied, or `null`. `scopes` is what the
161
+ selector offers, always including the everything option under the empty key, and
162
+ it travels with every answer because the box that asked the question is redrawn
163
+ from it.
164
+
165
+ ## The engine is optional
166
+
167
+ `splent_feature_elasticsearch` provides the transport. A product may install
168
+ search without it, and a product that installed it may be running while the
169
+ node is down. Both end up on the fallback, and the answer is labelled either
170
+ way. The contract names elasticsearch under `requires.features` because
171
+ `services.py` imports its exception types and the contract scanner reads
172
+ dependencies out of import statements; the import itself is guarded and the
173
+ transport is reached by name through the service locator.
174
+
175
+ One index per source, named `<ELASTICSEARCH_INDEX_PREFIX>_<source_key>`. The
176
+ prefix belongs to the engine feature, which keeps two products sharing a node
177
+ out of each other's documents, so everything here passes the bare source key
178
+ and lets the engine put it in the right namespace.
179
+
180
+ `reindex` drops each index and builds it again, because writing over it leaves
181
+ behind every document the feature no longer has. The window in which an index
182
+ is incomplete costs results and never privacy, which is the only direction that
183
+ is safe to be wrong in.
184
+
185
+ ## Configuration
186
+
187
+ | Env var | Default | Purpose |
188
+ | --- | --- | --- |
189
+ | `SEARCH_PATH` | `search` | The word the search is served under. It ends up in links people send each other. |
190
+ | `SEARCH_LIMIT` | `20` | Candidates taken from each source, per source rather than in total. |
191
+ | `SEARCH_MIN_CHARS` | `2` | Below this a term is somebody still typing, not a question. |
192
+ | `SEARCH_NAV` | `true` | Whether the public header carries a search box. |
193
+
194
+ ## Tests
195
+
196
+ ```bash
197
+ splent feature:test splent_feature_search
198
+ ```
199
+
200
+ The sources in the tests are fakes registered through the framework registry,
201
+ so nothing here depends on a content feature being installed, and the engine is
202
+ a fake index that proposes exactly what a test put in it, including documents
203
+ the resolver then refuses.
@@ -0,0 +1,196 @@
1
+ # splent_feature_search
2
+
3
+ Search across a whole product, over material this feature knows nothing about.
4
+
5
+ A SPLENT product is assembled from features, and the material worth searching
6
+ belongs to them: pages to courses, articles to post, people to team. This
7
+ feature owns the search page, the index and the ranking, and owns no content at
8
+ all. A content feature makes itself searchable by registering three callables
9
+ with the framework, and a search written today keeps working with a content
10
+ feature written next year. Neither imports the other.
11
+
12
+ ## The seam
13
+
14
+ `splent_framework.search.search_registry` is the whole contract. A content
15
+ feature calls this once, from `init_feature`:
16
+
17
+ ```python
18
+ from splent_framework.search.search_registry import register_search_source
19
+
20
+ def init_feature(app):
21
+ register_search_source(
22
+ key="courses",
23
+ label="Pages",
24
+ fetch=fetch_pages,
25
+ resolve=resolve_page,
26
+ find=find_pages, # optional
27
+ scopes=course_scopes, # optional
28
+ current_scope=course_of_url, # optional
29
+ order=100,
30
+ )
31
+ ```
32
+
33
+ `fetch()` yields every document, for building an index. Each one is
34
+ `{"id": str, "title": str, "body": str}` and may carry `"url"`, `"scope"` and
35
+ any extra field the feature wants stored.
36
+
37
+ `resolve(doc_id, user)` is the authority. Given one candidate and the reader
38
+ asking, it returns a presentable result, `{"title": ..., "url": ...,
39
+ "snippet": ...}`, or `None` to drop it. It is called once per candidate on
40
+ every search, so it should be a primary key lookup and not a scan.
41
+
42
+ `find(term, user, scope=None)` is optional and is how the feature searches
43
+ itself when no engine is reachable. Same return shape as `resolve`. A source
44
+ that omits it contributes nothing while the engine is down, which is a decision
45
+ the feature gets to make. When a scope arrives it has to be honoured: without an
46
+ index there is nothing else between the reader and the whole site.
47
+
48
+ `order` sorts the groups on the results page. `label` names the group and is
49
+ translated at render time, so pass a plain or a lazy string, never one already
50
+ translated at startup.
51
+
52
+ ## Narrowing
53
+
54
+ A wiki holding fourteen academic years has a page called "Lab 5" in every one of
55
+ them, so "everywhere" is the least useful answer the box can give. Two more
56
+ optional callables turn one box into "search this course", without this feature
57
+ ever learning what a course is.
58
+
59
+ `scopes(user)` returns the places this reader may narrow to, as
60
+ `[{"key": "egc-20252026", "label": "EGC 2025/2026"}, ...]` in display order,
61
+ already filtered to what they may be told exists. The list is rendered in a
62
+ selector, so a course that has not been announced is a course whose name the
63
+ selector would announce.
64
+
65
+ `current_scope()` returns where the reader is standing right now, read out of
66
+ the request, or `None`. It belongs to the feature that owns those URLs. Only
67
+ courses knows that the second segment of `/cursos/egc-20252026/paginas/lab-5`
68
+ names a course, and a search feature taking the URL apart itself would be
69
+ guessing at another feature's routing, would guess wrong the day the product
70
+ serves it under a different word, and would have to guess again for every
71
+ content feature ever written.
72
+
73
+ A document carries `"scope"` with the key of the place it belongs to. It is
74
+ indexed as a keyword, matched whole and never analysed, and a narrowed search
75
+ filters on it. A document with no scope belongs nowhere in particular and does
76
+ not appear in a narrowed search.
77
+
78
+ A scope is applied as it arrives and is never checked against the list first. A
79
+ name nobody offers matches no document and answers nothing, which is what a
80
+ stale bookmark or an edited address deserves; checking it and falling back would
81
+ turn that same request into a search over the whole site, which is the one
82
+ answer nobody asked for. Nothing leaks either way, because every candidate is
83
+ still resolved by the feature that owns it.
84
+
85
+ The box in the header defaults to `current_scope()` and lets the reader switch
86
+ to everything or to another place. On the search page, the scope the page
87
+ searched wins, so the two boxes never disagree. An empty `scope=` means
88
+ everything and is not the same as no `scope=` at all, which means wherever the
89
+ reader is.
90
+
91
+ ## Why the split
92
+
93
+ An index is a cache and is never the authority on visibility.
94
+
95
+ It is a copy taken at some moment in the past, and material is withheld between
96
+ one reindex and the next. If a search answered out of the index, the first
97
+ reader to type "exam" after an exam was pulled would be shown its title, and a
98
+ title gives away as much as a body does. So Elasticsearch is only ever allowed
99
+ to propose candidates: the query asks for ids and switches `_source` off, and
100
+ every id goes back to the feature that owns it through
101
+ `resolve_search_hit(key, doc_id, user)`, at request time, with the reader in
102
+ hand. Nothing stored in the index is ever rendered, not even the title.
103
+
104
+ Deny by default, exactly like the file access registry. A source nobody
105
+ registered, a resolver that raises, a result with no URL: each one drops its
106
+ candidate and leaves the rest of the search alone.
107
+
108
+ The other half of the rule is what never goes into an index. Documents carry no
109
+ visibility, and the registry says so. Recording who may read what would be
110
+ wrong the moment somebody withheld a page, and it would be answering a question
111
+ about a reader who is not there yet and will be somebody else tomorrow.
112
+
113
+ ## What a product gets
114
+
115
+ - `GET /<SEARCH_PATH>` (`search.index`), a server-rendered page. It is a plain
116
+ GET form and it works with JavaScript switched off. A term shorter than
117
+ `SEARCH_MIN_CHARS` renders the empty state, not an error.
118
+ - `GET /<SEARCH_PATH>/results.json` (`search.results`), the same answer as JSON,
119
+ for the script that makes the page feel immediate.
120
+ - A search box in `layout.nav`, unless `SEARCH_NAV` is off. It answers over
121
+ AJAX into a panel under the box, with the arrow keys moving, Enter opening,
122
+ Escape closing, and a plain form submit when there is no script. Nothing in
123
+ that panel is written with `innerHTML`.
124
+ - `SearchService` in the service locator, with `query()`, `scopes()`,
125
+ `reindex()` and `index_report()`.
126
+ - `splent feature:search reindex [--source KEY]` and
127
+ `splent feature:search status`.
128
+
129
+ The JSON answer:
130
+
131
+ ```json
132
+ {
133
+ "query": "grafo",
134
+ "engine": "elasticsearch",
135
+ "scope": "egc-20252026",
136
+ "scopes": [
137
+ {"key": "", "label": "Everything"},
138
+ {"key": "egc-20252026", "label": "EGC 2025/2026"}
139
+ ],
140
+ "total": 12,
141
+ "groups": [
142
+ {"key": "courses", "label": "Pages",
143
+ "results": [{"title": "...", "url": "/cursos/...", "snippet": "..."}]}
144
+ ]
145
+ }
146
+ ```
147
+
148
+ `engine` says how the answer was produced: `elasticsearch` from the index,
149
+ `fallback` from the sources' own `find()`, `none` when nothing produced it. The
150
+ page says so too, in a sentence, rather than letting a thin answer look like a
151
+ site with nothing in it.
152
+
153
+ `scope` is the narrowing that was applied, or `null`. `scopes` is what the
154
+ selector offers, always including the everything option under the empty key, and
155
+ it travels with every answer because the box that asked the question is redrawn
156
+ from it.
157
+
158
+ ## The engine is optional
159
+
160
+ `splent_feature_elasticsearch` provides the transport. A product may install
161
+ search without it, and a product that installed it may be running while the
162
+ node is down. Both end up on the fallback, and the answer is labelled either
163
+ way. The contract names elasticsearch under `requires.features` because
164
+ `services.py` imports its exception types and the contract scanner reads
165
+ dependencies out of import statements; the import itself is guarded and the
166
+ transport is reached by name through the service locator.
167
+
168
+ One index per source, named `<ELASTICSEARCH_INDEX_PREFIX>_<source_key>`. The
169
+ prefix belongs to the engine feature, which keeps two products sharing a node
170
+ out of each other's documents, so everything here passes the bare source key
171
+ and lets the engine put it in the right namespace.
172
+
173
+ `reindex` drops each index and builds it again, because writing over it leaves
174
+ behind every document the feature no longer has. The window in which an index
175
+ is incomplete costs results and never privacy, which is the only direction that
176
+ is safe to be wrong in.
177
+
178
+ ## Configuration
179
+
180
+ | Env var | Default | Purpose |
181
+ | --- | --- | --- |
182
+ | `SEARCH_PATH` | `search` | The word the search is served under. It ends up in links people send each other. |
183
+ | `SEARCH_LIMIT` | `20` | Candidates taken from each source, per source rather than in total. |
184
+ | `SEARCH_MIN_CHARS` | `2` | Below this a term is somebody still typing, not a question. |
185
+ | `SEARCH_NAV` | `true` | Whether the public header carries a search box. |
186
+
187
+ ## Tests
188
+
189
+ ```bash
190
+ splent feature:test splent_feature_search
191
+ ```
192
+
193
+ The sources in the tests are fakes registered through the framework registry,
194
+ so nothing here depends on a content feature being installed, and the engine is
195
+ a fake index that proposes exactly what a test put in it, including documents
196
+ the resolver then refuses.
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80.3.1", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "splent_feature_search"
7
+ version = "0.3.1"
8
+ readme = "README.md"
9
+ requires-python = ">=3.13"
10
+
11
+ # Nothing. The Elasticsearch client belongs to splent_feature_elasticsearch,
12
+ # which owns the transport; this feature reaches it through the service
13
+ # locator and works without it.
14
+ dependencies = [
15
+ "splent_framework>=1.9.0",
16
+ ]
17
+
18
+ [tool.setuptools]
19
+ package-dir = { "" = "src" }
20
+ include-package-data = true
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+ exclude = ["*.tests", "*.tests.*"]
25
+
26
+ [tool.splent]
27
+ cli_version = "1.15.0"
28
+
29
+ # ── Feature Contract (auto-generated) ────────────────────────────────────────
30
+ # Do not edit manually — re-run `splent feature:contract --write` to refresh.
31
+ # Preserved across refreshes: description, category, tags, env,
32
+ # requires.features_manual (dependencies invisible to static analysis)
33
+ # and provides.routes_manual (routes built from configuration at
34
+ # registration time, which have no literal path to find in source).
35
+ [tool.splent.contract]
36
+ description = "Product-wide search over whatever the installed features declare searchable, with the owning feature deciding at request time who may see each hit"
37
+ archetype = "light"
38
+
39
+ [tool.splent.contract.provides]
40
+ routes = ["/<SEARCH_PATH>", "/<SEARCH_PATH>/results.json"]
41
+ routes_manual = ["/<SEARCH_PATH>", "/<SEARCH_PATH>/results.json"]
42
+ blueprints = []
43
+ models = []
44
+ commands = ["reindex", "status"]
45
+ hooks = ["layout.nav"]
46
+ services = ["SearchService"]
47
+ signals = []
48
+ translations = ["es"]
49
+ docker = []
50
+
51
+ [tool.splent.contract.requires]
52
+ features = ["elasticsearch"]
53
+ env_vars = ["SEARCH_LIMIT", "SEARCH_MIN_CHARS", "SEARCH_NAV", "SEARCH_PATH", "SEARCH_PLACEHOLDER"]
54
+ signals = []
55
+
56
+ [tool.splent.contract.extensible]
57
+ services = ["SearchService"]
58
+ templates = ["search/nav_box.html", "search/results.html"]
59
+ models = []
60
+ hooks = ["layout.nav"]
61
+ routes = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+