@dmthepm/commune 0.1.1 → 0.3.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.
- package/README.md +133 -1
- package/lib/cli/errors.d.ts +3 -2
- package/lib/cli/errors.js +2 -1
- package/lib/cli/main.js +40 -2
- package/lib/cli/query.d.ts +25 -1
- package/lib/cli/query.js +44 -4
- package/lib/cli/update.d.ts +16 -0
- package/lib/cli/update.js +91 -0
- package/lib/cli/usage.d.ts +1 -1
- package/lib/cli/usage.js +19 -5
- package/lib/integration.js +29 -4
- package/lib/lib/dates.d.ts +100 -0
- package/lib/lib/dates.js +182 -0
- package/lib/lib/graph.d.ts +87 -5
- package/lib/lib/graph.js +170 -16
- package/package.json +1 -4
- package/src/components/Backlinks.astro +5 -1
- package/src/components/BacklinksScript.astro +11 -1
- package/src/components/Header.astro +76 -1
- package/src/components/HeaderStarScript.astro +41 -3
- package/src/components/SearchModal.astro +165 -32
- package/src/components/SlidingPanes.astro +782 -0
- package/src/components/Updates.astro +114 -0
- package/src/styles/design-system.css +88 -6
- package/src/styles/notes.css +36 -0
- package/src/components/PlausibleScript.astro +0 -13
- package/src/components/panes.ts +0 -61
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
---
|
|
2
|
-
interface Result { url: string; title: string; excerpt?: string; score?: number; source?: '
|
|
2
|
+
interface Result { url: string; title: string; excerpt?: string; score?: number; source?: 'substring'|'semantic'; }
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/**
|
|
6
|
+
* A URL this site serves that answers `GET <endpoint>?q=<query>` with
|
|
7
|
+
* `{ results: [{ url, title, snippet }] }`. Supplying it adds a second,
|
|
8
|
+
* semantic search tier, merged with the local substring tier by RRF.
|
|
9
|
+
*
|
|
10
|
+
* There is no default, deliberately. The engine ships no component that
|
|
11
|
+
* calls a URL a stranger does not have (#56): with this prop unset, the
|
|
12
|
+
* semantic tier is not rendered at all — no fetch, no code for one — and
|
|
13
|
+
* search is the static substring pass over `/backlinks.json`, which the
|
|
14
|
+
* build itself produces.
|
|
15
|
+
*/
|
|
16
|
+
semanticEndpoint?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { semanticEndpoint } = Astro.props;
|
|
3
20
|
---
|
|
4
21
|
<div id="commune-search" hidden>
|
|
5
22
|
<div class="shade" onclick="window.CommuneSearch.close()"></div>
|
|
6
|
-
<div class="modal" role="dialog" aria-modal="true">
|
|
7
|
-
<
|
|
8
|
-
<
|
|
23
|
+
<div class="modal" role="dialog" aria-modal="true" aria-label="Search">
|
|
24
|
+
<label class="sr-only" for="q">Search notes and pages</label>
|
|
25
|
+
<input id="q" type="search" autocomplete="off" autocorrect="off" spellcheck="false"
|
|
26
|
+
placeholder="Search notes & docs… (Esc to close)"
|
|
27
|
+
aria-describedby="results-status" />
|
|
28
|
+
<p id="results-status" class="empty" role="status" aria-live="polite">Start typing to search.</p>
|
|
29
|
+
<ul id="results" aria-label="Search results"></ul>
|
|
9
30
|
</div>
|
|
10
31
|
</div>
|
|
11
32
|
|
|
@@ -48,6 +69,13 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
48
69
|
#q:focus{
|
|
49
70
|
border-color:var(--c-accent);
|
|
50
71
|
}
|
|
72
|
+
/* type="search" earns the Search key on touch keyboards; its stock clear
|
|
73
|
+
button is an unthemed browser widget in the middle of a designed modal. */
|
|
74
|
+
#q::-webkit-search-cancel-button,
|
|
75
|
+
#q::-webkit-search-decoration{
|
|
76
|
+
-webkit-appearance:none;
|
|
77
|
+
appearance:none;
|
|
78
|
+
}
|
|
51
79
|
#results{
|
|
52
80
|
list-style:none;
|
|
53
81
|
margin:1rem 0 .5rem;
|
|
@@ -55,6 +83,11 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
55
83
|
max-height:60vh;
|
|
56
84
|
overflow:auto;
|
|
57
85
|
}
|
|
86
|
+
/* The status line stands in for the list when it is empty; the list should
|
|
87
|
+
not also reserve its own margin underneath. */
|
|
88
|
+
#results:empty{
|
|
89
|
+
margin:0;
|
|
90
|
+
}
|
|
58
91
|
#results li{
|
|
59
92
|
padding:.85rem .75rem;
|
|
60
93
|
border-radius:var(--c-radius-sm);
|
|
@@ -78,12 +111,47 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
78
111
|
}
|
|
79
112
|
.empty{
|
|
80
113
|
text-align:center;
|
|
81
|
-
padding:2rem;
|
|
114
|
+
padding:2rem 1rem;
|
|
115
|
+
margin:0;
|
|
82
116
|
color:var(--c-text-muted);
|
|
83
117
|
font-size:.95rem;
|
|
118
|
+
line-height:1.6;
|
|
119
|
+
}
|
|
120
|
+
.empty[hidden]{display:none}
|
|
121
|
+
.empty .term{
|
|
122
|
+
color:var(--c-text);
|
|
123
|
+
font-weight:600;
|
|
124
|
+
overflow-wrap:anywhere;
|
|
125
|
+
}
|
|
126
|
+
.sr-only{
|
|
127
|
+
position:absolute;
|
|
128
|
+
width:1px;
|
|
129
|
+
height:1px;
|
|
130
|
+
padding:0;
|
|
131
|
+
margin:-1px;
|
|
132
|
+
overflow:hidden;
|
|
133
|
+
clip:rect(0, 0, 0, 0);
|
|
134
|
+
white-space:nowrap;
|
|
135
|
+
border:0;
|
|
84
136
|
}
|
|
85
137
|
</style>
|
|
86
138
|
|
|
139
|
+
{semanticEndpoint && (
|
|
140
|
+
<script is:inline define:vars={{ semanticEndpoint }}>
|
|
141
|
+
// The semantic tier, rendered only because a page passed an endpoint.
|
|
142
|
+
// `semanticEndpoint` is that page's own URL; the engine never picks one.
|
|
143
|
+
window.CommuneSemanticSearch = async function (query) {
|
|
144
|
+
try {
|
|
145
|
+
const sep = semanticEndpoint.includes('?') ? '&' : '?';
|
|
146
|
+
const r = await fetch(`${semanticEndpoint}${sep}q=${encodeURIComponent(query)}`);
|
|
147
|
+
if (!r.ok) return [];
|
|
148
|
+
const j = await r.json();
|
|
149
|
+
return (j.results ?? []).slice(0, 10).map(x => ({ url: x.url, title: x.title, excerpt: x.snippet, source: 'semantic' }));
|
|
150
|
+
} catch { return []; }
|
|
151
|
+
};
|
|
152
|
+
</script>
|
|
153
|
+
)}
|
|
154
|
+
|
|
87
155
|
<script is:inline>
|
|
88
156
|
// Lightweight RRF combiner
|
|
89
157
|
const rrf = (lists) => {
|
|
@@ -97,18 +165,6 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
97
165
|
return Array.from(map.values()).sort((a,b) => b.score - a.score);
|
|
98
166
|
};
|
|
99
167
|
|
|
100
|
-
// Pagefind removed - semantic search will handle full-text needs
|
|
101
|
-
// If we ever need dedicated lexical full-text search, we can re-add it
|
|
102
|
-
|
|
103
|
-
async function doSemantic(query){
|
|
104
|
-
try{
|
|
105
|
-
const r = await fetch(`/api/ask?q=${encodeURIComponent(query)}`);
|
|
106
|
-
if (!r.ok) return [];
|
|
107
|
-
const j = await r.json();
|
|
108
|
-
return (j.results ?? []).slice(0,10).map(x => ({ url: x.url, title: x.title, excerpt: x.snippet, source:'semantic' }));
|
|
109
|
-
}catch{ return []; }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
168
|
// DEV fallback: use backlinks graph as minimal search index
|
|
113
169
|
async function devIndex() {
|
|
114
170
|
try {
|
|
@@ -135,70 +191,147 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
|
|
|
135
191
|
const pos = hay.indexOf(norm);
|
|
136
192
|
return pos === -1 ? null : { ...d, score: 1 / (1 + pos) };
|
|
137
193
|
}).filter(Boolean).sort((a,b) => b.score - a.score);
|
|
138
|
-
return scored.slice(0, 20).map(d => ({ url: d.url, title: d.title, excerpt: d.excerpt, source: '
|
|
194
|
+
return scored.slice(0, 20).map(d => ({ url: d.url, title: d.title, excerpt: d.excerpt, source: 'substring' }));
|
|
139
195
|
}
|
|
140
196
|
|
|
141
197
|
// Wire up modal
|
|
142
198
|
(function mountSearch(){
|
|
143
199
|
const root = document.getElementById('commune-search');
|
|
200
|
+
const modal = root.querySelector('.modal');
|
|
144
201
|
const input = root.querySelector('#q');
|
|
145
202
|
const list = root.querySelector('#results');
|
|
203
|
+
const status = root.querySelector('#results-status');
|
|
204
|
+
|
|
205
|
+
const IDLE_MESSAGE = 'Start typing to search.';
|
|
146
206
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
207
|
+
// Titles and summaries are the vault author's prose. A stray <, & or " in
|
|
208
|
+
// one of them should read as that character, not break out of the markup
|
|
209
|
+
// being built here.
|
|
210
|
+
const escapeHtml = (value) => String(value ?? '')
|
|
211
|
+
.replace(/&/g, '&')
|
|
212
|
+
.replace(/</g, '<')
|
|
213
|
+
.replace(/>/g, '>')
|
|
214
|
+
.replace(/"/g, '"')
|
|
215
|
+
.replace(/'/g, ''');
|
|
216
|
+
|
|
217
|
+
function showStatus(html) {
|
|
218
|
+
status.innerHTML = html;
|
|
219
|
+
status.hidden = false;
|
|
220
|
+
list.replaceChildren();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function render(results, query) {
|
|
224
|
+
// Before anything is typed there is no result set to be empty, so
|
|
225
|
+
// saying "no results" is a false negative. Only a query with no matches
|
|
226
|
+
// is one.
|
|
227
|
+
if (!query) {
|
|
228
|
+
showStatus(IDLE_MESSAGE);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!results.length) {
|
|
232
|
+
showStatus(`No results for <span class="term">${escapeHtml(query)}</span>.<br />Try a shorter word, a tag, or an alias.`);
|
|
150
233
|
return;
|
|
151
234
|
}
|
|
235
|
+
status.hidden = true;
|
|
152
236
|
list.innerHTML = results.map(r => (
|
|
153
237
|
`<li>
|
|
154
|
-
<a href="${r.url}">${r.title}</a>
|
|
155
|
-
${r.excerpt ? `<div class="excerpt">${r.excerpt}</div>` : ''}
|
|
238
|
+
<a href="${escapeHtml(r.url)}">${escapeHtml(r.title)}</a>
|
|
239
|
+
${r.excerpt ? `<div class="excerpt">${escapeHtml(r.excerpt)}</div>` : ''}
|
|
156
240
|
</li>`
|
|
157
241
|
)).join('');
|
|
158
242
|
// Wire close on click and enter-to-go
|
|
159
243
|
list.querySelectorAll('a').forEach(a => a.onclick = () => window.CommuneSearch.close());
|
|
160
244
|
}
|
|
161
245
|
|
|
162
|
-
// SEARCH STRATEGY
|
|
163
|
-
// 1. Substring
|
|
164
|
-
//
|
|
165
|
-
//
|
|
246
|
+
// SEARCH STRATEGY:
|
|
247
|
+
// 1. Substring over `/backlinks.json` — always present, same-origin, built
|
|
248
|
+
// by the engine. Exact matching on title, summary, aliases and tags.
|
|
249
|
+
// 2. Semantic — present only when the page passed `semanticEndpoint`, in
|
|
250
|
+
// which case the block above defined `window.CommuneSemanticSearch`.
|
|
251
|
+
// With both, RRF merges them; with one, RRF preserves its order.
|
|
166
252
|
|
|
167
253
|
let t;
|
|
254
|
+
let queryToken = 0;
|
|
168
255
|
async function search(q) {
|
|
256
|
+
const token = ++queryToken;
|
|
257
|
+
const semanticTier = typeof window.CommuneSemanticSearch === 'function'
|
|
258
|
+
? window.CommuneSemanticSearch(q).catch(() => [])
|
|
259
|
+
: Promise.resolve([]);
|
|
169
260
|
const [substring, semantic] = await Promise.all([
|
|
170
|
-
doDevSearch(q),
|
|
171
|
-
|
|
261
|
+
doDevSearch(q),
|
|
262
|
+
semanticTier,
|
|
172
263
|
]);
|
|
264
|
+
// A slower earlier query must not overwrite a later one's results.
|
|
265
|
+
if (token !== queryToken) return;
|
|
173
266
|
let merged = rrf([substring, semantic]);
|
|
174
|
-
render(merged);
|
|
267
|
+
render(merged, q);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Focus belongs to the dialog while it is open: tabbing past the last
|
|
271
|
+
// control must not land on the page behind it, and closing must put focus
|
|
272
|
+
// back where the reader left it rather than at the top of the document.
|
|
273
|
+
let lastFocused = null;
|
|
274
|
+
|
|
275
|
+
const focusables = () => Array.from(
|
|
276
|
+
modal.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
|
277
|
+
).filter((el) => !el.hasAttribute('disabled') && el.offsetParent !== null);
|
|
278
|
+
|
|
279
|
+
function trapTab(e) {
|
|
280
|
+
if (e.key !== 'Tab' || root.hidden) return;
|
|
281
|
+
const items = focusables();
|
|
282
|
+
if (!items.length) { e.preventDefault(); return; }
|
|
283
|
+
const first = items[0];
|
|
284
|
+
const last = items[items.length - 1];
|
|
285
|
+
const active = document.activeElement;
|
|
286
|
+
if (e.shiftKey && (active === first || !modal.contains(active))) {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
last.focus();
|
|
289
|
+
} else if (!e.shiftKey && (active === last || !modal.contains(active))) {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
first.focus();
|
|
292
|
+
}
|
|
175
293
|
}
|
|
176
294
|
|
|
177
295
|
function open() {
|
|
296
|
+
if (!root.hidden) return;
|
|
297
|
+
lastFocused = document.activeElement;
|
|
178
298
|
root.hidden = false;
|
|
179
299
|
document.body.classList.add('modal-open');
|
|
180
300
|
input.value = '';
|
|
181
|
-
|
|
301
|
+
queryToken++;
|
|
302
|
+
render([], '');
|
|
182
303
|
input.focus();
|
|
183
304
|
}
|
|
184
305
|
function close() {
|
|
306
|
+
if (root.hidden) return;
|
|
185
307
|
root.hidden = true;
|
|
186
308
|
document.body.classList.remove('modal-open');
|
|
309
|
+
queryToken++;
|
|
310
|
+
if (lastFocused instanceof HTMLElement && document.contains(lastFocused)) {
|
|
311
|
+
lastFocused.focus({ preventScroll: true });
|
|
312
|
+
}
|
|
313
|
+
lastFocused = null;
|
|
187
314
|
}
|
|
188
315
|
window.CommuneSearch = { open, close };
|
|
189
316
|
|
|
190
317
|
addEventListener('commune:openSearch', open);
|
|
191
318
|
addEventListener('keydown', e => {
|
|
192
|
-
const mac =
|
|
319
|
+
const mac = window.CommuneIsMac
|
|
320
|
+
? window.CommuneIsMac()
|
|
321
|
+
: /mac/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '');
|
|
193
322
|
if ((mac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
194
323
|
e.preventDefault(); open();
|
|
195
324
|
}
|
|
196
325
|
if (e.key === 'Escape' && !root.hidden) close();
|
|
326
|
+
trapTab(e);
|
|
197
327
|
});
|
|
198
328
|
|
|
199
329
|
input.addEventListener('input', () => {
|
|
200
330
|
clearTimeout(t);
|
|
201
331
|
const q = input.value.trim();
|
|
332
|
+
// Clearing the box returns to the idle state at once rather than
|
|
333
|
+
// running an empty query 200ms later and calling it "no results".
|
|
334
|
+
if (!q) { queryToken++; render([], ''); return; }
|
|
202
335
|
t = setTimeout(() => search(q), 200);
|
|
203
336
|
});
|
|
204
337
|
|