diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 394dc2509..8b25bb54e 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -11,4 +11,3 @@ dist-ssr
.vinxi
__unconfig*
todos.json
-.source
diff --git a/apps/web/content/docs/dev/contribution.mdx b/apps/web/content/docs/dev/contribution.mdx
index 441cb1b55..7a2387997 100644
--- a/apps/web/content/docs/dev/contribution.mdx
+++ b/apps/web/content/docs/dev/contribution.mdx
@@ -109,6 +109,20 @@ npm run build
---
+## Docs for AI Agents
+
+The documentation portal in `apps/web` speaks Markdown as well as HTML, so agents and LLM tools can read it without scraping:
+
+| URL | What you get |
+| :--- | :--- |
+| `/llms.txt` | An index of every docs page with its title, URL and description |
+| `/llms-full.txt` | The whole documentation as one Markdown document |
+| `/docs/.md` | A single page as Markdown, for example `/docs/dev/setup.md` |
+
+Requesting a docs page with an `Accept: text/markdown` header redirects to its `.md` twin, so a curious agent gets Markdown without asking twice. Every page also has a **Copy Markdown** button and an **Open in** menu for ChatGPT, Claude and friends.
+
+---
+
## Pull Request Guidelines
1. **Focused PRs**: Keep changes scoped to a single feature or bug fix.
diff --git a/apps/web/content/docs/dev/data-loading.mdx b/apps/web/content/docs/dev/data-loading.mdx
index 65992a1ce..bcd886f2a 100644
--- a/apps/web/content/docs/dev/data-loading.mdx
+++ b/apps/web/content/docs/dev/data-loading.mdx
@@ -1,7 +1,7 @@
---
title: Data Loading
description: Load data in TanStack Start routes with server-side query warming and client-side suspense.
-icon: DownloadCloud
+icon: CloudDownload
---
VitNode loads feature data through plugin routes. `definePluginRoute({ load })`
diff --git a/apps/web/content/docs/dev/routing/loading-states.mdx b/apps/web/content/docs/dev/routing/loading-states.mdx
index 23a1b7cc7..a2cbcee0c 100644
--- a/apps/web/content/docs/dev/routing/loading-states.mdx
+++ b/apps/web/content/docs/dev/routing/loading-states.mdx
@@ -1,7 +1,7 @@
---
title: Loading States
description: Render instant skeleton shapes while TanStack Start routes load data or download chunks.
-icon: Loader2
+icon: LoaderCircle
---
import { TypeTable } from 'fumadocs-ui/components/type-table'
diff --git a/apps/web/content/docs/ui/alert-dialog.mdx b/apps/web/content/docs/ui/alert-dialog.mdx
index fef0fb3f8..5224201bb 100644
--- a/apps/web/content/docs/ui/alert-dialog.mdx
+++ b/apps/web/content/docs/ui/alert-dialog.mdx
@@ -1,7 +1,7 @@
---
title: Alert Dialog
description: Display important messages to users in a modal dialog.
-icon: AlertOctagon
+icon: OctagonAlert
---
A modal dialog that interrupts the user with important content and expects a
diff --git a/apps/web/content/docs/ui/alert.mdx b/apps/web/content/docs/ui/alert.mdx
index 9777d19bd..7061c7dd6 100644
--- a/apps/web/content/docs/ui/alert.mdx
+++ b/apps/web/content/docs/ui/alert.mdx
@@ -1,7 +1,7 @@
---
title: Alert
description: Display an important status message with a clear action or next step.
-icon: AlertTriangle
+icon: TriangleAlert
---
## Preview
diff --git a/apps/web/content/docs/ui/auto-form.mdx b/apps/web/content/docs/ui/auto-form.mdx
index 6124f6d4d..4b719ff88 100644
--- a/apps/web/content/docs/ui/auto-form.mdx
+++ b/apps/web/content/docs/ui/auto-form.mdx
@@ -1,7 +1,7 @@
---
title: Auto Form
description: Generates forms based on Zod schemas and react-hook-form with validation and input controls.
-icon: FormInput
+icon: RectangleEllipsis
---
import { TypeTable } from "fumadocs-ui/components/type-table"
diff --git a/apps/web/content/docs/ui/checkbox.mdx b/apps/web/content/docs/ui/checkbox.mdx
index de4094481..892926901 100644
--- a/apps/web/content/docs/ui/checkbox.mdx
+++ b/apps/web/content/docs/ui/checkbox.mdx
@@ -1,7 +1,7 @@
---
title: Checkbox
description: Let people select one or more options with accessible checked states.
-icon: CheckSquare
+icon: SquareCheck
---
## Preview
diff --git a/apps/web/content/docs/ui/confirm-action-alert-dialog.mdx b/apps/web/content/docs/ui/confirm-action-alert-dialog.mdx
index c2d6b9d81..0cfdbfb2b 100644
--- a/apps/web/content/docs/ui/confirm-action-alert-dialog.mdx
+++ b/apps/web/content/docs/ui/confirm-action-alert-dialog.mdx
@@ -1,7 +1,7 @@
---
title: Confirm Action Alert Dialog
description: A dialog component for confirming destructive actions like delete operations, built on top of the Alert Dialog.
-icon: HelpCircle
+icon: CircleQuestionMark
---
The Confirm Action Alert Dialog is a specialized dialog component built on top of the [Alert Dialog](/docs/ui/alert-dialog) that provides a consistent way to confirm potentially destructive or irreversible actions before they're executed.
diff --git a/apps/web/content/docs/ui/editor.mdx b/apps/web/content/docs/ui/editor.mdx
index 13fe7b548..d195c188c 100644
--- a/apps/web/content/docs/ui/editor.mdx
+++ b/apps/web/content/docs/ui/editor.mdx
@@ -8,6 +8,146 @@ icon: PenTool
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+## What you get
+
+The toolbar ships with everything below, and every feature is also available
+through its own keyboard shortcut or markdown-style input rule.
+
+| Group | Controls |
+| ---------- | --------------------------------------------------------------------------------------------- |
+| History | Undo, redo |
+| Text | Paragraph, headings 1-4, font size, bold, italic, underline, strike, inline code, text colour |
+| Layout | Left / center / right / justify alignment |
+| Blocks | Bullet list, ordered list, blockquote, code block, horizontal rule |
+| Rich media | Tables, emoji, audio |
+
+A few extras run quietly in the background:
+
+- **Typography** turns `(c)`, `->`, `1/2` and friends into `©`, `→` and `½` as you type.
+- **Emoji** has three ways in - see [Emoji](#emoji) below.
+- **Placeholder** shows a hint while the editor is empty. Override it with the
+ `placeholder` prop.
+- **Drag handle** appears next to the block under your cursor so any block -
+ nested ones included - can be dragged somewhere else. A **drop cursor** shows
+ where it will land.
+- **Trailing node** always keeps an empty paragraph at the end, so you can never
+ get stuck under a table or a code block.
+
+### Emoji
+
+There are three ways to get an emoji into the editor, and they all end up as the
+same node:
+
+- **Type a shortcode.** `:tada:` becomes 🎉 the moment you close the second
+ colon.
+- **Use the `:` menu.** Start typing `:tad` and an autocomplete appears. Arrow
+ keys move, `Enter` or `Tab` inserts, `Esc` closes. Results are ranked, so
+ `:flag` puts flags first rather than everything tagged "flag".
+- **Open the picker.** The toolbar's emoji button opens a searchable,
+ virtualised grid with categories and skin tones, from
+ [Ferruccio's emoji picker](https://emoji.ferrucc.io/). Hovering an emoji
+ previews it in the footer with its name and its shortcode, so the picker also
+ teaches you what to type next time.
+
+Pasting a raw emoji character works too - it is upgraded to a node on the way
+in.
+
+#### Custom emoji
+
+Your own emoji are app-wide, so they live in `vitnode.config.ts` and every
+`Editor` in the app picks them up - no props to pass:
+
+```ts title="src/vitnode.config.ts"
+import { buildConfig } from '@vitnode/core/vitnode.config'
+
+export const vitNodeConfig = buildConfig({
+ editor: {
+ emojis: [
+ {
+ label: 'VitNode',
+ emojis: [
+ {
+ name: 'vitnode',
+ src: '/logo_vitnode_icon.svg',
+ tags: ['logo', 'brand'],
+ },
+ ],
+ },
+ ],
+ },
+ // ...the rest of your config
+})
+```
+
+That one entry gives you all three routes at once: `:vitnode:` converts as you
+type, `:vitn` finds it in the `:` menu, and the picker grows a **VitNode**
+section you can search.
+
+
+
+A custom emoji is stored as `` with
+your image inside, so `EditorContent` renders it on public pages exactly as the
+editor does - as long as `src` is still reachable from there.
+
+### Tables
+
+Insert a 3x3 table with a header row from the table menu, then use the same menu
+to add or remove rows and columns, toggle the header row or column, merge or
+split cells, or delete the table. Columns are resizable by dragging their edge,
+and wide tables scroll horizontally instead of breaking the page.
+
+### Audio
+
+Paste a link to an `mp3`, `wav`, `ogg` (or any other browser-playable file) and
+it renders as a native `
-
+
Docs for your agent
diff --git a/apps/web/src/site/marketing/footer.tsx b/apps/web/src/site/marketing/footer.tsx
index 54d647378..78f52b567 100644
--- a/apps/web/src/site/marketing/footer.tsx
+++ b/apps/web/src/site/marketing/footer.tsx
@@ -4,7 +4,13 @@ import { RouterLink } from '@vitnode/core/tanstack/layout'
import { AUTHOR_NAME, AUTHOR_URL, LICENSE_URL, REPOSITORY_URL } from './links'
import { CanaryPill, GitHubIcon } from './shared'
-const COLUMNS = [
+interface FooterLink {
+ href: string
+ label: string
+ target?: React.HTMLAttributeAnchorTarget
+}
+
+const COLUMNS: { links: FooterLink[]; title: string }[] = [
{
links: [
{ href: '/', label: 'Overview' },
@@ -33,7 +39,7 @@ const COLUMNS = [
{ href: '/discover', label: 'Discover' },
{ href: '/search', label: 'Search' },
{ href: '/docs/dev/contribution', label: 'Contribute' },
- { href: '/llms-full.txt', label: 'Docs for AI agents' },
+ { href: '/llms-full.txt', label: 'Docs for AI agents', target: '_blank' },
],
title: 'Community',
},
@@ -72,11 +78,15 @@ export const SiteFooter = () => (
+ }
+ >
+
+
+
+
+ {PRESET_COLORS.map(color => (
+
+
+ setColor(event.target.value)}
+ placeholder="#2563eb"
+ value={activeColor}
+ />
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx
new file mode 100644
index 000000000..c59ae4dbc
--- /dev/null
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx
@@ -0,0 +1,49 @@
+import { SmilePlusIcon } from "lucide-react";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import { Button } from "@/components/ui/button";
+import { Loader } from "@/components/ui/loader";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { TooltipWithContent } from "@/components/ui/tooltip";
+
+const EmojiPicker = React.lazy(async () =>
+ import("./emoji/emoji-picker").then(module => ({
+ default: module.EmojiPicker,
+ })),
+);
+
+export const EmojiAction = () => {
+ const t = useTranslations("core.global.editor.emoji");
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+ }
+ >
+ setOpen(false)} />
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/emoji/emoji-picker.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/emoji/emoji-picker.tsx
new file mode 100644
index 000000000..2f11dd200
--- /dev/null
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/emoji/emoji-picker.tsx
@@ -0,0 +1,124 @@
+import { EmojiPicker as Picker } from "@ferrucc-io/emoji-picker";
+import { emojiToShortcode } from "@tiptap/extension-emoji";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import { useEditorConfig } from "@/components/editor-provider";
+
+import {
+ customEmojiToPickerSections,
+ shortcodeOf,
+} from "../../../emoji/custom-emoji";
+import { useToolbarEditor } from "../../use-toolbar-editor";
+
+const EMOJIS_PER_ROW = 9;
+const EMOJI_SIZE = 30;
+const LIST_HEIGHT = 288;
+
+export const EmojiPicker = ({ onSelect }: { onSelect: () => void }) => {
+ const t = useTranslations("core.global.editor.emoji");
+ const { editor } = useToolbarEditor();
+ const { emojis } = useEditorConfig();
+ const customSections = React.useMemo(
+ () => customEmojiToPickerSections(emojis),
+ [emojis],
+ );
+ const customSources = React.useMemo(
+ () =>
+ new Map(
+ emojis?.flatMap(section =>
+ section.emojis.map(emoji => [emoji.name, emoji.src] as const),
+ ),
+ ),
+ [emojis],
+ );
+
+ return (
+ {
+ const shortcode =
+ shortcodeOf(selected) ??
+ emojiToShortcode(selected, editor.storage.emoji.emojis);
+ const chain = editor.chain().focus();
+
+ if (shortcode) {
+ chain.setEmoji(shortcode).run();
+ } else {
+ chain.insertContent(selected).run();
+ }
+
+ onSelect();
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {({ previewedEmoji }) => {
+ if (!previewedEmoji) {
+ return (
+
+ {t("hint")}
+
+ );
+ }
+
+ const customName = shortcodeOf(previewedEmoji.emoji);
+ const customSrc = customName
+ ? customSources.get(customName)
+ : undefined;
+ const shortcode =
+ customName ??
+ emojiToShortcode(
+ previewedEmoji.emoji,
+ editor.storage.emoji.emojis,
+ );
+
+ return (
+
+
+ {customSrc ? (
+
+ ) : (
+ previewedEmoji.emoji
+ )}
+
+
+
+
+ {previewedEmoji.name}
+
+
+ {!!shortcode && (
+
+ :{shortcode}:
+
+ )}
+
+
+ );
+ }}
+
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/font-size-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/font-size-action.tsx
new file mode 100644
index 000000000..a93d61aa9
--- /dev/null
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/font-size-action.tsx
@@ -0,0 +1,73 @@
+import { useEditorState } from "@tiptap/react";
+import { ChevronDown, TypeIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+import { SUPPORTED_FONT_SIZES } from "../../extension";
+import { useToolbarEditor } from "../use-toolbar-editor";
+
+const DEFAULT_VALUE = "default";
+
+export const FontSizeAction = () => {
+ const t = useTranslations("core.global.editor");
+ const { editor } = useToolbarEditor();
+ const activeValue = useEditorState({
+ editor,
+ selector: ctx => {
+ const fontSize: unknown = ctx.editor.getAttributes("textStyle").fontSize;
+
+ return typeof fontSize === "string" && fontSize.length > 0
+ ? fontSize
+ : DEFAULT_VALUE;
+ },
+ });
+
+ return (
+
+
+ }
+ >
+
+ {activeValue === DEFAULT_VALUE ? t("font_size.default") : activeValue}
+
+
+
+
+
+
+ editor.chain().focus().unsetFontSize().run()}
+ value={DEFAULT_VALUE}
+ >
+ {t("font_size.default")}
+
+
+ {SUPPORTED_FONT_SIZES.map(size => (
+ editor.chain().focus().setFontSize(size).run()}
+ value={size}
+ >
+ {size}
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/table-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/table-action.tsx
new file mode 100644
index 000000000..12882f2cf
--- /dev/null
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/table-action.tsx
@@ -0,0 +1,140 @@
+import { useEditorState } from "@tiptap/react";
+import {
+ BetweenHorizontalStartIcon,
+ BetweenVerticalStartIcon,
+ CombineIcon,
+ Grid2x2PlusIcon,
+ Grid3x3Icon,
+ PanelLeftIcon,
+ PanelTopIcon,
+ Trash2Icon,
+} from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+
+import { useToolbarEditor } from "../use-toolbar-editor";
+
+export const TableAction = () => {
+ const t = useTranslations("core.global.editor.table");
+ const { editor } = useToolbarEditor();
+ const isInTable = useEditorState({
+ editor,
+ selector: ctx => ctx.editor.isActive("table"),
+ });
+
+ const actions = [
+ {
+ icon: ,
+ label: t("add_column_before"),
+ run: () => editor.chain().focus().addColumnBefore().run(),
+ },
+ {
+ icon: ,
+ label: t("add_column_after"),
+ run: () => editor.chain().focus().addColumnAfter().run(),
+ },
+ {
+ icon: ,
+ label: t("add_row_before"),
+ run: () => editor.chain().focus().addRowBefore().run(),
+ },
+ {
+ icon: ,
+ label: t("add_row_after"),
+ run: () => editor.chain().focus().addRowAfter().run(),
+ },
+ {
+ icon: ,
+ label: t("toggle_header_row"),
+ run: () => editor.chain().focus().toggleHeaderRow().run(),
+ },
+ {
+ icon: ,
+ label: t("toggle_header_column"),
+ run: () => editor.chain().focus().toggleHeaderColumn().run(),
+ },
+ {
+ icon: ,
+ label: t("merge_or_split"),
+ run: () => editor.chain().focus().mergeOrSplit().run(),
+ },
+ ];
+
+ return (
+
+
+ }
+ >
+
+
+
+
+
+ editor
+ .chain()
+ .focus()
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
+ .run()
+ }
+ >
+
+ {t("insert")}
+
+
+ {isInTable && (
+ <>
+
+
+ {actions.map(item => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+
+ editor.chain().focus().deleteColumn().run()}
+ variant="destructive"
+ >
+
+ {t("delete_column")}
+
+ editor.chain().focus().deleteRow().run()}
+ variant="destructive"
+ >
+
+ {t("delete_row")}
+
+ editor.chain().focus().deleteTable().run()}
+ variant="destructive"
+ >
+
+ {t("delete")}
+
+ >
+ )}
+
+
+ );
+};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/text-format-more/text-format-more.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/text-format-more/text-format-more.tsx
index 584f6f2f2..ef3b18a8a 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/text-format-more/text-format-more.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/text-format-more/text-format-more.tsx
@@ -1,5 +1,9 @@
import { useEditorState } from "@tiptap/react";
-import { EllipsisVerticalIcon, StrikethroughIcon } from "lucide-react";
+import {
+ CodeXmlIcon,
+ EllipsisVerticalIcon,
+ StrikethroughIcon,
+} from "lucide-react";
import { useTranslations } from "use-intl";
import { Button } from "@/components/ui/button";
@@ -18,10 +22,11 @@ import { useToolbarEditor } from "../../use-toolbar-editor";
export const TextFormatMore = () => {
const t = useTranslations("core.global.editor.text_format_more");
const { editor } = useToolbarEditor();
- const { isStrike } = useEditorState({
+ const { isCode, isStrike } = useEditorState({
editor,
selector: ctx => {
return {
+ isCode: ctx.editor.isActive("code"),
isStrike: ctx.editor.isActive("strike"),
};
},
@@ -34,7 +39,7 @@ export const TextFormatMore = () => {