@acms-subir/core 0.0.1 → 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.
- package/CHANGELOG.md +47 -0
- package/cli.mjs +255 -14
- package/integration.mjs +85 -3
- package/package.json +6 -5
- package/src/layouts/Site.astro +112 -0
- package/src/lib/plaintext.ts +22 -0
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +167 -23
- package/src/lib/repo/sqlite.ts +3 -1
- package/src/lib/repo/types.ts +19 -3
- package/src/lib/search-config.ts +10 -0
- package/src/lib/search-i18n.ts +45 -0
- package/src/pages/admin/index.astro +22 -8
- package/src/pages/api/search.ts +44 -0
- package/src/pages/search.astro +83 -18
- package/src/styles/site.css +85 -0
- package/src/lib/repo/search.ts +0 -14
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../lib/repo';
|
|
3
|
+
import { authorSlug, publicTags } from '../../lib/schema';
|
|
4
|
+
import { collectionUrl, tagUrl } from '../../lib/content';
|
|
5
|
+
import { localeHomeUrl, normalizePageLocale } from '../../lib/i18n';
|
|
6
|
+
import { formatDate } from '../../lib/dates';
|
|
7
|
+
|
|
8
|
+
// Public search API behind the site's search modal. Published-only by
|
|
9
|
+
// construction — the same contract as rss/sitemap — so it needs no auth and
|
|
10
|
+
// the middleware already leaves /api alone. `locale` boosts the visitor's
|
|
11
|
+
// language to the top of page results; it never filters. Snippets are safe
|
|
12
|
+
// HTML (entity-escaped, <mark> around matches) ready for innerHTML.
|
|
13
|
+
|
|
14
|
+
const LIMITS = { posts: 10, pages: 5, tags: 5, authors: 5 };
|
|
15
|
+
|
|
16
|
+
export const GET: APIRoute = async ({ locals, url }) => {
|
|
17
|
+
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=60' };
|
|
18
|
+
const q = (url.searchParams.get('q') ?? '').trim().slice(0, 100);
|
|
19
|
+
if (!q) return new Response(JSON.stringify({ posts: [], pages: [], tags: [], authors: [] }), { headers });
|
|
20
|
+
|
|
21
|
+
const repo = await getRepo(locals);
|
|
22
|
+
const locale = normalizePageLocale(url.searchParams.get('locale') ?? '');
|
|
23
|
+
const [results, settings] = await Promise.all([repo.searchPublished(q, { locale }), repo.getSettings()]);
|
|
24
|
+
|
|
25
|
+
const body = {
|
|
26
|
+
posts: results.posts.slice(0, LIMITS.posts).map(({ doc: p, snippet }) => ({
|
|
27
|
+
title: p.title,
|
|
28
|
+
url: collectionUrl(p.collection, p.slug),
|
|
29
|
+
excerpt: p.excerpt,
|
|
30
|
+
snippet,
|
|
31
|
+
date: p.publishedAt ? formatDate(p.publishedAt, settings) : '',
|
|
32
|
+
tags: publicTags(p.tags),
|
|
33
|
+
})),
|
|
34
|
+
pages: results.pages.slice(0, LIMITS.pages).map(({ doc: p, snippet }) => ({
|
|
35
|
+
title: p.title,
|
|
36
|
+
url: p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}`,
|
|
37
|
+
snippet,
|
|
38
|
+
locale: p.locale,
|
|
39
|
+
})),
|
|
40
|
+
tags: results.tags.slice(0, LIMITS.tags).map(({ tag, count }) => ({ name: tag.name, url: tagUrl(tag.slug), count })),
|
|
41
|
+
authors: results.authors.slice(0, LIMITS.authors).map((u) => ({ name: u.name, url: `/author/${authorSlug(u)}`, avatar: u.avatar })),
|
|
42
|
+
};
|
|
43
|
+
return new Response(JSON.stringify(body), { headers });
|
|
44
|
+
};
|
package/src/pages/search.astro
CHANGED
|
@@ -1,41 +1,54 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../lib/repo';
|
|
4
|
-
import { formatDate } from '../lib/dates';
|
|
5
|
-
import {
|
|
4
|
+
import { formatDate, siteLang } from '../lib/dates';
|
|
5
|
+
import { collectionUrl, tagUrl } from '../lib/content';
|
|
6
|
+
import { authorSlug } from '../lib/schema';
|
|
7
|
+
import { I18N, localeFromPath, localeHomeUrl, normalizePageLocale } from '../lib/i18n';
|
|
8
|
+
import { searchStrings } from '../lib/search-i18n';
|
|
9
|
+
|
|
10
|
+
// The no-JS search surface (the modal's form falls back here). Serves both
|
|
11
|
+
// /search and the locale-prefixed /<locale>/search routes — the locale comes
|
|
12
|
+
// from the path, same trick as the per-locale home routes, and only boosts
|
|
13
|
+
// page ranking; every locale's content stays visible.
|
|
6
14
|
|
|
7
15
|
const repo = await getRepo(Astro.locals);
|
|
8
16
|
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
|
9
|
-
const results = q ? await repo.searchPublished(q) : { posts: [], pages: [] };
|
|
10
17
|
const settings = await repo.getSettings();
|
|
11
|
-
const
|
|
18
|
+
const reqLocale = I18N.enabled ? localeFromPath(Astro.url.pathname) : '';
|
|
19
|
+
const locale = normalizePageLocale(reqLocale);
|
|
20
|
+
const t = searchStrings((reqLocale || siteLang(settings)).slice(0, 2));
|
|
21
|
+
const results = q
|
|
22
|
+
? await repo.searchPublished(q, { locale })
|
|
23
|
+
: { posts: [], pages: [], tags: [], authors: [] };
|
|
24
|
+
const total = results.posts.length + results.pages.length + results.tags.length + results.authors.length;
|
|
12
25
|
---
|
|
13
26
|
|
|
14
|
-
<Site title={q ?
|
|
27
|
+
<Site title={q ? `${t.label}: ${q}` : t.label} description={`${t.label} · ${settings.siteTitle}`} noindex={Boolean(q)}>
|
|
15
28
|
<header class="search-hero">
|
|
16
|
-
<h1>
|
|
17
|
-
<form class="search-form" action=
|
|
29
|
+
<h1>{t.label}</h1>
|
|
30
|
+
<form class="search-form" action={Astro.url.pathname} method="get" role="search">
|
|
18
31
|
<input
|
|
19
32
|
type="search"
|
|
20
33
|
name="q"
|
|
21
34
|
value={q}
|
|
22
|
-
placeholder={
|
|
23
|
-
aria-label=
|
|
35
|
+
placeholder={t.placeholder}
|
|
36
|
+
aria-label={t.label}
|
|
24
37
|
autofocus={!q}
|
|
25
38
|
/>
|
|
26
|
-
<button type="submit">
|
|
39
|
+
<button type="submit">{t.button}</button>
|
|
27
40
|
</form>
|
|
28
41
|
</header>
|
|
29
42
|
|
|
30
43
|
{q && (
|
|
31
44
|
<p class="search-count">
|
|
32
|
-
{total === 0 ?
|
|
45
|
+
{total === 0 ? `${t.empty} “${q}”.` : `${total} ${total === 1 ? t.result : t.results} · “${q}”`}
|
|
33
46
|
</p>
|
|
34
47
|
)}
|
|
35
48
|
|
|
36
49
|
{results.posts.length > 0 && (
|
|
37
50
|
<ul class="post-list">
|
|
38
|
-
{results.posts.map((p) => (
|
|
51
|
+
{results.posts.map(({ doc: p, snippet }) => (
|
|
39
52
|
<li class="post-item">
|
|
40
53
|
{p.cover && (
|
|
41
54
|
<a class="post-thumb" href={collectionUrl(p.collection, p.slug)}><img src={p.cover} alt={p.coverAlt} loading="lazy" /></a>
|
|
@@ -45,7 +58,11 @@ const total = results.posts.length + results.pages.length;
|
|
|
45
58
|
<div class="post-meta">
|
|
46
59
|
{p.publishedAt && <time datetime={new Date(p.publishedAt).toISOString()}>{formatDate(p.publishedAt, settings)}</time>}
|
|
47
60
|
</div>
|
|
48
|
-
{
|
|
61
|
+
{snippet ? (
|
|
62
|
+
<p class="post-excerpt" set:html={snippet} />
|
|
63
|
+
) : (
|
|
64
|
+
p.excerpt && <p class="post-excerpt">{p.excerpt}</p>
|
|
65
|
+
)}
|
|
49
66
|
</div>
|
|
50
67
|
</li>
|
|
51
68
|
))}
|
|
@@ -53,12 +70,46 @@ const total = results.posts.length + results.pages.length;
|
|
|
53
70
|
)}
|
|
54
71
|
|
|
55
72
|
{results.pages.length > 0 && (
|
|
56
|
-
<section class="search-
|
|
57
|
-
<h2>
|
|
73
|
+
<section class="search-section">
|
|
74
|
+
<h2>{t.pages}</h2>
|
|
58
75
|
<ul class="post-list">
|
|
59
|
-
{results.pages.map((p) => (
|
|
76
|
+
{results.pages.map(({ doc: p, snippet }) => (
|
|
60
77
|
<li class="post-item">
|
|
61
|
-
<div
|
|
78
|
+
<div>
|
|
79
|
+
<a href={p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}`}>{p.title}</a>
|
|
80
|
+
{I18N.enabled && p.locale !== locale && (
|
|
81
|
+
<span class="search-loc">{(p.locale || I18N.defaultLocale).toUpperCase()}</span>
|
|
82
|
+
)}
|
|
83
|
+
{snippet && <p class="post-excerpt" set:html={snippet} />}
|
|
84
|
+
</div>
|
|
85
|
+
</li>
|
|
86
|
+
))}
|
|
87
|
+
</ul>
|
|
88
|
+
</section>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{results.tags.length > 0 && (
|
|
92
|
+
<section class="search-section">
|
|
93
|
+
<h2>{t.tags}</h2>
|
|
94
|
+
<div class="search-tags">
|
|
95
|
+
{results.tags.map(({ tag, count }) => (
|
|
96
|
+
<a class="tag" href={tagUrl(tag.slug)}>{tag.name} <span class="search-tag-count">{count}</span></a>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</section>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{results.authors.length > 0 && (
|
|
103
|
+
<section class="search-section">
|
|
104
|
+
<h2>{t.authors}</h2>
|
|
105
|
+
<ul class="post-list">
|
|
106
|
+
{results.authors.map((u) => (
|
|
107
|
+
<li class="post-item">
|
|
108
|
+
{u.avatar && <img class="search-avatar" src={u.avatar} alt="" loading="lazy" />}
|
|
109
|
+
<div>
|
|
110
|
+
<a href={`/author/${authorSlug(u)}`}>{u.name}</a>
|
|
111
|
+
{u.bio && <p class="post-excerpt">{u.bio}</p>}
|
|
112
|
+
</div>
|
|
62
113
|
</li>
|
|
63
114
|
))}
|
|
64
115
|
</ul>
|
|
@@ -79,5 +130,19 @@ const total = results.posts.length + results.pages.length;
|
|
|
79
130
|
background: var(--accent); color: #fff; cursor: pointer;
|
|
80
131
|
}
|
|
81
132
|
.search-count { color: var(--muted); }
|
|
82
|
-
.search-
|
|
133
|
+
.search-section h2 { font-size: 1.1rem; margin-top: 2rem; }
|
|
134
|
+
/* snippets arrive pre-escaped with <mark> around matches (set:html), so
|
|
135
|
+
the highlight style must reach into the injected fragment */
|
|
136
|
+
.post-excerpt :global(mark) {
|
|
137
|
+
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
|
138
|
+
color: inherit; border-radius: 3px; padding: 0 1px;
|
|
139
|
+
}
|
|
140
|
+
.search-loc {
|
|
141
|
+
font-size: 11px; font-weight: 700; letter-spacing: 0.06em; color: var(--muted);
|
|
142
|
+
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; margin-left: 8px;
|
|
143
|
+
vertical-align: 2px;
|
|
144
|
+
}
|
|
145
|
+
.search-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
146
|
+
.search-tag-count { color: var(--muted); }
|
|
147
|
+
.search-avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; flex: none; }
|
|
83
148
|
</style>
|
package/src/styles/site.css
CHANGED
|
@@ -425,3 +425,88 @@ a.tag:hover { background: var(--accent); color: #fff; }
|
|
|
425
425
|
.pc-cal-row .d { flex-basis: 84px; }
|
|
426
426
|
}
|
|
427
427
|
}
|
|
428
|
+
|
|
429
|
+
/* Site search (.pc-search) — the header trigger + overlay modal rendered by
|
|
430
|
+
Site.astro. The server renders the dialog around a real GET form to the
|
|
431
|
+
/search page, so search works with JS disabled; the inline script upgrades
|
|
432
|
+
it to live /api/search results. Scoped custom properties default to global
|
|
433
|
+
tokens — theme CSS and Settings → Code injection are unlayered, so either
|
|
434
|
+
overrides any of this without !important. */
|
|
435
|
+
@layer base {
|
|
436
|
+
.site-nav a.pc-search-trigger {
|
|
437
|
+
display: inline-flex; align-items: center; align-self: center;
|
|
438
|
+
color: var(--muted); padding: 2px;
|
|
439
|
+
}
|
|
440
|
+
.site-nav a.pc-search-trigger:hover { color: var(--ink); }
|
|
441
|
+
.pc-search-trigger svg { display: block; }
|
|
442
|
+
|
|
443
|
+
dialog.pc-search {
|
|
444
|
+
--search-bg: var(--panel);
|
|
445
|
+
--search-ink: var(--ink);
|
|
446
|
+
--search-muted: var(--muted);
|
|
447
|
+
--search-line: var(--line);
|
|
448
|
+
--search-accent: var(--accent);
|
|
449
|
+
--search-radius: 12px;
|
|
450
|
+
--search-veil: rgb(0 0 0 / 0.35);
|
|
451
|
+
--search-width: 640px;
|
|
452
|
+
--search-top: 9vh;
|
|
453
|
+
width: min(var(--search-width), 92vw);
|
|
454
|
+
margin: var(--search-top) auto auto;
|
|
455
|
+
border: 0; padding: 0; background: transparent; overflow: visible;
|
|
456
|
+
}
|
|
457
|
+
dialog.pc-search::backdrop { background: var(--search-veil); backdrop-filter: blur(3px); }
|
|
458
|
+
|
|
459
|
+
.pc-search-box {
|
|
460
|
+
display: flex; align-items: center; gap: 12px;
|
|
461
|
+
background: var(--search-bg); color: var(--search-ink);
|
|
462
|
+
border-radius: var(--search-radius);
|
|
463
|
+
box-shadow: 0 14px 40px rgb(0 0 0 / 0.18), 0 2px 6px rgb(0 0 0 / 0.08);
|
|
464
|
+
padding: 15px 18px;
|
|
465
|
+
}
|
|
466
|
+
.pc-search-box svg { flex: none; color: var(--search-muted); }
|
|
467
|
+
.pc-search-input {
|
|
468
|
+
flex: 1; min-width: 0; border: 0; outline: 0; background: transparent;
|
|
469
|
+
font: inherit; font-size: 17px; color: var(--search-ink);
|
|
470
|
+
}
|
|
471
|
+
.pc-search-input::placeholder { color: var(--search-muted); }
|
|
472
|
+
.pc-search-input::-webkit-search-cancel-button { display: none; }
|
|
473
|
+
.pc-search-kbd {
|
|
474
|
+
flex: none; border: 1px solid var(--search-line); border-radius: 6px;
|
|
475
|
+
background: transparent; color: var(--search-muted); cursor: pointer;
|
|
476
|
+
font-size: 11px; font-weight: 700; letter-spacing: 0.04em; padding: 2px 7px;
|
|
477
|
+
}
|
|
478
|
+
.pc-search-kbd:hover { color: var(--search-ink); }
|
|
479
|
+
|
|
480
|
+
.pc-search-results {
|
|
481
|
+
margin-top: 10px; padding: 2px 0 8px;
|
|
482
|
+
background: var(--search-bg); border-radius: var(--search-radius);
|
|
483
|
+
box-shadow: 0 14px 40px rgb(0 0 0 / 0.16);
|
|
484
|
+
max-height: min(56vh, 460px); overflow-y: auto;
|
|
485
|
+
}
|
|
486
|
+
.pc-search-group {
|
|
487
|
+
font-size: 10.5px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase;
|
|
488
|
+
color: var(--search-muted); padding: 14px 18px 4px;
|
|
489
|
+
}
|
|
490
|
+
a.pc-search-hit {
|
|
491
|
+
display: flex; align-items: baseline; gap: 10px;
|
|
492
|
+
padding: 8px 18px; text-decoration: none; color: var(--search-ink); font-size: 15.5px;
|
|
493
|
+
}
|
|
494
|
+
a.pc-search-hit:hover, a.pc-search-hit.sel {
|
|
495
|
+
background: color-mix(in srgb, var(--search-ink) 6%, transparent);
|
|
496
|
+
}
|
|
497
|
+
.pc-search-t { min-width: 0; }
|
|
498
|
+
.pc-search-snip {
|
|
499
|
+
display: block; font-size: 13px; color: var(--search-muted);
|
|
500
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
501
|
+
}
|
|
502
|
+
.pc-search-snip mark {
|
|
503
|
+
background: color-mix(in srgb, var(--search-accent) 22%, transparent);
|
|
504
|
+
color: inherit; border-radius: 3px; padding: 0 1px;
|
|
505
|
+
}
|
|
506
|
+
.pc-search-m { margin-left: auto; flex: none; font-size: 12.5px; color: var(--search-muted); }
|
|
507
|
+
.pc-search-empty { padding: 18px; color: var(--search-muted); font-size: 14.5px; }
|
|
508
|
+
|
|
509
|
+
@media (max-width: 640px) {
|
|
510
|
+
dialog.pc-search { --search-top: 4vh; width: 94vw; }
|
|
511
|
+
}
|
|
512
|
+
}
|
package/src/lib/repo/search.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import type { Post } from '../schema';
|
|
2
|
-
|
|
3
|
-
// Shared ranking for the LIKE-based search: title hits beat excerpt hits beat
|
|
4
|
-
// body hits; recency breaks ties. Swap the adapters' SQL for FTS later and
|
|
5
|
-
// this ranking can go — the repo method signature stays the same.
|
|
6
|
-
export function rankByQuery(posts: Post[], query: string): Post[] {
|
|
7
|
-
const q = query.toLowerCase();
|
|
8
|
-
const score = (p: Post) =>
|
|
9
|
-
(p.title.toLowerCase().includes(q) ? 4 : 0) +
|
|
10
|
-
(p.excerpt.toLowerCase().includes(q) ? 2 : 0) +
|
|
11
|
-
(p.tags.some((t) => t.toLowerCase().includes(q)) ? 2 : 0) +
|
|
12
|
-
1;
|
|
13
|
-
return [...posts].sort((a, b) => score(b) - score(a) || (b.publishedAt ?? 0) - (a.publishedAt ?? 0));
|
|
14
|
-
}
|