+ Wrap any SQL database in instant APIs, a Studio your team can actually use, and a native MCP server. One role-based access layer governs all of it.
+
-
+
diff --git a/app/plugins/scrollRestoration.client.ts b/app/plugins/scrollRestoration.client.ts
new file mode 100644
index 000000000..4d026e31e
--- /dev/null
+++ b/app/plugins/scrollRestoration.client.ts
@@ -0,0 +1,13 @@
+import { scrollPositions } from '~/router.options';
+
+export default defineNuxtPlugin(() => {
+ const router = useRouter();
+
+ router.beforeEach(() => {
+ const key = window.history.state?.key as string | undefined;
+ const scroller = document.getElementById('docs-scroll');
+ if (key && scroller) {
+ scrollPositions.set(key, scroller.scrollTop);
+ }
+ });
+});
diff --git a/app/router.options.ts b/app/router.options.ts
new file mode 100644
index 000000000..fac292b13
--- /dev/null
+++ b/app/router.options.ts
@@ -0,0 +1,48 @@
+import type { RouterConfig } from '@nuxt/schema';
+import { nextTick } from 'vue';
+
+export const scrollPositions = new Map();
+
+function getHashTarget(hash: string): HTMLElement | null {
+ const id = decodeURIComponent(hash.replace(/^#/, ''));
+ return document.getElementById(id) ?? document.querySelector(hash) as HTMLElement | null;
+}
+
+function scrollToHash(scroller: HTMLElement | null, hash: string): boolean {
+ const target = getHashTarget(hash);
+ if (!target) return false;
+
+ if (scroller) {
+ scroller.scrollTo({ top: Math.max(0, target.offsetTop - 80), behavior: 'smooth' });
+ }
+ else {
+ window.scrollTo({ top: Math.max(0, target.offsetTop - 80), behavior: 'smooth' });
+ }
+ return true;
+}
+
+export default {
+ scrollBehavior: async (to, from, savedPosition) => {
+ const scroller = document.getElementById('docs-scroll');
+ const scrollTarget = scroller ?? window;
+
+ if (savedPosition) {
+ const key = window.history.state?.key as string | undefined;
+ const top = key ? (scrollPositions.get(key) ?? 0) : 0;
+ await nextTick();
+ scrollTarget.scrollTo({ top, left: 0 });
+ return;
+ }
+
+ if (to.hash) {
+ await nextTick();
+ requestAnimationFrame(() => scrollToHash(scroller, to.hash));
+ return false;
+ }
+
+ if (to.path !== from.path) {
+ await nextTick();
+ scrollTarget.scrollTo(0, 0);
+ }
+ },
+};
diff --git a/app/services/typesenseService.ts b/app/services/typesenseService.ts
new file mode 100644
index 000000000..97b8308d9
--- /dev/null
+++ b/app/services/typesenseService.ts
@@ -0,0 +1,254 @@
+import { parseTypesenseUrl } from '#shared/utils/parseTypesenseUrl';
+
+export interface TypesenseSearchState {
+ query: string;
+ page: number;
+ hitsPerPage: number;
+ sort?: string;
+ filters: Record;
+}
+
+export interface TypesenseSearchConfig {
+ query_by: string;
+ query_by_weights?: string;
+ facet_by?: string;
+ sort_by?: string;
+ filter_by?: string;
+ group_by?: string;
+ group_limit?: number;
+ include_fields?: string;
+ highlight_fields?: string;
+ highlight_full_fields?: string;
+ prefix?: string | boolean;
+ use_cache?: boolean;
+}
+
+export interface TypesenseSearchParams {
+ indexName: string;
+ searchConfig: TypesenseSearchConfig;
+ state: TypesenseSearchState;
+ /**
+ * Facet attributes that need unfiltered counts. When the user has applied a
+ * filter on one of these attributes, we fire a parallel multi_search query
+ * with that filter removed so the filter widget can still show counts for
+ * unselected values. https://typesense.org/docs/30.2/api/search.html#facet-results
+ */
+ facetRefinementAttributes?: string[];
+}
+
+export interface TypesenseFacetCount {
+ value: string;
+ count: number;
+ highlighted?: string;
+}
+
+export interface TypesenseFacetResults {
+ [field: string]: TypesenseFacetCount[];
+}
+
+export interface TypesenseSearchHit> {
+ document: TDocument;
+ highlights?: Array>;
+ highlight?: Record;
+ text_match?: number;
+ text_match_info?: Record;
+}
+
+export interface TypesenseGroupedHit> {
+ group_key: string[];
+ hits: TypesenseSearchHit[];
+ found?: number;
+}
+
+export interface TypesenseSearchResult> {
+ hits: TypesenseSearchHit[];
+ grouped_hits?: TypesenseGroupedHit[];
+ facets: TypesenseFacetResults;
+ found: number;
+ search_time_ms: number;
+ page: number;
+ out_of: number;
+}
+
+interface TypesenseServiceOptions {
+ typesenseUrl: string;
+ typesensePublicApiKey: string;
+}
+
+interface TypesenseAPISearchParams {
+ q: string;
+ query_by: string;
+ page: number;
+ per_page: number;
+ query_by_weights?: string;
+ facet_by?: string;
+ sort_by?: string;
+ filter_by?: string;
+ group_by?: string;
+ group_limit?: number;
+ include_fields?: string;
+ highlight_fields?: string;
+ highlight_full_fields?: string;
+ prefix?: string | boolean;
+ use_cache?: boolean;
+}
+
+interface RawSearchResponse {
+ hits?: TypesenseSearchHit[];
+ grouped_hits?: TypesenseGroupedHit[];
+ facet_counts?: Array<{ field_name: string; counts: Array<{ value: string; count: number; highlighted?: string }> }>;
+ found?: number;
+ page?: number;
+ out_of?: number;
+}
+
+export class TypesenseService {
+ private typesenseNode: ReturnType;
+ private typesensePublicApiKey: string;
+
+ constructor(options: TypesenseServiceOptions) {
+ this.typesenseNode = parseTypesenseUrl(options.typesenseUrl);
+ this.typesensePublicApiKey = options.typesensePublicApiKey;
+ }
+
+ private get baseUrl() {
+ const path = this.typesenseNode.path ?? '';
+ return `${this.typesenseNode.protocol}://${this.typesenseNode.host}:${this.typesenseNode.port}${path}`;
+ }
+
+ private buildSearchParams({ searchConfig, state }: TypesenseSearchParams, excludeFilterAttribute?: string): TypesenseAPISearchParams {
+ const params: TypesenseAPISearchParams = {
+ q: state.query || '*',
+ query_by: searchConfig.query_by,
+ page: state.page,
+ per_page: state.hitsPerPage,
+ };
+
+ if (searchConfig.query_by_weights) params.query_by_weights = searchConfig.query_by_weights;
+ if (searchConfig.facet_by) params.facet_by = searchConfig.facet_by;
+ if (state.sort) params.sort_by = state.sort;
+ else if (searchConfig.sort_by) params.sort_by = searchConfig.sort_by;
+ if (searchConfig.group_by) params.group_by = searchConfig.group_by;
+ if (searchConfig.group_limit) params.group_limit = searchConfig.group_limit;
+ if (searchConfig.include_fields) params.include_fields = searchConfig.include_fields;
+ if (searchConfig.highlight_fields) params.highlight_fields = searchConfig.highlight_fields;
+ if (searchConfig.highlight_full_fields) params.highlight_full_fields = searchConfig.highlight_full_fields;
+ if (searchConfig.prefix !== undefined) params.prefix = searchConfig.prefix;
+ if (searchConfig.use_cache !== undefined) params.use_cache = searchConfig.use_cache;
+
+ const filterParts: string[] = [];
+ if (searchConfig.filter_by) filterParts.push(searchConfig.filter_by);
+ for (const [attribute, values] of Object.entries(state.filters)) {
+ if (values.length === 0 || attribute === excludeFilterAttribute) continue;
+ const clause = values.map(value => `${attribute}:=${value}`).join(' || ');
+ filterParts.push(`(${clause})`);
+ }
+ if (filterParts.length > 0) params.filter_by = filterParts.join(' && ');
+
+ return params;
+ }
+
+ private toFacets(facetCounts: RawSearchResponse['facet_counts']): TypesenseFacetResults {
+ const facets: TypesenseFacetResults = {};
+ for (const facetCount of facetCounts ?? []) {
+ facets[facetCount.field_name] = facetCount.counts.map(count => ({
+ value: count.value,
+ count: count.count,
+ highlighted: count.highlighted,
+ }));
+ }
+ return facets;
+ }
+
+ async search>(
+ params: TypesenseSearchParams,
+ signal?: AbortSignal,
+ ): Promise> {
+ const requestStartTime = performance.now();
+ const refinementAttributes = (params.facetRefinementAttributes ?? [])
+ .filter(attribute => params.state.filters[attribute]?.length);
+
+ if (refinementAttributes.length === 0) {
+ const apiParams = this.buildSearchParams(params);
+ const url = new URL(`${this.baseUrl}/collections/${params.indexName}/documents/search`);
+ for (const [key, value] of Object.entries(apiParams)) {
+ url.searchParams.append(key, String(value));
+ }
+
+ const response = await $fetch>(url.toString(), {
+ headers: { 'X-TYPESENSE-API-KEY': this.typesensePublicApiKey },
+ signal,
+ });
+
+ return {
+ hits: response.hits ?? [],
+ grouped_hits: response.grouped_hits ?? [],
+ facets: this.toFacets(response.facet_counts),
+ found: response.found ?? 0,
+ search_time_ms: Math.round(performance.now() - requestStartTime),
+ page: response.page ?? 1,
+ out_of: response.out_of ?? 0,
+ };
+ }
+
+ // Refinement path: fire the main search + one facet-only search per
+ // refinement attribute (each with that attribute's filter removed) in a
+ // single multi_search request, then merge the facet counts back.
+ const searches: Array = [
+ { ...this.buildSearchParams(params), collection: params.indexName },
+ ];
+ for (const attribute of refinementAttributes) {
+ searches.push({
+ ...this.buildSearchParams(params, attribute),
+ collection: params.indexName,
+ facet_by: attribute,
+ per_page: 0,
+ });
+ }
+
+ const response = await $fetch<{ results: RawSearchResponse[] }>(`${this.baseUrl}/multi_search`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-TYPESENSE-API-KEY': this.typesensePublicApiKey,
+ },
+ body: { searches },
+ signal,
+ });
+
+ const main = response.results[0]!;
+ const facets = this.toFacets(main.facet_counts);
+ for (const [index, attribute] of refinementAttributes.entries()) {
+ const refinement = response.results[index + 1];
+ const refinementCount = refinement?.facet_counts?.find(entry => entry.field_name === attribute);
+ if (refinementCount) {
+ facets[attribute] = refinementCount.counts.map(count => ({
+ value: count.value,
+ count: count.count,
+ highlighted: count.highlighted,
+ }));
+ }
+ }
+
+ return {
+ hits: main.hits ?? [],
+ grouped_hits: main.grouped_hits ?? [],
+ facets,
+ found: main.found ?? 0,
+ search_time_ms: Math.round(performance.now() - requestStartTime),
+ page: main.page ?? 1,
+ out_of: main.out_of ?? 0,
+ };
+ }
+}
+
+let typesenseService: TypesenseService | null = null;
+
+export function getTypesenseService(options?: TypesenseServiceOptions) {
+ if (!typesenseService) {
+ if (!options) throw new Error('getTypesenseService: first call requires options');
+ typesenseService = new TypesenseService(options);
+ }
+
+ return typesenseService;
+}
diff --git a/app/utils/agent-deeplinks.ts b/app/utils/agent-deeplinks.ts
new file mode 100644
index 000000000..ce8011b04
--- /dev/null
+++ b/app/utils/agent-deeplinks.ts
@@ -0,0 +1,34 @@
+// IDE deeplink helpers for the docs MCP server and agent prompt copy actions.
+// Toolkit-supported IDEs are sourced from @nuxtjs/mcp-toolkit's deeplink route.
+
+export type McpIde = 'cursor' | 'vscode';
+
+export interface McpIdeOption {
+ id: McpIde;
+ label: string;
+ icon: string;
+}
+
+export const MCP_IDES: McpIdeOption[] = [
+ { id: 'cursor', label: 'Add to Cursor', icon: 'i-simple-icons:cursor' },
+ { id: 'vscode', label: 'Add to VS Code', icon: 'i-simple-icons:visualstudiocode' },
+];
+
+export function mcpDeeplinkPath(baseURL: string, ide?: McpIde) {
+ const base = baseURL.replace(/\/$/, '');
+ const path = `${base}/mcp/deeplink`;
+ return ide ? `${path}?ide=${ide}` : path;
+}
+
+export function mcpServerUrl(origin: string, baseURL: string) {
+ const base = baseURL.replace(/\/$/, '');
+ return `${origin}${base}/mcp`;
+}
+
+export function chatGptPromptUrl(prompt: string) {
+ return `https://chatgpt.com/?hints=search&q=${encodeURIComponent(prompt)}`;
+}
+
+export function claudePromptUrl(prompt: string) {
+ return `https://claude.ai/new?q=${encodeURIComponent(prompt)}`;
+}
diff --git a/app/utils/cardColor.ts b/app/utils/cardColor.ts
deleted file mode 100644
index 6ac1aff62..000000000
--- a/app/utils/cardColor.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import uiColors from '#ui-colors';
-
-function getInitials(str: string) {
- return str
- .split(' ')
- .map(word => word.charAt(0))
- .join('');
-}
-
-function hashStr(str: string) {
- let hash = 0;
- for (let i = 0; i < str.length; i++) {
- hash = str.charCodeAt(i) + ((hash << 5) - hash);
- }
- return hash;
-}
-
-/**
- * Get a UI color for a given string
- *
- * This uses a deterministic hash to retrieve a semi-random color to ensure server and client
- * rendering result in the same color name when given the same string
- *
- * It'll take the first letter of every word, generate a hash out of that, and use that hash to grab
- * the color out of the array of UI colors. This ensures that phrases starting with the same word don't
- * necessarily get the same color
- */
-export default function (str: string): typeof uiColors[number] {
- const hash = hashStr(getInitials(str));
- const index = hash % uiColors.length;
- return uiColors[index]!;
-}
diff --git a/app/utils/deployments.ts b/app/utils/deployments.ts
new file mode 100644
index 000000000..1c9caa916
--- /dev/null
+++ b/app/utils/deployments.ts
@@ -0,0 +1,16 @@
+import type { Deployment } from './userPreferences';
+
+export interface DeploymentOption {
+ slug: Deployment;
+ label: string;
+ icon: string;
+ description: string;
+}
+
+export const deployments: DeploymentOption[] = [
+ { slug: 'cloud', label: 'Directus Cloud', icon: 'material-symbols:cloud-outline', description: 'Managed hosting by Directus.' },
+ { slug: 'self-hosted', label: 'Self-Hosted', icon: 'material-symbols:dns-outline', description: 'Run Directus on your own infrastructure.' },
+];
+
+export const getDeployment = (slug: string): DeploymentOption | undefined =>
+ deployments.find(d => d.slug === slug);
diff --git a/app/utils/experience.ts b/app/utils/experience.ts
new file mode 100644
index 000000000..16931f826
--- /dev/null
+++ b/app/utils/experience.ts
@@ -0,0 +1,17 @@
+import type { Experience } from './userPreferences';
+
+export interface ExperienceOption {
+ slug: Experience;
+ label: string;
+ icon: string;
+ description: string;
+}
+
+export const experiences: ExperienceOption[] = [
+ { slug: 'new', label: 'New to Directus', icon: 'material-symbols:auto-awesome-outline', description: 'First time or just evaluating.' },
+ { slug: 'familiar', label: 'Some Experience', icon: 'material-symbols:potted-plant-outline', description: 'Built something, still learning.' },
+ { slug: 'experienced', label: 'Power User', icon: 'material-symbols:forest-outline', description: 'Daily / production use.' },
+];
+
+export const getExperience = (slug: string): ExperienceOption | undefined =>
+ experiences.find(e => e.slug === slug);
diff --git a/app/utils/highlightHtml.ts b/app/utils/highlightHtml.ts
new file mode 100644
index 000000000..e7daa2cde
--- /dev/null
+++ b/app/utils/highlightHtml.ts
@@ -0,0 +1,21 @@
+const MARK_OPEN = '\uE000';
+const MARK_CLOSE = '\uE001';
+
+function escapeHtml(value: string) {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function sanitizeHighlightHtml(value: string) {
+ return escapeHtml(
+ value
+ .replace(//gi, MARK_OPEN)
+ .replace(/<\/mark>/gi, MARK_CLOSE),
+ )
+ .replaceAll(MARK_OPEN, '')
+ .replaceAll(MARK_CLOSE, '');
+}
diff --git a/app/utils/libraries.ts b/app/utils/libraries.ts
new file mode 100644
index 000000000..d5a9f14c1
--- /dev/null
+++ b/app/utils/libraries.ts
@@ -0,0 +1,28 @@
+export interface LibraryOption {
+ value: string;
+ label: string;
+ icon: string;
+ matchLabels: string[];
+}
+
+export const libraries: LibraryOption[] = [
+ { value: '0', label: 'SDK', icon: 'simple-icons:directus', matchLabels: ['Directus SDK', 'SDK'] },
+ { value: '1', label: 'REST', icon: 'material-symbols:language', matchLabels: ['REST'] },
+ { value: '2', label: 'GraphQL', icon: 'simple-icons:graphql', matchLabels: ['GraphQL'] },
+];
+
+export const sampleVariants: LibraryOption[] = [
+ { value: 'fetch', label: 'Fetch', icon: 'simple-icons:javascript', matchLabels: ['Fetch', 'JavaScript fetch', 'fetch'] },
+ { value: 'curl', label: 'cURL', icon: 'simple-icons:curl', matchLabels: ['cURL', 'curl'] },
+];
+
+export const allSampleOptions: LibraryOption[] = [...libraries, ...sampleVariants];
+
+export const getSampleOptionByLabel = (label: string): LibraryOption | undefined =>
+ allSampleOptions.find(l => l.matchLabels.includes(label));
+
+export const getLibraryByLabel = (label: string): LibraryOption | undefined =>
+ libraries.find(l => l.matchLabels.includes(label));
+
+export const getLibraryByValue = (value: string): LibraryOption | undefined =>
+ libraries.find(l => l.value === value);
diff --git a/app/utils/relativeTime.ts b/app/utils/relativeTime.ts
new file mode 100644
index 000000000..cd46ffda2
--- /dev/null
+++ b/app/utils/relativeTime.ts
@@ -0,0 +1,12 @@
+export function relativeTime(ts?: number): string {
+ if (!ts) return '';
+ const diff = Date.now() - ts;
+ const m = Math.floor(diff / 60000);
+ if (m < 1) return 'just now';
+ if (m < 60) return `${m}m`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h}h`;
+ const d = Math.floor(h / 24);
+ if (d < 7) return `${d}d`;
+ return `${Math.floor(d / 7)}w`;
+}
diff --git a/app/utils/roles.ts b/app/utils/roles.ts
new file mode 100644
index 000000000..05d09461a
--- /dev/null
+++ b/app/utils/roles.ts
@@ -0,0 +1,16 @@
+import type { Role } from './userPreferences';
+
+export interface RoleOption {
+ slug: Role;
+ label: string;
+ icon: string;
+ description: string;
+}
+
+export const roles: RoleOption[] = [
+ { slug: 'developer', label: 'Developer', icon: 'material-symbols:code', description: 'Show code-first content and technical detail.' },
+ { slug: 'non-developer', label: 'Non-Developer', icon: 'material-symbols:person-outline', description: 'Focus on UI workflows and concepts.' },
+];
+
+export const getRole = (slug: string): RoleOption | undefined =>
+ roles.find(r => r.slug === slug);
diff --git a/app/utils/safePolygon.ts b/app/utils/safePolygon.ts
new file mode 100644
index 000000000..53669a106
--- /dev/null
+++ b/app/utils/safePolygon.ts
@@ -0,0 +1,179 @@
+/**
+ * Safe-triangle hover intent guard.
+ *
+ * Inspired by @floating-ui/react's safePolygon (MIT) and Amazon's mega-menu
+ * pattern. Our case is simpler: trigger (results list) is always to the LEFT
+ * of the floating pane (preview), so the safe area is a triangle from the
+ * cursor's exit point to the floating pane's two left corners.
+ *
+ * Usage: pass `trigger` (results container) and `floating` (preview pane) refs.
+ * Call `onPointerMove(event)` from a pointermove listener that covers both
+ * elements and the gap between them. While the cursor is inside the triangle,
+ * `isGuarding()` returns true — use that to swallow hover-driven highlight
+ * events on intermediate rows.
+ */
+
+type Point = [number, number];
+
+export interface SafePolygonOptions {
+ /** Pixel buffer around the polygon edges. Default 0.5 (matches Floating UI). */
+ buffer?: number;
+ /** Require sustained cursor motion toward the floating element. Default true. */
+ requireIntent?: boolean;
+}
+
+export interface SafePolygon {
+ /** Call from pointermove on the trigger element. Returns true if cursor is inside the safe polygon. */
+ onPointerMove: (event: PointerEvent | MouseEvent) => boolean;
+ /** True while a recent pointer motion is still inside the polygon. */
+ isGuarding: () => boolean;
+ /** Reset state — call when the floating target changes (e.g. results list rebuilds). */
+ reset: () => void;
+}
+
+function isPointInPolygon(point: Point, polygon: Point[]) {
+ const [x, y] = point;
+ let isInside = false;
+ const length = polygon.length;
+ for (let i = 0, j = length - 1; i < length; j = i++) {
+ const [xi, yi] = polygon[i] || [0, 0];
+ const [xj, yj] = polygon[j] || [0, 0];
+ const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
+ if (intersect) isInside = !isInside;
+ }
+ return isInside;
+}
+
+export function createSafePolygon(
+ getTrigger: () => HTMLElement | null,
+ getFloating: () => HTMLElement | null,
+ options: SafePolygonOptions = {},
+): SafePolygon {
+ const { buffer = 0.5, requireIntent = true } = options;
+
+ let anchorX: number | null = null;
+ let anchorY: number | null = null;
+ let lastX: number | null = null;
+ let lastY: number | null = null;
+ let prevX: number | null = null;
+ let lastTime = 0;
+ let guarding = false;
+
+ function getCursorSpeed(x: number, y: number): number | null {
+ const now = performance.now();
+ const elapsed = now - lastTime;
+ if (lastX === null || lastY === null || elapsed === 0) {
+ lastX = x;
+ lastY = y;
+ lastTime = now;
+ return null;
+ }
+ const dx = x - lastX;
+ const dy = y - lastY;
+ const speed = Math.sqrt(dx * dx + dy * dy) / elapsed;
+ lastX = x;
+ lastY = y;
+ lastTime = now;
+ return speed;
+ }
+
+ function reset() {
+ anchorX = null;
+ anchorY = null;
+ lastX = null;
+ lastY = null;
+ prevX = null;
+ lastTime = 0;
+ guarding = false;
+ }
+
+ function onPointerMove(event: PointerEvent | MouseEvent): boolean {
+ const trigger = getTrigger();
+ const floating = getFloating();
+ if (!trigger || !floating) {
+ guarding = false;
+ return false;
+ }
+
+ const { clientX, clientY } = event;
+ const triggerRect = trigger.getBoundingClientRect();
+ const floatingRect = floating.getBoundingClientRect();
+
+ const insideTrigger
+ = clientX >= triggerRect.left
+ && clientX <= triggerRect.right
+ && clientY >= triggerRect.top
+ && clientY <= triggerRect.bottom;
+
+ // Anchor = cursor position when the user begins moving toward the
+ // floating pane. Update anchor while cursor is NOT moving rightward
+ // (toward the pane). Once the cursor commits to a rightward move,
+ // freeze the anchor so the triangle has a real apex behind the cursor.
+ if (insideTrigger) {
+ const movingTowardFloating = prevX !== null && clientX > prevX;
+ if (!movingTowardFloating || anchorX === null) {
+ anchorX = clientX;
+ anchorY = clientY;
+ }
+ }
+ prevX = clientX;
+
+ if (anchorX === null || anchorY === null) {
+ guarding = false;
+ return false;
+ }
+
+ // Triangle from anchor → top-left and bottom-left corners of floating.
+ // Buffer pulls the floating-side edge inward by a hair so the triangle
+ // doesn't render flush against the pane border.
+ const polygon: Point[] = [
+ [anchorX, anchorY],
+ [floatingRect.left + buffer, floatingRect.top],
+ [floatingRect.left + buffer, floatingRect.bottom],
+ ];
+
+ // Trough between trigger right edge and floating left edge: any cursor
+ // inside this strip should be guarded regardless of triangle math.
+ const top = Math.min(triggerRect.top, floatingRect.top);
+ const bottom = Math.max(triggerRect.bottom, floatingRect.bottom);
+ const rectPoly: Point[] = [
+ [triggerRect.right - 1, bottom],
+ [triggerRect.right - 1, top],
+ [floatingRect.left + 1, top],
+ [floatingRect.left + 1, bottom],
+ ];
+
+ const insideTrough = isPointInPolygon([clientX, clientY], rectPoly);
+ const insidePolygon = isPointInPolygon([clientX, clientY], polygon);
+
+ if (insideTrough) {
+ guarding = true;
+ return true;
+ }
+
+ // Cursor crossed the floating pane's left edge → no longer guarding.
+ if (clientX >= floatingRect.left + 1) {
+ guarding = false;
+ return false;
+ }
+
+ // Intent check: if the cursor stalls (slow motion) outside the trigger,
+ // drop the guard so hover responds normally.
+ if (requireIntent && !insideTrigger) {
+ const speed = getCursorSpeed(clientX, clientY);
+ if (speed !== null && speed < 0.1) {
+ guarding = false;
+ return false;
+ }
+ }
+
+ guarding = insidePolygon;
+ return insidePolygon;
+ }
+
+ return {
+ onPointerMove,
+ isGuarding: () => guarding,
+ reset,
+ };
+}
diff --git a/app/utils/searchResults.ts b/app/utils/searchResults.ts
new file mode 100644
index 000000000..ca25a29a4
--- /dev/null
+++ b/app/utils/searchResults.ts
@@ -0,0 +1,218 @@
+import { withoutBase } from 'ufo';
+import type {
+ TypesenseGroupedHit,
+ TypesenseSearchHit,
+ TypesenseSearchResult,
+} from '~/services/typesenseService';
+import type { DocsSectionId } from '#shared/utils/docsSections';
+import { docsSections } from '#shared/utils/docsSections';
+
+export interface DocsSearchDocument {
+ id: string;
+ group_id: string;
+ url: string;
+ title: string;
+ search_title: string;
+ description?: string;
+ anchor?: string;
+ heading?: string;
+ hierarchy: string[];
+ path_tokens: string;
+ path_depth: number;
+ rank_order: number;
+ section: DocsSectionId;
+ doc_type: 'page' | 'api-operation' | 'api-tag';
+ technologies?: string[];
+ content: string;
+ code_blocks?: string[];
+ weight: number;
+ chunk_index: number;
+}
+
+export interface DocsSearchItem {
+ id: string;
+ to: string;
+ title: string;
+ titleHtml?: string;
+ breadcrumb: string;
+ snippetHtml?: string;
+ content: string;
+ description?: string;
+ matchedHeadings: string[];
+ section: DocsSectionId;
+ sectionLabel: string;
+ framework?: string;
+ docTypeLabel: string;
+ docType: DocsSearchDocument['doc_type'];
+}
+
+export interface UserSearchPrefs {
+ framework?: string | null;
+ deployment?: string | null;
+}
+
+function clipSnippet(value: string, limit = 140) {
+ if (value.length <= limit) return value;
+ return `${value.slice(0, limit - 1).trimEnd()}…`;
+}
+
+function normalizeSnippet(value: string) {
+ return value.replace(/\s+/g, ' ').trim();
+}
+
+function getHighlightMap(hit: TypesenseSearchHit) {
+ const entries = Array.isArray(hit.highlights) ? hit.highlights : [];
+ const highlights = new Map();
+ for (const entry of entries) {
+ const field = typeof entry.field === 'string' ? entry.field : typeof entry.field === 'number' ? String(entry.field) : '';
+ const snippet = typeof entry.snippet === 'string'
+ ? entry.snippet
+ : Array.isArray(entry.snippets) && typeof entry.snippets[0] === 'string'
+ ? entry.snippets[0]
+ : '';
+ if (field && snippet) highlights.set(field, snippet);
+ }
+ return highlights;
+}
+
+function getSectionLabel(sectionId: DocsSectionId) {
+ return docsSections.find(section => section.id === sectionId)?.label ?? sectionId;
+}
+
+function getDocTypeLabel(document: DocsSearchDocument) {
+ if (document.doc_type.startsWith('api-') || document.section === 'api' || document.section === 'reference') {
+ return 'Reference';
+ }
+ if (document.section === 'tutorials') {
+ return 'Tutorial';
+ }
+ return 'Guide';
+}
+
+function normalizeResultUrl(url: string) {
+ if (!url.startsWith('/docs')) return url;
+ return withoutBase(url, '/docs') || '/';
+}
+
+export function buildSearchItem(
+ displayHit: TypesenseSearchHit,
+ {
+ targetHit = displayHit,
+ matchedHeadings = [],
+ }: {
+ targetHit?: TypesenseSearchHit;
+ matchedHeadings?: string[];
+ } = {},
+) {
+ const document = displayHit.document;
+ const targetDocument = targetHit.document;
+ const displayHighlights = getHighlightMap(displayHit);
+ const targetHighlights = getHighlightMap(targetHit);
+ const displayTitle = document.title;
+ const titleHtml = displayHighlights.get('title');
+ const snippetHtml = targetHighlights.get('content') || displayHighlights.get('content');
+ const breadcrumb = document.hierarchy.filter(h => h !== displayTitle).join(' › ');
+ const content = clipSnippet(normalizeSnippet(targetDocument.content), 400);
+ const description = document.description?.trim() || undefined;
+
+ return {
+ id: document.id,
+ to: normalizeResultUrl(targetDocument.url),
+ title: displayTitle,
+ titleHtml,
+ breadcrumb,
+ snippetHtml,
+ content,
+ description,
+ matchedHeadings,
+ section: document.section,
+ sectionLabel: getSectionLabel(document.section),
+ framework: document.technologies?.[0],
+ docTypeLabel: getDocTypeLabel(document),
+ docType: document.doc_type,
+ } satisfies DocsSearchItem;
+}
+
+function getDisplayHit(group: TypesenseGroupedHit) {
+ return group.hits.find(hit => hit.document.chunk_index === 0 && !hit.document.anchor)
+ ?? group.hits.find(hit => !hit.document.anchor)
+ ?? group.hits[0]!;
+}
+
+export function flattenGroups(result: TypesenseSearchResult) {
+ if (result.grouped_hits?.length) {
+ return result.grouped_hits.map((group: TypesenseGroupedHit) => {
+ const primaryHit = group.hits[0]!;
+ const displayHit = getDisplayHit(group);
+ const matchedHeadings = [...new Set(group.hits
+ .map(hit => hit.document.heading?.trim())
+ .filter((heading): heading is string => Boolean(heading))
+ .filter(heading => heading !== displayHit.document.search_title && heading !== displayHit.document.title))];
+ return buildSearchItem(displayHit, { targetHit: primaryHit, matchedHeadings });
+ });
+ }
+ return result.hits.map(hit => buildSearchItem(hit));
+}
+
+/**
+ * Soft section boosts for docs search.
+ *
+ * We intentionally keep this as optional re-ranking via `_eval(...)`, not
+ * filtering or pinning, so strong text matches can still win.
+ *
+ * Typesense refs:
+ * - Ranking / optional boosts: https://typesense.org/docs/guide/ranking-and-relevance.html
+ * - Personalization: https://typesense.org/docs/guide/personalization.html
+ * Section priority used for both Typesense ranking boosts and chip-bar
+ * ordering in the palette. Sections not listed here render after the listed
+ * ones in their docsSections order.
+ *
+ * Weights are relative only. Keep guides, api, and frameworks near the top,
+ * while still letting `_text_match(buckets: 10)` win on strong matches.
+ */
+export const sectionPriority: Array<{ id: DocsSectionId; weight: number }> = [
+ { id: 'guides', weight: 6 },
+ { id: 'api', weight: 6 },
+ { id: 'frameworks', weight: 5 },
+ { id: 'reference', weight: 3 },
+ { id: 'getting-started', weight: 2 },
+];
+
+function buildSectionBoostClauses() {
+ return sectionPriority.map(({ id, weight }) => `(section:=${id}):${weight}`);
+}
+
+export function hasDynamicPersonalization(prefs: UserSearchPrefs) {
+ return Boolean(prefs.framework || prefs.deployment === 'cloud');
+}
+
+/**
+ * Build the Typesense `sort_by` expression for docs search.
+ *
+ * Why this shape:
+ * - `_text_match(buckets: 10):desc` keeps text relevance first, while still
+ * allowing later clauses to re-rank near-ties within buckets.
+ * - framework-specific clauses come before generic section clauses so a
+ * preferred framework can beat broad `frameworks` boosting.
+ * - `_eval([...])` is used for soft boosts only. We don't hide results.
+ *
+ * Typesense refs:
+ * - Search params: https://typesense.org/docs/30.2/api/search.html
+ * - Relevance / buckets: https://typesense.org/docs/guide/ranking-and-relevance.html
+ * - Personalization: https://typesense.org/docs/guide/personalization.html
+ */
+export function buildPersonalizedSortBy(prefs: UserSearchPrefs) {
+ const clauses: string[] = [];
+
+ if (prefs.framework) {
+ clauses.push(`(section:=frameworks && technologies:=${prefs.framework}):10`);
+ clauses.push(`(technologies:=${prefs.framework}):3`);
+ }
+ if (prefs.deployment === 'cloud') {
+ clauses.push('(section:=guides):1');
+ }
+
+ clauses.push(...buildSectionBoostClauses());
+
+ return `_text_match(buckets: 10):desc,_eval([${clauses.join(', ')}]):desc,rank_order:asc`;
+}
diff --git a/app/utils/transformAlgoliaSearchItems.ts b/app/utils/transformAlgoliaSearchItems.ts
deleted file mode 100644
index 5a86103d5..000000000
--- a/app/utils/transformAlgoliaSearchItems.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { DocSearchHit } from "@docsearch/react";
-import { withoutTrailingSlash } from "ufo";
-
-function getRelativePath(absoluteUrl: string) {
- const { pathname, hash } = new URL(absoluteUrl);
- const url = window.location.origin;
- const relativeUrl = pathname.replace(url, '/') + hash;
- return withoutTrailingSlash(relativeUrl);
-}
-
-/**
- * Transform Algolia search items to ensure off-site links aren't rendered as relative links
- */
-export default function (items: DocSearchHit[]): DocSearchHit[] {
- return items.map((item) => {
- const relativePath = getRelativePath(item.url);
-
- let url = relativePath;
-
- if (!relativePath.startsWith('/docs')) {
- url = item.url;
- }
-
- return {
- ...item,
- url,
- };
- });
-}
diff --git a/app/utils/useCases.ts b/app/utils/useCases.ts
new file mode 100644
index 000000000..1387e285a
--- /dev/null
+++ b/app/utils/useCases.ts
@@ -0,0 +1,19 @@
+export interface UseCase {
+ slug: string;
+ label: string;
+ icon: string;
+ description: string;
+}
+
+export const useCases: UseCase[] = [
+ { slug: 'headless-cms', label: 'Headless CMS', icon: 'material-symbols:article-outline', description: 'Power websites and apps with structured content.' },
+ { slug: 'client-website', label: 'Client Website', icon: 'material-symbols:language', description: 'Marketing sites and client projects.' },
+ { slug: 'internal-tool', label: 'Internal App', icon: 'material-symbols:corporate-fare', description: 'Admin panels and internal tooling.' },
+ { slug: 'api-backend', label: 'API Backend', icon: 'material-symbols:api', description: 'Data platform and backend-as-a-service.' },
+ { slug: 'multi-tenant-app', label: 'Multi-Tenant App', icon: 'material-symbols:groups-outline', description: 'SaaS apps serving multiple customers.' },
+ { slug: 'ecommerce', label: 'Ecommerce', icon: 'material-symbols:shopping-cart-outline', description: 'Storefronts and product catalogs.' },
+ { slug: 'other', label: 'Other', icon: 'material-symbols:more-horiz', description: 'Something else.' },
+];
+
+export const getUseCase = (slug: string): UseCase | undefined =>
+ useCases.find(u => u.slug === slug);
diff --git a/app/utils/userPreferences.ts b/app/utils/userPreferences.ts
new file mode 100644
index 000000000..a7fcd5959
--- /dev/null
+++ b/app/utils/userPreferences.ts
@@ -0,0 +1,34 @@
+export type Deployment = 'cloud' | 'self-hosted';
+export type Role = 'developer' | 'non-developer';
+export type Experience = 'new' | 'familiar' | 'experienced';
+export type OnboardingState = 'idle' | 'active' | 'onboarded' | 'dismissed';
+
+export interface UserPreferences {
+ framework: string | null;
+ useCase: string | null;
+ deployment: Deployment | null;
+ role: Role | null;
+ experience: Experience | null;
+ onboarding: OnboardingState | null;
+}
+
+export const PREFS_COOKIE = 'directus-docs-prefs';
+export const LEGACY_FRAMEWORK_COOKIE = 'framework';
+export const API_CONSUMER_LS_KEY = 'code-group-api-consumer';
+
+export const defaultPrefs: UserPreferences = {
+ framework: null,
+ useCase: null,
+ deployment: null,
+ role: null,
+ experience: null,
+ onboarding: null,
+};
+
+export const RECENTS_LS_KEY = 'directus-docs-recents';
+export const FAVORITES_LS_KEY = 'directus-docs-favorites';
+export const RECENTS_LIMIT = 20;
+
+export const INSTANCE_URLS_LS_KEY = 'directus-docs-instance-urls';
+export const LEGACY_INSTANCE_URL_COOKIE = 'directus-instance-url';
+export const INSTANCE_URLS_LIMIT = 5;
diff --git a/content/_partials/deployment-public-instance.md b/content/_partials/deployment-public-instance.md
index 4f4729c9b..52761a287 100644
--- a/content/_partials/deployment-public-instance.md
+++ b/content/_partials/deployment-public-instance.md
@@ -1,4 +1,4 @@
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
**Public instance required**
Real-time deployment status updates rely on webhooks sent from your provider to your Directus instance. Your instance must be publicly accessible for these webhooks to reach it. If you're developing locally, use a tunneling tool like [ngrok](https://ngrok.com/) or [untun](https://github.com/unjs/untun) to expose your local API.
::
diff --git a/content/_partials/engine-studio-box.md b/content/_partials/engine-studio-box.md
index 2a45c0860..7f8066b87 100644
--- a/content/_partials/engine-studio-box.md
+++ b/content/_partials/engine-studio-box.md
@@ -1,5 +1,5 @@
-::shiny-grid{class="lg:grid-cols-2"}
- :::shiny-card
+::u-page-grid{class="lg:grid-cols-2"}
+ :::u-page-card
---
title: APIs and Developer Tools
description: Build with REST, GraphQL, the SDK, realtime, auth, and Flows.
@@ -8,7 +8,7 @@
:product-link{product="connect"} :product-link{product="realtime"} :product-link{product="auth"} :product-link{product="automate"}
:::
- :::shiny-card
+ :::u-page-card
---
title: Data Studio
description: A web app for your whole team to manage content, files, users, and dashboards.
diff --git a/content/cloud/1.getting-started/2.teams.md b/content/cloud/1.getting-started/2.teams.md
index 3d3f9dbad..d581f0af4 100644
--- a/content/cloud/1.getting-started/2.teams.md
+++ b/content/cloud/1.getting-started/2.teams.md
@@ -25,7 +25,7 @@ The team name is a text name assigned to a team, used in the cloud dashboard. Th
-To update team settings, open the team menu in the dashboard header and select the desired team. Click "Settings" to enter the team settings page. Toggle :icon{name="material-symbols:edit" title="Edit Button"} to allow edits. Edit team name and team slug as desired, and save accordingly.
+To update team settings, open the team menu in the dashboard header and select the desired team. Click "Settings" to enter the team settings page. Toggle :icon{name="material-symbols:edit-outline" title="Edit Button"} to allow edits. Edit team name and team slug as desired, and save accordingly.
## View Team Activity
diff --git a/content/cloud/1.getting-started/3.accounts.md b/content/cloud/1.getting-started/3.accounts.md
index a0d0dae72..14e1c2185 100644
--- a/content/cloud/1.getting-started/3.accounts.md
+++ b/content/cloud/1.getting-started/3.accounts.md
@@ -19,7 +19,7 @@ don't have a GitHub account or prefer not to use this login method, email-and-pa

-To update your name or email, click :icon{name="material-symbols:account-circle-full"} in the dashboard header to enter your account page, then toggle :icon{name="material-symbols:edit"} to allow edits.
+To update your name or email, click :icon{name="material-symbols:account-circle-full"} in the dashboard header to enter your account page, then toggle :icon{name="material-symbols:edit-outline"} to allow edits.
Change your name and email as desired, then click the "Save" button.
@@ -41,7 +41,7 @@ To destroy your Directus Cloud account, click :icon{name="material-symbols:accou
Type in your password, then click the "Destroy Account" button.
-::callout{icon="material-symbols:dangerous" class="max-w-2xl" color="error"}
+::callout{icon="material-symbols:dangerous-outline" class="max-w-2xl" color="error"}
Destroying your account completely removes your account and data from Directus Cloud. This action is permanent and
irreversible. Proceed with caution!
diff --git a/content/cloud/4.billing/2.changing-tier.md b/content/cloud/4.billing/2.changing-tier.md
index 1d8222d46..f2c99e86d 100644
--- a/content/cloud/4.billing/2.changing-tier.md
+++ b/content/cloud/4.billing/2.changing-tier.md
@@ -8,6 +8,6 @@ description: Learn how to change the tier of your Directus Cloud project.
You can change between the different [cloud project tiers](/cloud/getting-started/introduction) from the Cloud Dashboard.
-To change your project tier, navigate to "Projects" and click on :icon{name="material-symbols:edit"} next to the project for which you wish to change its tier. Scroll down to find the list of tiers. Once selected, you can click on "Make Changes" to confirm.
+To change your project tier, navigate to "Projects" and click on :icon{name="material-symbols:edit-outline"} next to the project for which you wish to change its tier. Scroll down to find the list of tiers. Once selected, you can click on "Make Changes" to confirm.
In order to change to an enterprise project, please [contact us](https://directus.io/contact).
diff --git a/content/cloud/4.billing/3.cancel-subscription.md b/content/cloud/4.billing/3.cancel-subscription.md
index 6643bce13..0f06b5822 100644
--- a/content/cloud/4.billing/3.cancel-subscription.md
+++ b/content/cloud/4.billing/3.cancel-subscription.md
@@ -8,7 +8,7 @@ description: Learn how to cancel your Directus Cloud project subscription.
Each Directus Cloud project is its own separate subscription.
-To cancel a subscription, navigate to the projects list and click on :icon{name="material-symbols:edit" title="Edit Button"} for said project.
+To cancel a subscription, navigate to the projects list and click on :icon{name="material-symbols:edit-outline" title="Edit Button"} for said project.
Scroll down and click on " :icon{name="material-symbols:local-fire-department-rounded" title="Fire Button"} Cancel Subscription". Enter the name of your project to confirm, and confirm. Your project will now be deleted.
diff --git a/content/configuration/ai.md b/content/configuration/ai.md
index 85388fbbc..8d72f15f3 100644
--- a/content/configuration/ai.md
+++ b/content/configuration/ai.md
@@ -12,7 +12,7 @@ description: Configuration for AI Assistant and Model Context Protocol (MCP) fea
| -------- | ----------- | ------------- |
| `AI_ENABLED` | Whether AI Assistant features are available. Set to `false` to completely disable AI Assistant across the entire instance, hiding the sidebar for all users and disabling the settings for administrators. | `true` |
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
When `AI_ENABLED` is set to `false`:
- The API routes for the assistant are not mounted
- AI Assistant sidebar is hidden from all users
@@ -27,7 +27,7 @@ This is useful for compliance requirements where AI features must be completely
| -------- | ----------- | ------------- |
| `MCP_ENABLED` | Whether the Model Context Protocol server is available for system administrators to enable in project settings. Set to `false` to completely disable MCP functionality across the entire instance. | `true` |
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
When `MCP_ENABLED` is set to `false`, the MCP server cannot be enabled through **Settings → AI → Model Context Protocol** in the admin interface, providing system administrators with complete control over AI integration features. See the [MCP Server](/guides/ai/mcp/installation) guide for more information.
::
@@ -72,7 +72,7 @@ Send AI Assistant traces to an external observability platform for monitoring us
| `AI_TELEMETRY_PROVIDER` | Telemetry provider to use. Supported values: `langfuse`, `braintrust`. | `langfuse` |
| `AI_TELEMETRY_RECORD_IO` | Include full prompt inputs and response outputs in traces. | `false` |
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
Enabling `AI_TELEMETRY_RECORD_IO` will send the full content of user messages and AI responses to your telemetry provider. Only enable this if your telemetry provider meets your data privacy requirements.
::
diff --git a/content/frameworks/.navigation.yml b/content/frameworks/.navigation.yml
index 014084143..9fccf8966 100644
--- a/content/frameworks/.navigation.yml
+++ b/content/frameworks/.navigation.yml
@@ -1,2 +1,2 @@
title: Frameworks
-icon: i-ph-brackets-curly
+icon: material-symbols:data-object
diff --git a/content/getting-started/7.create-an-automation.md b/content/getting-started/7.create-an-automation.md
index 8e63f2e81..10a4e8799 100644
--- a/content/getting-started/7.create-an-automation.md
+++ b/content/getting-started/7.create-an-automation.md
@@ -22,13 +22,13 @@ Create a `posts` collection with at least a `title` and `content` field. [Follow

-Navigate to the Flows section in the Settings module. Click on :icon{name="material-symbols:add-circle"} in the page header and name the new flow "Post Created".
+Navigate to the Flows section in the Settings module. Click on :icon{name="material-symbols:add-circle-outline"} in the page header and name the new flow "Post Created".
## Configure a Trigger

-Click on :icon{name="material-symbols:play-arrow"} to open trigger setup. Select "Event Hook" as the trigger type and select "Action (Non-Blocking)". This will allow you to set up this flow to respond to when an event takes place by running an action that doesn't interrupt.
+Click on :icon{name="material-symbols:play-arrow-outline"} to open trigger setup. Select "Event Hook" as the trigger type and select "Action (Non-Blocking)". This will allow you to set up this flow to respond to when an event takes place by running an action that doesn't interrupt.
Select `items.create` as the scope, and then check the "Posts" collection. This combination means that the operation will be triggered when an post is created.
@@ -36,7 +36,7 @@ Select `items.create` as the scope, and then check the "Posts" collection. This

-Click on :icon{name="material-symbols:add-circle"} on the trigger panel.
+Click on :icon{name="material-symbols:add-circle-outline"} on the trigger panel.
Here, you can create an operation. Give it the name "Notify Post Created" and the key "notify_post_created" will be written alongside.
@@ -50,4 +50,4 @@ Now, when you create a post, the user you entered will be notified.
## Next Steps
-Read more about different [triggers](/guides/automate/triggers) available in flows and how data is passed through a flow with [the data chain](/guides/automate/data-chain).
+Read more about different [triggers](/guides/flows/triggers) available in flows and how data is passed through a flow with [the data chain](/guides/flows/data-chain).
diff --git a/content/getting-started/9.resources.md b/content/getting-started/9.resources.md
index 49e62f3b4..26e341b93 100644
--- a/content/getting-started/9.resources.md
+++ b/content/getting-started/9.resources.md
@@ -18,7 +18,7 @@ title: Resources & Links
**Tutorials**: Framework, project, and other implementation guides.
::
-::callout{icon="material-symbols:groups" color="error" to="/community/overview/welcome"}
+::callout{icon="material-symbols:groups-outline" color="error" to="/community/overview/welcome"}
**Community**: Get involved through feature requests, code contribution, education, and more.
::
diff --git a/content/guides/01.data-model/.navigation.yml b/content/guides/01.data-model/.navigation.yml
index 287d10c68..e69de29bb 100644
--- a/content/guides/01.data-model/.navigation.yml
+++ b/content/guides/01.data-model/.navigation.yml
@@ -1 +0,0 @@
-icon: directus-explore
diff --git a/content/guides/01.data-model/4.relationships.md b/content/guides/01.data-model/4.relationships.md
index c9004a3e5..a63385890 100644
--- a/content/guides/01.data-model/4.relationships.md
+++ b/content/guides/01.data-model/4.relationships.md
@@ -75,7 +75,7 @@ Read our tutorial on using a Builder (M2A) to create reusable page components.
When you create a Translations interface in Directus, a translations O2M `Alias` field is created, as well as a `languages` collection and a junction collection between your main collection and `languages`. All translated text is stored in the junction collection.
-::callout{icon="material-symbols:auto-fix-high" color="secondary" to="/guides/content/translations#quick-setup-with-generate-translations"}
+::callout{icon="material-symbols:wand-stars-outline" color="secondary" to="/guides/content/translations#quick-setup-with-generate-translations"}
Use the **Generate Translations** wizard in collection settings to automatically create the full translations infrastructure — languages collection, junction collection, relationships, and fields — in one step.
::
diff --git a/content/guides/02.content/.navigation.yml b/content/guides/02.content/.navigation.yml
index 41f604a62..e69de29bb 100644
--- a/content/guides/02.content/.navigation.yml
+++ b/content/guides/02.content/.navigation.yml
@@ -1 +0,0 @@
-icon: directus-editor
diff --git a/content/guides/02.content/3.layouts.md b/content/guides/02.content/3.layouts.md
index c78c904e8..4662a0f6f 100644
--- a/content/guides/02.content/3.layouts.md
+++ b/content/guides/02.content/3.layouts.md
@@ -57,9 +57,9 @@ layout used in the content module.
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Adjust Column Width** | Click and drag the column divider to resize as desired. |
| **Add Field** | Select :icon{name="material-symbols:add-circle-outline-rounded"} in the page subheader and select the desired Field(s). |
-| **Remove Field** | Select :icon{name="material-symbols:arrow-drop-down-circle"} in the column title and click **"Hide Field"**. |
-| **Sort Items by Column** | Select :icon{name="material-symbols:arrow-drop-down-circle"} in the column title and sort ascending or descending. |
-| **Set Text Alignment** | Select :icon{name="material-symbols:arrow-drop-down-circle"} in the column title and set left, right, or center. |
+| **Remove Field** | Select :icon{name="material-symbols:arrow-drop-down-circle-outline"} in the column title and click **"Hide Field"**. |
+| **Sort Items by Column** | Select :icon{name="material-symbols:arrow-drop-down-circle-outline"} in the column title and sort ascending or descending. |
+| **Set Text Alignment** | Select :icon{name="material-symbols:arrow-drop-down-circle-outline"} in the column title and set left, right, or center. |
| **Toggle & Reorder Columns** | Click the column header, then drag-and-drop as desired. |
| **Select All** | Click :icon{name="material-symbols:check-box-outline"} in the selection column header. |
@@ -101,7 +101,7 @@ file library. It includes the following controls.
| **Card Size** | Toggle the card size as it appears in the page area. |
| **Order Field** | Click to select the field you wish to order by from the dropdown menu. |
| **Order Direction** | Toggle ascending and descending order. |
-| **Select All** | Click ":icon{name="material-symbols:check-circle"} Select All" in the selection column header. |
+| **Select All** | Click ":icon{name="material-symbols:check-circle-outline"} Select All" in the selection column header. |
### Page Area
@@ -172,7 +172,7 @@ There is no Subheader on the Map Layout.
| Control | Description |
|---|---|
| **Zoom** | Click :icon{name="material-symbols:add"} and :icon{name="material-symbols:remove"} in the upper left hand corner of the page area to zoom in and out. |
-| **Find my Location** | Click :icon{name="material-symbols:my-location"} to zoom into your current location on the map. |
+| **Find my Location** | Click :icon{name="material-symbols:my-location-outline"} to zoom into your current location on the map. |
| **Reframe** | Click the square in the upper left-hand corner to resize and reframe the map area. |
| **Select Item** | Click a single item to enter its item page. |
| **Select Items** | Click and drag to select multiple items at once, opening the item page. |
@@ -224,8 +224,8 @@ There is no Subheader for the Kanban Layout.
|---|---|
| **Create Task and Assign Status** | Click :icon{name="material-symbols:add"} in a status column and the item page will open. |
| **Sort Panels** | Drag and drop items to reposition or change task status. |
-| **Add Status Panel** | Click :icon{name="material-symbols:add-box"} and add a group name (i.e. new status column). |
-| **Edit or Delete Status Column** | Click :icon{name="material-symbols:more-horiz"} and then click :icon{name="material-symbols:edit"} to edit or :icon{name="material-symbols:delete"} to delete. |
+| **Add Status Panel** | Click :icon{name="material-symbols:add-box-outline"} and add a group name (i.e. new status column). |
+| **Edit or Delete Status Column** | Click :icon{name="material-symbols:more-horiz"} and then click :icon{name="material-symbols:edit-outline"} to edit or :icon{name="material-symbols:delete-outline"} to delete. |
::callout{icon="material-symbols:info-outline"}
**Configuration Requirements**
diff --git a/content/guides/02.content/4.import-export.md b/content/guides/02.content/4.import-export.md
index 61d7f257e..743e2ab46 100644
--- a/content/guides/02.content/4.import-export.md
+++ b/content/guides/02.content/4.import-export.md
@@ -38,7 +38,7 @@ During import operations, errors are collected up to the maximum defined by [`MA
When exporting items, the export items menu provides granular control over exactly which items and
fields are exported, how they are exported, and where they are exported.
-To export items, follow the steps below, navigate to the desired collection and select "Import / Export" from the sidebar. Click on "Export Items" and the export items menu will appear. Select the desired format from CSV, JSON, XML, or YAML and click :icon{name="material-symbols:download-for-offline"} to download the file.
+To export items, follow the steps below, navigate to the desired collection and select "Import / Export" from the sidebar. Click on "Export Items" and the export items menu will appear. Select the desired format from CSV, JSON, XML, or YAML and click :icon{name="material-symbols:download-for-offline-outline"} to download the file.
## Export Items Menu
diff --git a/content/guides/02.content/5.live-preview.md b/content/guides/02.content/5.live-preview.md
index c60b61514..8b4a2f41f 100644
--- a/content/guides/02.content/5.live-preview.md
+++ b/content/guides/02.content/5.live-preview.md
@@ -47,7 +47,7 @@ and "click" save, you should see a live preview of the item on the right-hand si

-Clicking on :icon{name="material-symbols:devices"} also lets you preview your content on desktop and mobile screens, while :icon{name="material-symbols:open-in-new"} allows you to pop the live preview out into a separate window.
+Clicking on :icon{name="material-symbols:devices-outline"} also lets you preview your content on desktop and mobile screens, while :icon{name="material-symbols:open-in-new"} allows you to pop the live preview out into a separate window.
## Using Versions
@@ -81,7 +81,7 @@ Visual editing in live preview requires:
### Using Visual Editing
-Click the :icon{name="material-symbols:edit"} button in the preview toolbar to highlight all editable elements. Click any highlighted element to open its editor in a drawer, modal, or popover.
+Click the :icon{name="material-symbols:edit-outline"} button in the preview toolbar to highlight all editable elements. Click any highlighted element to open its editor in a drawer, modal, or popover.
The display options menu provides additional controls:
diff --git a/content/guides/02.content/6.content-versioning.md b/content/guides/02.content/6.content-versioning.md
index 48aa066d1..691887458 100644
--- a/content/guides/02.content/6.content-versioning.md
+++ b/content/guides/02.content/6.content-versioning.md
@@ -58,7 +58,7 @@ The draft version:
The keys `published`, `main`, and `draft` are all reserved and cannot be used for custom versions. Attempting to create a version with any of these keys returns a `400` error.
::
-::callout{icon="material-symbols:warning"}
+::callout{icon="material-symbols:warning-outline"}
**Backward Compatibility**
The reserved global "draft" version was introduced in Directus 11.16.0. If you have an existing version with the key `draft` and a custom name other than "Draft", the display name will be standardized to "Draft" (i.e. transformed) to support the new global versioning feature. The version content and functionality remain unchanged.
::
diff --git a/content/guides/02.content/7.translations.md b/content/guides/02.content/7.translations.md
index a9fae16b3..9d11821ef 100644
--- a/content/guides/02.content/7.translations.md
+++ b/content/guides/02.content/7.translations.md
@@ -23,7 +23,7 @@ This article refers to translating your content in Directus. Many parts of the D
To create a translation string, navigate to Settings > Translation Strings and click on :icon{name="material-symbols:add-circle-rounded"} in the page header and a drawer will open.
-Add a key and click on "Create New" to open another drawer. Select the language and type in the corresponding translation. Click on :icon{name="material-symbols:check-circle"} to confirm and add the translation.
+Add a key and click on "Create New" to open another drawer. Select the language and type in the corresponding translation. Click on :icon{name="material-symbols:check-circle-outline"} to confirm and add the translation.
## Use a Translation String
diff --git a/content/guides/02.content/8.visual-editor/1.frontend-library.md b/content/guides/02.content/8.visual-editor/1.frontend-library.md
index 598fa1ebc..dd2e21ad6 100644
--- a/content/guides/02.content/8.visual-editor/1.frontend-library.md
+++ b/content/guides/02.content/8.visual-editor/1.frontend-library.md
@@ -118,7 +118,7 @@ It is recommended to call the global `remove()` method on client-side navigation
If you have CSP configured, be sure to make your site available for use inside an iFrame in Directus. If you’re unsure where your CSP is defined, check your web server configuration files, your site’s build configuration, or your hosting platform’s security settings.
::
-::callout{icon="material-symbols:info"}
+::callout{icon="material-symbols:info-outline"}
**Usage with Directus Cloud and local development**
Connecting your local development environment to a Directus Cloud Professional instance must be done by exposing your localhost to the web through an SSL secured connection. There are multiple ways to achieve this:
diff --git a/content/guides/02.content/8.visual-editor/2.studio-module.md b/content/guides/02.content/8.visual-editor/2.studio-module.md
index 3e14a5e91..1bd5fd16c 100644
--- a/content/guides/02.content/8.visual-editor/2.studio-module.md
+++ b/content/guides/02.content/8.visual-editor/2.studio-module.md
@@ -8,7 +8,7 @@ The visual editor module enables content editors to render their website within

-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
Visual editing also works in the [**Live Preview**](/guides/content/live-preview#visual-editing-in-live-preview) pane on item detail pages. This gives the same editing experience without switching modules.
::
@@ -69,11 +69,11 @@ Hovering over an editable item will highlight it within the module.

-Click the :icon{name="material-symbols:edit"} icon in the toolbar will highlight all the editable items on the page.
+Click the :icon{name="material-symbols:edit-outline"} icon in the toolbar will highlight all the editable items on the page.

-Clicking the :icon{name="material-symbols:edit"} beside an editable element will open an editor in either a drawer, modal, or popover depending on which `mode` was specified in the elements `data-directus` attribute on the frontend.
+Clicking the :icon{name="material-symbols:edit-outline"} beside an editable element will open an editor in either a drawer, modal, or popover depending on which `mode` was specified in the elements `data-directus` attribute on the frontend.

diff --git a/content/guides/03.auth/.navigation.yml b/content/guides/03.auth/.navigation.yml
index 8b3a757a0..e69de29bb 100644
--- a/content/guides/03.auth/.navigation.yml
+++ b/content/guides/03.auth/.navigation.yml
@@ -1 +0,0 @@
-icon: directus-auth
diff --git a/content/guides/03.auth/6.accountability.md b/content/guides/03.auth/6.accountability.md
index c0aae9079..0aeb7d5a2 100644
--- a/content/guides/03.auth/6.accountability.md
+++ b/content/guides/03.auth/6.accountability.md
@@ -8,7 +8,7 @@ description: Learn to audit user activity and enforce accountability using the a
The activity feed provides a collective timeline of all data-changing actions taken within your project. It is accessed via the notifications tray of the sidebar, and has the same filtering and search features as the [Collection Page](/guides/data-model/collections).
-The activity feed can be accessed via the notifications drawer (:icon{name="heroicons-outline:bell" title="Bell"}) located in the bottom-left corner.
+The activity feed can be accessed via the notifications drawer (:icon{name="material-symbols:notifications-outline" title="Bell"}) located in the bottom-left corner.
::callout{icon="material-symbols:warning-rounded" color="warning"}
**External Changes**
diff --git a/content/guides/04.connect/.navigation.yml b/content/guides/04.connect/.navigation.yml
index f492d43ad..ddb8571ce 100644
--- a/content/guides/04.connect/.navigation.yml
+++ b/content/guides/04.connect/.navigation.yml
@@ -1,2 +1 @@
title: APIs
-icon: directus-connect
diff --git a/content/guides/04.connect/5.errors.md b/content/guides/04.connect/5.errors.md
index 197815f68..99ed925b1 100644
--- a/content/guides/04.connect/5.errors.md
+++ b/content/guides/04.connect/5.errors.md
@@ -1,26 +1,272 @@
---
stableId: bcae2f68-6534-45df-8085-5a2c0d6e20c5
-title: Error Codes
-description: Learn about Directus error codes - understand what each code means, from validation failures to rate limits exceeded. Troubleshoot issues with your API requests and resolve errors efficiently.
+title: Errors
+description: Learn how Directus returns errors over REST, GraphQL, and the SDK. Includes core error codes, HTTP status codes, response shape, and patterns for handling errors in your application.
---
-Below are the global error codes used within Directus, and what they mean.
-
-| Error Code | Status | Description |
-| ------------------------ | ------ | ---------------------------------------------------------------- |
-| `FAILED_VALIDATION` | 400 | Validation for this particular item failed. |
-| `FORBIDDEN` | 403 | You are not allowed to do the current action. |
-| `INVALID_TOKEN` | 403 | Provided token is invalid. |
-| `TOKEN_EXPIRED` | 401 | Provided token is valid but has expired. |
-| `INVALID_CREDENTIALS` | 401 | Username / password or access token is wrong. |
-| `INVALID_IP` | 401 | Your IP address isn't allow-listed to be used with this user. |
-| `INVALID_OTP` | 401 | Incorrect OTP was provided. |
-| `INVALID_PAYLOAD` | 400 | Provided payload is invalid. |
-| `INVALID_QUERY` | 400 | The requested query parameters can not be used. |
-| `UNSUPPORTED_MEDIA_TYPE` | 415 | Provided payload format or `Content-Type` header is unsupported. |
-| `REQUESTS_EXCEEDED` | 429 | You have exceeded the rate limit. |
-| `ROUTE_NOT_FOUND` | 404 | Endpoint does not exist. |
-| `SERVICE_UNAVAILABLE` | 503 | Could not use external service. |
-| `UNPROCESSABLE_CONTENT` | 422 | You tried doing something illegal. |
-
-To prevent revealing which items exist, all actions for non-existing items will return a `FORBIDDEN` error.
+Directus uses conventional HTTP response codes to indicate the success or failure of an API request:
+
+- Codes in the `2xx` range indicate success.
+- Codes in the `4xx` range indicate an error caused by the request (a missing parameter, a permission issue, a validation failure, etc.).
+- Codes in the `5xx` range indicate an error on the server.
+
+All errors are returned in a consistent JSON shape so you can handle them programmatically using the `code` value in `extensions`.
+
+## Error Response Shape
+
+Every error response from the REST API follows the same structure:
+
+::code-group
+```json [JSON]
+{
+ "errors": [
+ {
+ "message": "You don't have permission to access this.",
+ "extensions": {
+ "code": "FORBIDDEN"
+ }
+ }
+ ]
+}
+```
+
+```ts [TypeScript]
+interface DirectusErrorResponse {
+ errors: DirectusError[];
+}
+
+interface DirectusError {
+ message: string;
+ extensions: {
+ code: string;
+ [key: string]: unknown;
+ };
+}
+```
+::
+
+A single response can contain multiple errors. Some errors include additional fields in `extensions` with context about what went wrong (the offending `collection`, `field`, or `value`, for example). In development mode, a `stack` trace is included in `extensions` to help with debugging.
+
+GraphQL responses follow the [GraphQL spec](https://spec.graphql.org/October2021/#sec-Errors) and place the `code` under `extensions.code` on each error in the `errors` array.
+
+## HTTP Status Codes
+
+| Status | Name | Description |
+| ------------------- | --------------------- | -------------------------------------------------------------------------------------------- |
+| `200` | OK | The request succeeded. |
+| `204` | No Content | The request succeeded and there is no response body. |
+| `400` | Bad Request | The request was invalid - usually a malformed payload, query, or validation failure. |
+| `401` | Unauthorized | Authentication failed or no valid credentials were provided. |
+| `403` | Forbidden | The authenticated user doesn't have permission to perform this action. |
+| `404` | Not Found | The requested route or resource doesn't exist. |
+| `405` | Method Not Allowed | The HTTP method isn't allowed on this endpoint. The `Allow` header lists supported methods. |
+| `408` | Request Timeout | The operation took too long to complete. |
+| `413` | Content Too Large | The uploaded payload exceeds the size limit. |
+| `415` | Unsupported Media Type | The `Content-Type` of the request body isn't supported. |
+| `416` | Range Not Satisfiable | The requested byte range can't be served for this file. |
+| `422` | Unprocessable Content | The request was well-formed but couldn't be processed. |
+| `429` | Too Many Requests | The rate limit has been exceeded. Back off and retry later. |
+| `500` | Internal Server Error | An unexpected error occurred. Non-admin users see a generic message. |
+| `503` | Service Unavailable | A required dependency or external service is unavailable. |
+
+::callout{icon="material-symbols:shield-outline"}
+To prevent revealing which items exist, all actions for non-existing items return a `FORBIDDEN` error rather than `404`.
+::
+
+## Error Codes
+
+The `code` value in `extensions` lets you handle errors programmatically without parsing the human-readable `message`. Built-in Directus error codes include:
+
+| Error Code | Status | Description |
+| ----------------------------- | ------ | --------------------------------------------------------------------------------- |
+| `CONTAINS_NULL_VALUES` | 400 | A field can't be set to non-nullable because existing rows contain null values. |
+| `CONTENT_TOO_LARGE` | 413 | Uploaded content exceeds the configured size limit. |
+| `EMAIL_LIMIT_EXCEEDED` | 429 | The email sending limit has been hit. |
+| `FAILED_VALIDATION` | 400 | A field value failed validation. |
+| `FORBIDDEN` | 403 | The user doesn't have permission to perform this action. |
+| `GRAPHQL_EXECUTION` | 400 | A GraphQL operation failed during execution setup. |
+| `GRAPHQL_VALIDATION` | 400 | A GraphQL operation failed validation. |
+| `ILLEGAL_ASSET_TRANSFORMATION`| 400 | The requested asset transformation parameters are not allowed. |
+| `INTERNAL_SERVER_ERROR` | 500 | An unexpected error occurred on the server. |
+| `INVALID_CREDENTIALS` | 401 | The provided email, password, or access token is wrong. |
+| `INVALID_FOREIGN_KEY` | 400 | A foreign key value doesn't reference an existing record. |
+| `INVALID_INVITE` | 400 | The invite link is no longer valid. |
+| `INVALID_IP` | 401 | The IP address isn't allow-listed for this user. |
+| `INVALID_METADATA` | 400 | Upload metadata is malformed. |
+| `INVALID_OTP` | 401 | The provided one-time password is incorrect. |
+| `INVALID_PAYLOAD` | 400 | The request body is invalid. |
+| `INVALID_PATH_PARAMETER` | 400 | A path parameter (like an ID) is malformed. |
+| `INVALID_PROVIDER` | 403 | The authentication provider is invalid or not enabled. |
+| `INVALID_PROVIDER_CONFIG` | 503 | The authentication provider is misconfigured. |
+| `INVALID_QUERY` | 400 | The query parameters can't be used as provided. |
+| `INVALID_TOKEN` | 403 | The access token is malformed or invalid. |
+| `LIMIT_EXCEEDED` | 403 | A configured limit (relations, depth, etc.) was exceeded. |
+| `METHOD_NOT_ALLOWED` | 405 | The HTTP method isn't allowed on this endpoint. |
+| `NOT_NULL_VIOLATION` | 400 | A required field was submitted as null. |
+| `OUT_OF_DATE` | 503 | The Directus instance is out of date for this operation. |
+| `OUT_OF_TIME` | 408 | The operation timed out. |
+| `RANGE_NOT_SATISFIABLE` | 416 | The byte range requested for a file can't be served. |
+| `RECORD_NOT_UNIQUE` | 400 | A unique constraint was violated. |
+| `REQUESTS_EXCEEDED` | 429 | The rate limit has been exceeded. |
+| `ROUTE_NOT_FOUND` | 404 | The requested endpoint doesn't exist. |
+| `SERVICE_UNAVAILABLE` | 503 | An external service Directus depends on is unavailable. |
+| `TOKEN_EXPIRED` | 401 | The access token is valid but has expired - refresh it. |
+| `UNEXPECTED_RESPONSE` | 503 | An external service returned an unexpected response. |
+| `UNPROCESSABLE_CONTENT` | 422 | The request was understood but can't be processed. |
+| `UNSUPPORTED_MEDIA_TYPE` | 415 | The `Content-Type` header or payload format isn't supported. |
+| `USER_SUSPENDED` | 401 | The user account is suspended. |
+| `VALUE_OUT_OF_RANGE` | 400 | A numeric value is outside the column's allowed range. |
+| `VALUE_TOO_LONG` | 400 | A value exceeds the column's maximum length. |
+
+::callout{icon="material-symbols:info-outline"}
+Extensions, flows, imports, and upload handlers can return additional error codes. Handle unknown codes with a generic fallback.
+::
+
+## Handling Errors
+
+### REST API
+
+Check the response status, then read `errors[].extensions.code` to branch on specific failure modes:
+
+```js
+const response = await fetch('https://example.directus.app/items/articles', {
+ headers: { Authorization: `Bearer ${token}` },
+});
+
+const body = await response.json();
+
+if (!response.ok) {
+ const error = body.errors?.[0];
+ const code = error?.extensions?.code;
+
+ switch (code) {
+ case 'TOKEN_EXPIRED':
+ // Refresh the token and retry
+ break;
+ case 'FORBIDDEN':
+ // Show a permissions message to the user
+ break;
+ case 'REQUESTS_EXCEEDED':
+ // Back off and retry later
+ break;
+ default:
+ console.error(error?.message);
+ }
+}
+```
+
+### SDK
+
+The SDK throws the parsed error response when a request fails. Wrap calls in `try/catch` and inspect `errors[].extensions.code`:
+
+```ts
+import { createDirectus, rest, readItems } from '@directus/sdk';
+
+const directus = createDirectus('https://example.directus.app').with(rest());
+
+try {
+ const articles = await directus.request(readItems('articles'));
+} catch (err) {
+ const error = err.errors?.[0];
+ const code = error?.extensions?.code;
+
+ if (code === 'TOKEN_EXPIRED') {
+ // Refresh the token and retry
+ } else if (code === 'FORBIDDEN') {
+ // Handle permission denial
+ } else {
+ console.error(error?.message ?? err);
+ }
+}
+```
+
+The SDK error includes the raw `response`, so you can read `err.response.status` when you use the default fetch client. Prefer `code` for programmatic handling because it is stable across transports.
+
+### GraphQL
+
+GraphQL resolver errors can return `200 OK` with an `errors` array in the response body. Request-level failures, such as invalid GraphQL syntax or validation errors, can return a non-2xx HTTP status. Check both the response status and the `errors` array:
+
+```js
+const response = await fetch('https://example.directus.app/graphql', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ query: '{ articles { id title } }' }),
+});
+
+const body = await response.json();
+const { data, errors } = body;
+
+if (!response.ok || errors) {
+ for (const error of errors ?? []) {
+ const code = error.extensions?.code;
+
+ if (code === 'FORBIDDEN') {
+ // Handle permission denial
+ }
+ }
+}
+```
+
+## Common Patterns
+
+### Refreshing an Expired Token
+
+`TOKEN_EXPIRED` indicates the request was authenticated but the access token has expired. Use the refresh token to get a new pair and retry the request:
+
+```ts
+import { createDirectus, rest, authentication } from '@directus/sdk';
+
+const directus = createDirectus('https://example.directus.app')
+ .with(authentication())
+ .with(rest());
+
+try {
+ await directus.request(/* ... */);
+} catch (err) {
+ if (err.errors?.[0]?.extensions?.code === 'TOKEN_EXPIRED') {
+ await directus.refresh();
+ await directus.request(/* ... */);
+ }
+}
+```
+
+### Backing Off on Rate Limits
+
+When you receive `REQUESTS_EXCEEDED`, retry with exponential backoff rather than retrying immediately:
+
+```ts
+async function withRetry(fn, attempts = 3) {
+ for (let i = 0; i < attempts; i++) {
+ try {
+ return await fn();
+ } catch (err) {
+ const code = err.errors?.[0]?.extensions?.code;
+ if (code !== 'REQUESTS_EXCEEDED' || i === attempts - 1) throw err;
+ await new Promise((r) => setTimeout(r, 2 ** i * 1000));
+ }
+ }
+}
+```
+
+### Surfacing Validation Errors
+
+`FAILED_VALIDATION` errors include the offending `field`, `path`, and validation `type` in `extensions`. Database constraint errors like `RECORD_NOT_UNIQUE`, `NOT_NULL_VIOLATION`, `INVALID_FOREIGN_KEY`, `VALUE_OUT_OF_RANGE`, and `VALUE_TOO_LONG` can include `collection`, `field`, or `value`. `INVALID_PAYLOAD` includes a `reason`. Use these fields to display actionable errors in your UI:
+
+```ts
+catch (err) {
+ for (const error of err.errors ?? []) {
+ const { code, field, collection } = error.extensions ?? {};
+ if (field) {
+ showFieldError(field, error.message);
+ }
+ }
+}
+```
+
+## Next Steps
+
+- Review [authentication](/guides/auth/tokens-cookies) for token and session handling.
+- Read the [SDK guide](/guides/connect/sdk) for the full client API.
diff --git a/content/guides/05.files/.navigation.yml b/content/guides/05.files/.navigation.yml
index 79a86a3c3..e69de29bb 100644
--- a/content/guides/05.files/.navigation.yml
+++ b/content/guides/05.files/.navigation.yml
@@ -1 +0,0 @@
-icon: directus-files
diff --git a/content/guides/05.files/1.upload.md b/content/guides/05.files/1.upload.md
index 311d40e18..1cef5a778 100644
--- a/content/guides/05.files/1.upload.md
+++ b/content/guides/05.files/1.upload.md
@@ -60,6 +60,6 @@ const result = await directus.request(uploadFiles(formData));
The file contents has to be provided in a property called `file`. All other properties of
the file object can be provided as well, except `filename_disk` and `filename_download`.
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
If `storage` is not specified, it defaults to the first location listed in [`STORAGE_LOCATIONS`](/configuration/files#storage-locations).
::
diff --git a/content/guides/05.files/3.manage.md b/content/guides/05.files/3.manage.md
index c48c58e34..e90ebb623 100644
--- a/content/guides/05.files/3.manage.md
+++ b/content/guides/05.files/3.manage.md
@@ -15,7 +15,7 @@ item page.
Notice the following buttons in the header:
-- :icon{name="material-symbols:check-circle"} – Saves any edits made to the file.
+- :icon{name="material-symbols:check-circle-outline"} – Saves any edits made to the file.
- :icon{name="material-symbols:tune"} – Opens the image editor.
- :icon{name="material-symbols:download"} – Downloads the file to your current device.
- :icon{name="material-symbols:drive-file-move-outline"} – Moves selected file(s) to another folder.
diff --git a/content/guides/06.automate/.navigation.yml b/content/guides/06.automate/.navigation.yml
deleted file mode 100644
index f7a94f1fe..000000000
--- a/content/guides/06.automate/.navigation.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-title: Flows
-icon: directus-automate
diff --git a/content/guides/06.flows/.navigation.yml b/content/guides/06.flows/.navigation.yml
new file mode 100644
index 000000000..ba66707ea
--- /dev/null
+++ b/content/guides/06.flows/.navigation.yml
@@ -0,0 +1 @@
+title: Flows
diff --git a/content/guides/06.automate/2.data-chain.md b/content/guides/06.flows/2.data-chain.md
similarity index 93%
rename from content/guides/06.automate/2.data-chain.md
rename to content/guides/06.flows/2.data-chain.md
index 491fee304..65ad58be5 100644
--- a/content/guides/06.automate/2.data-chain.md
+++ b/content/guides/06.flows/2.data-chain.md
@@ -42,7 +42,7 @@ Real-life examples of data chains and their data structures will vary, based on
## Data Chain Variables
-While [configuring your operations](/guides/automate/operations), you can use keys from the data chain as variables to
+While [configuring your operations](/guides/flows/operations), you can use keys from the data chain as variables to
access data. The syntax to do so is as follows:
```json
@@ -73,4 +73,4 @@ You can mix your own hardcoded JSON alongside variables. You can also use dot no
```
Certain operations use dropdowns, toggles, checkboxes, and other input options. However, you can bypass this entirely to
-input raw values directly with [Toggle to Raw Editor](/guides/automate/operations).
+input raw values directly with [Toggle to Raw Editor](/guides/flows/operations).
diff --git a/content/guides/06.automate/3.triggers.md b/content/guides/06.flows/3.triggers.md
similarity index 96%
rename from content/guides/06.automate/3.triggers.md
rename to content/guides/06.flows/3.triggers.md
index 8157d5283..6b77addbc 100644
--- a/content/guides/06.automate/3.triggers.md
+++ b/content/guides/06.flows/3.triggers.md
@@ -4,7 +4,7 @@ title: Triggers
description: Triggers define the action or events that start flows.
---
-A trigger defines the event that starts a [flow](/guides/automate/flows). This could be from an internal or external activity, such as
+A trigger defines the event that starts a [flow](/guides/flows). This could be from an internal or external activity, such as
changes to data, logins, errors, incoming webhooks, schedules, operations from other flows, or even the click of a
button within the Data Studio.
@@ -55,7 +55,7 @@ cancel the transaction.
::callout{icon="material-symbols:info-outline"}
**Cancelling Transactions**
To completely cancel a transaction, you'll need to throw an error within a
-[script operation](/guides/automate/operations) or end the Flow on a [failure path](/guides/automate/flows).
+[script operation](/guides/flows/operations) or end the Flow on a [failure path](/guides/flows).
::
### Actions
@@ -167,4 +167,4 @@ Each input field can have its own data type, interface, and display options. Som
to immediately alter the user input (such as trimming whitespace and slugifying text).
Data provided by users when triggering a manual Flow with a confirmation dialog will be accessible in `$trigger.body` in
-the [data chain](/guides/automate/data-chain).
+the [data chain](/guides/flows/data-chain).
diff --git a/content/guides/06.automate/4.operations.md b/content/guides/06.flows/4.operations.md
similarity index 97%
rename from content/guides/06.automate/4.operations.md
rename to content/guides/06.flows/4.operations.md
index a4228d0e8..2cb4c9456 100644
--- a/content/guides/06.automate/4.operations.md
+++ b/content/guides/06.flows/4.operations.md
@@ -32,7 +32,7 @@ fields that may be absent, use the inverse operator (such as `_nnull`) and place
::
::callout{icon="material-symbols:warning-rounded" color="warning"}
-When using an [Event Hook](/guides/automate/triggers) configured to be "Filter (Blocking)", if your flow ends
+When using an [Event Hook](/guides/flows/triggers) configured to be "Filter (Blocking)", if your flow ends
with a condition that executes with a `reject` path, it will cancel your database transaction.
::
@@ -97,7 +97,7 @@ Make sure your `return` value is valid JSON.
::callout{icon="material-symbols:info-outline"}
**Throwing Errors**
If you throw an error in a Run Script operation, it will immediately break your flow chain and stop execution of
-subsequent flows. If you used a ["Blocking" Event hook](/guides/automate/triggers), throwing an error will cancel
+subsequent flows. If you used a ["Blocking" Event hook](/guides/flows/triggers), throwing an error will cancel
the original event transaction to the database.
::
@@ -344,7 +344,7 @@ This operation throws a custom error to halt flow execution with a specific erro
This operation does not generate data. It immediately throws an error that halts flow execution.
::callout{icon="material-symbols:warning-rounded" color="warning"}
-When using an [Event Hook](/guides/automate/triggers) configured to be "Filter (Blocking)", if your flow ends with a
+When using an [Event Hook](/guides/flows/triggers) configured to be "Filter (Blocking)", if your flow ends with a
Throw Error operation, it will cancel your database transaction.
::
@@ -369,7 +369,7 @@ onto its `operationKey`.

This operation starts another flow and (optionally) passes data into it. It should be used in combination with the
-[Another Flow](/guides/automate/triggers) trigger.
+[Another Flow](/guides/flows/triggers) trigger.
### Options
diff --git a/content/guides/06.automate/1.flows.md b/content/guides/06.flows/index.md
similarity index 100%
rename from content/guides/06.automate/1.flows.md
rename to content/guides/06.flows/index.md
diff --git a/content/guides/07.realtime/.navigation.yml b/content/guides/07.realtime/.navigation.yml
index 10cc17f45..e69de29bb 100644
--- a/content/guides/07.realtime/.navigation.yml
+++ b/content/guides/07.realtime/.navigation.yml
@@ -1 +0,0 @@
-icon: directus-realtime
diff --git a/content/guides/08.insights/.navigation.yml b/content/guides/08.insights/.navigation.yml
index edf4e2297..e69de29bb 100644
--- a/content/guides/08.insights/.navigation.yml
+++ b/content/guides/08.insights/.navigation.yml
@@ -1 +0,0 @@
-icon: directus-insights
diff --git a/content/guides/09.extensions/.navigation.yml b/content/guides/09.extensions/.navigation.yml
index ed789fd8b..e69de29bb 100644
--- a/content/guides/09.extensions/.navigation.yml
+++ b/content/guides/09.extensions/.navigation.yml
@@ -1 +0,0 @@
-icon: directus-marketplace
diff --git a/content/guides/09.extensions/2.api-extensions/0.index.md b/content/guides/09.extensions/2.api-extensions/0.index.md
index bb7e7d68a..012bacc3a 100644
--- a/content/guides/09.extensions/2.api-extensions/0.index.md
+++ b/content/guides/09.extensions/2.api-extensions/0.index.md
@@ -10,8 +10,8 @@ API Extensions extend the functionality of the API.
## Extension Types
-::shiny-grid
- :::shiny-card
+::u-page-grid
+ :::u-page-card
---
title: Hooks
to: '/guides/extensions/api-extensions/hooks'
@@ -20,7 +20,7 @@ API Extensions extend the functionality of the API.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Endpoints
to: '/guides/extensions/api-extensions/endpoints'
@@ -29,7 +29,7 @@ API Extensions extend the functionality of the API.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Operations
to: '/guides/extensions/api-extensions/operations'
@@ -41,8 +41,8 @@ API Extensions extend the functionality of the API.
## Resources
-::shiny-grid
- :::shiny-card
+::u-page-grid
+ :::u-page-card
---
title: Services
to: '/guides/extensions/api-extensions/services'
@@ -51,7 +51,7 @@ API Extensions extend the functionality of the API.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Sandbox
to: '/guides/extensions/api-extensions/sandbox'
diff --git a/content/guides/09.extensions/2.api-extensions/3.operations.md b/content/guides/09.extensions/2.api-extensions/3.operations.md
index 4bc8a0e4f..393d5cbf5 100644
--- a/content/guides/09.extensions/2.api-extensions/3.operations.md
+++ b/content/guides/09.extensions/2.api-extensions/3.operations.md
@@ -81,7 +81,7 @@ The `id` in both the app and the api entrypoint must be the same.
### Handler Function
-The handler function is called when the operation is executed. It must return a value to trigger the `resolve` anchor or throw with a value to trigger the `reject` anchor. The returned value will be added to the [data chain](/guides/automate/data-chain).
+The handler function is called when the operation is executed. It must return a value to trigger the `resolve` anchor or throw with a value to trigger the `reject` anchor. The returned value will be added to the [data chain](/guides/flows/data-chain).
The handler function receives `options` and `context`. `options` contains the operation's option values, while `context` has the following properties:
diff --git a/content/guides/09.extensions/3.app-extensions/0.index.md b/content/guides/09.extensions/3.app-extensions/0.index.md
index c9ecf32e3..1fd25cad4 100644
--- a/content/guides/09.extensions/3.app-extensions/0.index.md
+++ b/content/guides/09.extensions/3.app-extensions/0.index.md
@@ -10,8 +10,8 @@ App Extensions extend the functionality of the Data Studio.
## Extension Types
-::shiny-grid
- :::shiny-card
+::u-page-grid
+ :::u-page-card
---
title: Interfaces
to: '/guides/extensions/app-extensions/interfaces'
@@ -19,7 +19,7 @@ App Extensions extend the functionality of the Data Studio.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Displays
to: '/guides/extensions/app-extensions/displays'
@@ -27,7 +27,7 @@ App Extensions extend the functionality of the Data Studio.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Layouts
to: '/guides/extensions/app-extensions/layouts'
@@ -35,7 +35,7 @@ App Extensions extend the functionality of the Data Studio.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Panels
to: '/guides/extensions/app-extensions/panels'
@@ -43,7 +43,7 @@ App Extensions extend the functionality of the Data Studio.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Modules
to: '/guides/extensions/app-extensions/modules'
@@ -51,7 +51,7 @@ App Extensions extend the functionality of the Data Studio.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Themes
to: '/guides/extensions/app-extensions/themes'
@@ -63,8 +63,8 @@ App Extensions extend the functionality of the Data Studio.
## Resources
-::shiny-grid
- :::shiny-card
+::u-page-grid
+ :::u-page-card
---
title: UI Library
to: '/guides/extensions/app-extensions/ui-library'
@@ -72,7 +72,7 @@ App Extensions extend the functionality of the Data Studio.
---
:::
- :::shiny-card
+ :::u-page-card
---
title: Composables
to: '/guides/extensions/app-extensions/composables'
diff --git a/content/guides/10.deployments/.navigation.yml b/content/guides/10.deployments/.navigation.yml
index 31de8deab..e1d492502 100644
--- a/content/guides/10.deployments/.navigation.yml
+++ b/content/guides/10.deployments/.navigation.yml
@@ -1,2 +1 @@
title: Deployments
-icon: directus-deployments
diff --git a/content/guides/11.ai/.navigation.yml b/content/guides/11.ai/.navigation.yml
index 7663840fc..cf01788f5 100644
--- a/content/guides/11.ai/.navigation.yml
+++ b/content/guides/11.ai/.navigation.yml
@@ -1,2 +1 @@
title: AI
-icon: directus-ai
diff --git a/content/guides/11.ai/0.index.md b/content/guides/11.ai/0.index.md
index 38d7486cf..66c61a82e 100644
--- a/content/guides/11.ai/0.index.md
+++ b/content/guides/11.ai/0.index.md
@@ -14,7 +14,7 @@ The built-in AI Assistant provides a conversational assistant directly within th
- No additional client setup required
- Best for content editors, quick tasks, and users who live in Directus
-::card{icon="material-symbols:chat" title="AI Assistant" to="/guides/ai/assistant"}
+::card{icon="material-symbols:chat-outline" title="AI Assistant" to="/guides/ai/assistant"}
Learn how to configure and use the built-in AI assistant.
::
@@ -26,7 +26,7 @@ The Model Context Protocol (MCP) server lets you connect external AI tools to Di
- Bring Directus capabilities to where you already work
- Best for developers, power users, and complex workflows
-::card{icon="material-symbols:hub" title="MCP Server" to="/guides/ai/mcp"}
+::card{icon="material-symbols:hub-outline" title="MCP Server" to="/guides/ai/mcp"}
Connect your preferred AI tools to Directus.
::
diff --git a/content/guides/11.ai/1.assistant/0.index.md b/content/guides/11.ai/1.assistant/0.index.md
index 9937dd992..a42af9df1 100644
--- a/content/guides/11.ai/1.assistant/0.index.md
+++ b/content/guides/11.ai/1.assistant/0.index.md
@@ -10,7 +10,7 @@ Directus AI Assistant is an embedded conversational assistant that helps you int
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
**AI Assistant requires an API key from a supported provider.** Administrators [configure API keys](/guides/ai/assistant/setup) for OpenAI, Anthropic, Google, or an OpenAI-compatible endpoint in Settings → AI.
::
@@ -63,7 +63,7 @@ Directus AI Assistant is an embedded conversational assistant that helps you int
| Gemini 3.1 Pro Preview | `gemini-3.1-pro-preview` | 1M tokens |
| Gemini 2.5 Flash Lite | `gemini-2.5-flash-lite` | 1M tokens |
-### :icon{name="material-symbols:cloud" class="align-middle mr-1 size-5"} OpenAI-Compatible
+### :icon{name="material-symbols:cloud-outline" class="align-middle mr-1 size-5"} OpenAI-Compatible
Use any OpenAI-compatible API endpoint including self-hosted models (Ollama, LM Studio), Azure OpenAI, DeepSeek, Mistral, and more. Configure custom models in [Settings → AI](/guides/ai/assistant/setup#openai-compatible-providers).
@@ -76,7 +76,7 @@ Have feedback or feature requests? [Submit on our roadmap](https://roadmap.direc
- **File Attachments**: Upload files or select from your library. Supports images, PDFs, text, audio, and video up to 50MB.
- **Streaming Responses**: Responses stream in real-time. Stop or retry at any time.
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
**Conversations are stored in your browser only.** They are not saved to the server or shared between devices. Closing your browser or clearing localStorage will delete your conversation history.
::
@@ -86,11 +86,11 @@ Have feedback or feature requests? [Submit on our roadmap](https://roadmap.direc
::card-group
-:::card{title="Admin Setup" icon="material-symbols:settings" to="/guides/ai/assistant/setup"}
+:::card{title="Admin Setup" icon="material-symbols:settings-outline" to="/guides/ai/assistant/setup"}
Configure API keys and customize the AI assistant behavior.
:::
-:::card{title="User Guide" icon="material-symbols:chat" to="/guides/ai/assistant/usage"}
+:::card{title="User Guide" icon="material-symbols:chat-outline" to="/guides/ai/assistant/usage"}
Learn how to use AI Assistant effectively in your daily workflow.
:::
@@ -98,7 +98,7 @@ Learn how to use AI Assistant effectively in your daily workflow.
Reference of all tools the AI can use to interact with Directus.
:::
-:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb" to="/guides/ai/assistant/tips"}
+:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb-outline" to="/guides/ai/assistant/tips"}
Get the most out of AI Assistant with practical tips and example prompts.
:::
diff --git a/content/guides/11.ai/1.assistant/1.setup.md b/content/guides/11.ai/1.assistant/1.setup.md
index 69bba2376..a7f6f0be3 100644
--- a/content/guides/11.ai/1.assistant/1.setup.md
+++ b/content/guides/11.ai/1.assistant/1.setup.md
@@ -16,7 +16,7 @@ AI Assistant requires an API key from a supported AI provider. This page covers
Alternatively, you can use an [OpenAI-compatible provider](#openai-compatible-providers) like Ollama or LM Studio for self-hosted models.
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
Note that all users of AI Assistant will share a single API key from your configured provider. Usage limits and costs will be shared across all users. See your provider's dashboard for monitoring usage details and costs.
::
@@ -36,7 +36,7 @@ OpenAI provides GPT-5 models (Nano, Mini, Standard).
4. Give it a name like "Directus AI Assistant"
5. Copy the key immediately - you won't be able to see it again
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
OpenAI requires a payment method and has usage-based pricing. Set spending limits in **Settings → Limits** to control costs.
::
@@ -52,7 +52,7 @@ Anthropic provides Claude models (Haiku 4.5, Sonnet 4.5, Opus 4.5).
4. Give it a name like "Directus AI Assistant"
5. Copy the key immediately
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
Anthropic requires a payment method and has usage-based pricing. Monitor usage in the Console dashboard.
::
@@ -68,7 +68,7 @@ Google provides Gemini models (2.5 Flash, 2.5 Pro, 3 Flash Preview, 3 Pro Previe
4. Select or create a Google Cloud project
5. Copy the generated API key
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
Google AI Studio offers a free tier with rate limits. For production use, consider enabling billing in Google Cloud Console to increase quotas.
::
@@ -120,11 +120,11 @@ Click **Save** to apply your changes. AI Assistant is now available to all users
In addition to the built-in providers, Directus supports any OpenAI-compatible API endpoint. This allows you to use self-hosted models, alternative providers, or private deployments.
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
**For best results, use built-in cloud providers.** Local models vary significantly in their tool-calling capabilities and may produce inconsistent results. If using OpenAI-compatible providers, we recommend cloud-hosted frontier models over locally-run models on personal hardware.
::
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
**File attachments are not supported with OpenAI-compatible providers.** [File uploads](/guides/ai/assistant/usage#files) require a built-in provider (OpenAI, Anthropic, or Google). The file attachment buttons are hidden when an OpenAI-compatible model is selected.
::
@@ -156,7 +156,7 @@ For each model, you can specify:
| **Supports Reasoning** | Whether the model has chain-of-thought capabilities |
| **Provider Options** | JSON object for model-specific parameters |
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
The **Provider Options** field allows you to pass provider-specific parameters to the AI SDK. This is useful for enabling features like extended thinking or custom sampling parameters. See the [Vercel AI SDK documentation](https://sdk.vercel.ai/providers/openai-compatible) for details.
::
@@ -177,7 +177,7 @@ The **Provider Options** field allows you to pass provider-specific parameters t
- **API Key**: `ollama` (required by the OpenAI SDK but ignored by Ollama)
- **Models**: Add your pulled models (e.g., `gpt-oss:20b`, `gpt-oss:120b`, `qwen3:8b`)
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
You can copy an existing model to an OpenAI-compatible name if needed: `ollama cp gpt-oss:20b gpt-4`
::
@@ -200,7 +200,7 @@ See [Ollama OpenAI compatibility docs](https://docs.ollama.com/api/openai-compat
- **API Key**: Your Azure OpenAI API key
- **Models**: Add your deployed model names
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
The v1 API (August 2025+) no longer requires an `api-version` header. If using an older API version, add `api-version` as a custom header (e.g., `2024-10-21`).
::
@@ -305,7 +305,7 @@ Enable reusable prompts in AI Assistant by configuring a prompts collection:
2. Find **AI Prompts Collection**
3. Either generate a new collection or select an existing one
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
This is the same collection used by the [MCP Server](/guides/ai/mcp/prompts). Prompts created here are available in both AI Assistant and external MCP clients. This also requires MCP to be enabled.
::
@@ -313,7 +313,7 @@ For details on creating prompts with variables, see [MCP Prompts](/guides/ai/mcp
## Managing Costs
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
**AI Assistant uses your own AI provider API keys.** Every message and tool call costs money. Be mindful of usage, especially with larger models. You are responsible for the costs of your usage.
::
@@ -361,7 +361,7 @@ BRAINTRUST_PROJECT_NAME=my-project # optional
Optionally enable `AI_TELEMETRY_RECORD_IO=true` to include full prompt inputs and AI responses in traces.
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
Enabling `AI_TELEMETRY_RECORD_IO` will send the full content of user messages and AI responses to your telemetry provider. Only enable this if your provider meets your data privacy requirements.
::
@@ -371,7 +371,7 @@ See the [AI Configuration](/configuration/ai#telemetry) reference for all availa
::card-group
-:::card{title="User Guide" icon="material-symbols:chat" to="/guides/ai/assistant/usage"}
+:::card{title="User Guide" icon="material-symbols:chat-outline" to="/guides/ai/assistant/usage"}
Learn how users interact with AI Assistant.
:::
@@ -379,7 +379,7 @@ Learn how users interact with AI Assistant.
See what actions the AI can perform.
:::
-:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb" to="/guides/ai/assistant/tips"}
+:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb-outline" to="/guides/ai/assistant/tips"}
Get the most out of AI Assistant.
:::
diff --git a/content/guides/11.ai/1.assistant/2.usage.md b/content/guides/11.ai/1.assistant/2.usage.md
index cfe2f65a4..b4db4361a 100644
--- a/content/guides/11.ai/1.assistant/2.usage.md
+++ b/content/guides/11.ai/1.assistant/2.usage.md
@@ -49,7 +49,7 @@ Reusable prompt templates stored in your Directus instance. Prompts can include
> "Write a blog post using our brand voice"
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
Prompts require MCP to be enabled and a prompts collection configured. See [Prompts Collection](/guides/ai/assistant/setup#prompts-collection) for setup.
::
@@ -65,7 +65,7 @@ When using the [Visual Editor](/guides/content/visual-editor/studio-module), you
> "Translate this heading to Spanish"
-::callout{icon="material-symbols:lightbulb" color="primary"}
+::callout{icon="material-symbols:lightbulb-outline" color="primary"}
Visual element context persists while navigating within the Visual Editor but clears when leaving the module.
::
@@ -97,11 +97,11 @@ You can also drag and drop files directly onto the conversation area.
Maximum file size is 50MB. Files are uploaded to your AI provider when the message is sent.
-::callout{icon="material-symbols:info" color="info"}
+::callout{icon="material-symbols:info-outline" color="info"}
**File attachments require a built-in provider.** OpenAI-compatible providers do not support file uploads. See [Setup](/guides/ai/assistant/setup#openai-compatible-providers) for details.
::
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
**Google file uploads expire after approximately 24 hours.** If you start a new conversation, you may need to re-upload files previously sent to Google.
::
@@ -172,7 +172,7 @@ If an error occurs, a retry button appears. Click to regenerate the last respons
## Context Limits
-::callout{icon="material-symbols:lightbulb" color="primary"}
+::callout{icon="material-symbols:lightbulb-outline" color="primary"}
Start a new conversation when switching topics. The AI performs better with focused, single-topic conversations. Long conversations may have older messages dropped automatically.
::
@@ -203,7 +203,7 @@ The AI operates with your existing [Directus permissions](/guides/auth/access-co
See what actions the AI can perform.
:::
-:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb" to="/guides/ai/assistant/tips"}
+:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb-outline" to="/guides/ai/assistant/tips"}
Get more out of AI Assistant with practical tips.
:::
diff --git a/content/guides/11.ai/1.assistant/3.tools.md b/content/guides/11.ai/1.assistant/3.tools.md
index 889ec811b..3cb83307c 100644
--- a/content/guides/11.ai/1.assistant/3.tools.md
+++ b/content/guides/11.ai/1.assistant/3.tools.md
@@ -25,7 +25,7 @@ These tools interact with your Directus instance via API to manage content, file
### Admin Only
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
Be careful when using these tools as deleting or modifying schema can result in data loss.
::
@@ -41,7 +41,7 @@ Be careful when using these tools as deleting or modifying schema can result in
## Page Context Tools
-::callout{icon="material-symbols:lightbulb" color="primary"}
+::callout{icon="material-symbols:lightbulb-outline" color="primary"}
Page Context tools let the AI work directly with what's on your screen. They are only available when editing data and may not be available on all pages. Approval modes cannot be changed for Page Context tools.
::
| Tool | Description |
@@ -62,12 +62,12 @@ Each tool can be configured with one of three approval modes:
| Mode | Behavior |
|------|----------|
| :icon{name="material-symbols:check" class="text-success"} **Always Allowed** | Execute immediately without asking |
-| :icon{name="material-symbols:approval-delegation" class="text-warning"} **Needs Approval** | Show approval dialog before executing (default) |
-| :icon{name="material-symbols:block" class="text-error"} **Disabled** | Tool is hidden from AI and not loaded into context |
+| :icon{name="material-symbols:approval-delegation-outline" class="text-warning"} **Needs Approval** | Show approval dialog before executing (default) |
+| :icon{name="material-symbols:block-outline" class="text-error"} **Disabled** | Tool is hidden from AI and not loaded into context |
Tool approval settings are stored locally in your browser and are unique to you. They won't sync to your Directus instance or affect other users.
-::callout{icon="material-symbols:lightbulb" color="primary"}
+::callout{icon="material-symbols:lightbulb-outline" color="primary"}
**Disable unused tools to reduce costs.** Disabled tools are not sent to the AI provider, reducing token usage. If you only manage content, disable schema and flow tools.
::
@@ -88,7 +88,7 @@ All tools default to **Ask** mode. Nothing executes without your explicit approv
::card-group
-:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb" to="/guides/ai/assistant/tips"}
+:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb-outline" to="/guides/ai/assistant/tips"}
Get the most out of AI Assistant.
:::
diff --git a/content/guides/11.ai/1.assistant/4.tips.md b/content/guides/11.ai/1.assistant/4.tips.md
index 256ad451b..592eb0694 100644
--- a/content/guides/11.ai/1.assistant/4.tips.md
+++ b/content/guides/11.ai/1.assistant/4.tips.md
@@ -38,8 +38,8 @@ Long conversations can lose context. When switching to a different task, clear t
## Configure Tool Approvals
- Read-only tools like Schema can safely be set to :icon{name="material-symbols:check" class="text-success"} **Always Allowed**
-- Keep write operations on :icon{name="material-symbols:approval-delegation" class="text-warning"} **Needs Approval** until you're confident
-- :icon{name="material-symbols:block" class="text-error"} **Disable** tools you don't need to reduce token usage
+- Keep write operations on :icon{name="material-symbols:approval-delegation-outline" class="text-warning"} **Needs Approval** until you're confident
+- :icon{name="material-symbols:block-outline" class="text-error"} **Disable** tools you don't need to reduce token usage
See [Tool Behavior](/guides/ai/assistant/tools#tool-behavior) for more details.
@@ -126,7 +126,7 @@ Transcribe this audio recording
AI Assistant requires API keys from OpenAI or Anthropic — you cannot use a ChatGPT Plus or Claude Pro subscription. API access is billed per token, so costs scale with usage. Be mindful of this, especially with larger models.
-::callout{icon="material-symbols:paid" color="warning"}
+::callout{icon="material-symbols:paid-outline" color="warning"}
**Disable tools you don't use.** Disabled tools are not loaded into context, reducing token usage and API costs. If you only work with content, disable schema modification tools.
::
diff --git a/content/guides/11.ai/1.assistant/5.security.md b/content/guides/11.ai/1.assistant/5.security.md
index 6af8c13af..6d3436dcc 100644
--- a/content/guides/11.ai/1.assistant/5.security.md
+++ b/content/guides/11.ai/1.assistant/5.security.md
@@ -30,7 +30,7 @@ Your messages, schema information, item data, and tool responses are sent to the
- [Anthropic Privacy Policy](https://www.anthropic.com/privacy)
- [Google Privacy Policy](https://policies.google.com/privacy)
-::callout{icon="material-symbols:warning" color="warning"}
+::callout{icon="material-symbols:warning-outline" color="warning"}
**Be mindful of what you discuss.** Avoid sharing sensitive personal data, credentials, or confidential information in AI conversations. This includes files — do not upload documents containing sensitive data unless you trust your provider's data handling policies.
::
@@ -49,7 +49,7 @@ All tools require approval by default. Configure per-tool settings in the chat h
::card-group
-:::card{title="User Guide" icon="material-symbols:chat" to="/guides/ai/assistant/usage"}
+:::card{title="User Guide" icon="material-symbols:chat-outline" to="/guides/ai/assistant/usage"}
Learn how to use AI Assistant effectively.
:::
@@ -57,7 +57,7 @@ Learn how to use AI Assistant effectively.
See what actions the AI can perform.
:::
-:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb" to="/guides/ai/assistant/tips"}
+:::card{title="Tips & Best Practices" icon="material-symbols:lightbulb-outline" to="/guides/ai/assistant/tips"}
Get the most out of AI Assistant.
:::
diff --git a/content/guides/11.ai/2.mcp/0.index.md b/content/guides/11.ai/2.mcp/0.index.md
index 935e916ca..62acb59df 100644
--- a/content/guides/11.ai/2.mcp/0.index.md
+++ b/content/guides/11.ai/2.mcp/0.index.md
@@ -1,7 +1,9 @@
---
stableId: 91f4d2fc-286d-47c0-8942-68af505ce679
-title: Overview
+title: Directus MCP
description: Connect AI assistants directly to your Directus instance. Let Claude, ChatGPT, and other AI tools manage your content without manual copy-pasting.
+navigation:
+ title: Overview
---
`;
+ }
+ return inlinePartials(partial, partials, depth + 1);
+ });
+}
+
+function stripBlockFences(body: string): string {
+ const lines = body.split('\n');
+ const out: string[] = [];
+ const openFences: number[] = [];
+ let inCodeBlock = false;
+ let codeFence = '';
+
+ for (const line of lines) {
+ const codeMatch = line.match(/^[\t ]*(```+|~~~+)/);
+ if (codeMatch) {
+ if (!inCodeBlock) {
+ inCodeBlock = true;
+ codeFence = codeMatch[1] ?? '';
+ }
+ else if (line.trim().startsWith(codeFence)) {
+ inCodeBlock = false;
+ codeFence = '';
+ }
+ out.push(line);
+ continue;
+ }
+
+ if (inCodeBlock) {
+ out.push(line);
+ continue;
+ }
+
+ const open = line.match(/^[\t ]*(:{2,})([a-z][a-z0-9-]*)(?:\{[^}]*\})?[\t ]*$/);
+ if (open) {
+ const openToken = open[1];
+ if (openToken) openFences.push(openToken.length);
+ continue;
+ }
+
+ const closeMatch = line.match(/^[\t ]*(:{2,})[\t ]*$/);
+ if (closeMatch && openFences.length > 0) {
+ const closeToken = closeMatch[1];
+ if (!closeToken) continue;
+ const closeLen = closeToken.length;
+ const lastOpen = openFences[openFences.length - 1];
+ if (closeLen === lastOpen) {
+ openFences.pop();
+ continue;
+ }
+ }
+
+ out.push(line);
+ }
+
+ return out.join('\n');
+}
+
+function rewriteInlineDirectives(body: string): string {
+ return body
+ .replace(VIDEO_EMBED, (_m, id: string) => `[Watch video](https://www.youtube.com/watch?v=${id})`)
+ .replace(DOC_CLI_SNIPPET, (_m, cmd: string) => `\n\`\`\`bash\n${cmd}\n\`\`\`\n`)
+ .replace(CTA_CLOUD_LINE, '')
+ .replace(PRODUCT_LINK, '');
+}
diff --git a/server/utils/directus-repos.ts b/server/utils/directus-repos.ts
new file mode 100644
index 000000000..b9812d4fe
--- /dev/null
+++ b/server/utils/directus-repos.ts
@@ -0,0 +1,17 @@
+// `@directus/sdk` and `@directus/extensions-sdk` live inside the `directus/directus`
+// monorepo (under `packages/`), not as separate repos. Use `path:packages/sdk` or
+// `path:packages/extensions-sdk` to scope a search-directus-code call to those packages.
+export const DIRECTUS_REPOS = {
+ directus: 'directus/directus',
+ examples: 'directus/examples',
+ docs: 'directus/docs',
+} as const;
+
+export type DirectusRepoSlug = keyof typeof DIRECTUS_REPOS;
+
+export const DIRECTUS_REPO_SLUGS = Object.keys(DIRECTUS_REPOS) as [DirectusRepoSlug, ...DirectusRepoSlug[]];
+
+export function directusRepoSearchQualifier(repo?: DirectusRepoSlug): string {
+ if (repo) return `repo:${DIRECTUS_REPOS[repo]}`;
+ return `(${Object.values(DIRECTUS_REPOS).map(full => `repo:${full}`).join(' OR ')})`;
+}
diff --git a/server/utils/docs-api-rate-limit.ts b/server/utils/docs-api-rate-limit.ts
new file mode 100644
index 000000000..28c9fe89a
--- /dev/null
+++ b/server/utils/docs-api-rate-limit.ts
@@ -0,0 +1,35 @@
+// Per-IP rate limit for the public docs HTTP API (60/min). Lower budget than chat
+// because these are read-only and cheap, but still protect against scraping abuse.
+
+interface Bucket {
+ count: number;
+ resetAt: number;
+}
+
+const WINDOW_MS = 60_000;
+const MAX = 60;
+const buckets = new Map();
+let lastSweep = 0;
+
+export function checkDocsApiRateLimit(key: string): { ok: boolean; retryAfter?: number } {
+ const now = Date.now();
+
+ if (now - lastSweep > WINDOW_MS) {
+ for (const [k, b] of buckets) {
+ if (b.resetAt < now) buckets.delete(k);
+ }
+ lastSweep = now;
+ }
+
+ const bucket = buckets.get(key);
+ if (!bucket || bucket.resetAt < now) {
+ buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
+ return { ok: true };
+ }
+
+ bucket.count++;
+ if (bucket.count > MAX) {
+ return { ok: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
+ }
+ return { ok: true };
+}
diff --git a/server/utils/mcp-rate-limit.ts b/server/utils/mcp-rate-limit.ts
new file mode 100644
index 000000000..794b30f23
--- /dev/null
+++ b/server/utils/mcp-rate-limit.ts
@@ -0,0 +1,27 @@
+const buckets = new Map();
+let lastSweep = 0;
+
+export function checkMcpRateLimit(key: string, max: number, windowMs: number): { ok: boolean; retryAfter?: number } {
+ const now = Date.now();
+
+ if (now - lastSweep > windowMs) {
+ for (const [k, b] of buckets) {
+ if (b.resetAt < now) buckets.delete(k);
+ }
+ lastSweep = now;
+ }
+
+ const bucket = buckets.get(key);
+ if (!bucket || bucket.resetAt <= now) {
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
+ return { ok: true };
+ }
+
+ bucket.count++;
+ if (bucket.count <= max) return { ok: true };
+
+ return {
+ ok: false,
+ retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
+ };
+}
diff --git a/server/utils/sliceUtf8.ts b/server/utils/sliceUtf8.ts
new file mode 100644
index 000000000..ad9e3d929
--- /dev/null
+++ b/server/utils/sliceUtf8.ts
@@ -0,0 +1,13 @@
+export function sliceUtf8(text: string, offset: number, bytes: number): { content: string; nextOffset: number | null; truncated: boolean } {
+ const buffer = Buffer.from(text, 'utf8');
+ const start = Math.min(offset, buffer.length);
+ let end = Math.min(start + bytes, buffer.length);
+ let content = buffer.subarray(start, end).toString('utf8');
+ while (end < buffer.length && content.endsWith('\uFFFD') && end > start) {
+ end--;
+ content = buffer.subarray(start, end).toString('utf8');
+ }
+ const consumed = Buffer.byteLength(content, 'utf8');
+ const nextOffset = start + consumed < buffer.length ? start + consumed : null;
+ return { content, nextOffset, truncated: nextOffset !== null };
+}
diff --git a/shared/utils/docsSections.ts b/shared/utils/docsSections.ts
new file mode 100644
index 000000000..380b01dea
--- /dev/null
+++ b/shared/utils/docsSections.ts
@@ -0,0 +1,128 @@
+export type DocsSectionId
+ = | 'getting-started'
+ | 'guides'
+ | 'deploy'
+ | 'tutorials'
+ | 'frameworks'
+ | 'reference'
+ | 'api'
+ | 'community';
+
+export type DocsGroupId = 'docs' | 'reference' | 'legacy-reference' | 'examples';
+
+export interface DocsSection {
+ id: DocsSectionId;
+ label: string;
+ to: string;
+ prefixes: string[];
+ icon: string;
+}
+
+export interface DocsGroup {
+ id: DocsGroupId;
+ label: string;
+ to: string;
+ icon: string;
+ sectionIds: DocsSectionId[];
+}
+
+export const docsSections: DocsSection[] = [
+ {
+ id: 'getting-started',
+ label: 'Get Started',
+ to: '/getting-started/overview',
+ prefixes: ['/getting-started'],
+ icon: 'material-symbols:rocket-launch-outline',
+ },
+ {
+ id: 'guides',
+ label: 'Guides',
+ to: '/guides/data-model/collections',
+ prefixes: ['/guides'],
+ icon: 'material-symbols:menu-book-outline',
+ },
+ {
+ id: 'deploy',
+ label: 'Hosting',
+ to: '/cloud/getting-started/introduction',
+ prefixes: ['/cloud', '/self-hosting', '/configuration'],
+ icon: 'material-symbols:cloud-outline',
+ },
+ {
+ id: 'frameworks',
+ label: 'Frameworks',
+ to: '/frameworks',
+ prefixes: ['/frameworks'],
+ icon: 'material-symbols:stacks-outline',
+ },
+ {
+ id: 'api',
+ label: 'API Reference',
+ to: '/api',
+ prefixes: ['/api'],
+ icon: 'material-symbols:code',
+ },
+ // {
+ // id: 'reference',
+ // label: 'Reference',
+ // to: '/reference/interfaces',
+ // prefixes: ['/reference'],
+ // icon: 'material-symbols:bookmarks-outline',
+ // },
+ {
+ id: 'tutorials',
+ label: 'Tutorials',
+ to: '/tutorials',
+ prefixes: ['/tutorials'],
+ icon: 'material-symbols:article-outline',
+ },
+ {
+ id: 'community',
+ label: 'Community',
+ to: '/community/overview/welcome',
+ prefixes: ['/community', '/releases'],
+ icon: 'material-symbols:groups-outline',
+ },
+];
+
+export const docsGroups: DocsGroup[] = [
+ {
+ id: 'docs',
+ label: 'Docs',
+ to: '/getting-started/overview',
+ icon: 'material-symbols:menu-book-outline',
+ sectionIds: ['getting-started', 'guides', 'deploy', 'frameworks', 'community'],
+ },
+ {
+ id: 'reference',
+ label: 'API',
+ to: '/api',
+ icon: 'material-symbols:code',
+ sectionIds: ['api'],
+ },
+ // {
+ // id: 'legacy-reference',
+ // label: 'Reference',
+ // to: '/reference/interfaces',
+ // icon: 'material-symbols:bookmarks-outline',
+ // sectionIds: ['reference'],
+ // },
+ {
+ id: 'examples',
+ label: 'Tutorials',
+ to: '/tutorials',
+ icon: 'material-symbols:article-outline',
+ sectionIds: ['tutorials'],
+ },
+];
+
+export const matchesPrefix = (path: string, prefix: string) =>
+ path === prefix || path.startsWith(`${prefix}/`);
+
+export const findSectionByPath = (path: string): DocsSection | null =>
+ docsSections.find(section =>
+ section.prefixes.some(prefix => matchesPrefix(path, prefix)),
+ ) ?? null;
+
+export const findGroupBySectionId = (sectionId: DocsSectionId): DocsGroup | null =>
+ docsGroups.find(group => group.sectionIds.includes(sectionId)) ?? null;
diff --git a/shared/utils/frameworks.ts b/shared/utils/frameworks.ts
new file mode 100644
index 000000000..dfc10e2fa
--- /dev/null
+++ b/shared/utils/frameworks.ts
@@ -0,0 +1,52 @@
+export interface Framework {
+ slug: string;
+ label: string;
+ icon: string;
+ description: string;
+}
+
+export const frameworks: Framework[] = [
+ { slug: 'nextjs', label: 'Next.js', icon: 'simple-icons:nextdotjs', description: 'React framework for production.' },
+ { slug: 'nuxt', label: 'Nuxt', icon: 'simple-icons:nuxt', description: 'Intuitive Vue framework.' },
+ { slug: 'sveltekit', label: 'SvelteKit', icon: 'simple-icons:svelte', description: 'The fastest way to build Svelte apps.' },
+ { slug: 'astro', label: 'Astro', icon: 'simple-icons:astro', description: 'The web framework for content-driven websites.' },
+ { slug: 'react', label: 'React', icon: 'simple-icons:react', description: 'The library for web and native user interfaces.' },
+ { slug: 'vue', label: 'Vue', icon: 'simple-icons:vuedotjs', description: 'The progressive JavaScript framework.' },
+ { slug: 'angular', label: 'Angular', icon: 'simple-icons:angular', description: 'Web development platform built on TypeScript.' },
+ { slug: 'solidstart', label: 'SolidStart', icon: 'simple-icons:solid', description: 'Fine-grained reactive framework.' },
+ { slug: 'eleventy', label: 'Eleventy', icon: 'simple-icons:eleventy', description: 'A simpler static site generator.' },
+ { slug: 'flutter', label: 'Flutter', icon: 'simple-icons:flutter', description: 'Build apps for any screen.' },
+ { slug: 'kotlin', label: 'Android (Kotlin)', icon: 'simple-icons:android', description: 'Native Android development with Kotlin.' },
+ { slug: 'swift', label: 'iOS (Swift)', icon: 'simple-icons:swift', description: 'Native iOS development with Swift.' },
+ { slug: 'laravel', label: 'Laravel', icon: 'simple-icons:laravel', description: 'PHP framework for web artisans.' },
+ { slug: 'django', label: 'Django', icon: 'simple-icons:django', description: 'High-level Python web framework.' },
+ { slug: 'flask', label: 'Flask', icon: 'simple-icons:flask', description: 'Python micro web framework.' },
+ { slug: 'spring-boot', label: 'Spring Boot', icon: 'simple-icons:springboot', description: 'Production-grade Java applications.' },
+];
+
+export const getFramework = (slug: string): Framework | undefined =>
+ frameworks.find(f => f.slug === slug);
+
+const groupLabels: Record = {
+ '1.getting-started': 'Getting Started',
+ '2.projects': 'Projects',
+ '3.tips-and-tricks': 'Tips & Tricks',
+ '4.migration': 'Migration',
+ '5.extensions': 'Extensions',
+ '6.self-hosting': 'Self-Hosting',
+ '7.workflows': 'Workflows',
+};
+
+export const tutorialGroupLabel = (stem: string | undefined): string => {
+ if (!stem) return 'Other';
+ const segments = stem.split('/');
+ const folder = segments[1];
+ return (folder && groupLabels[folder]) || 'Other';
+};
+
+export const tutorialGroupOrder = (stem: string | undefined): number => {
+ if (!stem) return 99;
+ const folder = stem.split('/')[1] ?? '';
+ const match = folder.match(/^(\d+)\./);
+ return match ? Number(match[1]) : 99;
+};
diff --git a/shared/utils/parseTypesenseUrl.ts b/shared/utils/parseTypesenseUrl.ts
new file mode 100644
index 000000000..c9a6efb68
--- /dev/null
+++ b/shared/utils/parseTypesenseUrl.ts
@@ -0,0 +1,24 @@
+export interface TypesenseNode {
+ host: string;
+ port: number;
+ protocol: string;
+ path: string;
+}
+
+export function parseTypesenseUrl(url: string): TypesenseNode {
+ try {
+ const parsedUrl = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`);
+ return {
+ host: parsedUrl.hostname,
+ port: Number.parseInt(parsedUrl.port, 10) || (parsedUrl.protocol === 'https:' ? 443 : 8108),
+ protocol: parsedUrl.protocol.replace(':', ''),
+ // Empty string (not undefined) so the official Typesense client's
+ // `${protocol}://${host}:${port}${path}${endpoint}` URL builder
+ // doesn't interpolate the literal "undefined".
+ path: parsedUrl.pathname === '/' ? '' : parsedUrl.pathname,
+ };
+ }
+ catch (error) {
+ throw new Error(`Invalid Typesense URL: ${url}`, { cause: error });
+ }
+}
diff --git a/tests/components/DocsSearchPalette.test.ts b/tests/components/DocsSearchPalette.test.ts
new file mode 100644
index 000000000..7e6adc2d1
--- /dev/null
+++ b/tests/components/DocsSearchPalette.test.ts
@@ -0,0 +1,249 @@
+import { nextTick, ref } from 'vue';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime';
+import DocsSearchPalette from '../../app/components/DocsSearchPalette.vue';
+import DocsSearchPreview from '../../app/components/DocsSearchPreview.vue';
+
+const {
+ defineShortcutsMock,
+ navigateToMock,
+ useDocsSearchMock,
+ usePageHistoryMock,
+} = vi.hoisted(() => ({
+ defineShortcutsMock: vi.fn(),
+ navigateToMock: vi.fn(),
+ useDocsSearchMock: vi.fn(),
+ usePageHistoryMock: vi.fn(),
+}));
+
+mockNuxtImport('useDocsSearch', () => useDocsSearchMock);
+mockNuxtImport('usePageHistory', () => usePageHistoryMock);
+mockNuxtImport('defineShortcuts', () => defineShortcutsMock);
+mockNuxtImport('navigateTo', () => navigateToMock);
+
+const stubHits = [
+ {
+ id: '1',
+ to: '/guides/data-model/collections',
+ title: 'Collections',
+ titleHtml: 'Collections',
+ breadcrumb: 'Guides › Data Model',
+ snippetHtml: 'Create and manage collections.',
+ content: 'Create and manage collections.',
+ section: 'guides',
+ sectionLabel: 'Guides',
+ docTypeLabel: 'Guide',
+ docType: 'page',
+ },
+ {
+ id: '2',
+ to: '/frameworks/nuxt/authentication',
+ title: 'Nuxt Authentication',
+ titleHtml: 'Nuxt Authentication',
+ breadcrumb: 'Frameworks › Nuxt',
+ snippetHtml: 'Authenticate users in Nuxt.',
+ content: 'Authenticate users in Nuxt.',
+ section: 'frameworks',
+ sectionLabel: 'Frameworks',
+ framework: 'nuxt',
+ docTypeLabel: 'Guide',
+ docType: 'page',
+ },
+ {
+ id: '3',
+ to: '/api/items#create-multiple-items',
+ title: 'POST /items/{collection}',
+ titleHtml: 'POST /items/{collection}',
+ breadcrumb: 'API Reference › Items',
+ snippetHtml: 'Create item records.',
+ content: 'Create item records.',
+ section: 'api',
+ sectionLabel: 'API Reference',
+ docTypeLabel: 'Reference',
+ docType: 'api-operation',
+ },
+ {
+ id: '4',
+ to: '/reference/query-parameters',
+ title: 'Query Parameters',
+ titleHtml: 'Query Parameters',
+ breadcrumb: 'Reference',
+ snippetHtml: 'Filter and paginate responses.',
+ content: 'Filter and paginate responses.',
+ section: 'reference',
+ sectionLabel: 'Reference',
+ docTypeLabel: 'Reference',
+ docType: 'page',
+ },
+ {
+ id: '5',
+ to: '/tutorials/build-a-chat-app',
+ title: 'Build a Chat App',
+ titleHtml: 'Build a Chat App',
+ breadcrumb: 'Tutorials',
+ snippetHtml: 'Step-by-step chat app tutorial.',
+ content: 'Step-by-step chat app tutorial.',
+ section: 'tutorials',
+ sectionLabel: 'Tutorials',
+ docTypeLabel: 'Tutorial',
+ docType: 'page',
+ },
+] as const;
+
+describe('DocsSearchPalette', () => {
+ const query = ref('auth');
+ const section = ref<'all' | 'guides' | 'frameworks' | 'api' | 'reference' | 'tutorials'>('all');
+ const pending = ref(false);
+ const found = ref(stubHits.length);
+ const items = ref([...stubHits]);
+ const sectionCounts = ref(new Map([
+ ['guides', 1],
+ ['frameworks', 1],
+ ['api', 1],
+ ['reference', 1],
+ ['tutorials', 1],
+ ]));
+ const clearMock = vi.fn();
+
+ beforeEach(() => {
+ query.value = 'auth';
+ section.value = 'all';
+ pending.value = false;
+ found.value = stubHits.length;
+ items.value = [...stubHits];
+ sectionCounts.value = new Map([
+ ['guides', 1],
+ ['frameworks', 1],
+ ['api', 1],
+ ['reference', 1],
+ ['tutorials', 1],
+ ]);
+ clearMock.mockReset();
+ navigateToMock.mockReset();
+ useDocsSearchMock.mockReset();
+ usePageHistoryMock.mockReset();
+ defineShortcutsMock.mockReset();
+
+ useDocsSearchMock.mockReturnValue({
+ query,
+ section,
+ pending,
+ found,
+ items,
+ sectionCounts,
+ minQueryLength: 2,
+ isTooShort: ref(false),
+ clear: clearMock,
+ });
+
+ usePageHistoryMock.mockReturnValue({
+ recents: ref([]),
+ favorites: ref([]),
+ });
+
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
+ configurable: true,
+ });
+
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: query.includes('min-width: 640px'),
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+ });
+
+ async function mountPalette() {
+ return mountSuspended(DocsSearchPalette, {
+ props: { open: false },
+ global: {
+ provide: {
+ navigation: ref([
+ {
+ title: 'Frameworks',
+ path: '/frameworks',
+ children: [
+ {
+ title: 'Nuxt',
+ path: '/frameworks/nuxt',
+ children: [
+ { title: 'Nuxt Authentication', path: '/frameworks/nuxt/authentication' },
+ ],
+ },
+ ],
+ },
+ ]),
+ },
+ },
+ });
+ }
+
+ async function openPalette(wrapper: Awaited>) {
+ await wrapper.setProps({ open: true });
+ await nextTick();
+ }
+
+ function findButton(label: string) {
+ return [...document.querySelectorAll('button')]
+ .find(button => button.textContent?.includes(label));
+ }
+
+ it('scopes [/] keydown to open state', async () => {
+ const wrapper = await mountPalette();
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ']' }));
+ await nextTick();
+ expect(section.value).toBe('all');
+
+ await openPalette(wrapper);
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ']' }));
+ await nextTick();
+ expect(section.value).toBe('guides');
+ });
+
+ it('renders the preview pane with a docs-prefixed display path', async () => {
+ const wrapper = await mountPalette();
+ await openPalette(wrapper);
+ items.value = [...stubHits];
+ await nextTick();
+
+ const preview = wrapper.getComponent(DocsSearchPreview);
+ expect(preview.text()).toContain('/docs/guides/data-model/collections');
+ });
+
+ it('keeps preview actions wired through the parent', async () => {
+ const wrapper = await mountPalette();
+ await openPalette(wrapper);
+
+ const openButton = findButton('Open page');
+ const copyButton = findButton('Copy link');
+ expect(openButton).toBeTruthy();
+ expect(copyButton).toBeTruthy();
+
+ const preview = wrapper.getComponent(DocsSearchPreview);
+ copyButton?.click();
+ await nextTick();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`${window.location.origin}/docs/guides/data-model/collections`);
+
+ const externalHit = {
+ ...stubHits[1],
+ to: 'http://docs.example.com/frameworks/nuxt/authentication',
+ };
+ const palette = wrapper.findComponent({ name: 'UCommandPalette' });
+ palette.vm.$emit('highlight', { ref: document.createElement('div'), value: externalHit });
+ await nextTick();
+
+ preview.vm.$emit('open');
+ await nextTick();
+ expect(navigateToMock).toHaveBeenCalledWith('http://docs.example.com/frameworks/nuxt/authentication', { external: true });
+ });
+});
diff --git a/tests/lib/typesenseAlias.test.ts b/tests/lib/typesenseAlias.test.ts
new file mode 100644
index 000000000..91d7f4bed
--- /dev/null
+++ b/tests/lib/typesenseAlias.test.ts
@@ -0,0 +1,27 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { getTypesenseBranchName, resolveBranchTypesenseAlias, slugifyBranch } from '../../lib/typesenseAlias';
+
+const originalEnv = { ...process.env };
+
+afterEach(() => {
+ process.env = { ...originalEnv };
+});
+
+describe('typesense alias helpers', () => {
+ it('slugifies branch names for preview aliases', () => {
+ expect(slugifyBranch('bry/Dockem 6: Typesense')).toBe('bry-dockem-6-typesense');
+ expect(resolveBranchTypesenseAlias('bry/foo')).toBe('directus-docs-preview-bry-foo');
+ });
+
+ it('maps main to the production alias', () => {
+ expect(resolveBranchTypesenseAlias('main')).toBe('directus-docs');
+ });
+
+ it('uses the Vercel branch env when GitHub env is absent', () => {
+ delete process.env.GITHUB_HEAD_REF;
+ delete process.env.GITHUB_REF_NAME;
+ process.env.VERCEL_GIT_COMMIT_REF = 'preview/search';
+
+ expect(getTypesenseBranchName()).toBe('preview/search');
+ });
+});
diff --git a/tests/scripts/index-docs-chunker.test.ts b/tests/scripts/index-docs-chunker.test.ts
new file mode 100644
index 000000000..3ec7a9ef8
--- /dev/null
+++ b/tests/scripts/index-docs-chunker.test.ts
@@ -0,0 +1,41 @@
+// @vitest-environment node
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { chunkMarkdownPage, loadPartials } from '../../scripts/index-docs-chunker.ts';
+
+describe('index-docs chunker', () => {
+ it('keeps MDC-heavy home page chunks clean', () => {
+ const sourcePath = path.resolve('content/index.md');
+ const partials = loadPartials();
+ const documents = chunkMarkdownPage({
+ sourcePath,
+ updatedAt: Math.round(fs.statSync(sourcePath).mtimeMs),
+ partials,
+ });
+
+ const combined = documents.map(document => document.content).join('\n');
+ expect(combined).toContain('Local Demo');
+ expect(combined).toContain('Try Directus locally in one command.');
+ expect(combined).not.toContain('shiny-card');
+ expect(combined).not.toContain('shiny-grid');
+ expect(combined).not.toContain('two-up');
+ });
+
+ it('extracts card titles from component-only sections', () => {
+ const sourcePath = path.resolve('content/guides/09.extensions/2.api-extensions/0.index.md');
+ const partials = loadPartials();
+ const documents = chunkMarkdownPage({
+ sourcePath,
+ updatedAt: Math.round(fs.statSync(sourcePath).mtimeMs),
+ partials,
+ });
+
+ const combined = documents.map(document => document.content).join('\n');
+ expect(combined).toContain('Hooks');
+ expect(combined).toContain('Endpoints');
+ expect(combined).toContain('Operations');
+ expect(combined).not.toContain('shiny-card');
+ });
+});
diff --git a/tests/server/sliceUtf8.test.ts b/tests/server/sliceUtf8.test.ts
new file mode 100644
index 000000000..76074535e
--- /dev/null
+++ b/tests/server/sliceUtf8.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+import { sliceUtf8 } from '../../server/utils/sliceUtf8';
+
+describe('sliceUtf8', () => {
+ it('backs up to a valid UTF-8 boundary', () => {
+ const first = sliceUtf8('abc😀def', 0, 5);
+
+ expect(first.content).toBe('abc');
+ expect(first.nextOffset).toBe(3);
+ expect(first.truncated).toBe(true);
+
+ const second = sliceUtf8('abc😀def', first.nextOffset!, 1024);
+
+ expect(second.content).toBe('😀def');
+ expect(second.nextOffset).toBeNull();
+ expect(second.truncated).toBe(false);
+ });
+});
diff --git a/tests/services/typesenseService.test.ts b/tests/services/typesenseService.test.ts
new file mode 100644
index 000000000..5b6c4ceba
--- /dev/null
+++ b/tests/services/typesenseService.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { TypesenseService } from '../../app/services/typesenseService';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('TypesenseService', () => {
+ it('uses multi_search to merge unfiltered facet counts for refinements', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ results: [
+ {
+ hits: [{ document: { id: '1', title: 'Auth' } }],
+ facet_counts: [{ field_name: 'section', counts: [{ value: 'guides', count: 1 }] }],
+ found: 1,
+ page: 1,
+ out_of: 1,
+ },
+ {
+ facet_counts: [{ field_name: 'section', counts: [{ value: 'guides', count: 3 }, { value: 'api', count: 2 }] }],
+ },
+ ],
+ });
+ vi.stubGlobal('$fetch', fetchMock);
+
+ const service = new TypesenseService({
+ typesenseUrl: 'https://search.example.com/typesense',
+ typesensePublicApiKey: 'public-key',
+ });
+
+ const result = await service.search({
+ indexName: 'directus-docs',
+ searchConfig: {
+ query_by: 'title,content',
+ facet_by: 'section',
+ filter_by: 'doc_type:=page',
+ },
+ state: {
+ query: 'auth',
+ page: 1,
+ hitsPerPage: 10,
+ filters: { section: ['guides'] },
+ },
+ facetRefinementAttributes: ['section'],
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://search.example.com:443/typesense/multi_search',
+ expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({ 'X-TYPESENSE-API-KEY': 'public-key' }),
+ }),
+ );
+ const request = fetchMock.mock.calls[0]?.[1];
+ expect(request.body.searches).toHaveLength(2);
+ expect(request.body.searches[0].filter_by).toBe('doc_type:=page && (section:=guides)');
+ expect(request.body.searches[1].filter_by).toBe('doc_type:=page');
+ expect(request.body.searches[1].per_page).toBe(0);
+ expect(result.facets.section).toEqual([
+ { value: 'guides', count: 3, highlighted: undefined },
+ { value: 'api', count: 2, highlighted: undefined },
+ ]);
+ });
+});
diff --git a/tests/shared/parseTypesenseUrl.test.ts b/tests/shared/parseTypesenseUrl.test.ts
new file mode 100644
index 000000000..433fbaa5e
--- /dev/null
+++ b/tests/shared/parseTypesenseUrl.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { parseTypesenseUrl } from '../../shared/utils/parseTypesenseUrl';
+
+describe('parseTypesenseUrl', () => {
+ it('defaults bare hosts to https port 443', () => {
+ expect(parseTypesenseUrl('search.example.com')).toEqual({
+ host: 'search.example.com',
+ port: 443,
+ protocol: 'https',
+ path: '',
+ });
+ });
+
+ it('keeps custom protocol, port, and path', () => {
+ expect(parseTypesenseUrl('http://localhost:8108/typesense')).toEqual({
+ host: 'localhost',
+ port: 8108,
+ protocol: 'http',
+ path: '/typesense',
+ });
+ });
+
+ it('throws for invalid URLs', () => {
+ expect(() => parseTypesenseUrl('http://')).toThrow('Invalid Typesense URL');
+ });
+});
diff --git a/tests/utils/highlightHtml.test.ts b/tests/utils/highlightHtml.test.ts
new file mode 100644
index 000000000..834c4b744
--- /dev/null
+++ b/tests/utils/highlightHtml.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { sanitizeHighlightHtml } from '../../app/utils/highlightHtml';
+
+describe('sanitizeHighlightHtml', () => {
+ it('keeps mark tags and escapes other markup', () => {
+ expect(sanitizeHighlightHtml('Col')).toBe(
+ 'Col<img src=x onerror=alert(1)>',
+ );
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 000000000..c64f1f42c
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineVitestConfig } from '@nuxt/test-utils/config';
+
+export default defineVitestConfig({
+ test: {
+ environment: 'nuxt',
+ environmentOptions: {
+ nuxt: {
+ domEnvironment: 'happy-dom',
+ },
+ },
+ },
+});