feat: add FediDB-powered autocomplete for explore and reader lookup

- Add FediDB API client (lib/fedidb.js) with MongoDB caching (24h TTL)
  for instance search, timeline support checks, and popular accounts
- Explore page: instance input now shows autocomplete suggestions from
  FediDB with software type, MAU count, and timeline support indicator
  (checkmark/cross) via background pre-check
- Reader page: @handle lookup input now shows popular fediverse accounts
  from FediDB with avatar, name, handle, and follower count
- Three new API endpoints: /api/instances, /api/instance-check,
  /api/popular-accounts
- Alpine.js components for both autocomplete UIs with keyboard navigation
This commit is contained in:
Ricardo
2026-02-27 09:26:45 +01:00
parent 27721e0969
commit cee0050be8
9 changed files with 747 additions and 19 deletions

View File

@@ -0,0 +1,214 @@
/**
* Autocomplete — Alpine.js components for FediDB-powered search suggestions.
* Registers `apInstanceSearch` for the explore page instance input.
*/
document.addEventListener("alpine:init", () => {
// eslint-disable-next-line no-undef
Alpine.data("apInstanceSearch", (mountPath) => ({
query: "",
suggestions: [],
showResults: false,
highlighted: -1,
abortController: null,
init() {
// Pick up server-rendered value (when returning to page with instance already loaded)
const input = this.$refs.input;
if (input && input.getAttribute("value")) {
this.query = input.getAttribute("value");
}
},
// Debounced search triggered by x-on:input
async search() {
const q = (this.query || "").trim();
if (q.length < 2) {
this.suggestions = [];
this.showResults = false;
return;
}
// Cancel any in-flight request
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
try {
const res = await fetch(
`${mountPath}/admin/reader/api/instances?q=${encodeURIComponent(q)}`,
{ signal: this.abortController.signal }
);
if (!res.ok) return;
const data = await res.json();
// Mark _timelineStatus as undefined (not yet checked)
this.suggestions = data.map((item) => ({
...item,
_timelineStatus: undefined,
}));
this.highlighted = -1;
this.showResults = this.suggestions.length > 0;
// Fire timeline support checks in parallel (non-blocking)
this.checkTimelineSupport();
} catch (err) {
if (err.name !== "AbortError") {
this.suggestions = [];
this.showResults = false;
}
}
},
// Check timeline support for each suggestion (background, non-blocking)
async checkTimelineSupport() {
const items = [...this.suggestions];
for (const item of items) {
// Only check if still in the current suggestions list
const match = this.suggestions.find((s) => s.domain === item.domain);
if (!match) continue;
match._timelineStatus = "checking";
try {
const res = await fetch(
`${mountPath}/admin/reader/api/instance-check?domain=${encodeURIComponent(item.domain)}`
);
if (!res.ok) continue;
const data = await res.json();
// Update the item in the current suggestions (if still present)
const current = this.suggestions.find((s) => s.domain === item.domain);
if (current) {
current._timelineStatus = data.supported;
}
} catch {
const current = this.suggestions.find((s) => s.domain === item.domain);
if (current) {
current._timelineStatus = false;
}
}
}
},
selectItem(item) {
this.query = item.domain;
this.showResults = false;
this.suggestions = [];
this.$refs.input.focus();
},
close() {
this.showResults = false;
this.highlighted = -1;
},
highlightNext() {
if (!this.showResults || this.suggestions.length === 0) return;
this.highlighted = (this.highlighted + 1) % this.suggestions.length;
},
highlightPrev() {
if (!this.showResults || this.suggestions.length === 0) return;
this.highlighted =
this.highlighted <= 0
? this.suggestions.length - 1
: this.highlighted - 1;
},
selectHighlighted(event) {
if (this.showResults && this.highlighted >= 0 && this.suggestions[this.highlighted]) {
event.preventDefault();
this.selectItem(this.suggestions[this.highlighted]);
}
// Otherwise let the form submit naturally
},
onSubmit() {
this.close();
},
}));
// eslint-disable-next-line no-undef
Alpine.data("apPopularAccounts", (mountPath) => ({
query: "",
suggestions: [],
allAccounts: [],
showResults: false,
highlighted: -1,
loaded: false,
// Load popular accounts on first focus (lazy)
async loadAccounts() {
if (this.loaded) return;
this.loaded = true;
try {
const res = await fetch(`${mountPath}/admin/reader/api/popular-accounts`);
if (!res.ok) return;
this.allAccounts = await res.json();
} catch {
// Non-critical
}
},
// Filter locally from preloaded list
filterAccounts() {
const q = (this.query || "").trim().toLowerCase();
if (q.length < 1 || this.allAccounts.length === 0) {
this.suggestions = [];
this.showResults = false;
return;
}
this.suggestions = this.allAccounts
.filter(
(a) =>
a.username.toLowerCase().includes(q) ||
a.name.toLowerCase().includes(q) ||
a.domain.toLowerCase().includes(q) ||
a.handle.toLowerCase().includes(q)
)
.slice(0, 8);
this.highlighted = -1;
this.showResults = this.suggestions.length > 0;
},
selectItem(item) {
this.query = item.handle;
this.showResults = false;
this.suggestions = [];
this.$refs.input.focus();
},
close() {
this.showResults = false;
this.highlighted = -1;
},
highlightNext() {
if (!this.showResults || this.suggestions.length === 0) return;
this.highlighted = (this.highlighted + 1) % this.suggestions.length;
},
highlightPrev() {
if (!this.showResults || this.suggestions.length === 0) return;
this.highlighted =
this.highlighted <= 0
? this.suggestions.length - 1
: this.highlighted - 1;
},
selectHighlighted(event) {
if (this.showResults && this.highlighted >= 0 && this.suggestions[this.highlighted]) {
event.preventDefault();
this.selectItem(this.suggestions[this.highlighted]);
}
},
onSubmit() {
this.close();
},
}));
});

View File

@@ -1838,6 +1838,162 @@
}
}
/* ---------- Autocomplete dropdown ---------- */
.ap-explore-autocomplete {
flex: 1;
min-width: 0;
position: relative;
}
.ap-explore-autocomplete__dropdown {
background: var(--color-background);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
left: 0;
max-height: 320px;
overflow-y: auto;
position: absolute;
right: 0;
top: 100%;
z-index: 100;
}
.ap-explore-autocomplete__item {
align-items: center;
background: none;
border: none;
color: var(--color-on-background);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: var(--font-size-s);
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
text-align: left;
width: 100%;
}
.ap-explore-autocomplete__item:hover,
.ap-explore-autocomplete__item--highlighted {
background: var(--color-offset);
}
.ap-explore-autocomplete__domain {
flex-shrink: 0;
font-weight: 600;
}
.ap-explore-autocomplete__meta {
color: var(--color-on-offset);
display: flex;
flex: 1;
gap: var(--space-xs);
min-width: 0;
}
.ap-explore-autocomplete__software {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-radius: var(--border-radius-small);
font-size: var(--font-size-xs);
padding: 1px 6px;
white-space: nowrap;
}
.ap-explore-autocomplete__mau {
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ap-explore-autocomplete__status {
flex-shrink: 0;
font-size: var(--font-size-s);
}
.ap-explore-autocomplete__checking {
opacity: 0.5;
}
/* ---------- Popular accounts autocomplete ---------- */
.ap-lookup-autocomplete {
flex: 1;
min-width: 0;
position: relative;
}
.ap-lookup-autocomplete__dropdown {
background: var(--color-background);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
left: 0;
max-height: 320px;
overflow-y: auto;
position: absolute;
right: 0;
top: 100%;
z-index: 100;
}
.ap-lookup-autocomplete__item {
align-items: center;
background: none;
border: none;
color: var(--color-on-background);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: var(--font-size-s);
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
text-align: left;
width: 100%;
}
.ap-lookup-autocomplete__item:hover,
.ap-lookup-autocomplete__item--highlighted {
background: var(--color-offset);
}
.ap-lookup-autocomplete__avatar {
border-radius: 50%;
flex-shrink: 0;
height: 28px;
object-fit: cover;
width: 28px;
}
.ap-lookup-autocomplete__info {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.ap-lookup-autocomplete__name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-lookup-autocomplete__handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-lookup-autocomplete__followers {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
white-space: nowrap;
}
/* Replies — indented from the other side */
.ap-post-detail__replies {
margin-left: var(--space-l);

View File

@@ -61,7 +61,13 @@ import {
import { resolveController } from "./lib/controllers/resolve.js";
import { tagTimelineController } from "./lib/controllers/tag-timeline.js";
import { apiTimelineController } from "./lib/controllers/api-timeline.js";
import { exploreController, exploreApiController } from "./lib/controllers/explore.js";
import {
exploreController,
exploreApiController,
instanceSearchApiController,
instanceCheckApiController,
popularAccountsApiController,
} from "./lib/controllers/explore.js";
import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
import { publicProfileController } from "./lib/controllers/public-profile.js";
import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
@@ -227,6 +233,9 @@ export default class ActivityPubEndpoint {
router.get("/admin/reader/api/timeline", apiTimelineController(mp));
router.get("/admin/reader/explore", exploreController(mp));
router.get("/admin/reader/api/explore", exploreApiController(mp));
router.get("/admin/reader/api/instances", instanceSearchApiController(mp));
router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp));
router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp));
router.post("/admin/reader/follow-tag", followTagController(mp));
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
router.get("/admin/reader/notifications", notificationsController(mp));

View File

@@ -7,6 +7,7 @@
import sanitizeHtml from "sanitize-html";
import { sanitizeContent } from "../timeline-store.js";
import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
const FETCH_TIMEOUT_MS = 10_000;
const MAX_RESULTS = 20;
@@ -291,3 +292,73 @@ export function exploreApiController(mountPath) {
}
};
}
/**
* AJAX API endpoint for instance autocomplete.
* Returns JSON array of matching instances from FediDB.
*/
export function instanceSearchApiController(mountPath) {
return async (request, response, next) => {
try {
const q = (request.query.q || "").trim();
if (!q || q.length < 2) {
return response.json([]);
}
const { application } = request.app.locals;
const kvCollection = application?.collections?.get("ap_kv") || null;
const results = await searchInstances(kvCollection, q, 8);
response.json(results);
} catch (error) {
next(error);
}
};
}
/**
* AJAX API endpoint to check if an instance supports public timeline exploration.
* Returns JSON { supported: boolean, error: string|null }.
*/
export function instanceCheckApiController(mountPath) {
return async (request, response, next) => {
try {
const domain = (request.query.domain || "").trim().toLowerCase();
if (!domain) {
return response.status(400).json({ supported: false, error: "Missing domain" });
}
// Validate domain to prevent SSRF
const validated = validateInstance(domain);
if (!validated) {
return response.status(400).json({ supported: false, error: "Invalid domain" });
}
const { application } = request.app.locals;
const kvCollection = application?.collections?.get("ap_kv") || null;
const result = await checkInstanceTimeline(kvCollection, validated);
response.json(result);
} catch (error) {
next(error);
}
};
}
/**
* AJAX API endpoint for popular fediverse accounts.
* Returns the full cached list; client-side filtering via Alpine.js.
*/
export function popularAccountsApiController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const kvCollection = application?.collections?.get("ap_kv") || null;
const accounts = await getPopularAccounts(kvCollection, 50);
response.json(accounts);
} catch (error) {
next(error);
}
};
}

195
lib/fedidb.js Normal file
View File

@@ -0,0 +1,195 @@
/**
* FediDB API client with MongoDB caching.
*
* Wraps https://api.fedidb.org/v1/ endpoints:
* - /servers?q=... — search known fediverse instances
* - /popular-accounts — top accounts by follower count
*
* Responses are cached in ap_kv to avoid hitting the API on every keystroke.
* Cache TTL: 24 hours for both datasets.
*/
const API_BASE = "https://api.fedidb.org/v1";
const FETCH_TIMEOUT_MS = 8_000;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Fetch with timeout helper.
* @param {string} url
* @returns {Promise<Response>}
*/
async function fetchWithTimeout(url) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
signal: controller.signal,
});
return res;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Get cached data from ap_kv, or null if expired/missing.
* @param {object} kvCollection - MongoDB ap_kv collection
* @param {string} cacheKey - Key to look up
* @returns {Promise<object|null>} Cached data or null
*/
async function getFromCache(kvCollection, cacheKey) {
if (!kvCollection) return null;
try {
const doc = await kvCollection.findOne({ _id: cacheKey });
if (!doc?.value?.data) return null;
const age = Date.now() - (doc.value.cachedAt || 0);
if (age > CACHE_TTL_MS) return null;
return doc.value.data;
} catch {
return null;
}
}
/**
* Write data to ap_kv cache.
* @param {object} kvCollection - MongoDB ap_kv collection
* @param {string} cacheKey - Key to store under
* @param {object} data - Data to cache
*/
async function writeToCache(kvCollection, cacheKey, data) {
if (!kvCollection) return;
try {
await kvCollection.updateOne(
{ _id: cacheKey },
{ $set: { value: { data, cachedAt: Date.now() } } },
{ upsert: true }
);
} catch {
// Cache write failure is non-critical
}
}
/**
* Search FediDB for instances matching a query.
* Returns a flat array of { domain, software, description, mau, openRegistration }.
*
* Results are cached per normalized query for 24 hours.
*
* @param {object} kvCollection - MongoDB ap_kv collection
* @param {string} query - Search term (e.g. "mast")
* @param {number} [limit=10] - Max results
* @returns {Promise<Array>}
*/
export async function searchInstances(kvCollection, query, limit = 10) {
const q = (query || "").trim().toLowerCase();
if (!q) return [];
const cacheKey = `fedidb:instances:${q}:${limit}`;
const cached = await getFromCache(kvCollection, cacheKey);
if (cached) return cached;
try {
const url = `${API_BASE}/servers?q=${encodeURIComponent(q)}&limit=${limit}`;
const res = await fetchWithTimeout(url);
if (!res.ok) return [];
const json = await res.json();
const servers = json.data || [];
const results = servers.map((s) => ({
domain: s.domain,
software: s.software?.name || "Unknown",
description: s.description || "",
mau: s.stats?.monthly_active_users || 0,
userCount: s.stats?.user_count || 0,
openRegistration: s.open_registration || false,
}));
await writeToCache(kvCollection, cacheKey, results);
return results;
} catch {
return [];
}
}
/**
* Check if a remote instance supports unauthenticated public timeline access.
* Makes a lightweight HEAD-like request (limit=1) to the Mastodon public timeline API.
*
* Cached per domain for 24 hours.
*
* @param {object} kvCollection - MongoDB ap_kv collection
* @param {string} domain - Instance hostname
* @returns {Promise<{ supported: boolean, error: string|null }>}
*/
export async function checkInstanceTimeline(kvCollection, domain) {
const cacheKey = `fedidb:timeline-check:${domain}`;
const cached = await getFromCache(kvCollection, cacheKey);
if (cached) return cached;
try {
const url = `https://${domain}/api/v1/timelines/public?local=true&limit=1`;
const res = await fetchWithTimeout(url);
let result;
if (res.ok) {
result = { supported: true, error: null };
} else {
let errorMsg = `HTTP ${res.status}`;
try {
const body = await res.json();
if (body.error) errorMsg = body.error;
} catch {
// Can't parse body
}
result = { supported: false, error: errorMsg };
}
await writeToCache(kvCollection, cacheKey, result);
return result;
} catch {
return { supported: false, error: "Connection failed" };
}
}
/**
* Fetch popular fediverse accounts from FediDB.
* Returns a flat array of { username, name, domain, handle, url, avatar, followers, bio }.
*
* Cached for 24 hours (single cache entry).
*
* @param {object} kvCollection - MongoDB ap_kv collection
* @param {number} [limit=50] - Max accounts to fetch
* @returns {Promise<Array>}
*/
export async function getPopularAccounts(kvCollection, limit = 50) {
const cacheKey = `fedidb:popular-accounts:${limit}`;
const cached = await getFromCache(kvCollection, cacheKey);
if (cached) return cached;
try {
const url = `${API_BASE}/popular-accounts?limit=${limit}`;
const res = await fetchWithTimeout(url);
if (!res.ok) return [];
const json = await res.json();
const accounts = json.data || [];
const results = accounts.map((a) => ({
username: a.username || "",
name: a.name || a.username || "",
domain: a.domain || "",
handle: `@${a.username}@${a.domain}`,
url: a.account_url || "",
avatar: a.avatar_url || "",
followers: a.followers_count || 0,
bio: (a.bio || "").replace(/<[^>]*>/g, "").slice(0, 120),
}));
await writeToCache(kvCollection, cacheKey, results);
return results;
} catch {
return [];
}
}

View File

@@ -220,7 +220,8 @@
"label": "Look up a fediverse post or account",
"button": "Look up",
"notFoundTitle": "Not found",
"notFound": "Could not find this post or account. The URL may be invalid, the server may be unavailable, or the content may have been deleted."
"notFound": "Could not find this post or account. The URL may be invalid, the server may be unavailable, or the content may have been deleted.",
"followersLabel": "followers"
},
"linkPreview": {
"label": "Link preview"
@@ -235,7 +236,10 @@
"loadError": "Could not load timeline from this instance. It may be unavailable or not support the Mastodon API.",
"timeout": "Request timed out. The instance may be slow or unavailable.",
"noResults": "No posts found on this instance's public timeline.",
"invalidInstance": "Invalid instance hostname. Please enter a valid domain name."
"invalidInstance": "Invalid instance hostname. Please enter a valid domain name.",
"mauLabel": "MAU",
"timelineSupported": "Public timeline available",
"timelineUnsupported": "Public timeline not available"
},
"tagTimeline": {
"postsTagged": "%d posts",

View File

@@ -9,9 +9,12 @@
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
</header>
{# Instance form #}
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form">
{# Instance form with autocomplete #}
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
x-data="apInstanceSearch('{{ mountPath }}')"
@submit="onSubmit">
<div class="ap-explore-form__row">
<div class="ap-explore-autocomplete">
<input
type="text"
name="instance"
@@ -20,7 +23,48 @@
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
autocomplete="off"
required>
required
x-model="query"
@input.debounce.300ms="search()"
@keydown.arrow-down.prevent="highlightNext()"
@keydown.arrow-up.prevent="highlightPrev()"
@keydown.enter="selectHighlighted($event)"
@keydown.escape="close()"
@focus="showResults && suggestions.length > 0 ? showResults = true : null"
@click.away="close()"
x-ref="input">
{# Autocomplete dropdown #}
<div class="ap-explore-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
<template x-for="(item, index) in suggestions" :key="item.domain">
<button type="button"
class="ap-explore-autocomplete__item"
:class="{ 'ap-explore-autocomplete__item--highlighted': index === highlighted }"
@click="selectItem(item)"
@mouseenter="highlighted = index">
<span class="ap-explore-autocomplete__domain" x-text="item.domain"></span>
<span class="ap-explore-autocomplete__meta">
<span class="ap-explore-autocomplete__software" x-text="item.software"></span>
<template x-if="item.mau > 0">
<span class="ap-explore-autocomplete__mau" x-text="item.mau.toLocaleString() + ' {{ __("activitypub.reader.explore.mauLabel") }}'"></span>
</template>
</span>
<span class="ap-explore-autocomplete__status" x-show="item._timelineStatus !== undefined">
<template x-if="item._timelineStatus === 'checking'">
<span class="ap-explore-autocomplete__checking">⏳</span>
</template>
<template x-if="item._timelineStatus === true">
<span class="ap-explore-autocomplete__supported" title="{{ __('activitypub.reader.explore.timelineSupported') }}">✅</span>
</template>
<template x-if="item._timelineStatus === false">
<span class="ap-explore-autocomplete__unsupported" title="{{ __('activitypub.reader.explore.timelineUnsupported') }}">❌</span>
</template>
</span>
</button>
</template>
</div>
</div>
<div class="ap-explore-form__scope">
<label class="ap-explore-form__scope-label">
<input type="radio" name="scope" value="local"

View File

@@ -21,11 +21,44 @@
</div>
{% endif %}
{# Fediverse lookup #}
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup">
{# Fediverse lookup with popular accounts autocomplete #}
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup"
x-data="apPopularAccounts('{{ mountPath }}')"
@submit="onSubmit">
<div class="ap-lookup-autocomplete">
<input type="text" name="q" class="ap-lookup__input"
placeholder="{{ __('activitypub.reader.resolve.placeholder') }}"
aria-label="{{ __('activitypub.reader.resolve.label') }}">
aria-label="{{ __('activitypub.reader.resolve.label') }}"
x-model="query"
@focus="loadAccounts()"
@input.debounce.200ms="filterAccounts()"
@keydown.arrow-down.prevent="highlightNext()"
@keydown.arrow-up.prevent="highlightPrev()"
@keydown.enter="selectHighlighted($event)"
@keydown.escape="close()"
@click.away="close()"
x-ref="input">
{# Popular accounts dropdown #}
<div class="ap-lookup-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
<template x-for="(item, index) in suggestions" :key="item.handle">
<button type="button"
class="ap-lookup-autocomplete__item"
:class="{ 'ap-lookup-autocomplete__item--highlighted': index === highlighted }"
@click="selectItem(item)"
@mouseenter="highlighted = index">
<img :src="item.avatar" :alt="item.name" class="ap-lookup-autocomplete__avatar"
onerror="this.style.display='none'">
<span class="ap-lookup-autocomplete__info">
<span class="ap-lookup-autocomplete__name" x-text="item.name"></span>
<span class="ap-lookup-autocomplete__handle" x-text="item.handle"></span>
</span>
<span class="ap-lookup-autocomplete__followers"
x-text="item.followers.toLocaleString() + ' {{ __("activitypub.reader.resolve.followersLabel") }}'"></span>
</button>
</template>
</div>
</div>
<button type="submit" class="ap-lookup__btn">{{ __("activitypub.reader.resolve.button") }}</button>
</form>

View File

@@ -3,6 +3,8 @@
{% block content %}
{# Infinite scroll component — must load before Alpine to register via alpine:init #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
{# Autocomplete components for explore + popular accounts #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>