@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.
@@ -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
+ };
@@ -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 { CONTENT, collectionUrl } from '../lib/content';
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 total = results.posts.length + results.pages.length;
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 ? `Search: ${q}` : 'Search'} description={`Search ${settings.siteTitle}`} noindex={Boolean(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>Search</h1>
17
- <form class="search-form" action="/search" method="get" role="search">
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={`Search ${CONTENT.posts.plural.toLowerCase()} and pages…`}
23
- aria-label="Search"
35
+ placeholder={t.placeholder}
36
+ aria-label={t.label}
24
37
  autofocus={!q}
25
38
  />
26
- <button type="submit">Search</button>
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 ? `Nothing found for “${q}”.` : `${total} ${total === 1 ? 'result' : 'results'} for “${q}”`}
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
- {p.excerpt && <p class="post-excerpt">{p.excerpt}</p>}
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-pages">
57
- <h2>Pages</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><a href={p.isHome ? '/' : `/${p.slug}`}>{p.title}</a></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-pages h2 { font-size: 1.1rem; margin-top: 2rem; }
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>
@@ -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
+ }
@@ -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
- }