Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5f63420
feat(cloudflare): add AI Search plugin
ttmx Jul 16, 2026
7e9e093
feat(demo): wire AI Search into the Cloudflare demo
ttmx Jul 16, 2026
1f8406d
chore: AI Search changeset and lockfile
ttmx Jul 16, 2026
faa7142
fix(ai-search): address review feedback
ttmx Jul 16, 2026
2ac62dc
Return 503 instead of 500 if AI Search fails.
ttmx Jul 16, 2026
d6d0f39
Stop needlessly stripping markdown from bylines
ttmx Jul 17, 2026
2ed3d9e
Apply suggestion from @emdashbot[bot]
ttmx Jul 28, 2026
8e322aa
feat(ai-search): package snippet endpoint
ttmx Jul 28, 2026
7e3a24a
feat(ai-search): add Astro snippet component
ttmx Jul 28, 2026
fa2f435
Fixed scheduled handler in worker.ts
ttmx Jul 30, 2026
268a905
fix(ai-search): make public search endpoint work on anonymous requests
scottbuscemi Jul 30, 2026
b4a56aa
style: format
emdashbot[bot] Jul 30, 2026
113f90e
Add docs for the Cloudflare AI Search plugin
ttmx Jul 30, 2026
8689771
Change plugin name from AI Search to Cloudflare AI Search
ttmx Jul 30, 2026
bb3932b
Simplify uploadItem()
ttmx Jul 31, 2026
9c5acdf
Remove "limit" from AISearchQueryInput, it is configurable in the ins…
ttmx Jul 31, 2026
6dc9db0
Don't index draft content if post was previously indexed.
ttmx Jul 31, 2026
29a9ece
Actually disable ai search plugin endpoint when plugin is disabled.
ttmx Jul 31, 2026
e426115
style: format
emdashbot[bot] Jul 31, 2026
770beef
Clarify AI Search result limit ownership
ttmx Aug 3, 2026
a5a7a18
Remove narrative comments from Cloudflare demo
ttmx Aug 3, 2026
43b35fc
Remove narrative AI Search test comments
ttmx Aug 3, 2026
578e41f
Remove weak AI Search build safety test
ttmx Aug 3, 2026
5aa3b2a
Fix hotpath db query, limited to 1 per minute per isolate
ttmx Aug 3, 2026
4e85a49
style: format
emdashbot[bot] Aug 3, 2026
84dce83
Fix AI Search removal hooks for deselected collections
ttmx Aug 4, 2026
c9e4fc1
Reconcile AI Search metadata configuration
ttmx Aug 4, 2026
2bca802
Validate and forward AI Search result limits
ttmx Aug 4, 2026
093aeb6
Refresh AI Search synonyms after updates
ttmx Aug 4, 2026
7cb86eb
Omit AI Search indexing timestamps from results
ttmx Aug 4, 2026
e96ebe5
Filter AI Search collections during retrieval
ttmx Aug 4, 2026
93c6150
Persist default AI Search collections
ttmx Aug 4, 2026
d5550fe
Remove unused AI Search install hook
ttmx Aug 4, 2026
b3ab6e5
style: format
emdashbot[bot] Aug 4, 2026
8090b47
fix(cloudflare): recognize Cloudflare's ai_search_not_found error
ttmx Aug 4, 2026
86e5248
fix(cloudflare): only create an AI Search instance when it is missing
ttmx Aug 4, 2026
4aa7e77
refactor(cloudflare): import cloudflare:workers once per isolate
ttmx Aug 4, 2026
bf7e1cf
fix(cloudflare): drop deselected collections from the AI Search index
ttmx Aug 4, 2026
5b05bc0
docs: require an initial AI Search sync during setup
ttmx Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ai-search-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
Comment thread
ttmx marked this conversation as resolved.
"@emdash-cms/cloudflare": minor
---

Adds a Cloudflare AI Search plugin with automatic content indexing, cron-driven reindexing, language-aware queries, synonyms, index status, admin configuration, and an Astro search component with the browser snippet included.
14 changes: 14 additions & 0 deletions demos/cloudflare/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
cloudflareImages,
cloudflareStream,
} from "@emdash-cms/cloudflare";
import { aiSearch } from "@emdash-cms/cloudflare/plugins";
import { formsPlugin } from "@emdash-cms/plugin-forms";
import webhookNotifier from "@emdash-cms/plugin-webhook-notifier";
import { defineConfig, fontProviders } from "astro/config";
Expand Down Expand Up @@ -72,6 +73,19 @@ export default defineConfig({
plugins: [
// Test plugin that exercises all v2 APIs
formsPlugin(),
aiSearch({
// AI Search instance name (created on first index). Default: "emdash-content".
instanceName: "emdash-content",
// wrangler.jsonc `ai_search_namespaces` binding name. Default: "AI_SEARCH".
binding: "AI_SEARCH",
// Hybrid search (vector + keyword). Default: true.
hybridSearch: true,
// Public result URLs returned to the AI Search snippet.
urlTemplates: {
posts: "/posts/{slug}?lang={locale}",
pages: "/pages/{slug}?lang={locale}",
},
}),
],
// Sandboxed plugins (run in isolated workers)
sandboxed: [webhookNotifier],
Expand Down
1 change: 1 addition & 0 deletions demos/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@astrojs/cloudflare": "catalog:",
"@astrojs/react": "catalog:",
"@cloudflare/ai-search-snippet": "^0.0.40",
"@emdash-cms/cloudflare": "workspace:*",
"@emdash-cms/plugin-forms": "workspace:*",
"@emdash-cms/plugin-webhook-notifier": "workspace:*",
Expand Down
204 changes: 60 additions & 144 deletions demos/cloudflare/src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
EmDashBodyEnd,
} from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import LiveSearch from "emdash/ui/search";
import { Font } from "astro:assets";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
import "../styles/theme.css";
Expand All @@ -23,7 +22,6 @@ interface Props {
publishedTime?: string | null;
modifiedTime?: string | null;
author?: string | null;
/** Pass content reference for plugin page contributions on content pages */
content?: { collection: string; id: string; slug?: string | null };
}

Expand All @@ -41,22 +39,14 @@ const {
content,
} = Astro.props;
const { siteTitle, siteTagline, siteLogo } = resolveBlogSiteIdentity(await getSiteSettings());
// If title already includes site title (from getSeoMeta), use as-is
const fullTitle = title.includes(siteTitle) ? title : `${title} — ${siteTitle}`;

// Fetch primary menu defined in seed
const menu = await getMenu("primary");

// Optional "social" menu. If a site defines a `social` menu in its seed,
// it'll render in the footer's "Connect" column. Otherwise that column
// just shows RSS, avoiding duplication with the "Navigate" column above.
const socialMenu = await getMenu("social");

// Fetch pages for footer
const { entries: pages } = await getEmDashCollection("pages");

// Build public page context for plugin contributions
// SEO data is passed here and rendered securely by EmDashHead
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
Expand All @@ -72,7 +62,6 @@ const pageCtx = createPublicPageContext({
siteName: siteTitle,
});

// Check if user is logged in (for showing admin link)
const isLoggedIn = !!Astro.locals.user;
---

Expand Down Expand Up @@ -110,14 +99,24 @@ const isLoggedIn = !!Astro.locals.user;
}
</a>
<div class="nav-right">
<LiveSearch
placeholder="Search..."
class="site-search"
inputClass="site-search-input"
resultsClass="site-search-results"
resultClass="site-search-result"
collections={["posts", "pages"]}
/>
<button type="button" class="nav-search-btn" data-search-trigger aria-label="Search">
<svg class="nav-search-icon" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true">
<path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/>
</svg>
<span>Search</span>
<kbd class="nav-search-kbd">⌘K</kbd>
</button>
<search-modal-snippet
id="site-search"
api-url="/api/ai-search"
placeholder="Search…"
theme="auto"
max-results="30"
max-render-results="10"
show-url="true"
show-date="true"
disable-analytics="true"
></search-modal-snippet>
<div class="nav-links">
{
menu?.items.map((item) => (
Expand Down Expand Up @@ -281,7 +280,6 @@ const isLoggedIn = !!Astro.locals.user;
</footer>

<script>
// Theme switcher
const THEME_REGEX = /theme=([^;]+)/;
const themeBtns =
document.querySelectorAll<HTMLButtonElement>(".theme-btn");
Expand Down Expand Up @@ -326,7 +324,6 @@ const isLoggedIn = !!Astro.locals.user;
return match ? match[1] : "system";
}

// Initialize
const storedTheme = getStoredTheme();
setTheme(storedTheme);

Expand All @@ -336,7 +333,6 @@ const isLoggedIn = !!Astro.locals.user;
});
});

// Listen for system preference changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
Expand Down Expand Up @@ -622,133 +618,66 @@ const isLoggedIn = !!Astro.locals.user;
gap: var(--spacing-6);
}

.nav-links {
display: flex;
gap: var(--spacing-5);
font-size: var(--font-size-sm);
}

.nav-links a {
text-decoration: none;
color: var(--color-text);
transition: color var(--transition-fast);
}

.nav-links a:hover {
color: var(--color-accent);
}

.nav-admin {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}

.nav-admin:hover {
color: var(--color-text);
}

/* Search styling */
.site-search {
position: relative;
width: var(--search-input-width);
--emdash-search-border-focus: var(--color-accent);
}

:global(.site-search-input) {
width: var(--search-input-width);
.nav-search-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
font-family: var(--font-sans);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
color var(--transition-fast);
}

:global(.site-search-input)::placeholder {
color: var(--color-muted);
.nav-search-btn:hover {
color: var(--color-text);
border-color: var(--color-accent);
}

:global(.site-search-input):focus,
:global(.site-search-input):focus-visible {
outline: none;
border-color: var(--color-accent) !important;
box-shadow: 0 0 0 3px var(--color-accent-ring);
.nav-search-icon {
width: 16px;
height: 16px;
}

:global(.site-search-results) {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: var(--spacing-2);
background: var(--color-bg);
.nav-search-kbd {
font-family: var(--font-sans);
font-size: var(--font-size-xs);
color: var(--color-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-dropdown);
max-height: 400px;
overflow-y: auto;
z-index: 1000;
border-radius: var(--radius);
padding: 0 var(--spacing-1);
}

:global(.site-search-results .emdash-live-search-loading),
:global(.site-search-results .emdash-live-search-no-results) {
padding: var(--spacing-4);
text-align: center;
color: var(--color-muted);
.nav-links {
display: flex;
gap: var(--spacing-5);
font-size: var(--font-size-sm);
}

:global(.site-search-result) {
display: block;
padding: var(--spacing-3) var(--spacing-4);
.nav-links a {
text-decoration: none;
color: var(--color-text);
border-bottom: 1px solid var(--color-border-subtle);
transition: background var(--transition-fast);
}

:global(.site-search-result):last-child {
border-bottom: none;
}

:global(.site-search-result):hover,
:global(.site-search-result):focus,
:global(.site-search-result.focused) {
background: var(--color-surface);
outline: none;
}

:global(.site-search-result .emdash-live-search-result-title) {
display: block;
font-weight: 500;
font-size: var(--font-size-sm);
transition: color var(--transition-fast);
}

:global(.site-search-result .emdash-live-search-result-collection) {
display: block;
font-size: var(--font-size-xs);
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
margin-top: 2px;
.nav-links a:hover {
color: var(--color-accent);
}

:global(.site-search-result .emdash-live-search-result-snippet) {
display: block;
.nav-admin {
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-top: var(--spacing-1);
line-height: var(--leading-snug);
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}

:global(.site-search-result .emdash-live-search-result-snippet mark) {
font-weight: 600;
.nav-admin:hover {
color: var(--color-text);
}

Expand Down Expand Up @@ -945,17 +874,6 @@ const isLoggedIn = !!Astro.locals.user;
display: contents;
}

.site-search {
order: 0;
max-width: 140px;
}

:global(.site-search-input) {
width: 140px !important;
padding: var(--spacing-1) var(--spacing-2) !important;
font-size: var(--font-size-sm) !important;
}

.nav-links {
order: 1;
width: 100%;
Expand Down Expand Up @@ -996,18 +914,16 @@ const isLoggedIn = !!Astro.locals.user;
</style>

<script>
// ⌘K / Ctrl+K to focus search
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
const searchInput = document.querySelector(
".site-search-input"
) as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}
});
import * as aiSearchSnippet from "@cloudflare/ai-search-snippet";

void aiSearchSnippet;

const searchModal = document.getElementById("site-search") as HTMLElement & {
open?: () => void;
};
document
.querySelector("[data-search-trigger]")
?.addEventListener("click", () => searchModal?.open?.());
</script>
<EmDashBodyEnd page={pageCtx} />
</body>
Expand Down
1 change: 1 addition & 0 deletions demos/cloudflare/src/pages/api/ai-search/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { POST, prerender } from "@emdash-cms/cloudflare/plugins/ai-search";
Loading
Loading