组件总览
按使用场景分组,点击进入每个组件的文档与交互示例。
@@ -68,6 +68,11 @@
import { BrandLogo } from '@recloudstudio/ui/icons'
import { docGroups, docPath } from '~/utils/catalog'
+const componentCount = docGroups
+ .filter(group => group.label !== '指南' && group.label !== 'Design Tokens')
+ .flatMap(group => group.items)
+ .filter(item => item.name !== 'locale').length
+
const installNuxt = `// nuxt.config.ts
export default defineNuxtConfig({
modules: [['@recloudstudio/ui/nuxt', { prefix: '' }]],
diff --git a/apps/playground/src/style.css b/apps/playground/src/style.css
index 8ba3cca..592b04d 100644
--- a/apps/playground/src/style.css
+++ b/apps/playground/src/style.css
@@ -5,8 +5,12 @@
/* 字体:全站统一使用 Maple Mono NF CN,由 nuxt.config.ts 中的 CDN 样式表加载;其余为加载失败时的回退 */
@theme {
- --font-sans: 'Maple Mono NF CN', 'Maple Mono CN', 'Noto Sans Mono CJK SC', ui-monospace, 'PingFang SC', 'Microsoft YaHei', monospace;
- --font-mono: 'Maple Mono NF CN', 'Maple Mono CN', 'Noto Sans Mono CJK SC', ui-monospace, 'DejaVu Sans Mono', monospace;
+ --font-sans:
+ "Maple Mono NF CN", "Maple Mono CN", "Noto Sans Mono CJK SC", ui-monospace, "PingFang SC",
+ "Microsoft YaHei", monospace;
+ --font-mono:
+ "Maple Mono NF CN", "Maple Mono CN", "Noto Sans Mono CJK SC", ui-monospace, "DejaVu Sans Mono",
+ monospace;
}
html {
@@ -28,7 +32,10 @@ body {
text-rendering: optimizeLegibility;
}
-h1, h2, h3, h4 {
+h1,
+h2,
+h3,
+h4 {
text-wrap: balance;
letter-spacing: normal;
}
@@ -37,7 +44,10 @@ p {
text-wrap: pretty;
}
-code, pre, kbd, samp {
+code,
+pre,
+kbd,
+samp {
font-family: var(--font-mono);
font-variant-ligatures: none;
letter-spacing: 0;
@@ -69,4 +79,3 @@ code, pre, kbd, samp {
.no-scrollbar {
scrollbar-width: none;
}
-
diff --git a/apps/playground/utils/catalog.ts b/apps/playground/utils/catalog.ts
index f2b4d4f..0908ea1 100644
--- a/apps/playground/utils/catalog.ts
+++ b/apps/playground/utils/catalog.ts
@@ -1,160 +1,290 @@
export interface DocItem {
- name: string
- title: string
- description: string
+ name: string;
+ title: string;
+ description: string;
}
export interface DocGroup {
- label: string
- items: DocItem[]
+ label: string;
+ items: DocItem[];
}
const groups: DocGroup[] = [
{
- label: '指南',
- items: [
- { name: 'index', title: '介绍', description: 'ReCloud UI 设计哲学与快速开始。' }
- ]
+ label: "指南",
+ items: [{ name: "index", title: "介绍", description: "ReCloud UI 设计哲学与快速开始。" }],
},
{
- label: 'Design Tokens',
+ label: "Design Tokens",
items: [
- { name: 'colors', title: '色彩与渐变', description: '品牌色度刻度与 180° 垂直天蓝渐变。' }
- ]
+ { name: "colors", title: "色彩与渐变", description: "品牌色度刻度与 180° 垂直天蓝渐变。" },
+ ],
},
{
- label: '通用',
+ label: "通用",
items: [
- { name: 'button', title: 'Button 按钮', description: '六种视觉层级,覆盖主操作与低干扰链接。' },
- { name: 'badge', title: 'Badge 徽章', description: '轻量状态标签与版本标记。' },
- { name: 'banner', title: 'Banner 横幅', description: '维护预警与安全事件通告条。' },
- { name: 'card', title: 'Card 卡片', description: '内容容器与分割线层级。' },
- { name: 'avatar', title: 'Avatar 头像', description: '图像、降级文本与在线状态。' },
- { name: 'kbd', title: 'Kbd 按键提示', description: '快捷键视觉化展示。' },
- { name: 'code-block', title: 'CodeBlock 代码块', description: '带复制、行号和文件标识的代码展示。' },
- { name: 'spinner', title: 'Spinner 加载', description: '环形加载指示器。' },
- { name: 'icon', title: 'Icon 图标', description: '支持组件、图标库前缀、内联 SVG 与图片的多源图标渲染体系。' }
- ]
+ {
+ name: "button",
+ title: "Button 按钮",
+ description: "六种视觉层级,覆盖主操作与低干扰链接。",
+ },
+ { name: "badge", title: "Badge 徽章", description: "轻量状态标签与版本标记。" },
+ { name: "banner", title: "Banner 横幅", description: "维护预警与安全事件通告条。" },
+ { name: "card", title: "Card 卡片", description: "内容容器与分割线层级。" },
+ { name: "avatar", title: "Avatar 头像", description: "图像、降级文本与在线状态。" },
+ { name: "kbd", title: "Kbd 按键提示", description: "快捷键视觉化展示。" },
+ {
+ name: "code-block",
+ title: "CodeBlock 代码块",
+ description: "带复制、行号和文件标识的代码展示。",
+ },
+ { name: "spinner", title: "Spinner 加载", description: "环形加载指示器。" },
+ {
+ name: "icon",
+ title: "Icon 图标",
+ description: "支持组件、图标库前缀、内联 SVG 与图片的多源图标渲染体系。",
+ },
+ ],
},
{
- label: '表单',
+ label: "表单",
items: [
- { name: 'field', title: 'Field / Form 表单布局', description: '统一关联标签、说明、错误信息与表单间距。' },
- { name: 'input', title: 'Input 输入框', description: '带标签、提示与错误态的文本输入。' },
- { name: 'tag-input', title: 'TagInput 标签输入', description: '可添加、删除与粘贴多个标签。' },
- { name: 'textarea', title: 'Textarea 文本域', description: '多行文本输入。' },
- { name: 'select', title: 'Select 选择器', description: '无障碍下拉选择。' },
- { name: 'checkbox', title: 'Checkbox 复选框', description: '布尔开关复选项。' },
- { name: 'switch', title: 'Switch 开关', description: '即时生效的切换开关。' },
- { name: 'radio-group', title: 'RadioGroup 单选组', description: '带描述的选项组。' },
- { name: 'slider', title: 'Slider 滑块', description: '单值/区间滑动输入。' },
- { name: 'rating', title: 'Rating 评分', description: '星级评分组件。' },
- { name: 'combobox', title: 'Combobox 组合框', description: '可搜索的下拉选择。' },
- { name: 'number-field', title: 'NumberField 数字输入', description: '步进增减数字。' },
- { name: 'multi-select', title: 'MultiSelect 多选', description: '带搜索和标签的多值选择。' },
- { name: 'pin-input', title: 'PinInput 验证码', description: 'OTP 分段输入。' },
- { name: 'editable', title: 'Editable 行内编辑', description: '点击即改的文本。' },
- { name: 'toggle', title: 'Toggle / ToggleGroup', description: '按压态工具按钮与分组。' },
- { name: 'label', title: 'Label 标签', description: '表单控件关联标签。' },
- { name: 'date-picker', title: 'DatePicker 日期选择', description: '单日期与日期范围筛选。' }
- ]
+ {
+ name: "field",
+ title: "Field / Form 表单布局",
+ description: "统一关联标签、说明、错误信息与表单间距。",
+ },
+ { name: "input", title: "Input 输入框", description: "带标签、提示与错误态的文本输入。" },
+ {
+ name: "tag-input",
+ title: "TagInput 标签输入",
+ description: "可添加、删除与粘贴多个标签。",
+ },
+ { name: "textarea", title: "Textarea 文本域", description: "多行文本输入。" },
+ { name: "select", title: "Select 选择器", description: "无障碍下拉选择。" },
+ { name: "checkbox", title: "Checkbox 复选框", description: "布尔开关复选项。" },
+ { name: "switch", title: "Switch 开关", description: "即时生效的切换开关。" },
+ { name: "radio-group", title: "RadioGroup 单选组", description: "带描述的选项组。" },
+ { name: "slider", title: "Slider 滑块", description: "单值/区间滑动输入。" },
+ { name: "rating", title: "Rating 评分", description: "星级评分组件。" },
+ { name: "combobox", title: "Combobox 组合框", description: "可搜索的下拉选择。" },
+ { name: "number-field", title: "NumberField 数字输入", description: "步进增减数字。" },
+ { name: "multi-select", title: "MultiSelect 多选", description: "带搜索和标签的多值选择。" },
+ { name: "pin-input", title: "PinInput 验证码", description: "OTP 分段输入。" },
+ { name: "editable", title: "Editable 行内编辑", description: "点击即改的文本。" },
+ { name: "toggle", title: "Toggle / ToggleGroup", description: "按压态工具按钮与分组。" },
+ { name: "label", title: "Label 标签", description: "表单控件关联标签。" },
+ {
+ name: "search-input",
+ title: "SearchInput 搜索框",
+ description: "带图标、清空、加载、快捷键与联想结果下拉的通用前端搜索框。",
+ },
+ { name: "date-picker", title: "DatePicker 日期选择", description: "单日期与日期范围筛选。" },
+ ],
},
{
- label: '数据展示',
+ label: "数据展示",
items: [
- { name: 'data-table', title: 'DataTable 数据表', description: '列插槽富单元格渲染。' },
- { name: 'progress', title: 'Progress 进度条', description: '任务进度与百分比。' },
- { name: 'skeleton', title: 'Skeleton 骨架屏', description: '加载占位。' },
- { name: 'empty-state', title: 'EmptyState 空状态', description: '空数据页面引导。' },
- { name: 'file-upload', title: 'FileUpload 文件上传', description: '拖放、校验、进度与失败重试。' },
- { name: 'separator', title: 'Separator 分隔线', description: '水平与垂直分隔。' },
- { name: 'timeline', title: 'Timeline 时间线', description: '操作日志与变更历史。' }
- ]
+ { name: "data-table", title: "DataTable 数据表", description: "列插槽富单元格渲染。" },
+ { name: "progress", title: "Progress 进度条", description: "任务进度与百分比。" },
+ { name: "skeleton", title: "Skeleton 骨架屏", description: "加载占位。" },
+ { name: "empty-state", title: "EmptyState 空状态", description: "空数据页面引导。" },
+ {
+ name: "file-upload",
+ title: "FileUpload 文件上传",
+ description: "拖放、校验、进度与失败重试。",
+ },
+ { name: "separator", title: "Separator 分隔线", description: "水平与垂直分隔。" },
+ { name: "timeline", title: "Timeline 时间线", description: "操作日志与变更历史。" },
+ ],
},
{
- label: '导航',
+ label: "导航",
items: [
- { name: 'tabs', title: 'Tabs 标签页', description: '视图切换标签。' },
- { name: 'pagination', title: 'Pagination 分页', description: '页码与前后翻页。' },
- { name: 'breadcrumb', title: 'Breadcrumb 面包屑', description: '层级路径导航。' },
- { name: 'stepper', title: 'Stepper 步骤条', description: '多步流程进度。' },
- { name: 'navigation-menu', title: 'NavigationMenu 导航菜单', description: '悬停展开的顶部导航。' },
- { name: 'toolbar', title: 'Toolbar 工具条', description: '编辑器式按钮组。' },
- { name: 'tree-view', title: 'TreeView 树形视图', description: '权限、资源与目录的层级浏览。' },
- { name: 'command-palette', title: 'CommandPalette 命令面板', description: '面向开发者工具的全局命令与导航入口。' },
- { name: 'app-shell', title: 'AppShell 应用布局', description: '控制台的侧栏、顶栏与主内容骨架。' },
- { name: 'resizable-panel', title: 'ResizablePanel 可调面板', description: '可拖拽、可键盘调整的工作区分栏。' }
- ]
+ { name: "tabs", title: "Tabs 标签页", description: "视图切换标签。" },
+ { name: "pagination", title: "Pagination 分页", description: "页码与前后翻页。" },
+ { name: "breadcrumb", title: "Breadcrumb 面包屑", description: "层级路径导航。" },
+ { name: "stepper", title: "Stepper 步骤条", description: "多步流程进度。" },
+ {
+ name: "navigation-menu",
+ title: "NavigationMenu 导航菜单",
+ description: "悬停展开的顶部导航。",
+ },
+ { name: "toolbar", title: "Toolbar 工具条", description: "编辑器式按钮组。" },
+ {
+ name: "tree-view",
+ title: "TreeView 树形视图",
+ description: "权限、资源与目录的层级浏览。",
+ },
+ {
+ name: "command-palette",
+ title: "CommandPalette 命令面板",
+ description: "面向开发者工具的全局命令与导航入口。",
+ },
+ {
+ name: "app-shell",
+ title: "AppShell 应用布局",
+ description: "控制台的侧栏、顶栏与主内容骨架。",
+ },
+ {
+ name: "resizable-panel",
+ title: "ResizablePanel 可调面板",
+ description: "可拖拽、可键盘调整的工作区分栏。",
+ },
+ ],
},
{
- label: '折叠披露',
+ label: "折叠披露",
items: [
- { name: 'accordion', title: 'Accordion 手风琴', description: '单开/多开问答折叠。' },
- { name: 'collapsible', title: 'CollapsiblePanel 折叠面板', description: '高级选项渐进披露。' }
- ]
+ { name: "accordion", title: "Accordion 手风琴", description: "单开/多开问答折叠。" },
+ {
+ name: "collapsible",
+ title: "CollapsiblePanel 折叠面板",
+ description: "高级选项渐进披露。",
+ },
+ ],
},
{
- label: '浮层反馈',
+ label: "浮层反馈",
items: [
- { name: 'dialog', title: 'Dialog 对话框', description: '模态确认与表单弹层。' },
- { name: 'alert-dialog', title: 'AlertDialog 警示对话框', description: '破坏性操作二次确认。' },
- { name: 'dropdown-menu', title: 'DropdownMenu 下拉菜单', description: '操作菜单与快捷键提示。' },
- { name: 'context-menu', title: 'ContextMenu 右键菜单', description: '区域右键操作。' },
- { name: 'tooltip', title: 'Tooltip 文字提示', description: '悬停即时提示。' },
- { name: 'popover', title: 'Popover 浮层', description: '轻量内容弹层。' },
- { name: 'hover-card', title: 'HoverCard 悬停卡片', description: '预览用户信息卡。' },
- { name: 'toast', title: 'Toast 吐司提示', description: '全局操作反馈队列。' },
- { name: 'sheet', title: 'Sheet 抽屉面板', description: '资源详情、配置与辅助操作面板。' }
- ]
+ { name: "dialog", title: "Dialog 对话框", description: "模态确认与表单弹层。" },
+ {
+ name: "alert-dialog",
+ title: "AlertDialog 警示对话框",
+ description: "破坏性操作二次确认。",
+ },
+ {
+ name: "dropdown-menu",
+ title: "DropdownMenu 下拉菜单",
+ description: "操作菜单与快捷键提示。",
+ },
+ { name: "context-menu", title: "ContextMenu 右键菜单", description: "区域右键操作。" },
+ { name: "tooltip", title: "Tooltip 文字提示", description: "悬停即时提示。" },
+ { name: "popover", title: "Popover 浮层", description: "轻量内容弹层。" },
+ { name: "hover-card", title: "HoverCard 悬停卡片", description: "预览用户信息卡。" },
+ { name: "toast", title: "Toast 吐司提示", description: "全局操作反馈队列。" },
+ { name: "sheet", title: "Sheet 抽屉面板", description: "资源详情、配置与辅助操作面板。" },
+ ],
},
{
- label: '文档排版',
+ label: "文档排版",
items: [
- { name: 'callout', title: 'Callout 提示块', description: '注意、提示、警告、危险语义提示块。' },
- { name: 'inline-code', title: 'InlineCode 行内代码', description: '与 CodeBlock 成套的等宽行内代码标记,可选一键复制。' },
- { name: 'steps', title: 'Steps 文档步骤', description: '长文指南与安装教程的垂直序列步骤。' },
- { name: 'anchor-heading', title: 'AnchorHeading 锚点标题', description: '悬停显现锚点符号并支持复制链接的标题。' },
- { name: 'toc', title: 'Toc 页内目录', description: '多级标题目录导航,支持滚动高亮与平滑跳转。' },
- { name: 'prev-next', title: 'PrevNext 翻页导航', description: '文章与文档的上一篇/下一篇双向卡片导航。' },
- { name: 'nav-tree', title: 'NavTree 文档树侧栏', description: '带分组、折叠、徽标与实时过滤的文档树侧栏。' },
- { name: 'code-group', title: 'CodeGroup 代码分组', description: '多包管理器与多语言代码块选项卡容器。' }
- ]
+ {
+ name: "callout",
+ title: "Callout 提示块",
+ description: "注意、提示、警告、危险语义提示块。",
+ },
+ {
+ name: "inline-code",
+ title: "InlineCode 行内代码",
+ description: "与 CodeBlock 成套的等宽行内代码标记,可选一键复制。",
+ },
+ { name: "steps", title: "Steps 文档步骤", description: "长文指南与安装教程的垂直序列步骤。" },
+ {
+ name: "anchor-heading",
+ title: "AnchorHeading 锚点标题",
+ description: "悬停显现锚点符号并支持复制链接的标题。",
+ },
+ {
+ name: "toc",
+ title: "Toc 页内目录",
+ description: "多级标题目录导航,支持滚动高亮与平滑跳转。",
+ },
+ {
+ name: "prev-next",
+ title: "PrevNext 翻页导航",
+ description: "文章与文档的上一篇/下一篇双向卡片导航。",
+ },
+ {
+ name: "nav-tree",
+ title: "NavTree 文档树侧栏",
+ description: "带分组、折叠、徽标与实时过滤的文档树侧栏。",
+ },
+ {
+ name: "code-group",
+ title: "CodeGroup 代码分组",
+ description: "多包管理器与多语言代码块选项卡容器。",
+ },
+ {
+ name: "markdown",
+ title: "Markdown 渲染系统",
+ description: "面向开发者文档的受控 Markdown/GFM 与扩展指令渲染器。",
+ },
+ ],
},
{
- label: '营销与门户',
+ label: "营销与门户",
items: [
- { name: 'navbar', title: 'Navbar 导航顶栏', description: '站点级全局顶栏,集成品牌标识、链接、操作与移动端抽屉。' },
- { name: 'footer', title: 'Footer 站点页脚', description: '多列分组链接、版权信息与社交链接插槽。' },
- { name: 'hero', title: 'Hero 首屏横幅', description: '落地页与产品首页主视觉横幅,支持居中、图文左右分栏及极简模式。' }
- ]
+ {
+ name: "navbar",
+ title: "Navbar 导航顶栏",
+ description: "站点级全局顶栏,集成品牌标识、链接、操作与移动端抽屉。",
+ },
+ {
+ name: "footer",
+ title: "Footer 站点页脚",
+ description: "多列分组链接、版权信息与社交链接插槽。",
+ },
+ {
+ name: "hero",
+ title: "Hero 首屏横幅",
+ description: "落地页与产品首页主视觉横幅,支持居中、图文左右分栏及极简模式。",
+ },
+ {
+ name: "container",
+ title: "Container 页面容器",
+ description: "规范响应式最大宽度与内边距的页面级排版容器。",
+ },
+ {
+ name: "section",
+ title: "Section 门户区块",
+ description: "具备标准垂直间距节奏、背景底色与内置标题插槽的营销区块容器。",
+ },
+ {
+ name: "theme-toggle",
+ title: "ThemeToggle 主题切换开关",
+ description: "可直接嵌入 Navbar 顶栏的主题明暗切换按钮与下拉组件。",
+ },
+ ],
},
{
- label: '工具',
+ label: "工具",
items: [
- { name: 'locale', title: 'i18n 国际化', description: '组件库内置文案注入、双语包与动态切换。' },
- { name: 'scroll-area', title: 'ScrollArea 滚动区', description: '自定义滚动条容器。' },
- { name: 'aspect-ratio', title: 'AspectRatio 宽高比', description: '固定比例容器。' },
- { name: 'visually-hidden', title: 'VisuallyHidden 隐藏文本', description: '屏幕阅读器专用。' }
- ]
- }
-]
+ {
+ name: "locale",
+ title: "i18n 国际化",
+ description: "组件库内置文案注入、双语包与动态切换。",
+ },
+ { name: "scroll-area", title: "ScrollArea 滚动区", description: "自定义滚动条容器。" },
+ { name: "aspect-ratio", title: "AspectRatio 宽高比", description: "固定比例容器。" },
+ {
+ name: "visually-hidden",
+ title: "VisuallyHidden 隐藏文本",
+ description: "屏幕阅读器专用。",
+ },
+ ],
+ },
+];
/** Component entries are presented alphabetically within each sidebar group. */
export const docGroups: DocGroup[] = groups.map((group) => ({
...group,
- items: [...group.items].sort((a, b) => a.title.localeCompare(b.title, 'en'))
-}))
+ items: [...group.items].sort((a, b) => a.title.localeCompare(b.title, "en")),
+}));
-export const flatDocs = docGroups.flatMap((g) => g.items.map((i) => ({ ...i, group: g.label })))
+export const flatDocs = docGroups.flatMap((g) => g.items.map((i) => ({ ...i, group: g.label })));
export function docPath(name: string): string {
- return name === 'index' ? '/' : `/components/${name}`
+ return name === "index" ? "/" : `/components/${name}`;
}
-export function adjacentDocs(name: string): { prev?: { name: string; title: string }; next?: { name: string; title: string } } {
- const idx = flatDocs.findIndex((d) => d.name === name)
+export function adjacentDocs(name: string): {
+ prev?: { name: string; title: string };
+ next?: { name: string; title: string };
+} {
+ const idx = flatDocs.findIndex((d) => d.name === name);
return {
prev: idx > 0 ? flatDocs[idx - 1] : undefined,
- next: idx >= 0 && idx < flatDocs.length - 1 ? flatDocs[idx + 1] : undefined
- }
+ next: idx >= 0 && idx < flatDocs.length - 1 ? flatDocs[idx + 1] : undefined,
+ };
}
diff --git a/bun.lock b/bun.lock
index a7de6b9..9cc4a14 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,13 +17,14 @@
"dependencies": {
"@recloudstudio/ui": "workspace:*",
"nuxt": "^4.0.0",
+ "vue-router": "^5.3.1",
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"shiki": "^4.5.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
- "vue-tsc": "^2.2.8",
+ "vue-tsc": "^3.3.12",
},
},
"packages/ui": {
@@ -32,15 +33,23 @@
"dependencies": {
"clsx": "^2.1.1",
"lucide-vue-next": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
"reka-ui": "^2.10.5",
+ "remark-directive": "^3.0.1",
+ "remark-gfm": "^4.0.1",
+ "remark-parse": "^11.0.0",
"shiki": "^4.5.0",
"tailwind-merge": "^3.0.2",
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.0.0",
},
"devDependencies": {
"@nuxt/kit": "^4.0.0",
"@nuxt/schema": "^4.0.0",
"@tailwindcss/cli": "^4.1.17",
+ "@types/mdast": "^4.0.4",
"@types/node": "^22.13.9",
+ "@types/unist": "^3.0.3",
"@vitejs/plugin-vue": "^6.0.9",
"tailwindcss": "^4.0.9",
"typescript": "^5.7.3",
@@ -519,6 +528,8 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -527,6 +538,8 @@
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
"@types/node": ["@types/node@22.20.4", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@@ -641,6 +654,8 @@
"b4a": ["b4a@1.9.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ=="],
+ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bare-events": ["bare-events@2.9.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA=="],
@@ -689,10 +704,14 @@
"chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="],
+ "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -765,6 +784,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="],
@@ -851,6 +872,8 @@
"exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="],
+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -959,8 +982,14 @@
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
+ "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+ "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
"is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
+ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -969,6 +998,8 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
@@ -981,6 +1012,8 @@
"is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="],
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
@@ -1051,6 +1084,8 @@
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
+ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
+
"lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
"lucide-vue-next": ["lucide-vue-next@1.0.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg=="],
@@ -1061,20 +1096,96 @@
"magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="],
+ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
+
+ "mdast-util-directive": ["mdast-util-directive@3.1.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-character": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Gqpa3MHorXYEZ0VfbroyvBlVKUfz371V6mIERqOYMcb0W1D3LwmFq1ZW4O2TDrbn+4ZmGLwMWeFRJgYZ+bc5fQ=="],
+
+ "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw=="],
+
+ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A=="],
+
+ "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
+
+ "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
+
+ "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
+
+ "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="],
+
+ "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
+
+ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
+
+ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+ "mdast-util-to-markdown": ["mdast-util-to-markdown@2.2.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA=="],
+
+ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+ "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="],
+
+ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="],
+
+ "micromark-extension-directive": ["micromark-extension-directive@3.0.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA=="],
+
+ "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
+
+ "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
+
+ "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
+
+ "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
+
+ "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="],
+
+ "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
+
+ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
+
+ "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+ "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+ "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="],
+
+ "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+ "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+ "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+ "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+ "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+ "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+ "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+ "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="],
+
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+ "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+ "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+ "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+ "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="],
@@ -1167,6 +1278,8 @@
"package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
+ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
@@ -1289,6 +1402,14 @@
"reka-ui": ["reka-ui@2.10.5", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.5", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-/8o0y9BThE0hcHKFg+3770tWcJh3qKdOEFxLOPb9HIajXeFoH/J530cZH62jUJunbqwBgpSBL9v7nB8LJ5pDXA=="],
+ "remark-directive": ["remark-directive@3.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", "micromark-extension-directive": "^3.0.0", "unified": "^11.0.0" } }, "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A=="],
+
+ "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
+
+ "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
+
+ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
@@ -1433,6 +1554,8 @@
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.11.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.6", "@turbo/darwin-arm64": "2.11.6", "@turbo/linux-64": "2.11.6", "@turbo/linux-arm64": "2.11.6", "@turbo/windows-64": "2.11.6", "@turbo/windows-arm64": "2.11.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-UppAr5XEHLfVdVp75nd+tzPp/wVEwS7Ken9aYjMlBnHqXynKn9IcQHd6GDOXBxDViSjpS+SzJcOF86v9Hf8GfQ=="],
@@ -1459,6 +1582,8 @@
"unicorn-magic": ["unicorn-magic@0.4.1", "", {}, "sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A=="],
+ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
"unimport": ["unimport@6.5.0", "", { "dependencies": { "acorn": "^8.18.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.2.1", "magic-string": "^1.2.3", "mlly": "^1.8.2", "pathe": "^2.0.3", "picomatch": "^4.0.7", "pkg-types": "^2.3.3", "scule": "^1.3.0", "strip-literal": "^4.0.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "oxc-parser": "*", "rolldown": "^1.0.0" }, "optionalPeers": ["oxc-parser", "rolldown"] }, "sha512-t0KLJLRbebz3f7NrQe+vy2ObeugVeM1XqI2oeYnauddmHSglYj3U6bxOJ65IbXmOFkcAGFJK2OyGH7aqHPM6Ug=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
@@ -1683,8 +1808,12 @@
"nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
+ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
"pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
+ "playground/vue-tsc": ["vue-tsc@3.3.12", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-kbpaz18O9jPVkXVHD1BEjpUXM6WhXSu+62L1sGGr6Wbzd8GbvkBQWjoj3KZ/C0aq2MZllce7UdtWzwCIYsBMGA=="],
+
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
@@ -1767,6 +1896,10 @@
"nitropack/unctx/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
+ "playground/vue-tsc/@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
+
+ "playground/vue-tsc/@vue/language-core": ["@vue/language-core@3.3.12", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA=="],
+
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"unplugin-dts/@volar/typescript/@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
@@ -1783,6 +1916,16 @@
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+ "playground/vue-tsc/@volar/typescript/@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
+
+ "playground/vue-tsc/@vue/language-core/@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
+
+ "playground/vue-tsc/@vue/language-core/alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
+
"unplugin-dts/@volar/typescript/@volar/language-core/@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
+
+ "playground/vue-tsc/@volar/typescript/@volar/language-core/@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
+
+ "playground/vue-tsc/@vue/language-core/@volar/language-core/@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
}
}
diff --git a/package.json b/package.json
index 6f90487..6d3a2e9 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"scripts": {
"build": "bunx turbo run build",
"generate": "bunx turbo run generate --filter=playground",
- "dev": "bunx turbo run dev",
+ "dev": "bunx turbo run dev --filter=playground",
"typecheck": "bunx turbo run typecheck",
"test": "bunx turbo run test",
"lint": "biome lint apps packages .github .changeset package.json turbo.json biome.json tsconfig.json",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index f284f44..40c575e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -21,6 +21,10 @@
"types": "./dist/icons/index.d.ts",
"import": "./dist/icons/index.js"
},
+ "./markdown": {
+ "types": "./dist/markdown/index.d.ts",
+ "import": "./dist/markdown/index.js"
+ },
"./nuxt": {
"types": "./dist/nuxt/index.d.ts",
"import": "./dist/nuxt/index.js"
@@ -44,9 +48,15 @@
"dependencies": {
"clsx": "^2.1.1",
"lucide-vue-next": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
"reka-ui": "^2.10.5",
+ "remark-directive": "^3.0.1",
+ "remark-gfm": "^4.0.1",
+ "remark-parse": "^11.0.0",
"shiki": "^4.5.0",
- "tailwind-merge": "^3.0.2"
+ "tailwind-merge": "^3.0.2",
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.0.0"
},
"peerDependencies": {
"@nuxt/kit": "^4.0.0",
@@ -64,9 +74,11 @@
"devDependencies": {
"@nuxt/kit": "^4.0.0",
"@nuxt/schema": "^4.0.0",
+ "@tailwindcss/cli": "^4.1.17",
+ "@types/mdast": "^4.0.4",
"@types/node": "^22.13.9",
+ "@types/unist": "^3.0.3",
"@vitejs/plugin-vue": "^6.0.9",
- "@tailwindcss/cli": "^4.1.17",
"tailwindcss": "^4.0.9",
"typescript": "^5.7.3",
"vite": "^8.0.0",
diff --git a/packages/ui/src/components/accordion/index.ts b/packages/ui/src/components/accordion/index.ts
index 8bbc95b..e2b7797 100644
--- a/packages/ui/src/components/accordion/index.ts
+++ b/packages/ui/src/components/accordion/index.ts
@@ -1,2 +1,2 @@
-export { default as Accordion } from './Accordion.vue'
-export type { AccordionProps, AccordionItemData } from './Accordion.vue'
+export { default as Accordion } from "./Accordion.vue";
+export type { AccordionProps, AccordionItemData } from "./Accordion.vue";
diff --git a/packages/ui/src/components/alert-dialog/index.ts b/packages/ui/src/components/alert-dialog/index.ts
index 8a55d2c..eba5cd6 100644
--- a/packages/ui/src/components/alert-dialog/index.ts
+++ b/packages/ui/src/components/alert-dialog/index.ts
@@ -1,2 +1,2 @@
-export { default as AlertDialog } from './AlertDialog.vue'
-export type { AlertDialogProps } from './AlertDialog.vue'
+export { default as AlertDialog } from "./AlertDialog.vue";
+export type { AlertDialogProps } from "./AlertDialog.vue";
diff --git a/packages/ui/src/components/anchor-heading/index.ts b/packages/ui/src/components/anchor-heading/index.ts
index 628a9f9..f72032b 100644
--- a/packages/ui/src/components/anchor-heading/index.ts
+++ b/packages/ui/src/components/anchor-heading/index.ts
@@ -1,2 +1,2 @@
-export { default as AnchorHeading } from './AnchorHeading.vue'
-export type { AnchorHeadingProps, HeadingLevel } from './types'
+export { default as AnchorHeading } from "./AnchorHeading.vue";
+export type { AnchorHeadingProps, HeadingLevel } from "./types";
diff --git a/packages/ui/src/components/anchor-heading/types.ts b/packages/ui/src/components/anchor-heading/types.ts
index 34d6a56..dc333df 100644
--- a/packages/ui/src/components/anchor-heading/types.ts
+++ b/packages/ui/src/components/anchor-heading/types.ts
@@ -1,8 +1,8 @@
-export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
+export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export interface AnchorHeadingProps {
- as?: HeadingLevel
- id?: string
- copyable?: boolean
- class?: string
+ as?: HeadingLevel;
+ id?: string;
+ copyable?: boolean;
+ class?: string;
}
diff --git a/packages/ui/src/components/api-table/ApiTable.vue b/packages/ui/src/components/api-table/ApiTable.vue
new file mode 100644
index 0000000..65e09da
--- /dev/null
+++ b/packages/ui/src/components/api-table/ApiTable.vue
@@ -0,0 +1,41 @@
+
+
+
+ {{ props.title }}
+
+
+
+ {{ value }}
+
+
+ {{ value }}
+
+
+ {{ value || '—' }}
+
+
+ {{ value }}
+
+
+
+
+
+
diff --git a/packages/ui/src/components/api-table/apiTable.test.ts b/packages/ui/src/components/api-table/apiTable.test.ts
new file mode 100644
index 0000000..c46d4f7
--- /dev/null
+++ b/packages/ui/src/components/api-table/apiTable.test.ts
@@ -0,0 +1,19 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const source = readFileSync(fileURLToPath(new URL("./ApiTable.vue", import.meta.url)), "utf8");
+
+describe("ApiTable component contract", () => {
+ it("renders API metadata through DataTable", () => {
+ expect(source).toContain('
{
+ expect(source).toContain("template #cell-name");
+ expect(source).toContain("template #cell-type");
+ expect(source).toContain("value || '—'");
+ });
+});
diff --git a/packages/ui/src/components/api-table/index.ts b/packages/ui/src/components/api-table/index.ts
new file mode 100644
index 0000000..d3d5080
--- /dev/null
+++ b/packages/ui/src/components/api-table/index.ts
@@ -0,0 +1,2 @@
+export { default as ApiTable } from "./ApiTable.vue";
+export type { ApiTableProps, ApiTableRow } from "./types";
diff --git a/packages/ui/src/components/api-table/types.ts b/packages/ui/src/components/api-table/types.ts
new file mode 100644
index 0000000..eefa722
--- /dev/null
+++ b/packages/ui/src/components/api-table/types.ts
@@ -0,0 +1,12 @@
+export type ApiTableRow = Record & {
+ name: string;
+ type: string;
+ default?: string;
+ description: string;
+};
+
+export interface ApiTableProps {
+ title?: string;
+ rows: ApiTableRow[];
+ class?: string;
+}
diff --git a/packages/ui/src/components/app-shell/index.ts b/packages/ui/src/components/app-shell/index.ts
index d9692fc..92b4511 100644
--- a/packages/ui/src/components/app-shell/index.ts
+++ b/packages/ui/src/components/app-shell/index.ts
@@ -1,2 +1,2 @@
-export { default as AppShell } from './AppShell.vue'
-export { default as Sidebar } from './Sidebar.vue'
+export { default as AppShell } from "./AppShell.vue";
+export { default as Sidebar } from "./Sidebar.vue";
diff --git a/packages/ui/src/components/aspect-ratio/index.ts b/packages/ui/src/components/aspect-ratio/index.ts
index 8b86aab..fe276eb 100644
--- a/packages/ui/src/components/aspect-ratio/index.ts
+++ b/packages/ui/src/components/aspect-ratio/index.ts
@@ -1,2 +1,2 @@
-export { default as AspectRatio } from './AspectRatio.vue'
-export type { AspectRatioProps } from './AspectRatio.vue'
+export { default as AspectRatio } from "./AspectRatio.vue";
+export type { AspectRatioProps } from "./AspectRatio.vue";
diff --git a/packages/ui/src/components/avatar/index.ts b/packages/ui/src/components/avatar/index.ts
index f407e96..98b9820 100644
--- a/packages/ui/src/components/avatar/index.ts
+++ b/packages/ui/src/components/avatar/index.ts
@@ -1,2 +1,2 @@
-export { default as Avatar } from './Avatar.vue'
-export type { AvatarProps } from './Avatar.vue'
+export { default as Avatar } from "./Avatar.vue";
+export type { AvatarProps } from "./Avatar.vue";
diff --git a/packages/ui/src/components/badge/index.ts b/packages/ui/src/components/badge/index.ts
index 3bf7fa5..0b716b8 100644
--- a/packages/ui/src/components/badge/index.ts
+++ b/packages/ui/src/components/badge/index.ts
@@ -1,2 +1,2 @@
-export { default as Badge } from './Badge.vue'
-export type { BadgeProps } from './Badge.vue'
+export { default as Badge } from "./Badge.vue";
+export type { BadgeProps } from "./Badge.vue";
diff --git a/packages/ui/src/components/banner/index.ts b/packages/ui/src/components/banner/index.ts
index 95e4f25..40929de 100644
--- a/packages/ui/src/components/banner/index.ts
+++ b/packages/ui/src/components/banner/index.ts
@@ -1,2 +1,2 @@
-export { default as Banner } from './Banner.vue'
-export type { BannerProps } from './Banner.vue'
+export { default as Banner } from "./Banner.vue";
+export type { BannerProps } from "./Banner.vue";
diff --git a/packages/ui/src/components/breadcrumb/index.ts b/packages/ui/src/components/breadcrumb/index.ts
index 0b7bb6a..4902add 100644
--- a/packages/ui/src/components/breadcrumb/index.ts
+++ b/packages/ui/src/components/breadcrumb/index.ts
@@ -1,2 +1,2 @@
-export { default as BreadcrumbNav } from './BreadcrumbNav.vue'
-export type { BreadcrumbNavProps, Crumb } from './BreadcrumbNav.vue'
+export { default as BreadcrumbNav } from "./BreadcrumbNav.vue";
+export type { BreadcrumbNavProps, Crumb } from "./BreadcrumbNav.vue";
diff --git a/packages/ui/src/components/button/index.ts b/packages/ui/src/components/button/index.ts
index 1e672d4..1dfcbf6 100644
--- a/packages/ui/src/components/button/index.ts
+++ b/packages/ui/src/components/button/index.ts
@@ -1,2 +1,2 @@
-export { default as Button } from './Button.vue'
-export type { ButtonProps } from './Button.vue'
+export { default as Button } from "./Button.vue";
+export type { ButtonProps } from "./Button.vue";
diff --git a/packages/ui/src/components/callout/callout.test.ts b/packages/ui/src/components/callout/callout.test.ts
index 29e3ce0..26885dd 100644
--- a/packages/ui/src/components/callout/callout.test.ts
+++ b/packages/ui/src/components/callout/callout.test.ts
@@ -1,9 +1,9 @@
-import { describe, expect, it } from 'vitest'
-import { Callout } from './index'
+import { describe, expect, it } from "vitest";
+import { Callout } from "./index";
-describe('Callout component', () => {
- it('is exported as a Vue component', () => {
- expect(Callout).toBeDefined()
- expect(Callout.name || Callout.__name).toBeDefined()
- })
-})
+describe("Callout component", () => {
+ it("is exported as a Vue component", () => {
+ expect(Callout).toBeDefined();
+ expect(Callout.name || Callout.__name).toBeDefined();
+ });
+});
diff --git a/packages/ui/src/components/callout/index.ts b/packages/ui/src/components/callout/index.ts
index 85fdc54..104a9bb 100644
--- a/packages/ui/src/components/callout/index.ts
+++ b/packages/ui/src/components/callout/index.ts
@@ -1,2 +1,2 @@
-export { default as Callout } from './Callout.vue'
-export type { CalloutProps, CalloutType } from './types'
+export { default as Callout } from "./Callout.vue";
+export type { CalloutProps, CalloutType } from "./types";
diff --git a/packages/ui/src/components/callout/types.ts b/packages/ui/src/components/callout/types.ts
index d9cd3b7..8043dab 100644
--- a/packages/ui/src/components/callout/types.ts
+++ b/packages/ui/src/components/callout/types.ts
@@ -1,9 +1,9 @@
-export type CalloutType = 'note' | 'tip' | 'info' | 'warning' | 'danger'
+export type CalloutType = "note" | "tip" | "info" | "warning" | "danger";
export interface CalloutProps {
- type?: CalloutType
- title?: string
- collapsible?: boolean
- defaultOpen?: boolean
- class?: string
+ type?: CalloutType;
+ title?: string;
+ collapsible?: boolean;
+ defaultOpen?: boolean;
+ class?: string;
}
diff --git a/packages/ui/src/components/card/index.ts b/packages/ui/src/components/card/index.ts
index de88791..74bafb1 100644
--- a/packages/ui/src/components/card/index.ts
+++ b/packages/ui/src/components/card/index.ts
@@ -1,2 +1,2 @@
-export { default as Card } from './Card.vue'
-export type { CardProps } from './Card.vue'
+export { default as Card } from "./Card.vue";
+export type { CardProps } from "./Card.vue";
diff --git a/packages/ui/src/components/checkbox/index.ts b/packages/ui/src/components/checkbox/index.ts
index 3b70ac8..32e9380 100644
--- a/packages/ui/src/components/checkbox/index.ts
+++ b/packages/ui/src/components/checkbox/index.ts
@@ -1,2 +1,2 @@
-export { default as Checkbox } from './Checkbox.vue'
-export type { CheckboxProps } from './Checkbox.vue'
+export { default as Checkbox } from "./Checkbox.vue";
+export type { CheckboxProps } from "./Checkbox.vue";
diff --git a/packages/ui/src/components/code-block/CodeBlock.vue b/packages/ui/src/components/code-block/CodeBlock.vue
index 4fe5658..941b378 100644
--- a/packages/ui/src/components/code-block/CodeBlock.vue
+++ b/packages/ui/src/components/code-block/CodeBlock.vue
@@ -11,7 +11,7 @@
{{ filename }}
- {{ language }}
+ {{ language }}
{{ isCollapsed ? commonLoc.expandCode : commonLoc.collapseCode }}
{{ copied ? commonLoc.copied : commonLoc.copy }}
+
+ {{ copied ? commonLoc.copied : '' }}
+
-
+
+
+
diff --git a/packages/ui/src/components/component-example/componentExample.test.ts b/packages/ui/src/components/component-example/componentExample.test.ts
new file mode 100644
index 0000000..a34b675
--- /dev/null
+++ b/packages/ui/src/components/component-example/componentExample.test.ts
@@ -0,0 +1,24 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const source = readFileSync(
+ fileURLToPath(new URL("./ComponentExample.vue", import.meta.url)),
+ "utf8",
+);
+const types = readFileSync(fileURLToPath(new URL("./types.ts", import.meta.url)), "utf8");
+
+describe("ComponentExample component contract", () => {
+ it("composes Card and CodeBlock around the preview and source", () => {
+ expect(source).toContain('');
+ expect(source).toContain(" {
+ expect(types).toContain("title?: string");
+ expect(types).toContain("description?: string");
+ expect(types).toContain("badge?: string");
+ expect(source).toContain("expanded: true");
+ });
+});
diff --git a/packages/ui/src/components/component-example/index.ts b/packages/ui/src/components/component-example/index.ts
new file mode 100644
index 0000000..9114f44
--- /dev/null
+++ b/packages/ui/src/components/component-example/index.ts
@@ -0,0 +1,2 @@
+export { default as ComponentExample } from "./ComponentExample.vue";
+export type { ComponentExampleProps } from "./types";
diff --git a/packages/ui/src/components/component-example/types.ts b/packages/ui/src/components/component-example/types.ts
new file mode 100644
index 0000000..164fb5d
--- /dev/null
+++ b/packages/ui/src/components/component-example/types.ts
@@ -0,0 +1,9 @@
+export interface ComponentExampleProps {
+ title?: string;
+ description?: string;
+ code?: string;
+ badge?: string;
+ /** Show the code expanded on first render. */
+ expanded?: boolean;
+ class?: string;
+}
diff --git a/packages/ui/src/components/container/Container.vue b/packages/ui/src/components/container/Container.vue
new file mode 100644
index 0000000..7a9b59a
--- /dev/null
+++ b/packages/ui/src/components/container/Container.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/container/container.test.ts b/packages/ui/src/components/container/container.test.ts
new file mode 100644
index 0000000..c14d673
--- /dev/null
+++ b/packages/ui/src/components/container/container.test.ts
@@ -0,0 +1,21 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const source = readFileSync(fileURLToPath(new URL("./Container.vue", import.meta.url)), "utf8");
+
+describe("Container component contract", () => {
+ it("supports max-width size options and padding rhythm", () => {
+ expect(source).toContain("sizeClasses[size]");
+ expect(source).toContain("padded ? 'px-4 sm:px-6 lg:px-8' : ''");
+ expect(source).toContain("size: 'xl'");
+ expect(source).toContain("as: 'div'");
+ });
+
+ it("provides comprehensive size maps", () => {
+ expect(source).toContain("max-w-2xl");
+ expect(source).toContain("max-w-4xl");
+ expect(source).toContain("max-w-7xl");
+ expect(source).toContain("max-w-screen-2xl");
+ });
+});
diff --git a/packages/ui/src/components/container/index.ts b/packages/ui/src/components/container/index.ts
new file mode 100644
index 0000000..baaa9c2
--- /dev/null
+++ b/packages/ui/src/components/container/index.ts
@@ -0,0 +1,2 @@
+export { default as Container } from "./Container.vue";
+export * from "./types";
diff --git a/packages/ui/src/components/container/types.ts b/packages/ui/src/components/container/types.ts
new file mode 100644
index 0000000..2e91777
--- /dev/null
+++ b/packages/ui/src/components/container/types.ts
@@ -0,0 +1,29 @@
+export type ContainerSize = "sm" | "md" | "lg" | "xl" | "2xl" | "full";
+
+export interface ContainerProps {
+ /**
+ * HTML element to render the container as.
+ * @default 'div'
+ */
+ as?: string;
+ /**
+ * Max width constraint of the container.
+ * - `sm`: max-w-screen-sm (~640px)
+ * - `md`: max-w-screen-md (~768px)
+ * - `lg`: max-w-screen-lg (~1024px)
+ * - `xl`: max-w-screen-xl (~1280px / 7xl)
+ * - `2xl`: max-w-screen-2xl (~1536px)
+ * - `full`: max-w-none (full width)
+ * @default 'xl'
+ */
+ size?: ContainerSize;
+ /**
+ * Horizontal padding rhythm.
+ * @default true
+ */
+ padded?: boolean;
+ /**
+ * Additional classes.
+ */
+ class?: string;
+}
diff --git a/packages/ui/src/components/context-menu/index.ts b/packages/ui/src/components/context-menu/index.ts
index 6407744..6a496d5 100644
--- a/packages/ui/src/components/context-menu/index.ts
+++ b/packages/ui/src/components/context-menu/index.ts
@@ -1,2 +1,2 @@
-export { default as ContextMenu } from './ContextMenu.vue'
-export type { ContextMenuProps, ContextMenuItemData } from './ContextMenu.vue'
+export { default as ContextMenu } from "./ContextMenu.vue";
+export type { ContextMenuProps, ContextMenuItemData } from "./ContextMenu.vue";
diff --git a/packages/ui/src/components/date-picker/dateUtils.test.ts b/packages/ui/src/components/date-picker/dateUtils.test.ts
index b19c8ef..cd9db12 100644
--- a/packages/ui/src/components/date-picker/dateUtils.test.ts
+++ b/packages/ui/src/components/date-picker/dateUtils.test.ts
@@ -1,23 +1,23 @@
-import { describe, expect, it } from 'vitest'
-import { addMonths, monthDays, parseDateKey, toDateKey } from './dateUtils'
+import { describe, expect, it } from "vitest";
+import { addMonths, monthDays, parseDateKey, toDateKey } from "./dateUtils";
-describe('dateUtils', () => {
- it('parses only valid ISO date keys', () => {
- const parsed = parseDateKey('2026-02-28')
- expect(parsed).toBeDefined()
+describe("dateUtils", () => {
+ it("parses only valid ISO date keys", () => {
+ const parsed = parseDateKey("2026-02-28");
+ expect(parsed).toBeDefined();
if (parsed) {
- expect(toDateKey(parsed)).toBe('2026-02-28')
+ expect(toDateKey(parsed)).toBe("2026-02-28");
}
- expect(parseDateKey('2026-02-30')).toBeUndefined()
- })
+ expect(parseDateKey("2026-02-30")).toBeUndefined();
+ });
- it('always generates a six-week calendar grid', () => {
- const days = monthDays(new Date(2026, 1, 1))
- expect(days).toHaveLength(42)
- expect(days.some((day) => day.key === '2026-02-01')).toBe(true)
- })
+ it("always generates a six-week calendar grid", () => {
+ const days = monthDays(new Date(2026, 1, 1));
+ expect(days).toHaveLength(42);
+ expect(days.some((day) => day.key === "2026-02-01")).toBe(true);
+ });
- it('moves months over year boundaries', () => {
- expect(toDateKey(addMonths(new Date(2026, 0, 1), -1))).toBe('2025-12-01')
- })
-})
+ it("moves months over year boundaries", () => {
+ expect(toDateKey(addMonths(new Date(2026, 0, 1), -1))).toBe("2025-12-01");
+ });
+});
diff --git a/packages/ui/src/components/date-picker/dateUtils.ts b/packages/ui/src/components/date-picker/dateUtils.ts
index 50108b8..372bd74 100644
--- a/packages/ui/src/components/date-picker/dateUtils.ts
+++ b/packages/ui/src/components/date-picker/dateUtils.ts
@@ -1,44 +1,50 @@
export interface DateRangeValue {
- start: string
- end: string
+ start: string;
+ end: string;
}
export function toDateKey(date: Date): string {
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
}
export function parseDateKey(value?: string): Date | undefined {
- if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined
- const [year, month, day] = value.split('-').map(Number)
- const date = new Date(year, month - 1, day)
- return toDateKey(date) === value ? date : undefined
+ if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
+ const [year = 0, month = 0, day = 0] = value.split("-").map(Number);
+ const date = new Date(year, month - 1, day);
+ return toDateKey(date) === value ? date : undefined;
}
export function compareDateKeys(a: string, b: string): number {
- return a.localeCompare(b)
+ return a.localeCompare(b);
}
export function formatDate(value?: string): string {
- const date = parseDateKey(value)
- return date ? new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).format(date) : ''
+ const date = parseDateKey(value);
+ return date
+ ? new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }).format(date)
+ : "";
}
export function monthDays(month: Date): Array<{ date: Date; key: string; outside: boolean }> {
- const first = new Date(month.getFullYear(), month.getMonth(), 1)
- const offset = (first.getDay() + 6) % 7
- const start = new Date(first)
- start.setDate(first.getDate() - offset)
+ const first = new Date(month.getFullYear(), month.getMonth(), 1);
+ const offset = (first.getDay() + 6) % 7;
+ const start = new Date(first);
+ start.setDate(first.getDate() - offset);
return Array.from({ length: 42 }, (_, index) => {
- const date = new Date(start)
- date.setDate(start.getDate() + index)
- return { date, key: toDateKey(date), outside: date.getMonth() !== month.getMonth() }
- })
+ const date = new Date(start);
+ date.setDate(start.getDate() + index);
+ return { date, key: toDateKey(date), outside: date.getMonth() !== month.getMonth() };
+ });
}
export function addMonths(month: Date, offset: number): Date {
- return new Date(month.getFullYear(), month.getMonth() + offset, 1)
+ return new Date(month.getFullYear(), month.getMonth() + offset, 1);
}
diff --git a/packages/ui/src/components/date-picker/index.ts b/packages/ui/src/components/date-picker/index.ts
index 7118089..ebbcecf 100644
--- a/packages/ui/src/components/date-picker/index.ts
+++ b/packages/ui/src/components/date-picker/index.ts
@@ -1,3 +1,3 @@
-export { default as DatePicker } from './DatePicker.vue'
-export { default as DateRangePicker } from './DateRangePicker.vue'
-export type { DateRangeValue } from './dateUtils'
+export { default as DatePicker } from "./DatePicker.vue";
+export { default as DateRangePicker } from "./DateRangePicker.vue";
+export type { DateRangeValue } from "./dateUtils";
diff --git a/packages/ui/src/components/dialog/index.ts b/packages/ui/src/components/dialog/index.ts
index 87a85b1..cddc4ed 100644
--- a/packages/ui/src/components/dialog/index.ts
+++ b/packages/ui/src/components/dialog/index.ts
@@ -1,2 +1,2 @@
-export { default as Dialog } from './Dialog.vue'
-export type { DialogProps } from './Dialog.vue'
+export { default as Dialog } from "./Dialog.vue";
+export type { DialogProps } from "./Dialog.vue";
diff --git a/packages/ui/src/components/dropdown/index.ts b/packages/ui/src/components/dropdown/index.ts
index 8558adf..c26bb5c 100644
--- a/packages/ui/src/components/dropdown/index.ts
+++ b/packages/ui/src/components/dropdown/index.ts
@@ -1,2 +1,2 @@
-export { default as DropdownMenu } from './DropdownMenu.vue'
-export type { DropdownMenuProps, DropdownMenuItemType } from './DropdownMenu.vue'
+export { default as DropdownMenu } from "./DropdownMenu.vue";
+export type { DropdownMenuProps, DropdownMenuItemType } from "./DropdownMenu.vue";
diff --git a/packages/ui/src/components/editable/index.ts b/packages/ui/src/components/editable/index.ts
index dece1bc..a767c75 100644
--- a/packages/ui/src/components/editable/index.ts
+++ b/packages/ui/src/components/editable/index.ts
@@ -1,2 +1,2 @@
-export { default as Editable } from './Editable.vue'
-export type { EditableProps } from './Editable.vue'
+export { default as Editable } from "./Editable.vue";
+export type { EditableProps } from "./Editable.vue";
diff --git a/packages/ui/src/components/empty/index.ts b/packages/ui/src/components/empty/index.ts
index c925b47..e9c5899 100644
--- a/packages/ui/src/components/empty/index.ts
+++ b/packages/ui/src/components/empty/index.ts
@@ -1,2 +1,2 @@
-export { default as EmptyState } from './EmptyState.vue'
-export type { EmptyStateProps } from './EmptyState.vue'
+export { default as EmptyState } from "./EmptyState.vue";
+export type { EmptyStateProps } from "./EmptyState.vue";
diff --git a/packages/ui/src/components/field/index.ts b/packages/ui/src/components/field/index.ts
index 78fbfcc..14a3a43 100644
--- a/packages/ui/src/components/field/index.ts
+++ b/packages/ui/src/components/field/index.ts
@@ -1,2 +1,2 @@
-export type { FieldProps } from './Field.vue'
-export { default as Field } from './Field.vue'
+export type { FieldProps } from "./Field.vue";
+export { default as Field } from "./Field.vue";
diff --git a/packages/ui/src/components/file-upload/index.ts b/packages/ui/src/components/file-upload/index.ts
index 223b211..2de97bf 100644
--- a/packages/ui/src/components/file-upload/index.ts
+++ b/packages/ui/src/components/file-upload/index.ts
@@ -1,2 +1,7 @@
-export { default as FileUpload } from './FileUpload.vue'
-export type { FileUploadHandler, FileUploadItem, FileUploadRequest, FileUploadStatus } from './types'
+export { default as FileUpload } from "./FileUpload.vue";
+export type {
+ FileUploadHandler,
+ FileUploadItem,
+ FileUploadRequest,
+ FileUploadStatus,
+} from "./types";
diff --git a/packages/ui/src/components/file-upload/types.ts b/packages/ui/src/components/file-upload/types.ts
index 3b8af8e..e745ca2 100644
--- a/packages/ui/src/components/file-upload/types.ts
+++ b/packages/ui/src/components/file-upload/types.ts
@@ -1,18 +1,18 @@
-export type FileUploadStatus = 'pending' | 'uploading' | 'success' | 'error' | 'cancelled'
+export type FileUploadStatus = "pending" | "uploading" | "success" | "error" | "cancelled";
export interface FileUploadItem {
- id: string
- file: File
- status: FileUploadStatus
- progress?: number
- error?: string
- previewUrl?: string
+ id: string;
+ file: File;
+ status: FileUploadStatus;
+ progress?: number;
+ error?: string;
+ previewUrl?: string;
}
export interface FileUploadRequest {
- file: File
- signal: AbortSignal
- setProgress: (value: number) => void
+ file: File;
+ signal: AbortSignal;
+ setProgress: (value: number) => void;
}
-export type FileUploadHandler = (request: FileUploadRequest) => Promise
+export type FileUploadHandler = (request: FileUploadRequest) => Promise;
diff --git a/packages/ui/src/components/footer/footer.test.ts b/packages/ui/src/components/footer/footer.test.ts
index 3f3b091..e74e057 100644
--- a/packages/ui/src/components/footer/footer.test.ts
+++ b/packages/ui/src/components/footer/footer.test.ts
@@ -1,35 +1,35 @@
-import { readFileSync } from 'node:fs'
-import { fileURLToPath } from 'node:url'
-import { describe, expect, it } from 'vitest'
-import { Footer } from './index'
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import { Footer } from "./index";
-const source = readFileSync(fileURLToPath(new URL('./Footer.vue', import.meta.url)), 'utf8')
-const types = readFileSync(fileURLToPath(new URL('./types.ts', import.meta.url)), 'utf8')
+const source = readFileSync(fileURLToPath(new URL("./Footer.vue", import.meta.url)), "utf8");
+const types = readFileSync(fileURLToPath(new URL("./types.ts", import.meta.url)), "utf8");
-describe('Footer component contract', () => {
- it('is exported as a Vue component', () => {
- expect(Footer).toBeDefined()
- })
+describe("Footer component contract", () => {
+ it("is exported as a Vue component", () => {
+ expect(Footer).toBeDefined();
+ });
- it('renders configured footer links and keeps the default copyright SSR-safe', () => {
- expect(source).toContain('v-for="link in col.links"')
- expect(source).toContain("'© ReCloud Studio. All rights reserved.'")
- expect(source).not.toContain('new Date()')
- })
+ it("renders configured footer links and keeps the default copyright SSR-safe", () => {
+ expect(source).toContain('v-for="link in col.links"');
+ expect(source).toContain("'© ReCloud Studio. All rights reserved.'");
+ expect(source).not.toContain("new Date()");
+ });
- it('uses semantic tokens and exposes social customization through its slot', () => {
- expect(source).toContain('hover:text-[color:var(--primary)]')
- expect(source).toContain('')
- })
+ it("uses semantic tokens and exposes social customization through its slot", () => {
+ expect(source).toContain("hover:text-[color:var(--primary)]");
+ expect(source).toContain('');
+ });
- it('maps supported social icon names and falls back to the accessible text name', () => {
- expect(types).toContain('icon?: string')
- expect(source).toContain("normalizedIcon === 'github' || normalizedIcon === 'discord'")
- expect(source).toContain("normalizedIcon === 'twitter' || normalizedIcon === 'x'")
- expect(source).toContain("socialIcon(s.icon) === 'github'")
- expect(source).toContain("socialIcon(s.icon) === 'discord'")
- expect(source).toContain("socialIcon(s.icon) === 'twitter'")
- expect(source).toContain('{{ s.name }} ')
- expect(source).toContain(':aria-label="s.name"')
- })
-})
+ it("maps supported social icon names and falls back to the accessible text name", () => {
+ expect(types).toContain("icon?: string");
+ expect(source).toContain("normalizedIcon === 'github' || normalizedIcon === 'discord'");
+ expect(source).toContain("normalizedIcon === 'twitter' || normalizedIcon === 'x'");
+ expect(source).toContain("socialIcon(s.icon) === 'github'");
+ expect(source).toContain("socialIcon(s.icon) === 'discord'");
+ expect(source).toContain("socialIcon(s.icon) === 'twitter'");
+ expect(source).toContain('{{ s.name }} ');
+ expect(source).toContain(':aria-label="s.name"');
+ });
+});
diff --git a/packages/ui/src/components/footer/index.ts b/packages/ui/src/components/footer/index.ts
index 538720d..e69cb0a 100644
--- a/packages/ui/src/components/footer/index.ts
+++ b/packages/ui/src/components/footer/index.ts
@@ -1,2 +1,2 @@
-export { default as Footer } from './Footer.vue'
-export type { FooterColumn, FooterLink, FooterProps, FooterSocial } from './types'
+export { default as Footer } from "./Footer.vue";
+export type { FooterColumn, FooterLink, FooterProps, FooterSocial } from "./types";
diff --git a/packages/ui/src/components/footer/types.ts b/packages/ui/src/components/footer/types.ts
index 27be639..07b74af 100644
--- a/packages/ui/src/components/footer/types.ts
+++ b/packages/ui/src/components/footer/types.ts
@@ -1,28 +1,28 @@
export interface FooterLink {
- label: string
- href?: string
- to?: string
- target?: string
+ label: string;
+ href?: string;
+ to?: string;
+ target?: string;
}
export interface FooterColumn {
- title: string
- links: FooterLink[]
+ title: string;
+ links: FooterLink[];
}
export interface FooterSocial {
- name: string
- href: string
+ name: string;
+ href: string;
/** Built-in icon name: github, discord, twitter, or x. Unknown values render the name text. */
- icon?: string
+ icon?: string;
}
export interface FooterProps {
- brandTitle?: string
- brandDescription?: string
- columns?: FooterColumn[]
- copyright?: string
- socials?: FooterSocial[]
- bordered?: boolean
- class?: string
+ brandTitle?: string;
+ brandDescription?: string;
+ columns?: FooterColumn[];
+ copyright?: string;
+ socials?: FooterSocial[];
+ bordered?: boolean;
+ class?: string;
}
diff --git a/packages/ui/src/components/form/index.ts b/packages/ui/src/components/form/index.ts
index 1e178e6..9c76ebf 100644
--- a/packages/ui/src/components/form/index.ts
+++ b/packages/ui/src/components/form/index.ts
@@ -1,2 +1,2 @@
-export type { FormProps } from './Form.vue'
-export { default as Form } from './Form.vue'
+export type { FormProps } from "./Form.vue";
+export { default as Form } from "./Form.vue";
diff --git a/packages/ui/src/components/hero/hero.test.ts b/packages/ui/src/components/hero/hero.test.ts
index 6d818f7..3e89542 100644
--- a/packages/ui/src/components/hero/hero.test.ts
+++ b/packages/ui/src/components/hero/hero.test.ts
@@ -1,26 +1,26 @@
-import { readFileSync } from 'node:fs'
-import { fileURLToPath } from 'node:url'
-import { describe, expect, it } from 'vitest'
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
-const source = readFileSync(fileURLToPath(new URL('./Hero.vue', import.meta.url)), 'utf8')
+const source = readFileSync(fileURLToPath(new URL("./Hero.vue", import.meta.url)), "utf8");
-describe('Hero component contract', () => {
- it('supports center, split, and minimal layouts', () => {
- expect(source).toContain("layout === 'center'")
- expect(source).toContain("layout === 'split'")
- expect(source).toContain("layout: 'center'")
- })
+describe("Hero component contract", () => {
+ it("supports center, split, and minimal layouts", () => {
+ expect(source).toContain("layout === 'center'");
+ expect(source).toContain("layout === 'split'");
+ expect(source).toContain("layout: 'center'");
+ });
- it('renders CTA controls with public click events and action overrides', () => {
- expect(source).toContain("@click=\"$emit('primary-click')\"")
- expect(source).toContain("@click=\"$emit('secondary-click')\"")
- expect(source).toContain('')
- expect(source).toContain('')
- })
+ it("renders CTA controls with public click events and action overrides", () => {
+ expect(source).toContain("@click=\"$emit('primary-click')\"");
+ expect(source).toContain("@click=\"$emit('secondary-click')\"");
+ expect(source).toContain('');
+ expect(source).toContain('');
+ });
- it('uses semantic primary tokens for badges and primary CTAs', () => {
- expect(source).toContain('bg-[color:var(--primary)]')
- expect(source).toContain('hover:bg-[color:var(--primary-hover)]')
- expect(source).not.toContain('#2563EB')
- })
-})
+ it("uses semantic primary tokens for badges and primary CTAs", () => {
+ expect(source).toContain("bg-[color:var(--primary)]");
+ expect(source).toContain("hover:bg-[color:var(--primary-hover)]");
+ expect(source).not.toContain("#2563EB");
+ });
+});
diff --git a/packages/ui/src/components/hero/index.ts b/packages/ui/src/components/hero/index.ts
index 1152ed0..6100534 100644
--- a/packages/ui/src/components/hero/index.ts
+++ b/packages/ui/src/components/hero/index.ts
@@ -1,2 +1,2 @@
-export { default as Hero } from './Hero.vue'
-export type { HeroLayout, HeroProps } from './types'
+export { default as Hero } from "./Hero.vue";
+export type { HeroLayout, HeroProps } from "./types";
diff --git a/packages/ui/src/components/hero/types.ts b/packages/ui/src/components/hero/types.ts
index 68170c0..3ee403f 100644
--- a/packages/ui/src/components/hero/types.ts
+++ b/packages/ui/src/components/hero/types.ts
@@ -1,11 +1,11 @@
-export type HeroLayout = 'center' | 'split' | 'minimal'
+export type HeroLayout = "center" | "split" | "minimal";
export interface HeroProps {
- layout?: HeroLayout
- badge?: string
- title?: string
- description?: string
- primaryActionText?: string
- secondaryActionText?: string
- class?: string
+ layout?: HeroLayout;
+ badge?: string;
+ title?: string;
+ description?: string;
+ primaryActionText?: string;
+ secondaryActionText?: string;
+ class?: string;
}
diff --git a/packages/ui/src/components/hover-card/index.ts b/packages/ui/src/components/hover-card/index.ts
index 39b7eab..afcb8ea 100644
--- a/packages/ui/src/components/hover-card/index.ts
+++ b/packages/ui/src/components/hover-card/index.ts
@@ -1,2 +1,2 @@
-export { default as HoverCard } from './HoverCard.vue'
-export type { HoverCardProps } from './HoverCard.vue'
+export { default as HoverCard } from "./HoverCard.vue";
+export type { HoverCardProps } from "./HoverCard.vue";
diff --git a/packages/ui/src/components/icon/icon.test.ts b/packages/ui/src/components/icon/icon.test.ts
index ab6785d..e3f3a56 100644
--- a/packages/ui/src/components/icon/icon.test.ts
+++ b/packages/ui/src/components/icon/icon.test.ts
@@ -1,30 +1,30 @@
-import { readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-import { describe, expect, it } from 'vitest'
-import { Icon } from './index'
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { Icon } from "./index";
-describe('Icon component', () => {
- it('is exported as a Vue component', () => {
- expect(Icon).toBeDefined()
- expect(Icon.name || Icon.__name).toBe('ReIcon')
- })
+describe("Icon component", () => {
+ it("is exported as a Vue component", () => {
+ expect(Icon).toBeDefined();
+ expect(Icon.name || Icon.__name).toBe("ReIcon");
+ });
- it('implements accessibility semantics, sizing, and slot fallbacks in template', () => {
- const sfcPath = resolve(__dirname, './Icon.vue')
- const source = readFileSync(sfcPath, 'utf-8')
+ it("implements accessibility semantics, sizing, and slot fallbacks in template", () => {
+ const sfcPath = resolve(__dirname, "./Icon.vue");
+ const source = readFileSync(sfcPath, "utf-8");
// Accessibility: label given sets role="img" and aria-label; absent sets aria-hidden="true"
- expect(source).toContain(':role="label ? \'img\' : undefined"')
- expect(source).toContain(':aria-label="label || undefined"')
- expect(source).toContain(':aria-hidden="label ? undefined : \'true\'"')
+ expect(source).toContain(":role=\"label ? 'img' : undefined\"");
+ expect(source).toContain(':aria-label="label || undefined"');
+ expect(source).toContain(":aria-hidden=\"label ? undefined : 'true'\"");
// Rendering branches: component, svg markup, image src, slot fallback
- expect(source).toContain("resolved?.kind === 'component'")
- expect(source).toContain("resolved?.kind === 'svg'")
- expect(source).toContain("resolved?.kind === 'image'")
- expect(source).toContain(' ')
+ expect(source).toContain("resolved?.kind === 'component'");
+ expect(source).toContain("resolved?.kind === 'svg'");
+ expect(source).toContain("resolved?.kind === 'image'");
+ expect(source).toContain(" ");
// Size binding
- expect(source).toContain(':style="sizeStyle"')
- })
-})
+ expect(source).toContain(':style="sizeStyle"');
+ });
+});
diff --git a/packages/ui/src/components/icon/index.ts b/packages/ui/src/components/icon/index.ts
index 27940d2..7caf3bf 100644
--- a/packages/ui/src/components/icon/index.ts
+++ b/packages/ui/src/components/icon/index.ts
@@ -1,4 +1,4 @@
-export { default as Icon } from './Icon.vue'
-export * from './types'
-export * from './registry'
-export { lookupLucideIcon, toPascalCase } from './lucideResolver'
+export { default as Icon } from "./Icon.vue";
+export * from "./types";
+export * from "./registry";
+export { lookupLucideIcon, toPascalCase } from "./lucideResolver";
diff --git a/packages/ui/src/components/icon/lucideResolver.ts b/packages/ui/src/components/icon/lucideResolver.ts
index 5629d3e..9fa9537 100644
--- a/packages/ui/src/components/icon/lucideResolver.ts
+++ b/packages/ui/src/components/icon/lucideResolver.ts
@@ -1,55 +1,49 @@
-import type { Component } from 'vue'
+import type { Component } from "vue";
/**
* 将 kebab-case、snake_case 或空格分隔的命名转换为 PascalCase。
* 例如:'settings' -> 'Settings', 'arrow-left' -> 'ArrowLeft', 'circle-alert' -> 'CircleAlert'。
*/
export function toPascalCase(name: string): string {
- if (!name) return ''
- const trimmed = name.trim()
- if (!trimmed) return ''
+ if (!name) return "";
+ const trimmed = name.trim();
+ if (!trimmed) return "";
- const parts = trimmed.split(/[-_\s]+/).filter(Boolean)
- if (parts.length === 0) return ''
+ const parts = trimmed.split(/[-_\s]+/).filter(Boolean);
+ if (parts.length === 0) return "";
- return parts
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
- .join('')
+ return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
}
/**
* 缓存已加载的 lucide-vue-next 模块,避免重复动态导入。
*/
-let lucideModulePromise: Promise> | null = null
+let lucideModulePromise: Promise> | null = null;
function loadLucideModule(): Promise> {
if (!lucideModulePromise) {
- lucideModulePromise = import('lucide-vue-next').then((m) => {
+ lucideModulePromise = import("lucide-vue-next").then((m) => {
// SAFETY: lucide-vue-next exports named Vue components under icon identifier keys.
- return m as Record
- })
+ return m as Record;
+ });
}
- return lucideModulePromise
+ return lucideModulePromise;
}
/**
* 从 lucide-vue-next 中查找指定名称的图标组件。
*/
export async function lookupLucideIcon(name: string): Promise {
- if (!name) return null
- const mod = await loadLucideModule()
- const pascal = toPascalCase(name)
+ if (!name) return null;
+ const mod = await loadLucideModule();
+ const pascal = toPascalCase(name);
- const candidate =
- mod[pascal] ??
- mod[`${pascal}Icon`] ??
- mod[name] ??
- mod[`${name}Icon`]
+ const candidate = mod[pascal] ?? mod[`${pascal}Icon`] ?? mod[name] ?? mod[`${name}Icon`];
- if (candidate && (typeof candidate === 'object' || typeof candidate === 'function')) {
+ if (candidate && (typeof candidate === "object" || typeof candidate === "function")) {
// SAFETY: the resolved candidate is a Vue component function or options object.
- return candidate as Component
+ return candidate as Component;
}
- return null
+ return null;
}
diff --git a/packages/ui/src/components/icon/registry.test.ts b/packages/ui/src/components/icon/registry.test.ts
index f672d14..c7bb20d 100644
--- a/packages/ui/src/components/icon/registry.test.ts
+++ b/packages/ui/src/components/icon/registry.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it } from 'vitest'
-import { toPascalCase } from './lucideResolver'
+import { describe, expect, it } from "vitest";
+import { toPascalCase } from "./lucideResolver";
import {
classifyIconSource,
looksLikeImage,
@@ -8,127 +8,127 @@ import {
registerIconResolver,
resolveIconSource,
setDefaultIconResolver,
- unregisterIconResolver
-} from './registry'
-
-describe('Icon registry and classification', () => {
- it('correctly converts naming conventions to PascalCase', () => {
- expect(toPascalCase('settings')).toBe('Settings')
- expect(toPascalCase('arrow-left')).toBe('ArrowLeft')
- expect(toPascalCase('circle_alert')).toBe('CircleAlert')
- expect(toPascalCase('folder open')).toBe('FolderOpen')
- expect(toPascalCase('ChevronRight')).toBe('ChevronRight')
- expect(toPascalCase('')).toBe('')
- })
-
- it('classifies SVG markup, image paths, and named icons accurately', () => {
- expect(looksLikeSvg(' ')).toBe(true)
- expect(looksLikeSvg(' ')).toBe(true)
- expect(looksLikeSvg('settings')).toBe(false)
-
- expect(looksLikeImage('/icons/app.svg')).toBe(true)
- expect(looksLikeImage('./logo.png')).toBe(true)
- expect(looksLikeImage('../assets/hero.webp')).toBe(true)
- expect(looksLikeImage('https://example.com/icon.svg')).toBe(true)
- expect(looksLikeImage('data:image/svg+xml;base64,PHN2Zz4...')).toBe(true)
- expect(looksLikeImage('lucide:settings')).toBe(false)
-
- expect(classifyIconSource(' ')).toBe('svg')
- expect(classifyIconSource('/brand.png')).toBe('image')
- expect(classifyIconSource('lucide:settings')).toBe('named')
- expect(classifyIconSource({ render: () => null })).toBe('component')
- })
-
- it('parses prefixed names accurately', () => {
- expect(parsePrefixedName('lucide:settings')).toEqual({
- prefix: 'lucide',
- name: 'settings'
- })
- expect(parsePrefixedName('MDI:home-outline')).toEqual({
- prefix: 'mdi',
- name: 'home-outline'
- })
- expect(parsePrefixedName('plain-name')).toBeNull()
- })
-
- it('resolves direct Vue component sources synchronously', () => {
- const dummyComponent = { name: 'DummyIcon' }
- const result = resolveIconSource(dummyComponent)
- expect(result).toEqual({ kind: 'component', component: dummyComponent })
- })
-
- it('resolves inline SVG markup synchronously', () => {
- const svgCode = ' '
- const result = resolveIconSource(svgCode)
- expect(result).toEqual({ kind: 'svg', markup: svgCode })
- })
-
- it('resolves image URLs synchronously', () => {
- const url = 'https://ui.worldexecute.me/icon.svg'
- const result = resolveIconSource(url)
- expect(result).toEqual({ kind: 'image', src: url })
- })
-
- it('supports registering and unregistering custom prefix resolvers', async () => {
- const customSvg = ' '
- registerIconResolver('testcustom', (name) => {
- if (name === 'check') return customSvg
- if (name === 'image') return '/resolved.png'
- return null
- })
-
- const checkResult = resolveIconSource('testcustom:check')
- expect(checkResult).toEqual({ kind: 'svg', markup: customSvg })
-
- const imgResult = resolveIconSource('testcustom:image')
- expect(imgResult).toEqual({ kind: 'image', src: '/resolved.png' })
-
- const unknownResult = resolveIconSource('testcustom:missing')
- expect(unknownResult).toBeNull()
-
- unregisterIconResolver('testcustom')
- const unregistered = resolveIconSource('testcustom:check')
+ unregisterIconResolver,
+} from "./registry";
+
+describe("Icon registry and classification", () => {
+ it("correctly converts naming conventions to PascalCase", () => {
+ expect(toPascalCase("settings")).toBe("Settings");
+ expect(toPascalCase("arrow-left")).toBe("ArrowLeft");
+ expect(toPascalCase("circle_alert")).toBe("CircleAlert");
+ expect(toPascalCase("folder open")).toBe("FolderOpen");
+ expect(toPascalCase("ChevronRight")).toBe("ChevronRight");
+ expect(toPascalCase("")).toBe("");
+ });
+
+ it("classifies SVG markup, image paths, and named icons accurately", () => {
+ expect(looksLikeSvg(' ')).toBe(true);
+ expect(looksLikeSvg(' ')).toBe(true);
+ expect(looksLikeSvg("settings")).toBe(false);
+
+ expect(looksLikeImage("/icons/app.svg")).toBe(true);
+ expect(looksLikeImage("./logo.png")).toBe(true);
+ expect(looksLikeImage("../assets/hero.webp")).toBe(true);
+ expect(looksLikeImage("https://example.com/icon.svg")).toBe(true);
+ expect(looksLikeImage("data:image/svg+xml;base64,PHN2Zz4...")).toBe(true);
+ expect(looksLikeImage("lucide:settings")).toBe(false);
+
+ expect(classifyIconSource(" ")).toBe("svg");
+ expect(classifyIconSource("/brand.png")).toBe("image");
+ expect(classifyIconSource("lucide:settings")).toBe("named");
+ expect(classifyIconSource({ render: () => null })).toBe("component");
+ });
+
+ it("parses prefixed names accurately", () => {
+ expect(parsePrefixedName("lucide:settings")).toEqual({
+ prefix: "lucide",
+ name: "settings",
+ });
+ expect(parsePrefixedName("MDI:home-outline")).toEqual({
+ prefix: "mdi",
+ name: "home-outline",
+ });
+ expect(parsePrefixedName("plain-name")).toBeNull();
+ });
+
+ it("resolves direct Vue component sources synchronously", () => {
+ const dummyComponent = { name: "DummyIcon" };
+ const result = resolveIconSource(dummyComponent);
+ expect(result).toEqual({ kind: "component", component: dummyComponent });
+ });
+
+ it("resolves inline SVG markup synchronously", () => {
+ const svgCode = ' ';
+ const result = resolveIconSource(svgCode);
+ expect(result).toEqual({ kind: "svg", markup: svgCode });
+ });
+
+ it("resolves image URLs synchronously", () => {
+ const url = "https://ui.worldexecute.me/icon.svg";
+ const result = resolveIconSource(url);
+ expect(result).toEqual({ kind: "image", src: url });
+ });
+
+ it("supports registering and unregistering custom prefix resolvers", async () => {
+ const customSvg = ' ';
+ registerIconResolver("testcustom", (name) => {
+ if (name === "check") return customSvg;
+ if (name === "image") return "/resolved.png";
+ return null;
+ });
+
+ const checkResult = resolveIconSource("testcustom:check");
+ expect(checkResult).toEqual({ kind: "svg", markup: customSvg });
+
+ const imgResult = resolveIconSource("testcustom:image");
+ expect(imgResult).toEqual({ kind: "image", src: "/resolved.png" });
+
+ const unknownResult = resolveIconSource("testcustom:missing");
+ expect(unknownResult).toBeNull();
+
+ unregisterIconResolver("testcustom");
+ const unregistered = resolveIconSource("testcustom:check");
// After unregistration, unregistered prefix with no default resolver resolves to null
- expect(unregistered).toBeNull()
- })
+ expect(unregistered).toBeNull();
+ });
- it('supports asynchronous custom resolvers', async () => {
- registerIconResolver('testasync', async (name) => {
- if (name === 'rocket') {
- return ' '
+ it("supports asynchronous custom resolvers", async () => {
+ registerIconResolver("testasync", async (name) => {
+ if (name === "rocket") {
+ return ' ';
}
- return null
- })
+ return null;
+ });
- const promise = resolveIconSource('testasync:rocket')
- expect(promise).toBeInstanceOf(Promise)
- const resolved = await promise
- expect(resolved).toEqual({ kind: 'svg', markup: ' ' })
+ const promise = resolveIconSource("testasync:rocket");
+ expect(promise).toBeInstanceOf(Promise);
+ const resolved = await promise;
+ expect(resolved).toEqual({ kind: "svg", markup: ' ' });
- unregisterIconResolver('testasync')
- })
+ unregisterIconResolver("testasync");
+ });
- it('falls back to default resolver for unknown prefixes and bare names', async () => {
+ it("falls back to default resolver for unknown prefixes and bare names", async () => {
setDefaultIconResolver((name) => {
- if (name === 'app-logo') return '/logo.svg'
- if (name === 'unknown:test') return ' '
- return null
- })
-
- const bareResult = resolveIconSource('app-logo')
- expect(bareResult).toEqual({ kind: 'image', src: '/logo.svg' })
-
- const prefixResult = resolveIconSource('unknown:test')
- expect(prefixResult).toEqual({ kind: 'svg', markup: ' ' })
-
- setDefaultIconResolver(null)
- })
-
- it('resolves lucide icons asynchronously via built-in resolver', async () => {
- const outcome = resolveIconSource('lucide:settings')
- expect(outcome).toBeInstanceOf(Promise)
- const resolved = await outcome
- expect(resolved).not.toBeNull()
- expect(resolved?.kind).toBe('component')
- })
-})
+ if (name === "app-logo") return "/logo.svg";
+ if (name === "unknown:test") return ' ';
+ return null;
+ });
+
+ const bareResult = resolveIconSource("app-logo");
+ expect(bareResult).toEqual({ kind: "image", src: "/logo.svg" });
+
+ const prefixResult = resolveIconSource("unknown:test");
+ expect(prefixResult).toEqual({ kind: "svg", markup: ' ' });
+
+ setDefaultIconResolver(null);
+ });
+
+ it("resolves lucide icons asynchronously via built-in resolver", async () => {
+ const outcome = resolveIconSource("lucide:settings");
+ expect(outcome).toBeInstanceOf(Promise);
+ const resolved = await outcome;
+ expect(resolved).not.toBeNull();
+ expect(resolved?.kind).toBe("component");
+ });
+});
diff --git a/packages/ui/src/components/icon/registry.ts b/packages/ui/src/components/icon/registry.ts
index 92d2102..71e1bb5 100644
--- a/packages/ui/src/components/icon/registry.ts
+++ b/packages/ui/src/components/icon/registry.ts
@@ -1,33 +1,33 @@
-import { ref } from 'vue'
-import { lookupLucideIcon } from './lucideResolver'
-import type { IconResolver, IconSource, ResolvedIcon } from './types'
+import { ref } from "vue";
+import { lookupLucideIcon } from "./lucideResolver";
+import type { IconResolver, IconSource, ResolvedIcon } from "./types";
/**
* 响应式版本号计数器,供 Icon 组件监听动态注册变更。
*/
-export const iconRegistryVersion = ref(0)
+export const iconRegistryVersion = ref(0);
-const prefixResolvers = new Map()
-let defaultIconResolver: IconResolver | null = null
+const prefixResolvers = new Map();
+let defaultIconResolver: IconResolver | null = null;
/**
* 注册指定前缀的图标库解析器(前缀不区分大小写)。
* 例如:`registerIconResolver('mdi', (name) => ...)`
*/
export function registerIconResolver(prefix: string, resolver: IconResolver): void {
- const normalized = prefix.trim().toLowerCase()
- if (!normalized) return
- prefixResolvers.set(normalized, resolver)
- iconRegistryVersion.value++
+ const normalized = prefix.trim().toLowerCase();
+ if (!normalized) return;
+ prefixResolvers.set(normalized, resolver);
+ iconRegistryVersion.value++;
}
/**
* 注销指定前缀的图标库解析器。
*/
export function unregisterIconResolver(prefix: string): void {
- const normalized = prefix.trim().toLowerCase()
+ const normalized = prefix.trim().toLowerCase();
if (prefixResolvers.delete(normalized)) {
- iconRegistryVersion.value++
+ iconRegistryVersion.value++;
}
}
@@ -35,152 +35,148 @@ export function unregisterIconResolver(prefix: string): void {
* 设置未带前缀图标名称的默认解析器。
*/
export function setDefaultIconResolver(resolver: IconResolver | null | undefined): void {
- defaultIconResolver = resolver ?? null
- iconRegistryVersion.value++
+ defaultIconResolver = resolver ?? null;
+ iconRegistryVersion.value++;
}
/**
* 判断字符串是否为内联 SVG 代码片段。
*/
export function looksLikeSvg(value: string): boolean {
- return /^\s*]/i.test(value)
+ return /^\s*]/i.test(value);
}
/**
* 判断字符串是否为图像路径、网络 URL 或 Data URL。
*/
export function looksLikeImage(value: string): boolean {
- const trimmed = value.trim()
+ const trimmed = value.trim();
if (
/^(?:https?:)?\/\//i.test(trimmed) ||
- trimmed.startsWith('/') ||
- trimmed.startsWith('./') ||
- trimmed.startsWith('../') ||
- trimmed.startsWith('data:image/')
+ trimmed.startsWith("/") ||
+ trimmed.startsWith("./") ||
+ trimmed.startsWith("../") ||
+ trimmed.startsWith("data:image/")
) {
- return true
+ return true;
}
- return /\.(svg|png|jpe?g|gif|webp|avif|ico)([?#].*)?$/i.test(trimmed)
+ return /\.(svg|png|jpe?g|gif|webp|avif|ico)([?#].*)?$/i.test(trimmed);
}
/**
* 解析带前缀的名称(例如 `'lucide:settings'` -> `{ prefix: 'lucide', name: 'settings' }`)。
*/
-export function parsePrefixedName(
- value: string
-): { prefix: string; name: string } | null {
- const trimmed = value.trim()
- const match = /^([a-zA-Z0-9_-]+):(.*)$/.exec(trimmed)
- if (!match) return null
+export function parsePrefixedName(value: string): { prefix: string; name: string } | null {
+ const trimmed = value.trim();
+ const match = /^([a-zA-Z0-9_-]+):(.*)$/.exec(trimmed);
+ if (!match) return null;
return {
- prefix: match[1].toLowerCase(),
- name: match[2].trim()
- }
+ prefix: match[1]?.toLowerCase() ?? "",
+ name: match[2]?.trim() ?? "",
+ };
}
/**
* 分析给定的图标源类型归类。
*/
-export function classifyIconSource(
- source: IconSource
-): 'component' | 'svg' | 'image' | 'named' {
- if (typeof source !== 'string') {
- return 'component'
+export function classifyIconSource(source: IconSource): "component" | "svg" | "image" | "named" {
+ if (typeof source !== "string") {
+ return "component";
}
if (looksLikeSvg(source)) {
- return 'svg'
+ return "svg";
}
if (looksLikeImage(source)) {
- return 'image'
+ return "image";
}
- return 'named'
+ return "named";
}
function isPromise(value: unknown): value is Promise {
- if (value === null || typeof value !== 'object') {
- return false
+ if (value === null || typeof value !== "object") {
+ return false;
}
// SAFETY: duck-typing thenable checks whether `then` is callable.
- const candidate = value as { then?: unknown }
- return typeof candidate.then === 'function'
+ const candidate = value as { then?: unknown };
+ return typeof candidate.then === "function";
}
function normalizeResolved(result: IconSource | null | undefined): ResolvedIcon | null {
- if (!result) return null
- if (typeof result !== 'string') {
- return { kind: 'component', component: result }
+ if (!result) return null;
+ if (typeof result !== "string") {
+ return { kind: "component", component: result };
}
- const trimmed = result.trim()
- if (!trimmed) return null
+ const trimmed = result.trim();
+ if (!trimmed) return null;
if (looksLikeSvg(trimmed)) {
- return { kind: 'svg', markup: trimmed }
+ return { kind: "svg", markup: trimmed };
}
if (looksLikeImage(trimmed)) {
- return { kind: 'image', src: trimmed }
+ return { kind: "image", src: trimmed };
}
- return null
+ return null;
}
/**
* 解析任意图标源为已规范化形态(支持同步返回或异步 Promise)。
*/
export function resolveIconSource(
- source: IconSource | null | undefined
+ source: IconSource | null | undefined,
): ResolvedIcon | Promise | null {
- if (!source) return null
+ if (!source) return null;
- if (typeof source !== 'string') {
- return { kind: 'component', component: source }
+ if (typeof source !== "string") {
+ return { kind: "component", component: source };
}
- const trimmed = source.trim()
- if (!trimmed) return null
+ const trimmed = source.trim();
+ if (!trimmed) return null;
if (looksLikeSvg(trimmed)) {
- return { kind: 'svg', markup: trimmed }
+ return { kind: "svg", markup: trimmed };
}
if (looksLikeImage(trimmed)) {
- return { kind: 'image', src: trimmed }
+ return { kind: "image", src: trimmed };
}
- const prefixed = parsePrefixedName(trimmed)
+ const prefixed = parsePrefixedName(trimmed);
if (prefixed) {
- const resolver = prefixResolvers.get(prefixed.prefix)
+ const resolver = prefixResolvers.get(prefixed.prefix);
if (resolver) {
- const outcome = resolver(prefixed.name)
+ const outcome = resolver(prefixed.name);
if (isPromise(outcome)) {
- return outcome.then(normalizeResolved)
+ return outcome.then(normalizeResolved);
}
- return normalizeResolved(outcome)
+ return normalizeResolved(outcome);
}
- if (prefixed.prefix === 'lucide') {
+ if (prefixed.prefix === "lucide") {
return lookupLucideIcon(prefixed.name).then((comp) => {
- return comp ? { kind: 'component', component: comp } : null
- })
+ return comp ? { kind: "component", component: comp } : null;
+ });
}
if (defaultIconResolver) {
- const outcome = defaultIconResolver(trimmed)
+ const outcome = defaultIconResolver(trimmed);
if (isPromise(outcome)) {
- return outcome.then(normalizeResolved)
+ return outcome.then(normalizeResolved);
}
- return normalizeResolved(outcome)
+ return normalizeResolved(outcome);
}
- return null
+ return null;
}
if (defaultIconResolver) {
- const outcome = defaultIconResolver(trimmed)
+ const outcome = defaultIconResolver(trimmed);
if (isPromise(outcome)) {
- return outcome.then(normalizeResolved)
+ return outcome.then(normalizeResolved);
}
- return normalizeResolved(outcome)
+ return normalizeResolved(outcome);
}
return lookupLucideIcon(trimmed).then((comp) => {
- return comp ? { kind: 'component', component: comp } : null
- })
+ return comp ? { kind: "component", component: comp } : null;
+ });
}
diff --git a/packages/ui/src/components/icon/types.ts b/packages/ui/src/components/icon/types.ts
index e5adcb0..7e15a9d 100644
--- a/packages/ui/src/components/icon/types.ts
+++ b/packages/ui/src/components/icon/types.ts
@@ -1,4 +1,4 @@
-import type { Component } from 'vue'
+import type { Component } from "vue";
/**
* 支持的图标源类型:
@@ -9,39 +9,39 @@ import type { Component } from 'vue'
* - 注册前缀名称(如 `lucide:settings`、`custom:logo`)
* - 纯图标名称(通过默认解析器或内置 lucide 处理)
*/
-export type IconSource = string | Component
+export type IconSource = string | Component;
/**
* 已规范化为具体渲染形态的结构。
*/
export type ResolvedIcon =
- | { kind: 'component'; component: Component }
- | { kind: 'svg'; markup: string }
- | { kind: 'image'; src: string }
+ | { kind: "component"; component: Component }
+ | { kind: "svg"; markup: string }
+ | { kind: "image"; src: string };
/**
* 图标解析器函数签名。
* 接收去除前缀后的名称(或无前缀时的完整名称),返回图标组件、SVG 代码、图片路径或其异步 Promise。
*/
export type IconResolver = (
- name: string
-) => IconSource | null | undefined | Promise
+ name: string,
+) => IconSource | null | undefined | Promise;
export interface IconProps {
/**
* 图标源:支持 Vue 组件、内联 SVG 字符串、图片 URL 或注册前缀名称。
*/
- icon?: IconSource
+ icon?: IconSource;
/**
* 图标尺寸,默认 `'1em'`。
* 传入数字时自动添加 `px`(例如 `16` -> `'16px'`);传入字符串按 CSS 属性原样生效。
*/
- size?: number | string
+ size?: number | string;
/**
* 无障碍描述文本。
* 提供时赋予 `role="img"` 与 `aria-label`;缺省时应用 `aria-hidden="true"` 标记为装饰图标。
*/
- label?: string
+ label?: string;
}
diff --git a/packages/ui/src/components/inline-code/InlineCode.vue b/packages/ui/src/components/inline-code/InlineCode.vue
index 943a553..34d6b3d 100644
--- a/packages/ui/src/components/inline-code/InlineCode.vue
+++ b/packages/ui/src/components/inline-code/InlineCode.vue
@@ -1,7 +1,7 @@
{
+ it("decorates each wrapped line independently without overflowing narrow containers", () => {
+ expect(source).toContain("'inline rounded-md");
+ expect(source).toContain("[overflow-wrap:anywhere]");
+ expect(source).toContain("[box-decoration-break:clone]");
+ expect(source).toContain("[-webkit-box-decoration-break:clone]");
+ expect(source).not.toContain("'inline-flex min-w-0");
+ });
+});
diff --git a/packages/ui/src/components/inline-code/types.ts b/packages/ui/src/components/inline-code/types.ts
index 86e8dcc..efc0130 100644
--- a/packages/ui/src/components/inline-code/types.ts
+++ b/packages/ui/src/components/inline-code/types.ts
@@ -1,6 +1,6 @@
export interface InlineCodeProps {
- code?: string
- copyable?: boolean
- variant?: 'subtle' | 'solid' | 'outline'
- class?: string
+ code?: string;
+ copyable?: boolean;
+ variant?: "subtle" | "solid" | "outline";
+ class?: string;
}
diff --git a/packages/ui/src/components/input/index.ts b/packages/ui/src/components/input/index.ts
index 5bbbd78..7162fb9 100644
--- a/packages/ui/src/components/input/index.ts
+++ b/packages/ui/src/components/input/index.ts
@@ -1,2 +1,2 @@
-export { default as Input } from './Input.vue'
-export type { InputProps } from './Input.vue'
+export { default as Input } from "./Input.vue";
+export type { InputProps } from "./Input.vue";
diff --git a/packages/ui/src/components/kbd/index.ts b/packages/ui/src/components/kbd/index.ts
index 37ec9e9..2d76834 100644
--- a/packages/ui/src/components/kbd/index.ts
+++ b/packages/ui/src/components/kbd/index.ts
@@ -1,2 +1,2 @@
-export { default as Kbd } from './Kbd.vue'
-export type { KbdProps } from './Kbd.vue'
+export { default as Kbd } from "./Kbd.vue";
+export type { KbdProps } from "./Kbd.vue";
diff --git a/packages/ui/src/components/label/index.ts b/packages/ui/src/components/label/index.ts
index ad9a11e..fa6097e 100644
--- a/packages/ui/src/components/label/index.ts
+++ b/packages/ui/src/components/label/index.ts
@@ -1,2 +1,2 @@
-export { default as Label } from './Label.vue'
-export type { LabelProps } from './Label.vue'
+export { default as Label } from "./Label.vue";
+export type { LabelProps } from "./Label.vue";
diff --git a/packages/ui/src/components/multi-select/index.ts b/packages/ui/src/components/multi-select/index.ts
index 92e9465..89c2550 100644
--- a/packages/ui/src/components/multi-select/index.ts
+++ b/packages/ui/src/components/multi-select/index.ts
@@ -1,2 +1,2 @@
-export { default as MultiSelect } from './MultiSelect.vue'
-export type { MultiSelectOption, MultiSelectProps } from './types'
+export { default as MultiSelect } from "./MultiSelect.vue";
+export type { MultiSelectOption, MultiSelectProps } from "./types";
diff --git a/packages/ui/src/components/multi-select/types.ts b/packages/ui/src/components/multi-select/types.ts
index ad54e47..7d172e0 100644
--- a/packages/ui/src/components/multi-select/types.ts
+++ b/packages/ui/src/components/multi-select/types.ts
@@ -1,21 +1,21 @@
export interface MultiSelectOption {
- label: string
- value: string
- disabled?: boolean
- group?: string
+ label: string;
+ value: string;
+ disabled?: boolean;
+ group?: string;
}
export interface MultiSelectProps {
- modelValue?: string[]
- options: MultiSelectOption[]
- placeholder?: string
- id?: string
- label?: string
- hint?: string
- error?: string
- required?: boolean
- emptyText?: string
- clearable?: boolean
- disabled?: boolean
- class?: string
+ modelValue?: string[];
+ options: MultiSelectOption[];
+ placeholder?: string;
+ id?: string;
+ label?: string;
+ hint?: string;
+ error?: string;
+ required?: boolean;
+ emptyText?: string;
+ clearable?: boolean;
+ disabled?: boolean;
+ class?: string;
}
diff --git a/packages/ui/src/components/nav-tree/NavTree.vue b/packages/ui/src/components/nav-tree/NavTree.vue
index b00f3ab..111a05e 100644
--- a/packages/ui/src/components/nav-tree/NavTree.vue
+++ b/packages/ui/src/components/nav-tree/NavTree.vue
@@ -51,7 +51,8 @@
:is="item.to ? 'RouterLink' : 'a'"
v-bind="item.to ? { to: item.to } : { href: item.href || '#' }"
:class="[
- 'group flex items-center justify-between rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#2563EB]',
+ 'group flex items-center justify-between rounded-lg px-2.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#2563EB]',
+ props.size === 'comfortable' ? 'py-2 text-sm' : 'py-1.5 text-xs',
isItemActive(item)
? 'bg-[#2563EB]/10 font-semibold text-[#1D4ED8] dark:bg-[#2563EB]/20 dark:text-[#70ACFE]'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800/60 dark:hover:text-slate-200'
@@ -86,6 +87,7 @@ const props = withDefaults(defineProps(), {
activeHref: '',
searchable: false,
searchPlaceholder: undefined,
+ size: 'compact',
class: ''
})
diff --git a/packages/ui/src/components/nav-tree/index.ts b/packages/ui/src/components/nav-tree/index.ts
index df463aa..5752990 100644
--- a/packages/ui/src/components/nav-tree/index.ts
+++ b/packages/ui/src/components/nav-tree/index.ts
@@ -1,2 +1,2 @@
-export { default as NavTree } from './NavTree.vue'
-export type { NavTreeGroup, NavTreeItem, NavTreeProps } from './types'
+export { default as NavTree } from "./NavTree.vue";
+export type { NavTreeGroup, NavTreeItem, NavTreeProps } from "./types";
diff --git a/packages/ui/src/components/nav-tree/types.ts b/packages/ui/src/components/nav-tree/types.ts
index f2aa57b..b8b0aeb 100644
--- a/packages/ui/src/components/nav-tree/types.ts
+++ b/packages/ui/src/components/nav-tree/types.ts
@@ -1,25 +1,27 @@
-import type { IconSource } from '../icon'
+import type { IconSource } from "../icon";
export interface NavTreeItem {
- title: string
- href?: string
- to?: string
- badge?: string
- icon?: IconSource
- disabled?: boolean
+ title: string;
+ href?: string;
+ to?: string;
+ badge?: string;
+ icon?: IconSource;
+ disabled?: boolean;
}
export interface NavTreeGroup {
- title: string
- collapsible?: boolean
- defaultOpen?: boolean
- items: NavTreeItem[]
+ title: string;
+ collapsible?: boolean;
+ defaultOpen?: boolean;
+ items: NavTreeItem[];
}
export interface NavTreeProps {
- groups: NavTreeGroup[]
- activeHref?: string
- searchable?: boolean
- searchPlaceholder?: string
- class?: string
+ groups: NavTreeGroup[];
+ activeHref?: string;
+ searchable?: boolean;
+ searchPlaceholder?: string;
+ /** Controls item type scale and vertical rhythm. */
+ size?: "compact" | "comfortable";
+ class?: string;
}
diff --git a/packages/ui/src/components/navbar/index.ts b/packages/ui/src/components/navbar/index.ts
index aae6961..f5b523e 100644
--- a/packages/ui/src/components/navbar/index.ts
+++ b/packages/ui/src/components/navbar/index.ts
@@ -1,2 +1,2 @@
-export { default as Navbar } from './Navbar.vue'
-export type { NavbarLink, NavbarProps } from './types'
+export { default as Navbar } from "./Navbar.vue";
+export type { NavbarLink, NavbarProps } from "./types";
diff --git a/packages/ui/src/components/navbar/navbar.test.ts b/packages/ui/src/components/navbar/navbar.test.ts
index 1a7befe..076f83d 100644
--- a/packages/ui/src/components/navbar/navbar.test.ts
+++ b/packages/ui/src/components/navbar/navbar.test.ts
@@ -1,20 +1,20 @@
-import { readFileSync } from 'node:fs'
-import { fileURLToPath } from 'node:url'
-import { describe, expect, it } from 'vitest'
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
-const source = readFileSync(fileURLToPath(new URL('./Navbar.vue', import.meta.url)), 'utf8')
+const source = readFileSync(fileURLToPath(new URL("./Navbar.vue", import.meta.url)), "utf8");
-describe('Navbar component contract', () => {
- it('exposes dialog state to the mobile menu trigger', () => {
- expect(source).toContain(':aria-expanded="mobileOpen"')
- expect(source).toContain('aria-haspopup="dialog"')
- expect(source).toContain('@click="mobileOpen = !mobileOpen"')
- expect(source).toContain(':open="mobileOpen"')
- })
+describe("Navbar component contract", () => {
+ it("exposes dialog state to the mobile menu trigger", () => {
+ expect(source).toContain(':aria-expanded="mobileOpen"');
+ expect(source).toContain('aria-haspopup="dialog"');
+ expect(source).toContain('@click="mobileOpen = !mobileOpen"');
+ expect(source).toContain(':open="mobileOpen"');
+ });
- it('uses semantic primary and ring tokens for brand and interactive states', () => {
- expect(source).toContain('bg-[color:var(--primary)]')
- expect(source).toContain('focus-visible:ring-[color:var(--ring)]')
- expect(source).not.toContain('#2563EB')
- })
-})
+ it("uses semantic primary and ring tokens for brand and interactive states", () => {
+ expect(source).toContain("bg-[color:var(--primary)]");
+ expect(source).toContain("focus-visible:ring-[color:var(--ring)]");
+ expect(source).not.toContain("#2563EB");
+ });
+});
diff --git a/packages/ui/src/components/navbar/types.ts b/packages/ui/src/components/navbar/types.ts
index 2b41866..de83ad3 100644
--- a/packages/ui/src/components/navbar/types.ts
+++ b/packages/ui/src/components/navbar/types.ts
@@ -1,18 +1,18 @@
export interface NavbarLink {
- label: string
- href?: string
- to?: string
- target?: string
- badge?: string
- active?: boolean
+ label: string;
+ href?: string;
+ to?: string;
+ target?: string;
+ badge?: string;
+ active?: boolean;
}
export interface NavbarProps {
- brandTitle?: string
- brandHref?: string
- brandTo?: string
- links?: NavbarLink[]
- sticky?: boolean
- bordered?: boolean
- class?: string
+ brandTitle?: string;
+ brandHref?: string;
+ brandTo?: string;
+ links?: NavbarLink[];
+ sticky?: boolean;
+ bordered?: boolean;
+ class?: string;
}
diff --git a/packages/ui/src/components/navigation-menu/index.ts b/packages/ui/src/components/navigation-menu/index.ts
index ab489a9..55316e8 100644
--- a/packages/ui/src/components/navigation-menu/index.ts
+++ b/packages/ui/src/components/navigation-menu/index.ts
@@ -1,3 +1,3 @@
-export { default as NavigationMenu } from './NavigationMenu.vue'
-export type { NavigationMenuProps } from './NavigationMenu.vue'
-export type { NavigationMenuItem } from './NavigationMenu.vue'
+export { default as NavigationMenu } from "./NavigationMenu.vue";
+export type { NavigationMenuProps } from "./NavigationMenu.vue";
+export type { NavigationMenuItem } from "./NavigationMenu.vue";
diff --git a/packages/ui/src/components/number-field/index.ts b/packages/ui/src/components/number-field/index.ts
index 685c1d9..bdcec14 100644
--- a/packages/ui/src/components/number-field/index.ts
+++ b/packages/ui/src/components/number-field/index.ts
@@ -1,2 +1,2 @@
-export { default as NumberField } from './NumberField.vue'
-export type { NumberFieldProps } from './NumberField.vue'
+export { default as NumberField } from "./NumberField.vue";
+export type { NumberFieldProps } from "./NumberField.vue";
diff --git a/packages/ui/src/components/page-header/PageHeader.vue b/packages/ui/src/components/page-header/PageHeader.vue
new file mode 100644
index 0000000..00a10f9
--- /dev/null
+++ b/packages/ui/src/components/page-header/PageHeader.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/packages/ui/src/components/page-header/index.ts b/packages/ui/src/components/page-header/index.ts
new file mode 100644
index 0000000..2ff71ca
--- /dev/null
+++ b/packages/ui/src/components/page-header/index.ts
@@ -0,0 +1,2 @@
+export { default as PageHeader } from "./PageHeader.vue";
+export type { PageHeaderProps } from "./types";
diff --git a/packages/ui/src/components/page-header/pageHeader.test.ts b/packages/ui/src/components/page-header/pageHeader.test.ts
new file mode 100644
index 0000000..9d2b79c
--- /dev/null
+++ b/packages/ui/src/components/page-header/pageHeader.test.ts
@@ -0,0 +1,14 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const source = readFileSync(fileURLToPath(new URL("./PageHeader.vue", import.meta.url)), "utf8");
+
+describe("PageHeader component contract", () => {
+ it("renders documentation metadata with semantic heading hierarchy", () => {
+ expect(source).toContain("
+
+
diff --git a/packages/ui/src/components/search-input/index.ts b/packages/ui/src/components/search-input/index.ts
new file mode 100644
index 0000000..f4c0d1c
--- /dev/null
+++ b/packages/ui/src/components/search-input/index.ts
@@ -0,0 +1,3 @@
+export { default as SearchInput } from "./SearchInput.vue";
+export { default as SearchBox } from "./SearchInput.vue";
+export * from "./types";
diff --git a/packages/ui/src/components/search-input/searchInput.test.ts b/packages/ui/src/components/search-input/searchInput.test.ts
new file mode 100644
index 0000000..b95f491
--- /dev/null
+++ b/packages/ui/src/components/search-input/searchInput.test.ts
@@ -0,0 +1,50 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const source = readFileSync(fileURLToPath(new URL("./SearchInput.vue", import.meta.url)), "utf8");
+const indexSource = readFileSync(fileURLToPath(new URL("./index.ts", import.meta.url)), "utf8");
+
+describe("SearchInput & SearchBox component contract", () => {
+ it("exports SearchInput and SearchBox aliases", () => {
+ expect(indexSource).toContain("export { default as SearchInput }");
+ expect(indexSource).toContain("export { default as SearchBox }");
+ expect(indexSource).toContain("types");
+ });
+
+ it("supports sizes, variants, and debounce", () => {
+ expect(source).toContain("sizeContainerClasses[size]");
+ expect(source).toContain("variantClasses[variant]");
+ expect(source).toContain("debounce: 300");
+ expect(source).toContain("variant: 'default'");
+ });
+
+ it("provides accessible role and search interaction", () => {
+ expect(source).toContain('role="searchbox"');
+ expect(source).toContain(":aria-label=\"ariaLabel || placeholder || '搜索'\"");
+ expect(source).toContain("emit('search', val)");
+ expect(source).toContain("emit('submit', currentVal)");
+ });
+
+ it("supports clearable button and loading state", () => {
+ expect(source).toContain('v-else-if="clearable && hasValue && !disabled && !readonly"');
+ expect(source).toContain("emit('clear')");
+ expect(source).toContain('v-if="loading"');
+ });
+
+ it("supports suggestions dropdown and keyboard navigation", () => {
+ expect(source).toContain('role="listbox"');
+ expect(source).toContain('role="option"');
+ expect(source).toContain("event.key === 'ArrowDown'");
+ expect(source).toContain("event.key === 'ArrowUp'");
+ expect(source).toContain("event.key === 'Enter'");
+ expect(source).toContain("event.key === 'Escape'");
+ expect(source).toContain("emit('select', item)");
+ });
+
+ it("supports shortcut key and global focus trigger", () => {
+ expect(source).toContain("shortcut");
+ expect(source).toContain("enableGlobalShortcut");
+ expect(source).toContain("window.addEventListener('keydown', handleGlobalShortcut)");
+ });
+});
diff --git a/packages/ui/src/components/search-input/types.ts b/packages/ui/src/components/search-input/types.ts
new file mode 100644
index 0000000..93056d6
--- /dev/null
+++ b/packages/ui/src/components/search-input/types.ts
@@ -0,0 +1,121 @@
+export type SearchInputSize = "sm" | "md" | "lg";
+export type SearchInputVariant = "default" | "filled" | "pill";
+
+export interface SearchSuggestion {
+ id?: string | number;
+ label: string;
+ description?: string;
+ category?: string;
+ icon?: string;
+ disabled?: boolean;
+ [key: string]: unknown;
+}
+
+export interface SearchInputProps {
+ /**
+ * Current search query string (v-model).
+ */
+ modelValue?: string;
+
+ /**
+ * Placeholder text when search query is empty.
+ */
+ placeholder?: string;
+
+ /**
+ * Search input size.
+ */
+ size?: SearchInputSize;
+
+ /**
+ * Visual variant: default (bordered), filled (muted background), pill (fully rounded).
+ */
+ variant?: SearchInputVariant;
+
+ /**
+ * Whether to show a clear (X) button when query is not empty.
+ */
+ clearable?: boolean;
+
+ /**
+ * Whether pressing Escape clears the search text (when dropdown is closed).
+ */
+ clearOnEsc?: boolean;
+
+ /**
+ * Shows a loading spinner when search request or backend fetch is in progress.
+ */
+ loading?: boolean;
+
+ /**
+ * Debounce time in milliseconds before emitting `@search`. Default: 300.
+ */
+ debounce?: number;
+
+ /**
+ * Keyboard shortcut hint to display (e.g., '⌘K', '/', 'Ctrl+K').
+ */
+ shortcut?: string;
+
+ /**
+ * If true, pressing the shortcut globally focuses this search input.
+ */
+ enableGlobalShortcut?: boolean;
+
+ /**
+ * Accessible ARIA label for the input.
+ */
+ ariaLabel?: string;
+
+ /**
+ * Disabled state.
+ */
+ disabled?: boolean;
+
+ /**
+ * Readonly state.
+ */
+ readonly?: boolean;
+
+ /**
+ * Autocomplete attribute for the HTML input.
+ */
+ autocomplete?: string;
+
+ /**
+ * Suggestions / backend results to display in the dropdown.
+ */
+ suggestions?: SearchSuggestion[];
+
+ /**
+ * Controlled open state for the suggestions dropdown (v-model:open).
+ */
+ open?: boolean;
+
+ /**
+ * Empty state text when suggestions array is empty while dropdown is open.
+ */
+ emptyText?: string;
+
+ /**
+ * Whether to automatically show the dropdown on focus when suggestions exist.
+ */
+ showDropdownOnFocus?: boolean;
+
+ /**
+ * Additional CSS classes for the container.
+ */
+ class?: string;
+}
+
+export interface SearchInputEmits {
+ (e: "update:modelValue", value: string): void;
+ (e: "update:open", open: boolean): void;
+ (e: "search", query: string): void;
+ (e: "submit", query: string): void;
+ (e: "clear"): void;
+ (e: "select", item: SearchSuggestion): void;
+ (e: "focus", event: FocusEvent): void;
+ (e: "blur", event: FocusEvent): void;
+ (e: "keydown", event: KeyboardEvent): void;
+}
diff --git a/packages/ui/src/components/section/Section.vue b/packages/ui/src/components/section/Section.vue
new file mode 100644
index 0000000..ac969e4
--- /dev/null
+++ b/packages/ui/src/components/section/Section.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/section/index.ts b/packages/ui/src/components/section/index.ts
new file mode 100644
index 0000000..18e0649
--- /dev/null
+++ b/packages/ui/src/components/section/index.ts
@@ -0,0 +1,2 @@
+export { default as Section } from "./Section.vue";
+export * from "./types";
diff --git a/packages/ui/src/components/section/section.test.ts b/packages/ui/src/components/section/section.test.ts
new file mode 100644
index 0000000..a3125f3
--- /dev/null
+++ b/packages/ui/src/components/section/section.test.ts
@@ -0,0 +1,24 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const source = readFileSync(fileURLToPath(new URL("./Section.vue", import.meta.url)), "utf8");
+
+describe("Section component contract", () => {
+ it("supports spacing and variant tokens", () => {
+ expect(source).toContain("variantClasses[variant]");
+ expect(source).toContain("spacingClasses[spacing]");
+ expect(source).toContain("spacing: 'md'");
+ });
+
+ it("embeds Container when enabled", () => {
+ expect(source).toContain(' {
+ expect(source).toContain('');
+ expect(source).toContain('{{ title }} ');
+ expect(source).toContain('{{ description }} ');
+ });
+});
diff --git a/packages/ui/src/components/section/types.ts b/packages/ui/src/components/section/types.ts
new file mode 100644
index 0000000..a700d11
--- /dev/null
+++ b/packages/ui/src/components/section/types.ts
@@ -0,0 +1,59 @@
+import type { ContainerSize } from "../container/types";
+
+export type SectionSpacing = "none" | "sm" | "md" | "lg" | "xl";
+export type SectionVariant = "default" | "muted" | "card" | "brand";
+
+export interface SectionProps {
+ /**
+ * HTML element to render the section as.
+ * @default 'section'
+ */
+ as?: string;
+ /**
+ * Background and surface variant of the section.
+ * - `default`: transparent / semantic canvas
+ * - `muted`: subtle surface background (`--surface-muted`)
+ * - `card`: card surface with border (`--card`)
+ * - `brand`: primary brand gradient / tint
+ * @default 'default'
+ */
+ variant?: SectionVariant;
+ /**
+ * Vertical padding rhythm.
+ * - `none`: py-0
+ * - `sm`: py-8 sm:py-12
+ * - `md`: py-12 sm:py-16
+ * - `lg`: py-16 sm:py-24
+ * - `xl`: py-20 sm:py-32
+ * @default 'md'
+ */
+ spacing?: SectionSpacing;
+ /**
+ * Whether to wrap children in a built-in ``.
+ * Pass `false` if the section needs full-bleed custom layout.
+ * @default true
+ */
+ container?: boolean;
+ /**
+ * Container max-width size when `container` is true.
+ * @default 'xl'
+ */
+ containerSize?: ContainerSize;
+ /**
+ * Optional section title in a built-in header.
+ */
+ title?: string;
+ /**
+ * Optional section subtitle or description.
+ */
+ description?: string;
+ /**
+ * Text alignment for built-in section header.
+ * @default 'center'
+ */
+ align?: "left" | "center";
+ /**
+ * Additional classes for the outer section element.
+ */
+ class?: string;
+}
diff --git a/packages/ui/src/components/select/index.ts b/packages/ui/src/components/select/index.ts
index b4a9f3d..955429d 100644
--- a/packages/ui/src/components/select/index.ts
+++ b/packages/ui/src/components/select/index.ts
@@ -1,2 +1,2 @@
-export { default as Select } from './Select.vue'
-export type { SelectProps, SelectOption } from './Select.vue'
+export { default as Select } from "./Select.vue";
+export type { SelectProps, SelectOption } from "./Select.vue";
diff --git a/packages/ui/src/components/separator/index.ts b/packages/ui/src/components/separator/index.ts
index d30b90e..161ce13 100644
--- a/packages/ui/src/components/separator/index.ts
+++ b/packages/ui/src/components/separator/index.ts
@@ -1,2 +1,2 @@
-export { default as Separator } from './Separator.vue'
-export type { SeparatorProps } from './Separator.vue'
+export { default as Separator } from "./Separator.vue";
+export type { SeparatorProps } from "./Separator.vue";
diff --git a/packages/ui/src/components/sheet/index.ts b/packages/ui/src/components/sheet/index.ts
index a8c9060..ee501c2 100644
--- a/packages/ui/src/components/sheet/index.ts
+++ b/packages/ui/src/components/sheet/index.ts
@@ -1 +1 @@
-export { default as Sheet } from './Sheet.vue'
+export { default as Sheet } from "./Sheet.vue";
diff --git a/packages/ui/src/components/skeleton/index.ts b/packages/ui/src/components/skeleton/index.ts
index efbb63f..3d2158a 100644
--- a/packages/ui/src/components/skeleton/index.ts
+++ b/packages/ui/src/components/skeleton/index.ts
@@ -1,2 +1,2 @@
-export { default as Skeleton } from './Skeleton.vue'
-export type { SkeletonProps } from './Skeleton.vue'
+export { default as Skeleton } from "./Skeleton.vue";
+export type { SkeletonProps } from "./Skeleton.vue";
diff --git a/packages/ui/src/components/slider/index.ts b/packages/ui/src/components/slider/index.ts
index 1324775..f36a664 100644
--- a/packages/ui/src/components/slider/index.ts
+++ b/packages/ui/src/components/slider/index.ts
@@ -1,2 +1,2 @@
-export { default as Slider } from './Slider.vue'
-export type { SliderProps } from './Slider.vue'
+export { default as Slider } from "./Slider.vue";
+export type { SliderProps } from "./Slider.vue";
diff --git a/packages/ui/src/components/spinner/index.ts b/packages/ui/src/components/spinner/index.ts
index 596a614..28cbe29 100644
--- a/packages/ui/src/components/spinner/index.ts
+++ b/packages/ui/src/components/spinner/index.ts
@@ -1,2 +1,2 @@
-export { default as Spinner } from './Spinner.vue'
-export type { SpinnerProps } from './Spinner.vue'
+export { default as Spinner } from "./Spinner.vue";
+export type { SpinnerProps } from "./Spinner.vue";
diff --git a/packages/ui/src/components/stepper/index.ts b/packages/ui/src/components/stepper/index.ts
index e9bf5c5..3cf4417 100644
--- a/packages/ui/src/components/stepper/index.ts
+++ b/packages/ui/src/components/stepper/index.ts
@@ -1,2 +1,2 @@
-export { default as Stepper } from './Stepper.vue'
-export type { StepperProps, StepData } from './Stepper.vue'
+export { default as Stepper } from "./Stepper.vue";
+export type { StepperProps, StepData } from "./Stepper.vue";
diff --git a/packages/ui/src/components/steps/index.ts b/packages/ui/src/components/steps/index.ts
index e1db8ed..56bfc47 100644
--- a/packages/ui/src/components/steps/index.ts
+++ b/packages/ui/src/components/steps/index.ts
@@ -1,3 +1,3 @@
-export { default as Steps } from './Steps.vue'
-export { default as StepItem } from './StepItem.vue'
-export type { StepItemProps, StepsProps } from './types'
+export { default as Steps } from "./Steps.vue";
+export { default as StepItem } from "./StepItem.vue";
+export type { StepItemProps, StepsProps } from "./types";
diff --git a/packages/ui/src/components/steps/types.ts b/packages/ui/src/components/steps/types.ts
index 1064753..3b89307 100644
--- a/packages/ui/src/components/steps/types.ts
+++ b/packages/ui/src/components/steps/types.ts
@@ -1,19 +1,19 @@
-import type { InjectionKey } from 'vue'
+import type { InjectionKey } from "vue";
export interface StepsProps {
- startIndex?: number
- class?: string
+ startIndex?: number;
+ class?: string;
}
export interface StepItemProps {
- title?: string
- description?: string
- step?: number | string
- class?: string
+ title?: string;
+ description?: string;
+ step?: number | string;
+ class?: string;
}
export interface StepsContext {
- registerStep: () => number
+ registerStep: () => number;
}
-export const stepsInjectionKey: InjectionKey = Symbol('re-steps')
+export const stepsInjectionKey: InjectionKey = Symbol("re-steps");
diff --git a/packages/ui/src/components/switch/index.ts b/packages/ui/src/components/switch/index.ts
index c0e4da5..464e26b 100644
--- a/packages/ui/src/components/switch/index.ts
+++ b/packages/ui/src/components/switch/index.ts
@@ -1,2 +1,2 @@
-export { default as Switch } from './Switch.vue'
-export type { SwitchProps } from './Switch.vue'
+export { default as Switch } from "./Switch.vue";
+export type { SwitchProps } from "./Switch.vue";
diff --git a/packages/ui/src/components/table/index.ts b/packages/ui/src/components/table/index.ts
index 052485b..338903f 100644
--- a/packages/ui/src/components/table/index.ts
+++ b/packages/ui/src/components/table/index.ts
@@ -1,2 +1,2 @@
-export { default as DataTable } from './DataTable.vue'
-export type { DataTableColumn, DataTableSort } from './types'
+export { default as DataTable } from "./DataTable.vue";
+export type { DataTableColumn, DataTableSort } from "./types";
diff --git a/packages/ui/src/components/table/types.ts b/packages/ui/src/components/table/types.ts
index ebe4ae6..626204b 100644
--- a/packages/ui/src/components/table/types.ts
+++ b/packages/ui/src/components/table/types.ts
@@ -1,14 +1,14 @@
export interface DataTableColumn {
- key: string
- label: string
- width?: string
- align?: 'left' | 'center' | 'right'
- slot?: boolean
- sortable?: boolean
- hideable?: boolean
+ key: string;
+ label: string;
+ width?: string;
+ align?: "left" | "center" | "right";
+ slot?: boolean;
+ sortable?: boolean;
+ hideable?: boolean;
}
export interface DataTableSort {
- key: string
- direction: 'asc' | 'desc'
+ key: string;
+ direction: "asc" | "desc";
}
diff --git a/packages/ui/src/components/tabs/index.ts b/packages/ui/src/components/tabs/index.ts
index f4debe5..45621dc 100644
--- a/packages/ui/src/components/tabs/index.ts
+++ b/packages/ui/src/components/tabs/index.ts
@@ -1,3 +1,3 @@
-export { default as Tabs } from './Tabs.vue'
-export { default as TabsContent } from './TabsContent.vue'
-export type { TabsProps, TabItem } from './Tabs.vue'
+export { default as Tabs } from "./Tabs.vue";
+export { default as TabsContent } from "./TabsContent.vue";
+export type { TabsProps, TabItem } from "./Tabs.vue";
diff --git a/packages/ui/src/components/tag-input/TagInput.vue b/packages/ui/src/components/tag-input/TagInput.vue
index 0f4eedf..0700adf 100644
--- a/packages/ui/src/components/tag-input/TagInput.vue
+++ b/packages/ui/src/components/tag-input/TagInput.vue
@@ -163,7 +163,7 @@ function onInput(event: Event) {
}
const parts = value.split(pattern)
const completed = parts.slice(0, -1)
- draft.value = parts[parts.length - 1]
+ draft.value = parts[parts.length - 1] ?? ""
target.value = draft.value
commitMany(completed)
}
diff --git a/packages/ui/src/components/tag-input/index.ts b/packages/ui/src/components/tag-input/index.ts
index b2cb9f2..43bd398 100644
--- a/packages/ui/src/components/tag-input/index.ts
+++ b/packages/ui/src/components/tag-input/index.ts
@@ -1,2 +1,2 @@
-export { default as TagInput } from './TagInput.vue'
-export type { TagInputProps } from './TagInput.vue'
+export { default as TagInput } from "./TagInput.vue";
+export type { TagInputProps } from "./TagInput.vue";
diff --git a/packages/ui/src/components/textarea/index.ts b/packages/ui/src/components/textarea/index.ts
index 5ba5914..c7ae5e9 100644
--- a/packages/ui/src/components/textarea/index.ts
+++ b/packages/ui/src/components/textarea/index.ts
@@ -1,2 +1,2 @@
-export { default as Textarea } from './Textarea.vue'
-export type { TextareaProps } from './Textarea.vue'
+export { default as Textarea } from "./Textarea.vue";
+export type { TextareaProps } from "./Textarea.vue";
diff --git a/packages/ui/src/components/theme-toggle/ThemeToggle.vue b/packages/ui/src/components/theme-toggle/ThemeToggle.vue
new file mode 100644
index 0000000..951d963
--- /dev/null
+++ b/packages/ui/src/components/theme-toggle/ThemeToggle.vue
@@ -0,0 +1,134 @@
+
+
+
+
+ 浅色模式
+ 深色模式
+ 跟随系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/theme-toggle/index.ts b/packages/ui/src/components/theme-toggle/index.ts
new file mode 100644
index 0000000..82c4f9b
--- /dev/null
+++ b/packages/ui/src/components/theme-toggle/index.ts
@@ -0,0 +1,2 @@
+export { default as ThemeToggle } from "./ThemeToggle.vue";
+export * from "./types";
diff --git a/packages/ui/src/components/theme-toggle/themeToggle.test.ts b/packages/ui/src/components/theme-toggle/themeToggle.test.ts
new file mode 100644
index 0000000..c312f08
--- /dev/null
+++ b/packages/ui/src/components/theme-toggle/themeToggle.test.ts
@@ -0,0 +1,26 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const source = readFileSync(fileURLToPath(new URL("./ThemeToggle.vue", import.meta.url)), "utf8");
+
+describe("ThemeToggle component contract", () => {
+ it("supports button, dropdown, and custom slots", () => {
+ expect(source).toContain("variant === 'dropdown'");
+ expect(source).toContain(" {
+ expect(source).toContain("isControlled");
+ expect(source).toContain("emit('update:modelValue', nextMode)");
+ expect(source).toContain("theme.setMode(nextMode)");
+ });
+
+ it("supports cycleSystem mode and accessible aria-label", () => {
+ expect(source).toContain("props.cycleSystem");
+ expect(source).toContain(':aria-label="computedAriaLabel"');
+ });
+});
diff --git a/packages/ui/src/components/theme-toggle/types.ts b/packages/ui/src/components/theme-toggle/types.ts
new file mode 100644
index 0000000..ffa51a3
--- /dev/null
+++ b/packages/ui/src/components/theme-toggle/types.ts
@@ -0,0 +1,38 @@
+import type { ThemeMode } from "../../composables/useTheme";
+
+export type ThemeToggleVariant = "button" | "dropdown" | "switch";
+export type ThemeToggleSize = "sm" | "md" | "lg";
+
+export interface ThemeToggleProps {
+ /**
+ * Component display variant.
+ * - `button`: Icon button toggling between light and dark (or cycling system).
+ * - `dropdown`: Dropdown selecting light, dark, or system.
+ * - `switch`: Switch/toggle style.
+ * @default 'button'
+ */
+ variant?: ThemeToggleVariant;
+ /**
+ * Size of the button or switch.
+ * @default 'md'
+ */
+ size?: ThemeToggleSize;
+ /**
+ * Controlled mode value (`v-model`). If omitted, uses global `useTheme()`.
+ */
+ modelValue?: ThemeMode;
+ /**
+ * Whether clicking button cycles between 'light' -> 'dark' -> 'system'.
+ * If false, toggles between 'light' and 'dark'.
+ * @default false
+ */
+ cycleSystem?: boolean;
+ /**
+ * Custom aria-label override.
+ */
+ ariaLabel?: string;
+ /**
+ * Additional classes.
+ */
+ class?: string;
+}
diff --git a/packages/ui/src/components/timeline/index.ts b/packages/ui/src/components/timeline/index.ts
index a426a17..a662b6b 100644
--- a/packages/ui/src/components/timeline/index.ts
+++ b/packages/ui/src/components/timeline/index.ts
@@ -1,2 +1,2 @@
-export { default as Timeline } from './Timeline.vue'
-export type { TimelineItem, TimelineStatus } from './types'
+export { default as Timeline } from "./Timeline.vue";
+export type { TimelineItem, TimelineStatus } from "./types";
diff --git a/packages/ui/src/components/timeline/types.ts b/packages/ui/src/components/timeline/types.ts
index ac7941f..ce078d9 100644
--- a/packages/ui/src/components/timeline/types.ts
+++ b/packages/ui/src/components/timeline/types.ts
@@ -1,12 +1,12 @@
-export type TimelineStatus = 'default' | 'info' | 'success' | 'warning' | 'error'
+export type TimelineStatus = "default" | "info" | "success" | "warning" | "error";
export interface TimelineItem {
- id: string
- title: string
- description?: string
- timestamp?: string
- status?: TimelineStatus
- actor?: string
- avatar?: string
- metadata?: string
+ id: string;
+ title: string;
+ description?: string;
+ timestamp?: string;
+ status?: TimelineStatus;
+ actor?: string;
+ avatar?: string;
+ metadata?: string;
}
diff --git a/packages/ui/src/components/toast/index.ts b/packages/ui/src/components/toast/index.ts
index 165bf3f..c3e50c8 100644
--- a/packages/ui/src/components/toast/index.ts
+++ b/packages/ui/src/components/toast/index.ts
@@ -1,2 +1,2 @@
-export { default as ToastProvider } from './ToastProvider.vue'
-export * from './useToast'
+export { default as ToastProvider } from "./ToastProvider.vue";
+export * from "./useToast";
diff --git a/packages/ui/src/components/toast/useToast.ts b/packages/ui/src/components/toast/useToast.ts
index 966f0ee..5ff89c7 100644
--- a/packages/ui/src/components/toast/useToast.ts
+++ b/packages/ui/src/components/toast/useToast.ts
@@ -1,44 +1,44 @@
-import { ref } from 'vue'
+import { ref } from "vue";
export interface ToastOptions {
- id?: string
- title?: string
- description?: string
- variant?: 'info' | 'success' | 'warning' | 'destructive'
- duration?: number
+ id?: string;
+ title?: string;
+ description?: string;
+ variant?: "info" | "success" | "warning" | "destructive";
+ duration?: number;
}
-const toasts = ref([])
+const toasts = ref([]);
export function useToast() {
function toast(options: ToastOptions) {
- const id = options.id || Math.random().toString(36).substring(2, 9)
- const duration = options.duration ?? 4000
+ const id = options.id || Math.random().toString(36).substring(2, 9);
+ const duration = options.duration ?? 4000;
const newToast: ToastOptions = {
...options,
id,
- variant: options.variant || 'info'
- }
+ variant: options.variant || "info",
+ };
- toasts.value.push(newToast)
+ toasts.value.push(newToast);
if (duration > 0) {
setTimeout(() => {
- dismiss(id)
- }, duration)
+ dismiss(id);
+ }, duration);
}
- return id
+ return id;
}
function dismiss(id: string) {
- toasts.value = toasts.value.filter(t => t.id !== id)
+ toasts.value = toasts.value.filter((t) => t.id !== id);
}
return {
toasts,
toast,
- dismiss
- }
+ dismiss,
+ };
}
diff --git a/packages/ui/src/components/toc/index.ts b/packages/ui/src/components/toc/index.ts
index 7ed8f19..408d941 100644
--- a/packages/ui/src/components/toc/index.ts
+++ b/packages/ui/src/components/toc/index.ts
@@ -1,2 +1,2 @@
-export { default as Toc } from './Toc.vue'
-export type { TocItem, TocProps } from './types'
+export { default as Toc } from "./Toc.vue";
+export type { TocItem, TocProps } from "./types";
diff --git a/packages/ui/src/components/toc/types.ts b/packages/ui/src/components/toc/types.ts
index c99dd84..3354e09 100644
--- a/packages/ui/src/components/toc/types.ts
+++ b/packages/ui/src/components/toc/types.ts
@@ -1,16 +1,16 @@
export interface TocItem {
- id: string
- title: string
- level?: number
- children?: TocItem[]
+ id: string;
+ title: string;
+ level?: number;
+ children?: TocItem[];
}
export interface TocProps {
- items?: TocItem[]
- selector?: string
- container?: string
- scrollSpy?: boolean
- title?: string
- offset?: number
- class?: string
+ items?: TocItem[];
+ selector?: string;
+ container?: string;
+ scrollSpy?: boolean;
+ title?: string;
+ offset?: number;
+ class?: string;
}
diff --git a/packages/ui/src/components/toggle-group/index.ts b/packages/ui/src/components/toggle-group/index.ts
index 535db28..97e4438 100644
--- a/packages/ui/src/components/toggle-group/index.ts
+++ b/packages/ui/src/components/toggle-group/index.ts
@@ -1,2 +1,2 @@
-export { default as ToggleGroup } from './ToggleGroup.vue'
-export type { ToggleGroupProps, ToggleGroupOption } from './ToggleGroup.vue'
+export { default as ToggleGroup } from "./ToggleGroup.vue";
+export type { ToggleGroupProps, ToggleGroupOption } from "./ToggleGroup.vue";
diff --git a/packages/ui/src/components/toggle/index.ts b/packages/ui/src/components/toggle/index.ts
index 3d329ad..c7274c5 100644
--- a/packages/ui/src/components/toggle/index.ts
+++ b/packages/ui/src/components/toggle/index.ts
@@ -1,2 +1,2 @@
-export { default as Toggle } from './Toggle.vue'
-export type { ToggleProps } from './Toggle.vue'
+export { default as Toggle } from "./Toggle.vue";
+export type { ToggleProps } from "./Toggle.vue";
diff --git a/packages/ui/src/components/toolbar/index.ts b/packages/ui/src/components/toolbar/index.ts
index 5f00e25..388a291 100644
--- a/packages/ui/src/components/toolbar/index.ts
+++ b/packages/ui/src/components/toolbar/index.ts
@@ -1,5 +1,5 @@
-export { default as Toolbar } from './Toolbar.vue'
-export type { ToolbarProps } from './Toolbar.vue'
-export { default as ToolbarButton } from './ToolbarButton.vue'
-export type { ToolbarButtonProps } from './ToolbarButton.vue'
-export { default as ToolbarSeparator } from './ToolbarSeparator.vue'
+export { default as Toolbar } from "./Toolbar.vue";
+export type { ToolbarProps } from "./Toolbar.vue";
+export { default as ToolbarButton } from "./ToolbarButton.vue";
+export type { ToolbarButtonProps } from "./ToolbarButton.vue";
+export { default as ToolbarSeparator } from "./ToolbarSeparator.vue";
diff --git a/packages/ui/src/components/tooltip/index.ts b/packages/ui/src/components/tooltip/index.ts
index 8ff1583..8003675 100644
--- a/packages/ui/src/components/tooltip/index.ts
+++ b/packages/ui/src/components/tooltip/index.ts
@@ -1,2 +1,2 @@
-export { default as Tooltip } from './Tooltip.vue'
-export type { TooltipProps } from './Tooltip.vue'
+export { default as Tooltip } from "./Tooltip.vue";
+export type { TooltipProps } from "./Tooltip.vue";
diff --git a/packages/ui/src/components/tree-view/index.ts b/packages/ui/src/components/tree-view/index.ts
index 3d5437b..5174407 100644
--- a/packages/ui/src/components/tree-view/index.ts
+++ b/packages/ui/src/components/tree-view/index.ts
@@ -1,2 +1,2 @@
-export { default as TreeView } from './TreeView.vue'
-export type { TreeDropEvent, TreeHighlightVariant, TreeNode } from './types'
+export { default as TreeView } from "./TreeView.vue";
+export type { TreeDropEvent, TreeHighlightVariant, TreeNode } from "./types";
diff --git a/packages/ui/src/components/tree-view/types.ts b/packages/ui/src/components/tree-view/types.ts
index 4e5c99f..b9d7faa 100644
--- a/packages/ui/src/components/tree-view/types.ts
+++ b/packages/ui/src/components/tree-view/types.ts
@@ -1,20 +1,20 @@
-import type { IconSource } from '../icon'
+import type { IconSource } from "../icon";
export interface TreeNode {
- id: string
- label: string
- disabled?: boolean
- children?: TreeNode[]
+ id: string;
+ label: string;
+ disabled?: boolean;
+ children?: TreeNode[];
/** True when node has dynamic children that haven't been fetched yet. */
- isLeaf?: boolean
+ isLeaf?: boolean;
/** Optional icon component, icon name or SVG source rendered before node label. */
- icon?: IconSource
+ icon?: IconSource;
}
export interface TreeDropEvent {
- draggedNode: TreeNode
- targetNode: TreeNode
- position: 'before' | 'inside' | 'after'
+ draggedNode: TreeNode;
+ targetNode: TreeNode;
+ position: "before" | "inside" | "after";
}
-export type TreeHighlightVariant = 'subtle' | 'solid' | 'bar'
+export type TreeHighlightVariant = "subtle" | "solid" | "bar";
diff --git a/packages/ui/src/components/visually-hidden/index.ts b/packages/ui/src/components/visually-hidden/index.ts
index d2dd955..96e41c6 100644
--- a/packages/ui/src/components/visually-hidden/index.ts
+++ b/packages/ui/src/components/visually-hidden/index.ts
@@ -1,2 +1,2 @@
-export { default as VisuallyHidden } from './VisuallyHidden.vue'
-export type { VisuallyHiddenProps } from './VisuallyHidden.vue'
+export { default as VisuallyHidden } from "./VisuallyHidden.vue";
+export type { VisuallyHiddenProps } from "./VisuallyHidden.vue";
diff --git a/packages/ui/src/composables/useTheme.ts b/packages/ui/src/composables/useTheme.ts
index 6ee32f3..959df09 100644
--- a/packages/ui/src/composables/useTheme.ts
+++ b/packages/ui/src/composables/useTheme.ts
@@ -1,114 +1,130 @@
-import { computed, onMounted, ref } from 'vue'
+import { computed, onMounted, ref } from "vue";
/** `auto` is retained as a backwards-compatible alias for `system`. */
-export type ThemeMode = 'light' | 'dark' | 'system' | 'auto'
-export type ResolvedThemeMode = 'light' | 'dark'
-export type ThemePalette = 'recloud' | 'ocean' | 'violet' | 'slate'
-export type ThemeDensity = 'compact' | 'comfortable' | 'spacious'
-export type ThemeRadius = 'none' | 'sm' | 'md' | 'lg'
-export type ThemeShadow = 'none' | 'sm' | 'md' | 'lg'
+export type ThemeMode = "light" | "dark" | "system" | "auto";
+export type ResolvedThemeMode = "light" | "dark";
+export type ThemePalette = "recloud" | "ocean" | "violet" | "slate";
+export type ThemeDensity = "compact" | "comfortable" | "spacious";
+export type ThemeRadius = "none" | "sm" | "md" | "lg";
+export type ThemeShadow = "none" | "sm" | "md" | "lg";
export interface ThemePreferences {
- mode: ThemeMode
- palette: ThemePalette
- density: ThemeDensity
- radius: ThemeRadius
- shadow: ThemeShadow
+ mode: ThemeMode;
+ palette: ThemePalette;
+ density: ThemeDensity;
+ radius: ThemeRadius;
+ shadow: ThemeShadow;
}
export interface UseThemeOptions {
- storageKey?: string
- defaultMode?: ThemeMode
- defaultPalette?: ThemePalette
- defaultDensity?: ThemeDensity
- defaultRadius?: ThemeRadius
- defaultShadow?: ThemeShadow
+ storageKey?: string;
+ defaultMode?: ThemeMode;
+ defaultPalette?: ThemePalette;
+ defaultDensity?: ThemeDensity;
+ defaultRadius?: ThemeRadius;
+ defaultShadow?: ThemeShadow;
}
const defaults: ThemePreferences = {
- mode: 'system',
- palette: 'recloud',
- density: 'comfortable',
- radius: 'md',
- shadow: 'sm'
-}
+ mode: "system",
+ palette: "recloud",
+ density: "comfortable",
+ radius: "md",
+ shadow: "sm",
+};
-const preferences = ref({ ...defaults })
-const systemIsDark = ref(false)
-let initialized = false
-let mediaQuery: MediaQueryList | undefined
-let storageKey = 'recloud-theme'
+const preferences = ref({ ...defaults });
+const systemIsDark = ref(false);
+let initialized = false;
+let mediaQuery: MediaQueryList | undefined;
+let storageKey = "recloud-theme";
-function normalizeMode(mode: ThemeMode): 'light' | 'dark' | 'system' {
- return mode === 'auto' ? 'system' : mode
+function normalizeMode(mode: ThemeMode): "light" | "dark" | "system" {
+ return mode === "auto" ? "system" : mode;
}
function resolveTheme(mode = preferences.value.mode): ResolvedThemeMode {
- return normalizeMode(mode) === 'dark' || (normalizeMode(mode) === 'system' && systemIsDark.value) ? 'dark' : 'light'
+ return normalizeMode(mode) === "dark" || (normalizeMode(mode) === "system" && systemIsDark.value)
+ ? "dark"
+ : "light";
}
function applyTheme() {
- if (typeof document === 'undefined') return
- const root = document.documentElement
- const resolved = resolveTheme()
- root.classList.toggle('dark', resolved === 'dark')
- root.dataset.theme = resolved
- root.dataset.themeMode = normalizeMode(preferences.value.mode)
- root.dataset.themePalette = preferences.value.palette
- root.dataset.themeDensity = preferences.value.density
- root.dataset.themeRadius = preferences.value.radius
- root.dataset.themeShadow = preferences.value.shadow
+ if (typeof document === "undefined") return;
+ const root = document.documentElement;
+ const resolved = resolveTheme();
+ root.classList.toggle("dark", resolved === "dark");
+ root.dataset.theme = resolved;
+ root.dataset.themeMode = normalizeMode(preferences.value.mode);
+ root.dataset.themePalette = preferences.value.palette;
+ root.dataset.themeDensity = preferences.value.density;
+ root.dataset.themeRadius = preferences.value.radius;
+ root.dataset.themeShadow = preferences.value.shadow;
}
function persist() {
- if (typeof window === 'undefined') return
- window.localStorage.setItem(storageKey, JSON.stringify(preferences.value))
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(storageKey, JSON.stringify(preferences.value));
}
function hydrate(options: UseThemeOptions) {
- if (initialized || typeof window === 'undefined') return
- initialized = true
- storageKey = options.storageKey ?? storageKey
+ if (initialized || typeof window === "undefined") return;
+ initialized = true;
+ storageKey = options.storageKey ?? storageKey;
const fallback: ThemePreferences = {
mode: options.defaultMode ?? defaults.mode,
palette: options.defaultPalette ?? defaults.palette,
density: options.defaultDensity ?? defaults.density,
radius: options.defaultRadius ?? defaults.radius,
- shadow: options.defaultShadow ?? defaults.shadow
- }
+ shadow: options.defaultShadow ?? defaults.shadow,
+ };
try {
- const saved = JSON.parse(window.localStorage.getItem(storageKey) ?? '{}') as Partial
- preferences.value = { ...fallback, ...saved }
+ const saved = JSON.parse(
+ window.localStorage.getItem(storageKey) ?? "{}",
+ ) as Partial;
+ preferences.value = { ...fallback, ...saved };
} catch {
- preferences.value = fallback
+ preferences.value = fallback;
}
- mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)')
- systemIsDark.value = mediaQuery?.matches ?? false
- mediaQuery?.addEventListener('change', (event) => {
- systemIsDark.value = event.matches
- if (normalizeMode(preferences.value.mode) === 'system') applyTheme()
- })
- applyTheme()
+ mediaQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
+ systemIsDark.value = mediaQuery?.matches ?? false;
+ mediaQuery?.addEventListener("change", (event) => {
+ systemIsDark.value = event.matches;
+ if (normalizeMode(preferences.value.mode) === "system") applyTheme();
+ });
+ applyTheme();
}
export function useTheme(options: UseThemeOptions = {}) {
- onMounted(() => hydrate(options))
+ onMounted(() => hydrate(options));
- const resolvedMode = computed(() => resolveTheme())
- const isDark = computed(() => resolvedMode.value === 'dark')
+ const resolvedMode = computed(() => resolveTheme());
+ const isDark = computed(() => resolvedMode.value === "dark");
function update(next: Partial) {
- preferences.value = { ...preferences.value, ...next }
- persist()
- applyTheme()
+ preferences.value = { ...preferences.value, ...next };
+ persist();
+ applyTheme();
}
- function setMode(mode: ThemeMode) { update({ mode: normalizeMode(mode) }) }
- function setPalette(palette: ThemePalette) { update({ palette }) }
- function setDensity(density: ThemeDensity) { update({ density }) }
- function setRadius(radius: ThemeRadius) { update({ radius }) }
- function setShadow(shadow: ThemeShadow) { update({ shadow }) }
- function toggle() { setMode(isDark.value ? 'light' : 'dark') }
+ function setMode(mode: ThemeMode) {
+ update({ mode: normalizeMode(mode) });
+ }
+ function setPalette(palette: ThemePalette) {
+ update({ palette });
+ }
+ function setDensity(density: ThemeDensity) {
+ update({ density });
+ }
+ function setRadius(radius: ThemeRadius) {
+ update({ radius });
+ }
+ function setShadow(shadow: ThemeShadow) {
+ update({ shadow });
+ }
+ function toggle() {
+ setMode(isDark.value ? "light" : "dark");
+ }
return {
mode: computed(() => preferences.value.mode),
@@ -125,6 +141,6 @@ export function useTheme(options: UseThemeOptions = {}) {
setRadius,
setShadow,
update,
- toggle
- }
+ toggle,
+ };
}
diff --git a/packages/ui/src/env.d.ts b/packages/ui/src/env.d.ts
index 5006088..92c5507 100644
--- a/packages/ui/src/env.d.ts
+++ b/packages/ui/src/env.d.ts
@@ -1,5 +1,5 @@
-declare module '*.vue' {
- import type { DefineComponent } from 'vue'
- const component: DefineComponent, Record, unknown>
- export default component
+declare module "*.vue" {
+ import type { DefineComponent } from "vue";
+ const component: DefineComponent, Record, unknown>;
+ export default component;
}
diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts
index de82c34..edf516a 100644
--- a/packages/ui/src/icons/index.ts
+++ b/packages/ui/src/icons/index.ts
@@ -1,5 +1,5 @@
-export { default as BrandLogo } from './BrandLogo.vue'
-export { default as BrandWordmark } from './BrandWordmark.vue'
+export { default as BrandLogo } from "./BrandLogo.vue";
+export { default as BrandWordmark } from "./BrandWordmark.vue";
// Re-export Lucide Icons for convenience
-export * from 'lucide-vue-next'
+export * from "lucide-vue-next";
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 6ff588b..89a5f66 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,80 +1,88 @@
// Components
-export * from './components/button'
-export * from './components/badge'
-export * from './components/card'
-export * from './components/banner'
-export * from './components/input'
-export * from './components/tag-input'
-export * from './components/field'
-export * from './components/form'
-export * from './components/textarea'
-export * from './components/switch'
-export * from './components/checkbox'
-export * from './components/select'
-export * from './components/dialog'
-export * from './components/dropdown'
-export * from './components/tooltip'
-export * from './components/tabs'
-export * from './components/toast'
-export * from './components/avatar'
-export * from './components/progress'
-export * from './components/skeleton'
-export * from './components/separator'
-export * from './components/empty'
-export * from './components/pagination'
-export * from './components/label'
-export * from './components/radio'
-export * from './components/toggle'
-export * from './components/toggle-group'
-export * from './components/accordion'
-export * from './components/collapsible'
-export * from './components/stepper'
-export * from './components/scroll-area'
-export * from './components/table'
-export * from './components/breadcrumb'
-export * from './components/kbd'
-export * from './components/code-block'
-export * from './components/spinner'
-export * from './components/popover'
-export * from './components/context-menu'
-export * from './components/alert-dialog'
-export * from './components/slider'
-export * from './components/rating'
-export * from './components/pin-input'
-export * from './components/number-field'
-export * from './components/combobox'
-export * from './components/editable'
-export * from './components/hover-card'
-export * from './components/aspect-ratio'
-export * from './components/toolbar'
-export * from './components/visually-hidden'
-export * from './components/navigation-menu'
-export * from './components/command-palette'
-export * from './components/sheet'
-export * from './components/date-picker'
-export * from './components/app-shell'
-export * from './components/resizable-panel'
-export * from './components/file-upload'
-export * from './components/multi-select'
-export * from './components/tree-view'
-export * from './components/timeline'
-export * from './components/callout'
-export * from './components/inline-code'
-export * from './components/steps'
-export * from './components/anchor-heading'
-export * from './components/toc'
-export * from './components/prev-next'
-export * from './components/nav-tree'
-export * from './components/code-group'
-export * from './components/navbar'
-export * from './components/footer'
-export * from './components/hero'
-export * from './components/icon'
+export * from "./components/button";
+export * from "./components/badge";
+export * from "./components/card";
+export * from "./components/component-example";
+export * from "./components/api-table";
+export * from "./components/page-header";
+export * from "./components/banner";
+export * from "./components/input";
+export * from "./components/tag-input";
+export * from "./components/field";
+export * from "./components/form";
+export * from "./components/textarea";
+export * from "./components/switch";
+export * from "./components/checkbox";
+export * from "./components/select";
+export * from "./components/dialog";
+export * from "./components/dropdown";
+export * from "./components/tooltip";
+export * from "./components/tabs";
+export * from "./components/toast";
+export * from "./components/avatar";
+export * from "./components/progress";
+export * from "./components/skeleton";
+export * from "./components/separator";
+export * from "./components/empty";
+export * from "./components/pagination";
+export * from "./components/label";
+export * from "./components/radio";
+export * from "./components/toggle";
+export * from "./components/toggle-group";
+export * from "./components/accordion";
+export * from "./components/collapsible";
+export * from "./components/stepper";
+export * from "./components/scroll-area";
+export * from "./components/table";
+export * from "./components/breadcrumb";
+export * from "./components/kbd";
+export * from "./components/code-block";
+export * from "./components/spinner";
+export * from "./components/popover";
+export * from "./components/context-menu";
+export * from "./components/alert-dialog";
+export * from "./components/slider";
+export * from "./components/rating";
+export * from "./components/pin-input";
+export * from "./components/number-field";
+export * from "./components/combobox";
+export * from "./components/editable";
+export * from "./components/hover-card";
+export * from "./components/aspect-ratio";
+export * from "./components/toolbar";
+export * from "./components/visually-hidden";
+export * from "./components/navigation-menu";
+export * from "./components/command-palette";
+export * from "./components/sheet";
+export * from "./components/date-picker";
+export * from "./components/app-shell";
+export * from "./components/resizable-panel";
+export * from "./components/file-upload";
+export * from "./components/multi-select";
+export * from "./components/tree-view";
+export * from "./components/timeline";
+export * from "./components/callout";
+export * from "./components/inline-code";
+export * from "./components/steps";
+export * from "./components/anchor-heading";
+export * from "./components/toc";
+export * from "./components/prev-next";
+export * from "./components/nav-tree";
+export * from "./components/code-group";
+export * from "./components/navbar";
+export * from "./components/footer";
+export * from "./components/hero";
+export * from "./components/container";
+export * from "./components/section";
+export * from "./components/theme-toggle";
+export * from "./components/search-input";
+export * from "./components/icon";
+export * from "./markdown";
// Composables & Utilities
-export * from './composables/useTheme'
-export * from './locale'
-export * from './locale/messages'
-export * from './utils/cn'
-export * from './utils/theme'
-export * from './utils/themeScript'
+export * from "./composables/useTheme";
+export * from "./locale";
+export * from "./locale/messages";
+export * from "./utils/cn";
+export * from "./utils/theme";
+export * from "./utils/themeScript";
diff --git a/packages/ui/src/locale/index.ts b/packages/ui/src/locale/index.ts
index d16f14a..eb53694 100644
--- a/packages/ui/src/locale/index.ts
+++ b/packages/ui/src/locale/index.ts
@@ -1,94 +1,97 @@
-import { computed, inject, provide, type InjectionKey, type Ref, shallowRef } from 'vue'
-import { enUS, zhCN, type DeepPartial, type LocaleMessages } from './messages'
+import { computed, inject, provide, type InjectionKey, type Ref, shallowRef } from "vue";
+import { enUS, zhCN, type DeepPartial, type LocaleMessages } from "./messages";
export interface LocaleContext {
- locale: Ref
- messages: Ref
- setLocale: (name: string, customMessages?: DeepPartial) => void
+ locale: Ref;
+ messages: Ref;
+ setLocale: (name: string, customMessages?: DeepPartial) => void;
}
-export const LOCALE_INJECTION_KEY: InjectionKey = Symbol('recloud:locale')
+export const LOCALE_INJECTION_KEY: InjectionKey = Symbol("recloud:locale");
const builtInLocales: Record = {
- 'zh-CN': zhCN,
+ "zh-CN": zhCN,
zh: zhCN,
- 'en-US': enUS,
- en: enUS
-}
+ "en-US": enUS,
+ en: enUS,
+};
-function mergeDeep>(target: T, source?: Record): T {
- if (!source) return target
- const output: Record = { ...target }
+function mergeDeep>(
+ target: T,
+ source?: Record,
+): T {
+ if (!source) return target;
+ const output: Record = { ...target };
for (const key of Object.keys(source)) {
- const sourceVal = source[key]
- const targetVal = output[key]
+ const sourceVal = source[key];
+ const targetVal = output[key];
if (
sourceVal !== null &&
sourceVal !== undefined &&
- typeof sourceVal === 'object' &&
+ typeof sourceVal === "object" &&
!Array.isArray(sourceVal) &&
targetVal !== null &&
targetVal !== undefined &&
- typeof targetVal === 'object' &&
+ typeof targetVal === "object" &&
!Array.isArray(targetVal)
) {
output[key] = mergeDeep(
targetVal as Record,
- sourceVal as Record
- )
+ sourceVal as Record,
+ );
} else if (sourceVal !== undefined) {
- output[key] = sourceVal
+ output[key] = sourceVal;
}
}
- return output as T
+ return output as T;
}
export function provideLocale(
- initialLocale: string = 'zh-CN',
- customMessages?: DeepPartial
+ initialLocale: string = "zh-CN",
+ customMessages?: DeepPartial,
): LocaleContext {
- const currentLocale = shallowRef(initialLocale)
- const base = builtInLocales[initialLocale] ?? zhCN
+ const currentLocale = shallowRef(initialLocale);
+ const base = builtInLocales[initialLocale] ?? zhCN;
const currentMessages = shallowRef(
- customMessages ? mergeDeep({ ...base }, customMessages as Record) : base
- )
+ customMessages ? mergeDeep({ ...base }, customMessages as Record) : base,
+ );
const context: LocaleContext = {
locale: currentLocale,
messages: currentMessages,
setLocale(name: string, override?: DeepPartial) {
- currentLocale.value = name
- const fallback = builtInLocales[name] ?? zhCN
+ currentLocale.value = name;
+ const fallback = builtInLocales[name] ?? zhCN;
currentMessages.value = override
? mergeDeep({ ...fallback }, override as Record)
- : fallback
- }
- }
+ : fallback;
+ },
+ };
- provide(LOCALE_INJECTION_KEY, context)
- return context
+ provide(LOCALE_INJECTION_KEY, context);
+ return context;
}
export function useLocale() {
- const injected = inject(LOCALE_INJECTION_KEY, null)
- if (injected) return injected
+ const injected = inject(LOCALE_INJECTION_KEY, null);
+ if (injected) return injected;
- const defaultLocale = shallowRef('zh-CN')
- const defaultMessages = shallowRef(zhCN)
+ const defaultLocale = shallowRef("zh-CN");
+ const defaultMessages = shallowRef(zhCN);
return {
locale: defaultLocale,
messages: defaultMessages,
setLocale(name: string, override?: DeepPartial) {
- defaultLocale.value = name
- const fallback = builtInLocales[name] ?? zhCN
+ defaultLocale.value = name;
+ const fallback = builtInLocales[name] ?? zhCN;
defaultMessages.value = override
? mergeDeep({ ...fallback }, override as Record)
- : fallback
- }
- }
+ : fallback;
+ },
+ };
}
export function useComponentLocale(componentKey: K) {
- const { messages } = useLocale()
- return computed(() => messages.value[componentKey])
+ const { messages } = useLocale();
+ return computed(() => messages.value[componentKey]);
}
diff --git a/packages/ui/src/locale/locale.test.ts b/packages/ui/src/locale/locale.test.ts
index 5545c04..d64041c 100644
--- a/packages/ui/src/locale/locale.test.ts
+++ b/packages/ui/src/locale/locale.test.ts
@@ -1,47 +1,47 @@
-import { describe, expect, it } from 'vitest'
-import { useLocale } from './index'
-import { zhCN, enUS } from './messages'
+import { describe, expect, it } from "vitest";
+import { useLocale } from "./index";
+import { zhCN, enUS } from "./messages";
-describe('i18n locale injection system', () => {
- it('returns default zhCN messages when no context is active', () => {
- const { locale, messages } = useLocale()
- expect(locale.value).toBe('zh-CN')
- expect(messages.value.pagination.prev).toBe('上一页')
- expect(messages.value.empty.defaultTitle).toBe('暂无数据')
- expect(messages.value.command.emptyText).toBe('没有找到匹配的命令。')
- })
+describe("i18n locale injection system", () => {
+ it("returns default zhCN messages when no context is active", () => {
+ const { locale, messages } = useLocale();
+ expect(locale.value).toBe("zh-CN");
+ expect(messages.value.pagination.prev).toBe("上一页");
+ expect(messages.value.empty.defaultTitle).toBe("暂无数据");
+ expect(messages.value.command.emptyText).toBe("没有找到匹配的命令。");
+ });
- it('switches between locales dynamically', () => {
- const { locale, messages, setLocale } = useLocale()
- setLocale('en-US')
- expect(locale.value).toBe('en-US')
- expect(messages.value.pagination.prev).toBe('Previous')
- expect(messages.value.empty.defaultTitle).toBe('No data available')
+ it("switches between locales dynamically", () => {
+ const { locale, messages, setLocale } = useLocale();
+ setLocale("en-US");
+ expect(locale.value).toBe("en-US");
+ expect(messages.value.pagination.prev).toBe("Previous");
+ expect(messages.value.empty.defaultTitle).toBe("No data available");
- setLocale('zh-CN')
- expect(locale.value).toBe('zh-CN')
- expect(messages.value.pagination.prev).toBe('上一页')
- })
+ setLocale("zh-CN");
+ expect(locale.value).toBe("zh-CN");
+ expect(messages.value.pagination.prev).toBe("上一页");
+ });
- it('supports custom message overrides', () => {
- const { locale, messages, setLocale } = useLocale()
- setLocale('en-US', {
+ it("supports custom message overrides", () => {
+ const { locale, messages, setLocale } = useLocale();
+ setLocale("en-US", {
pagination: {
- prev: 'Go Back'
- }
- })
- expect(locale.value).toBe('en-US')
- expect(messages.value.pagination.prev).toBe('Go Back')
- expect(messages.value.pagination.next).toBe('Next')
- })
+ prev: "Go Back",
+ },
+ });
+ expect(locale.value).toBe("en-US");
+ expect(messages.value.pagination.prev).toBe("Go Back");
+ expect(messages.value.pagination.next).toBe("Next");
+ });
- it('formats parameterized pagination and rules correctly', () => {
- expect(zhCN.pagination.pageSummary(2, 10)).toBe('第 2 / 10 页')
- expect(enUS.pagination.pageSummary(2, 10)).toBe('Page 2 of 10')
- expect(zhCN.pagination.totalSummary(1, 5, 50)).toBe('第 1 / 5 页,共 50 条')
- expect(enUS.pagination.totalSummary(1, 5, 50)).toBe('Page 1 of 5, 50 total')
+ it("formats parameterized pagination and rules correctly", () => {
+ expect(zhCN.pagination.pageSummary(2, 10)).toBe("第 2 / 10 页");
+ expect(enUS.pagination.pageSummary(2, 10)).toBe("Page 2 of 10");
+ expect(zhCN.pagination.totalSummary(1, 5, 50)).toBe("第 1 / 5 页,共 50 条");
+ expect(enUS.pagination.totalSummary(1, 5, 50)).toBe("Page 1 of 5, 50 total");
- expect(zhCN.fileUpload.fileLimitReject(3)).toBe('最多上传 3 个文件')
- expect(enUS.fileUpload.fileLimitReject(3)).toBe('Maximum 3 files allowed')
- })
-})
+ expect(zhCN.fileUpload.fileLimitReject(3)).toBe("最多上传 3 个文件");
+ expect(enUS.fileUpload.fileLimitReject(3)).toBe("Maximum 3 files allowed");
+ });
+});
diff --git a/packages/ui/src/locale/messages.ts b/packages/ui/src/locale/messages.ts
index 120f387..8a8e3d0 100644
--- a/packages/ui/src/locale/messages.ts
+++ b/packages/ui/src/locale/messages.ts
@@ -1,107 +1,110 @@
export interface LocaleMessages {
common: {
- close: string
- cancel: string
- confirm: string
- retry: string
- remove: string
- search: string
- loading: string
- noData: string
- clear: string
- save: string
- edit: string
- copy: string
- copied: string
- expandCode: string
- collapseCode: string
- }
+ close: string;
+ cancel: string;
+ confirm: string;
+ retry: string;
+ remove: string;
+ search: string;
+ loading: string;
+ noData: string;
+ clear: string;
+ save: string;
+ edit: string;
+ copy: string;
+ copied: string;
+ expandCode: string;
+ collapseCode: string;
+ };
pagination: {
- prev: string
- next: string
- pageSummary: (page: number, totalPages: number) => string
- totalSummary: (page: number, totalPages: number, total: number) => string
- ariaLabel: string
- }
+ prev: string;
+ next: string;
+ pageSummary: (page: number, totalPages: number) => string;
+ totalSummary: (page: number, totalPages: number, total: number) => string;
+ ariaLabel: string;
+ };
empty: {
- defaultTitle: string
- loadFailed: string
- }
+ defaultTitle: string;
+ loadFailed: string;
+ };
command: {
- searchPlaceholder: string
- emptyText: string
- label: string
- }
+ searchPlaceholder: string;
+ emptyText: string;
+ label: string;
+ };
fileUpload: {
- label: string
- dropzonePrefix: string
- dropzoneSuffix: string
- anyFiles: string
- acceptRule: (accept: string) => string
- maxSizeRule: (size: string) => string
- fileLimitReject: (max: number) => string
- typeReject: string
- sizeReject: (size: string) => string
- failedDefault: string
- cancelled: string
- statusPending: string
- statusUploading: string
- statusSuccess: string
- statusError: string
- statusCancelled: string
- cancelAria: (name: string) => string
- retryAria: (name: string) => string
- removeAria: (name: string) => string
- }
+ label: string;
+ dropzonePrefix: string;
+ dropzoneSuffix: string;
+ anyFiles: string;
+ acceptRule: (accept: string) => string;
+ maxSizeRule: (size: string) => string;
+ fileLimitReject: (max: number) => string;
+ typeReject: string;
+ sizeReject: (size: string) => string;
+ failedDefault: string;
+ cancelled: string;
+ statusPending: string;
+ statusUploading: string;
+ statusSuccess: string;
+ statusError: string;
+ statusCancelled: string;
+ cancelAria: (name: string) => string;
+ retryAria: (name: string) => string;
+ removeAria: (name: string) => string;
+ };
tree: {
- selectedBadge: string
- collapseNode: (label: string) => string
- expandNode: (label: string) => string
- selectNode: (label: string) => string
- label: string
- }
+ selectedBadge: string;
+ collapseNode: (label: string) => string;
+ expandNode: (label: string) => string;
+ selectNode: (label: string) => string;
+ label: string;
+ };
table: {
- selectAllAria: string
- selectRowAria: (index: number) => string
- }
+ selectAllAria: string;
+ selectRowAria: (index: number) => string;
+ };
select: {
- placeholder: string
- }
+ placeholder: string;
+ };
combobox: {
- placeholder: string
- emptyText: string
- clearAria: string
- }
+ placeholder: string;
+ emptyText: string;
+ clearAria: string;
+ };
multiSelect: {
- placeholder: string
- emptyText: string
- clearAria: string
- removeAria: (label: string) => string
- }
+ placeholder: string;
+ emptyText: string;
+ clearAria: string;
+ removeAria: (label: string) => string;
+ };
datePicker: {
- placeholder: string
- rangePlaceholder: string
- prevMonthAria: string
- nextMonthAria: string
- pendingRangeEnd: string
- weekdays: string[]
- }
+ placeholder: string;
+ rangePlaceholder: string;
+ prevMonthAria: string;
+ nextMonthAria: string;
+ pendingRangeEnd: string;
+ weekdays: string[];
+ };
appShell: {
- sidebarLabel: string
- resizeHandleAria: string
- }
+ sidebarLabel: string;
+ resizeHandleAria: string;
+ };
alertDialog: {
- confirm: string
- cancel: string
- }
+ confirm: string;
+ cancel: string;
+ };
+ codeBlock: {
+ region: string;
+ };
docs: {
- toc: string
- prev: string
- next: string
- copyLink: string
- copiedLink: string
- searchPlaceholder: string
- }
+ toc: string;
+ prev: string;
+ next: string;
+ copyLink: string;
+ copiedLink: string;
+ searchPlaceholder: string;
+ };
}
export type DeepPartial = {
@@ -109,217 +112,223 @@ export type DeepPartial = {
? T[P]
: T[P] extends object
? DeepPartial
- : T[P]
-}
+ : T[P];
+};
export const zhCN: LocaleMessages = {
common: {
- close: '关闭',
- cancel: '取消',
- confirm: '确认',
- retry: '重试',
- remove: '移除',
- search: '搜索',
- loading: '加载中',
- noData: '暂无数据',
- clear: '清除',
- save: '保存',
- edit: '编辑',
- copy: '复制',
- copied: '已复制',
- expandCode: '展开代码',
- collapseCode: '收起代码'
+ close: "关闭",
+ cancel: "取消",
+ confirm: "确认",
+ retry: "重试",
+ remove: "移除",
+ search: "搜索",
+ loading: "加载中",
+ noData: "暂无数据",
+ clear: "清除",
+ save: "保存",
+ edit: "编辑",
+ copy: "复制",
+ copied: "已复制",
+ expandCode: "展开代码",
+ collapseCode: "收起代码",
},
pagination: {
- prev: '上一页',
- next: '下一页',
+ prev: "上一页",
+ next: "下一页",
pageSummary: (page, totalPages) => `第 ${page} / ${totalPages} 页`,
totalSummary: (page, totalPages, total) => `第 ${page} / ${totalPages} 页,共 ${total} 条`,
- ariaLabel: '分页导航'
+ ariaLabel: "分页导航",
},
empty: {
- defaultTitle: '暂无数据',
- loadFailed: '数据加载失败'
+ defaultTitle: "暂无数据",
+ loadFailed: "数据加载失败",
},
command: {
- searchPlaceholder: '搜索命令…',
- emptyText: '没有找到匹配的命令。',
- label: '命令面板'
+ searchPlaceholder: "搜索命令…",
+ emptyText: "没有找到匹配的命令。",
+ label: "命令面板",
},
fileUpload: {
- label: '上传文件',
- dropzonePrefix: '选择文件',
- dropzoneSuffix: '或拖放到这里',
- anyFiles: '支持任意文件类型',
+ label: "上传文件",
+ dropzonePrefix: "选择文件",
+ dropzoneSuffix: "或拖放到这里",
+ anyFiles: "支持任意文件类型",
acceptRule: (accept) => `支持 ${accept}`,
maxSizeRule: (size) => `单个文件最大 ${size}`,
fileLimitReject: (max) => `最多上传 ${max} 个文件`,
- typeReject: '文件类型不受支持',
+ typeReject: "文件类型不受支持",
sizeReject: (size) => `文件不能超过 ${size}`,
- failedDefault: '上传失败,请重试',
- cancelled: '上传已取消',
- statusPending: '等待上传',
- statusUploading: '上传中',
- statusSuccess: '已完成',
- statusError: '失败',
- statusCancelled: '已取消',
+ failedDefault: "上传失败,请重试",
+ cancelled: "上传已取消",
+ statusPending: "等待上传",
+ statusUploading: "上传中",
+ statusSuccess: "已完成",
+ statusError: "失败",
+ statusCancelled: "已取消",
cancelAria: (name) => `取消上传 ${name}`,
retryAria: (name) => `重试上传 ${name}`,
- removeAria: (name) => `移除 ${name}`
+ removeAria: (name) => `移除 ${name}`,
},
tree: {
- selectedBadge: '已选',
+ selectedBadge: "已选",
collapseNode: (label) => `收起 ${label}`,
expandNode: (label) => `展开 ${label}`,
selectNode: (label) => `选择 ${label}`,
- label: '树形目录'
+ label: "树形目录",
},
table: {
- selectAllAria: '选择当前页所有行',
- selectRowAria: (index) => `选择第 ${index} 行`
+ selectAllAria: "选择当前页所有行",
+ selectRowAria: (index) => `选择第 ${index} 行`,
},
select: {
- placeholder: '请选择...'
+ placeholder: "请选择...",
},
combobox: {
- placeholder: '搜索或选择...',
- emptyText: '无匹配项',
- clearAria: '清除'
+ placeholder: "搜索或选择...",
+ emptyText: "无匹配项",
+ clearAria: "清除",
},
multiSelect: {
- placeholder: '搜索并选择...',
- emptyText: '无匹配项',
- clearAria: '清除所有选择',
- removeAria: (label) => `移除 ${label}`
+ placeholder: "搜索并选择...",
+ emptyText: "无匹配项",
+ clearAria: "清除所有选择",
+ removeAria: (label) => `移除 ${label}`,
},
datePicker: {
- placeholder: '选择日期',
- rangePlaceholder: '选择日期范围',
- prevMonthAria: '上个月',
- nextMonthAria: '下个月',
- pendingRangeEnd: '已选择开始日期,请选择结束日期。',
- weekdays: ['一', '二', '三', '四', '五', '六', '日']
+ placeholder: "选择日期",
+ rangePlaceholder: "选择日期范围",
+ prevMonthAria: "上个月",
+ nextMonthAria: "下个月",
+ pendingRangeEnd: "已选择开始日期,请选择结束日期。",
+ weekdays: ["一", "二", "三", "四", "五", "六", "日"],
},
appShell: {
- sidebarLabel: '侧边导航',
- resizeHandleAria: '调整面板大小'
+ sidebarLabel: "侧边导航",
+ resizeHandleAria: "调整面板大小",
},
alertDialog: {
- confirm: '确认',
- cancel: '取消'
+ confirm: "确认",
+ cancel: "取消",
+ },
+ codeBlock: {
+ region: "代码区域",
},
docs: {
- toc: '本页目录',
- prev: '上一篇',
- next: '下一篇',
- copyLink: '复制链接',
- copiedLink: '已复制链接',
- searchPlaceholder: '搜索文档...'
- }
-}
+ toc: "本页目录",
+ prev: "上一篇",
+ next: "下一篇",
+ copyLink: "复制链接",
+ copiedLink: "已复制链接",
+ searchPlaceholder: "搜索文档...",
+ },
+};
export const enUS: LocaleMessages = {
common: {
- close: 'Close',
- cancel: 'Cancel',
- confirm: 'Confirm',
- retry: 'Retry',
- remove: 'Remove',
- search: 'Search',
- loading: 'Loading',
- noData: 'No data',
- clear: 'Clear',
- save: 'Save',
- edit: 'Edit',
- copy: 'Copy',
- copied: 'Copied',
- expandCode: 'Expand code',
- collapseCode: 'Collapse code'
+ close: "Close",
+ cancel: "Cancel",
+ confirm: "Confirm",
+ retry: "Retry",
+ remove: "Remove",
+ search: "Search",
+ loading: "Loading",
+ noData: "No data",
+ clear: "Clear",
+ save: "Save",
+ edit: "Edit",
+ copy: "Copy",
+ copied: "Copied",
+ expandCode: "Expand code",
+ collapseCode: "Collapse code",
},
pagination: {
- prev: 'Previous',
- next: 'Next',
+ prev: "Previous",
+ next: "Next",
pageSummary: (page, totalPages) => `Page ${page} of ${totalPages}`,
totalSummary: (page, totalPages, total) => `Page ${page} of ${totalPages}, ${total} total`,
- ariaLabel: 'Pagination'
+ ariaLabel: "Pagination",
},
empty: {
- defaultTitle: 'No data available',
- loadFailed: 'Failed to load data'
+ defaultTitle: "No data available",
+ loadFailed: "Failed to load data",
},
command: {
- searchPlaceholder: 'Search commands…',
- emptyText: 'No matching commands found.',
- label: 'Command palette'
+ searchPlaceholder: "Search commands…",
+ emptyText: "No matching commands found.",
+ label: "Command palette",
},
fileUpload: {
- label: 'Upload files',
- dropzonePrefix: 'Choose a file',
- dropzoneSuffix: 'or drag and drop here',
- anyFiles: 'Supports any file type',
+ label: "Upload files",
+ dropzonePrefix: "Choose a file",
+ dropzoneSuffix: "or drag and drop here",
+ anyFiles: "Supports any file type",
acceptRule: (accept) => `Supports ${accept}`,
maxSizeRule: (size) => `Max file size ${size}`,
fileLimitReject: (max) => `Maximum ${max} files allowed`,
- typeReject: 'File type not supported',
+ typeReject: "File type not supported",
sizeReject: (size) => `File must not exceed ${size}`,
- failedDefault: 'Upload failed, please retry',
- cancelled: 'Upload cancelled',
- statusPending: 'Pending',
- statusUploading: 'Uploading',
- statusSuccess: 'Completed',
- statusError: 'Failed',
- statusCancelled: 'Cancelled',
+ failedDefault: "Upload failed, please retry",
+ cancelled: "Upload cancelled",
+ statusPending: "Pending",
+ statusUploading: "Uploading",
+ statusSuccess: "Completed",
+ statusError: "Failed",
+ statusCancelled: "Cancelled",
cancelAria: (name) => `Cancel upload for ${name}`,
retryAria: (name) => `Retry upload for ${name}`,
- removeAria: (name) => `Remove ${name}`
+ removeAria: (name) => `Remove ${name}`,
},
tree: {
- selectedBadge: 'Selected',
+ selectedBadge: "Selected",
collapseNode: (label) => `Collapse ${label}`,
expandNode: (label) => `Expand ${label}`,
selectNode: (label) => `Select ${label}`,
- label: 'Tree View'
+ label: "Tree View",
},
table: {
- selectAllAria: 'Select all rows on current page',
- selectRowAria: (index) => `Select row ${index}`
+ selectAllAria: "Select all rows on current page",
+ selectRowAria: (index) => `Select row ${index}`,
},
select: {
- placeholder: 'Please select...'
+ placeholder: "Please select...",
},
combobox: {
- placeholder: 'Search or select...',
- emptyText: 'No matching results',
- clearAria: 'Clear'
+ placeholder: "Search or select...",
+ emptyText: "No matching results",
+ clearAria: "Clear",
},
multiSelect: {
- placeholder: 'Search and select...',
- emptyText: 'No matching items',
- clearAria: 'Clear all selections',
- removeAria: (label) => `Remove ${label}`
+ placeholder: "Search and select...",
+ emptyText: "No matching items",
+ clearAria: "Clear all selections",
+ removeAria: (label) => `Remove ${label}`,
},
datePicker: {
- placeholder: 'Select date',
- rangePlaceholder: 'Select date range',
- prevMonthAria: 'Previous month',
- nextMonthAria: 'Next month',
- pendingRangeEnd: 'Start date selected, please choose end date.',
- weekdays: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
+ placeholder: "Select date",
+ rangePlaceholder: "Select date range",
+ prevMonthAria: "Previous month",
+ nextMonthAria: "Next month",
+ pendingRangeEnd: "Start date selected, please choose end date.",
+ weekdays: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
},
appShell: {
- sidebarLabel: 'Sidebar navigation',
- resizeHandleAria: 'Resize panel'
+ sidebarLabel: "Sidebar navigation",
+ resizeHandleAria: "Resize panel",
},
alertDialog: {
- confirm: 'Confirm',
- cancel: 'Cancel'
+ confirm: "Confirm",
+ cancel: "Cancel",
+ },
+ codeBlock: {
+ region: "Code region",
},
docs: {
- toc: 'On this page',
- prev: 'Previous',
- next: 'Next',
- copyLink: 'Copy link',
- copiedLink: 'Link copied',
- searchPlaceholder: 'Search documentation...'
- }
-}
+ toc: "On this page",
+ prev: "Previous",
+ next: "Next",
+ copyLink: "Copy link",
+ copiedLink: "Link copied",
+ searchPlaceholder: "Search documentation...",
+ },
+};
diff --git a/packages/ui/src/markdown/MarkdownRenderer.vue b/packages/ui/src/markdown/MarkdownRenderer.vue
new file mode 100644
index 0000000..ae57014
--- /dev/null
+++ b/packages/ui/src/markdown/MarkdownRenderer.vue
@@ -0,0 +1,440 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/markdown/compile.test.ts b/packages/ui/src/markdown/compile.test.ts
new file mode 100644
index 0000000..9ac720b
--- /dev/null
+++ b/packages/ui/src/markdown/compile.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from "vitest";
+import { compileMarkdown, slugifyHeading } from "./compile";
+
+describe("compileMarkdown", () => {
+ it("compiles commonmark blocks, inlines, and headings metadata", () => {
+ const md = `
+# Installation
+
+Install the package via bun:
+
+Here is an [example link](https://example.com).
+
+\`\`\`bash
+bun add @recloudstudio/ui
+\`\`\`
+`;
+
+ const doc = compileMarkdown(md);
+ expect(doc.headings).toEqual([{ id: "installation", title: "Installation", level: 1 }]);
+ expect(doc.links).toEqual([{ href: "https://example.com", title: undefined, external: true }]);
+ expect(doc.codeBlocks).toHaveLength(1);
+ expect(doc.codeBlocks[0]?.language).toBe("bash");
+ expect(doc.codeBlocks[0]?.code.trim()).toBe("bun add @recloudstudio/ui");
+ });
+
+ it("slugifies headings with duplicate deduplication", () => {
+ expect(slugifyHeading("Quick Start Guide!")).toBe("quick-start-guide");
+
+ const md = `
+## Overview
+### Overview
+#### Overview
+`;
+ const doc = compileMarkdown(md);
+ expect(doc.headings.map((h) => h.id)).toEqual(["overview", "overview-1", "overview-2"]);
+ });
+
+ it("compiles GFM tables and task lists", () => {
+ const md = `
+| Prop | Type | Default |
+| :--- | :---: | ---: |
+| content | string | '' |
+
+- [x] Done task
+- [ ] Todo task
+`;
+
+ const doc = compileMarkdown(md);
+ const table = doc.children.find((child) => child.type === "table");
+ expect(table).toBeDefined();
+ if (table && table.type === "table") {
+ expect(table.align).toEqual(["left", "center", "right"]);
+ expect(table.children).toHaveLength(2);
+ }
+
+ const list = doc.children.find((child) => child.type === "list");
+ expect(list).toBeDefined();
+ if (list && list.type === "list") {
+ expect(list.children[0]?.checked).toBe(true);
+ expect(list.children[1]?.checked).toBe(false);
+ }
+ });
+
+ it("compiles callout directives", () => {
+ const md = `
+:::warning{title="Sensitive Credential" collapsible="true"}
+Never commit your secret token.
+:::
+`;
+
+ const doc = compileMarkdown(md);
+ const callout = doc.children.find((child) => child.type === "callout");
+ expect(callout).toBeDefined();
+ if (callout && callout.type === "callout") {
+ expect(callout.calloutType).toBe("warning");
+ expect(callout.title).toBe("Sensitive Credential");
+ expect(callout.collapsible).toBe(true);
+ }
+ });
+
+ it("compiles steps directives with deterministic step numbering", () => {
+ const md = `
+:::steps{startIndex="1"}
+:::step{title="Install"}
+Run bun add.
+:::
+:::step{title="Configure"}
+Edit nuxt.config.ts.
+:::
+:::
+`;
+
+ const doc = compileMarkdown(md);
+ const steps = doc.children.find((child) => child.type === "steps");
+ expect(steps).toBeDefined();
+ if (steps && steps.type === "steps") {
+ expect(steps.children).toHaveLength(2);
+ expect(steps.children[0]?.step).toBe(1);
+ expect(steps.children[1]?.step).toBe(2);
+ expect(steps.children[0]?.title).toBe("Install");
+ }
+ });
+
+ it("compiles code-group directives into multi-tab structures", () => {
+ const md = `
+:::code-group
+
+\`\`\`bash [bun]
+bun add @recloudstudio/ui
+\`\`\`
+
+\`\`\`bash [pnpm]
+pnpm add @recloudstudio/ui
+\`\`\`
+
+:::
+`;
+
+ const doc = compileMarkdown(md);
+ const group = doc.children.find((child) => child.type === "codeGroup");
+ expect(group).toBeDefined();
+ if (group && group.type === "codeGroup") {
+ expect(group.tabs.map((tab) => tab.label)).toEqual(["bun", "pnpm"]);
+ expect(group.children).toHaveLength(2);
+ expect(group.children[0]?.code.trim()).toBe("bun add @recloudstudio/ui");
+ }
+ });
+
+ it("filters unsafe javascript protocols by default", () => {
+ const md = `
+[Click me](javascript:alert(1))
+)
+[Safe Link](https://worldexecute.me)
+`;
+
+ const doc = compileMarkdown(md);
+ expect(doc.links).toEqual([
+ { href: "https://worldexecute.me", title: undefined, external: true },
+ ]);
+ });
+});
diff --git a/packages/ui/src/markdown/compile.ts b/packages/ui/src/markdown/compile.ts
new file mode 100644
index 0000000..7da39ec
--- /dev/null
+++ b/packages/ui/src/markdown/compile.ts
@@ -0,0 +1,659 @@
+import { toString as nodeToString } from "mdast-util-to-string";
+import type {
+ BlockContent,
+ Blockquote,
+ Code,
+ DefinitionContent,
+ Heading,
+ Html,
+ Image,
+ InlineCode,
+ Link,
+ List,
+ ListItem,
+ Paragraph,
+ PhrasingContent,
+ Root,
+ Table,
+ TableCell,
+ TableRow,
+} from "mdast";
+import remarkDirective from "remark-directive";
+import remarkGfm from "remark-gfm";
+import remarkParse from "remark-parse";
+import { unified } from "unified";
+import type { CalloutType } from "../components/callout";
+import type { CodeGroupTab } from "../components/code-group";
+import type {
+ CompileMarkdownOptions,
+ MarkdownBlockNode,
+ MarkdownCalloutNode,
+ MarkdownCodeBlockMeta,
+ MarkdownCodeBlockNode,
+ MarkdownCodeGroupNode,
+ MarkdownDocument,
+ MarkdownHeading,
+ MarkdownHeadingLevel,
+ MarkdownInlineNode,
+ MarkdownLinkMeta,
+ MarkdownListItemNode,
+ MarkdownSanitizeOptions,
+ MarkdownStepItemNode,
+ MarkdownStepsNode,
+ MarkdownTableCellNode,
+ MarkdownTableNode,
+ MarkdownTableRowNode,
+} from "./types";
+
+const DEFAULT_PROTOCOLS = ["http:", "https:", "mailto:", "tel:"];
+
+interface DirectiveNode {
+ type: "containerDirective" | "leafDirective" | "textDirective";
+ name: string;
+ attributes?: Record;
+ children?: (BlockContent | DefinitionContent | PhrasingContent)[];
+}
+
+const isSafeUrl = (url: string, allowedProtocols = DEFAULT_PROTOCOLS): boolean => {
+ const trimmed = url.trim();
+ if (!trimmed) return false;
+ if (
+ trimmed.startsWith("#") ||
+ trimmed.startsWith("/") ||
+ trimmed.startsWith("./") ||
+ trimmed.startsWith("../")
+ ) {
+ return true;
+ }
+
+ try {
+ const parsed = new URL(trimmed, "https://example.com");
+ return allowedProtocols.includes(parsed.protocol);
+ } catch {
+ return false;
+ }
+};
+
+const resolveUrl = (url: string, baseUrl?: string): string => {
+ const trimmed = url.trim();
+ if (!baseUrl || !trimmed) return trimmed;
+ if (trimmed.startsWith("#") || /^[a-z]+:/i.test(trimmed)) return trimmed;
+
+ try {
+ return new URL(trimmed, baseUrl).toString();
+ } catch {
+ return trimmed;
+ }
+};
+
+export const slugifyHeading = (text: string): string => {
+ return text
+ .toLowerCase()
+ .trim()
+ .replace(/[\s\W-]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+};
+
+const parseCodeMeta = (
+ rawLang = "",
+ rawMeta = "",
+): {
+ language: string;
+ title?: string;
+ copyable?: boolean;
+ collapsible?: boolean;
+ collapsed?: boolean;
+ highlightLines?: number[];
+} => {
+ const [langToken] = rawLang.trim().split(/\s+/);
+ const language = langToken || "text";
+ const meta = `${rawLang.slice(language.length)} ${rawMeta}`.trim();
+
+ const titleMatch = meta.match(/title=(?:"([^"]+)"|'([^']+)'|([^\s]+))/);
+ const labelMatch = meta.match(/\[([^\]]+)\]/);
+ const copyable = !meta.includes("no-copy");
+ const collapsible = meta.includes("collapsible");
+ const collapsed = meta.includes("collapsed");
+
+ let highlightLines: number[] | undefined;
+ const linesMatch = meta.match(/\{([\d,\s-]+)\}/);
+ if (linesMatch?.[1]) {
+ const lines = new Set();
+ const parts = linesMatch[1].split(",");
+ for (const part of parts) {
+ const trimmed = part.trim();
+ if (trimmed.includes("-")) {
+ const [start = Number.NaN, end = Number.NaN] = trimmed
+ .split("-")
+ .map((v) => Number.parseInt(v, 10));
+ if (!Number.isNaN(start) && !Number.isNaN(end)) {
+ for (let i = Math.min(start, end); i <= Math.max(start, end); i += 1) {
+ lines.add(i);
+ }
+ }
+ } else {
+ const line = Number.parseInt(trimmed, 10);
+ if (!Number.isNaN(line)) lines.add(line);
+ }
+ }
+ highlightLines = Array.from(lines).sort((a, b) => a - b);
+ }
+
+ return {
+ language,
+ title: titleMatch?.[1] ?? titleMatch?.[2] ?? titleMatch?.[3] ?? labelMatch?.[1],
+ copyable,
+ collapsible,
+ collapsed,
+ highlightLines,
+ };
+};
+
+class MarkdownCompilerContext {
+ headings: MarkdownHeading[] = [];
+ links: MarkdownLinkMeta[] = [];
+ codeBlocks: MarkdownCodeBlockMeta[] = [];
+ private headingCounts = new Map();
+
+ constructor(
+ public readonly baseUrl?: string,
+ public readonly sanitize: MarkdownSanitizeOptions | false = {},
+ ) {}
+
+ createHeadingId(rawText: string): string {
+ const base = slugifyHeading(rawText) || "section";
+ const count = this.headingCounts.get(base) ?? 0;
+ this.headingCounts.set(base, count + 1);
+ return count === 0 ? base : `${base}-${count}`;
+ }
+
+ filterUrl(rawUrl: string): { url: string; safe: boolean; external: boolean } {
+ const resolved = resolveUrl(rawUrl, this.baseUrl);
+ if (this.sanitize === false) {
+ return {
+ url: resolved,
+ safe: true,
+ external: /^https?:\/\//i.test(resolved),
+ };
+ }
+
+ const protocols = this.sanitize.allowedProtocols ?? DEFAULT_PROTOCOLS;
+ const safe = isSafeUrl(resolved, protocols);
+ const external = /^https?:\/\//i.test(resolved);
+ return {
+ url: safe ? resolved : "",
+ safe,
+ external,
+ };
+ }
+}
+
+const compileInlineNodes = (
+ nodes: PhrasingContent[],
+ context: MarkdownCompilerContext,
+): MarkdownInlineNode[] => {
+ const result: MarkdownInlineNode[] = [];
+
+ for (const node of nodes) {
+ switch (node.type) {
+ case "text":
+ result.push({ type: "text", value: node.value });
+ break;
+ case "inlineCode":
+ result.push({ type: "inlineCode", value: (node as InlineCode).value });
+ break;
+ case "emphasis":
+ result.push({
+ type: "emphasis",
+ children: compileInlineNodes(node.children, context),
+ });
+ break;
+ case "strong":
+ result.push({
+ type: "strong",
+ children: compileInlineNodes(node.children, context),
+ });
+ break;
+ case "delete":
+ result.push({
+ type: "delete",
+ children: compileInlineNodes(node.children, context),
+ });
+ break;
+ case "break":
+ result.push({ type: "break" });
+ break;
+ case "link": {
+ const linkNode = node as Link;
+ const { url, safe, external } = context.filterUrl(linkNode.url);
+ if (!safe && linkNode.url) {
+ result.push(...compileInlineNodes(linkNode.children, context));
+ break;
+ }
+ const title = linkNode.title ?? undefined;
+ context.links.push({ href: url, title, external });
+ result.push({
+ type: "link",
+ url,
+ title,
+ external,
+ children: compileInlineNodes(linkNode.children, context),
+ });
+ break;
+ }
+ case "image": {
+ const imageNode = node as Image;
+ const { url, safe } = context.filterUrl(imageNode.url);
+ if (!safe) break;
+ result.push({
+ type: "image",
+ url,
+ alt: imageNode.alt ?? undefined,
+ title: imageNode.title ?? undefined,
+ });
+ break;
+ }
+ case "html": {
+ if (context.sanitize === false || context.sanitize.allowRawHtml) {
+ result.push({ type: "text", value: (node as Html).value });
+ }
+ break;
+ }
+ default:
+ if (
+ "children" in node &&
+ Array.isArray((node as { children: PhrasingContent[] }).children)
+ ) {
+ result.push(
+ ...compileInlineNodes((node as { children: PhrasingContent[] }).children, context),
+ );
+ }
+ break;
+ }
+ }
+
+ return result;
+};
+
+const compileCalloutDirective = (
+ node: DirectiveNode,
+ context: MarkdownCompilerContext,
+): MarkdownCalloutNode => {
+ const validTypes: CalloutType[] = ["note", "tip", "info", "warning", "danger"];
+ const calloutType = validTypes.includes(node.name as CalloutType)
+ ? (node.name as CalloutType)
+ : "info";
+
+ const attrs = node.attributes ?? {};
+ const title = typeof attrs.title === "string" ? attrs.title : undefined;
+ const collapsible = attrs.collapsible === true || attrs.collapsible === "true";
+ const defaultOpen = !(attrs.defaultOpen === false || attrs.defaultOpen === "false");
+
+ const children = compileBlockNodes(
+ (node.children ?? []) as (BlockContent | DefinitionContent)[],
+ context,
+ );
+
+ return {
+ type: "callout",
+ calloutType,
+ title,
+ collapsible,
+ defaultOpen,
+ children,
+ };
+};
+
+const compileStepsDirective = (
+ node: DirectiveNode,
+ context: MarkdownCompilerContext,
+): MarkdownStepsNode => {
+ const attrs = node.attributes ?? {};
+ const startIndex =
+ typeof attrs.startIndex === "number"
+ ? attrs.startIndex
+ : typeof attrs.startIndex === "string"
+ ? Number.parseInt(attrs.startIndex, 10) || 1
+ : 1;
+
+ const children: MarkdownStepItemNode[] = [];
+ let stepIndex = startIndex;
+
+ for (const child of node.children ?? []) {
+ const isDirective = child.type === "containerDirective" || child.type === "leafDirective";
+ if (!isDirective) continue;
+
+ const dir = child as DirectiveNode;
+ if (dir.name === "step") {
+ const childAttrs = dir.attributes ?? {};
+ const title = typeof childAttrs.title === "string" ? childAttrs.title : `Step ${stepIndex}`;
+ const description =
+ typeof childAttrs.description === "string" ? childAttrs.description : undefined;
+ const stepValue =
+ typeof childAttrs.step === "number"
+ ? childAttrs.step
+ : typeof childAttrs.step === "string"
+ ? Number.parseInt(childAttrs.step, 10) || stepIndex
+ : stepIndex;
+
+ const stepChildren = compileBlockNodes(
+ (dir.children ?? []) as (BlockContent | DefinitionContent)[],
+ context,
+ );
+
+ children.push({
+ type: "step",
+ title,
+ description,
+ step: stepValue,
+ children: stepChildren,
+ });
+
+ stepIndex += 1;
+ }
+ }
+
+ return {
+ type: "steps",
+ startIndex,
+ children,
+ };
+};
+
+const compileCodeGroupDirective = (
+ node: DirectiveNode,
+ context: MarkdownCompilerContext,
+): MarkdownCodeGroupNode => {
+ const tabs: CodeGroupTab[] = [];
+ const children: MarkdownCodeBlockNode[] = [];
+
+ let index = 0;
+ for (const child of node.children ?? []) {
+ if (child.type === "code") {
+ const codeNode = child as Code;
+ const meta = parseCodeMeta(codeNode.lang ?? "", codeNode.meta ?? "");
+ const codeMeta: MarkdownCodeBlockMeta = {
+ code: codeNode.value,
+ language: meta.language,
+ title: meta.title,
+ copyable: meta.copyable,
+ collapsible: meta.collapsible,
+ collapsed: meta.collapsed,
+ highlightLines: meta.highlightLines,
+ };
+
+ context.codeBlocks.push(codeMeta);
+ children.push({
+ type: "codeBlock",
+ ...codeMeta,
+ });
+
+ const tabLabel = meta.title || meta.language || `Tab ${index + 1}`;
+ tabs.push({
+ label: tabLabel,
+ key: `${index}-${tabLabel}`,
+ });
+ index += 1;
+ }
+ }
+
+ return {
+ type: "codeGroup",
+ tabs,
+ children,
+ };
+};
+
+const compileTableNode = (node: Table, context: MarkdownCompilerContext): MarkdownTableNode => {
+ const align = node.align ?? [];
+ const children: MarkdownTableRowNode[] = [];
+
+ node.children.forEach((rowNode: TableRow, rowIndex: number) => {
+ const isHeader = rowIndex === 0;
+ const cells: MarkdownTableCellNode[] = rowNode.children.map(
+ (cell: TableCell, colIndex: number) => ({
+ type: "tableCell",
+ isHeader,
+ align: align[colIndex] ?? null,
+ children: compileInlineNodes(cell.children, context),
+ }),
+ );
+
+ children.push({
+ type: "tableRow",
+ children: cells,
+ });
+ });
+
+ return {
+ type: "table",
+ align,
+ children,
+ };
+};
+
+const compileBlockNodes = (
+ nodes: (BlockContent | DefinitionContent)[],
+ context: MarkdownCompilerContext,
+): MarkdownBlockNode[] => {
+ const result: MarkdownBlockNode[] = [];
+
+ let index = 0;
+ while (index < nodes.length) {
+ const node = nodes[index];
+ if (!node) {
+ index += 1;
+ continue;
+ }
+
+ // SAFETY: remark-directive registers custom container/leaf directives in unist AST
+ const candidateDirectiveNode = node as unknown as DirectiveNode;
+ const isStepsDirective =
+ (node.type === "containerDirective" || node.type === "leafDirective") &&
+ candidateDirectiveNode.name === "steps";
+
+ if (isStepsDirective) {
+ const stepsDir = candidateDirectiveNode;
+ const compiledSteps = compileStepsDirective(stepsDir, context);
+
+ // Fallback for flat fence syntax where step directives sit next to steps container
+ if (compiledSteps.children.length <= 1) {
+ let peek = index + 1;
+ let autoStepIndex = compiledSteps.children.length + (compiledSteps.startIndex ?? 1);
+ while (peek < nodes.length) {
+ const nextNode = nodes[peek];
+ // SAFETY: remark-directive registers custom container/leaf directives in unist AST
+ const candidateDirective = nextNode as unknown as DirectiveNode;
+ const isStepDirective =
+ Boolean(nextNode) &&
+ (nextNode?.type === "containerDirective" || nextNode?.type === "leafDirective") &&
+ candidateDirective.name === "step";
+
+ if (isStepDirective && nextNode) {
+ const stepDir = candidateDirective;
+ const childAttrs = stepDir.attributes ?? {};
+ const title =
+ typeof childAttrs.title === "string" ? childAttrs.title : `Step ${autoStepIndex}`;
+ const description =
+ typeof childAttrs.description === "string" ? childAttrs.description : undefined;
+ const stepValue =
+ typeof childAttrs.step === "number"
+ ? childAttrs.step
+ : typeof childAttrs.step === "string"
+ ? Number.parseInt(childAttrs.step, 10) || autoStepIndex
+ : autoStepIndex;
+
+ const stepChildren = compileBlockNodes(
+ (stepDir.children ?? []) as (BlockContent | DefinitionContent)[],
+ context,
+ );
+
+ compiledSteps.children.push({
+ type: "step",
+ title,
+ description,
+ step: stepValue,
+ children: stepChildren,
+ });
+
+ autoStepIndex += 1;
+ peek += 1;
+ continue;
+ }
+
+ // SAFETY: checking raw trailing directive fence artifacts emitted as paragraphs by unist
+ const rawParagraph = nextNode as unknown as { children?: { value?: string }[] };
+ const isOrphanDirectiveFence =
+ Boolean(nextNode) &&
+ nextNode?.type === "paragraph" &&
+ rawParagraph.children?.[0]?.value?.trim() === ":::";
+
+ if (isOrphanDirectiveFence) {
+ peek += 1;
+ }
+ break;
+ }
+ index = peek - 1;
+ }
+
+ result.push(compiledSteps);
+ index += 1;
+ continue;
+ }
+
+ switch (node.type) {
+ case "heading": {
+ const headingNode = node as Heading;
+ const text = nodeToString(headingNode).trim();
+ const id = context.createHeadingId(text);
+ const level = Math.min(Math.max(headingNode.depth, 1), 6) as MarkdownHeadingLevel;
+ const headingMeta: MarkdownHeading = { id, title: text, level };
+ context.headings.push(headingMeta);
+ result.push({
+ type: "heading",
+ level,
+ id,
+ title: text,
+ children: compileInlineNodes(headingNode.children, context),
+ });
+ break;
+ }
+ case "paragraph": {
+ const paragraphNode = node as Paragraph;
+ result.push({
+ type: "paragraph",
+ children: compileInlineNodes(paragraphNode.children, context),
+ });
+ break;
+ }
+ case "blockquote": {
+ const quoteNode = node as Blockquote;
+ result.push({
+ type: "blockquote",
+ children: compileBlockNodes(quoteNode.children, context),
+ });
+ break;
+ }
+ case "list": {
+ const listNode = node as List;
+ const items: MarkdownListItemNode[] = listNode.children.map((item: ListItem) => ({
+ type: "listItem",
+ checked: item.checked ?? null,
+ children: compileBlockNodes(item.children, context),
+ }));
+ result.push({
+ type: "list",
+ ordered: listNode.ordered ?? false,
+ start: listNode.start ?? undefined,
+ spread: listNode.spread ?? false,
+ children: items,
+ });
+ break;
+ }
+ case "table": {
+ result.push(compileTableNode(node as Table, context));
+ break;
+ }
+ case "code": {
+ const codeNode = node as Code;
+ const meta = parseCodeMeta(codeNode.lang ?? "", codeNode.meta ?? "");
+ const codeMeta: MarkdownCodeBlockMeta = {
+ code: codeNode.value,
+ language: meta.language,
+ title: meta.title,
+ copyable: meta.copyable,
+ collapsible: meta.collapsible,
+ collapsed: meta.collapsed,
+ highlightLines: meta.highlightLines,
+ };
+ context.codeBlocks.push(codeMeta);
+ result.push({
+ type: "codeBlock",
+ ...codeMeta,
+ });
+ break;
+ }
+ case "thematicBreak": {
+ result.push({ type: "thematicBreak" });
+ break;
+ }
+ case "html": {
+ if (context.sanitize === false || context.sanitize.allowRawHtml) {
+ result.push({
+ type: "paragraph",
+ children: [{ type: "text", value: (node as Html).value }],
+ });
+ }
+ break;
+ }
+ case "containerDirective":
+ case "leafDirective": {
+ // SAFETY: remark-directive registers custom AST node types that are not part of standard mdast BlockContent
+ const directiveNode = node as unknown as DirectiveNode;
+ if (directiveNode.name === "steps") {
+ result.push(compileStepsDirective(directiveNode, context));
+ } else if (directiveNode.name === "code-group") {
+ result.push(compileCodeGroupDirective(directiveNode, context));
+ } else {
+ result.push(compileCalloutDirective(directiveNode, context));
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ index += 1;
+ }
+
+ return result;
+};
+
+export const compileMarkdown = (
+ rawMarkdown: string,
+ options: CompileMarkdownOptions = {},
+): MarkdownDocument => {
+ const processor = unified().use(remarkParse);
+
+ if (options.features?.gfm !== false) {
+ processor.use(remarkGfm);
+ }
+
+ if (options.features?.directives !== false) {
+ processor.use(remarkDirective);
+ }
+
+ const rootAst = processor.parse(rawMarkdown || "") as Root;
+ const context = new MarkdownCompilerContext(options.baseUrl, options.sanitize);
+ const children = compileBlockNodes(
+ rootAst.children as (BlockContent | DefinitionContent)[],
+ context,
+ );
+
+ return {
+ type: "document",
+ children,
+ headings: context.headings,
+ links: context.links,
+ codeBlocks: context.codeBlocks,
+ };
+};
diff --git a/packages/ui/src/markdown/index.ts b/packages/ui/src/markdown/index.ts
new file mode 100644
index 0000000..4d37552
--- /dev/null
+++ b/packages/ui/src/markdown/index.ts
@@ -0,0 +1,3 @@
+export { default as MarkdownRenderer } from "./MarkdownRenderer.vue";
+export * from "./compile";
+export * from "./types";
diff --git a/packages/ui/src/markdown/markdownRenderer.test.ts b/packages/ui/src/markdown/markdownRenderer.test.ts
new file mode 100644
index 0000000..ee47047
--- /dev/null
+++ b/packages/ui/src/markdown/markdownRenderer.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import MarkdownRenderer from "./MarkdownRenderer.vue";
+import { compileMarkdown } from "./compile";
+
+describe("MarkdownRenderer component", () => {
+ it("is exported as a valid Vue component", () => {
+ expect(MarkdownRenderer).toBeDefined();
+ expect(MarkdownRenderer.__name || MarkdownRenderer.name).toBe("MarkdownRenderer");
+ });
+
+ it("compiles AST and exposes metadata ready for renderer consumption", () => {
+ const doc = compileMarkdown('# Hello ReCloud\n\n:::info{title="Doc"}\nBody\n:::');
+ expect(doc.type).toBe("document");
+ expect(doc.headings).toEqual([{ id: "hello-recloud", title: "Hello ReCloud", level: 1 }]);
+ expect(doc.children).toHaveLength(2);
+ });
+});
diff --git a/packages/ui/src/markdown/types.ts b/packages/ui/src/markdown/types.ts
new file mode 100644
index 0000000..d7b22df
--- /dev/null
+++ b/packages/ui/src/markdown/types.ts
@@ -0,0 +1,240 @@
+import type { Component } from "vue";
+import type { CalloutType } from "../components/callout";
+import type { CodeGroupTab } from "../components/code-group";
+
+export interface MarkdownSourcePosition {
+ start: { line: number; column: number; offset?: number };
+ end: { line: number; column: number; offset?: number };
+}
+
+export type MarkdownHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
+
+export interface MarkdownHeading {
+ id: string;
+ title: string;
+ level: MarkdownHeadingLevel;
+}
+
+export interface MarkdownLinkMeta {
+ href: string;
+ title?: string;
+ external: boolean;
+}
+
+export interface MarkdownCodeBlockMeta {
+ code: string;
+ language: string;
+ title?: string;
+ copyable?: boolean;
+ collapsible?: boolean;
+ collapsed?: boolean;
+ highlightLines?: number[];
+}
+
+export interface MarkdownTextNode {
+ type: "text";
+ value: string;
+}
+
+export interface MarkdownInlineCodeNode {
+ type: "inlineCode";
+ value: string;
+}
+
+export interface MarkdownEmphasisNode {
+ type: "emphasis";
+ children: MarkdownInlineNode[];
+}
+
+export interface MarkdownStrongNode {
+ type: "strong";
+ children: MarkdownInlineNode[];
+}
+
+export interface MarkdownDeleteNode {
+ type: "delete";
+ children: MarkdownInlineNode[];
+}
+
+export interface MarkdownLinkNode {
+ type: "link";
+ url: string;
+ title?: string;
+ external: boolean;
+ children: MarkdownInlineNode[];
+}
+
+export interface MarkdownImageNode {
+ type: "image";
+ url: string;
+ alt?: string;
+ title?: string;
+}
+
+export interface MarkdownBreakNode {
+ type: "break";
+}
+
+export type MarkdownInlineNode =
+ | MarkdownTextNode
+ | MarkdownInlineCodeNode
+ | MarkdownEmphasisNode
+ | MarkdownStrongNode
+ | MarkdownDeleteNode
+ | MarkdownLinkNode
+ | MarkdownImageNode
+ | MarkdownBreakNode;
+
+export interface MarkdownHeadingNode {
+ type: "heading";
+ level: MarkdownHeadingLevel;
+ id: string;
+ title: string;
+ children: MarkdownInlineNode[];
+}
+
+export interface MarkdownParagraphNode {
+ type: "paragraph";
+ children: MarkdownInlineNode[];
+}
+
+export interface MarkdownBlockquoteNode {
+ type: "blockquote";
+ children: MarkdownBlockNode[];
+}
+
+export interface MarkdownListNode {
+ type: "list";
+ ordered: boolean;
+ start?: number;
+ spread?: boolean;
+ children: MarkdownListItemNode[];
+}
+
+export interface MarkdownListItemNode {
+ type: "listItem";
+ checked?: boolean | null;
+ children: MarkdownBlockNode[];
+}
+
+export interface MarkdownTableNode {
+ type: "table";
+ align?: ("left" | "right" | "center" | null)[];
+ children: MarkdownTableRowNode[];
+}
+
+export interface MarkdownTableRowNode {
+ type: "tableRow";
+ children: MarkdownTableCellNode[];
+}
+
+export interface MarkdownTableCellNode {
+ type: "tableCell";
+ isHeader?: boolean;
+ align?: "left" | "right" | "center" | null;
+ children: MarkdownInlineNode[];
+}
+
+export interface MarkdownCodeBlockNode extends MarkdownCodeBlockMeta {
+ type: "codeBlock";
+}
+
+export interface MarkdownThematicBreakNode {
+ type: "thematicBreak";
+}
+
+export interface MarkdownCalloutNode {
+ type: "callout";
+ calloutType: CalloutType;
+ title?: string;
+ collapsible?: boolean;
+ defaultOpen?: boolean;
+ children: MarkdownBlockNode[];
+}
+
+export interface MarkdownStepsNode {
+ type: "steps";
+ startIndex?: number;
+ children: MarkdownStepItemNode[];
+}
+
+export interface MarkdownStepItemNode {
+ type: "step";
+ title: string;
+ description?: string;
+ step?: number;
+ children: MarkdownBlockNode[];
+}
+
+export interface MarkdownCodeGroupNode {
+ type: "codeGroup";
+ tabs: CodeGroupTab[];
+ children: MarkdownCodeBlockNode[];
+}
+
+export type MarkdownBlockNode =
+ | MarkdownHeadingNode
+ | MarkdownParagraphNode
+ | MarkdownBlockquoteNode
+ | MarkdownListNode
+ | MarkdownTableNode
+ | MarkdownCodeBlockNode
+ | MarkdownThematicBreakNode
+ | MarkdownCalloutNode
+ | MarkdownStepsNode
+ | MarkdownCodeGroupNode;
+
+export interface MarkdownDocument {
+ type: "document";
+ children: MarkdownBlockNode[];
+ headings: MarkdownHeading[];
+ links: MarkdownLinkMeta[];
+ codeBlocks: MarkdownCodeBlockMeta[];
+}
+
+export interface MarkdownSanitizeOptions {
+ allowRawHtml?: boolean;
+ allowedProtocols?: string[];
+}
+
+export interface MarkdownFeatures {
+ gfm?: boolean;
+ directives?: boolean;
+ codeGroups?: boolean;
+ callouts?: boolean;
+ steps?: boolean;
+}
+
+export interface CompileMarkdownOptions {
+ baseUrl?: string;
+ features?: MarkdownFeatures;
+ sanitize?: MarkdownSanitizeOptions | false;
+}
+
+export interface MarkdownComponentRegistry {
+ heading?: Component;
+ paragraph?: Component;
+ inlineCode?: Component;
+ codeBlock?: Component;
+ link?: Component;
+ image?: Component;
+ blockquote?: Component;
+ list?: Component;
+ listItem?: Component;
+ table?: Component;
+ thematicBreak?: Component;
+ callout?: Component;
+ steps?: Component;
+ stepItem?: Component;
+ codeGroup?: Component;
+}
+
+export interface MarkdownRendererProps {
+ content?: string;
+ ast?: MarkdownDocument;
+ components?: MarkdownComponentRegistry;
+ baseUrl?: string;
+ features?: MarkdownFeatures;
+ sanitize?: MarkdownSanitizeOptions | false;
+ class?: string;
+}
diff --git a/packages/ui/src/nuxt/index.ts b/packages/ui/src/nuxt/index.ts
index 8d9bd8c..6b8cb98 100644
--- a/packages/ui/src/nuxt/index.ts
+++ b/packages/ui/src/nuxt/index.ts
@@ -1,30 +1,37 @@
-import { addComponentExports, addImportsSources, createResolver, defineNuxtModule, addTypeTemplate } from '@nuxt/kit'
-import type { NuxtModule } from '@nuxt/schema'
-import type { ModuleOptions } from './types'
-import { createThemeInitScript } from '../utils/themeScript'
+import { existsSync } from "node:fs";
+import {
+ addComponentExports,
+ addImportsSources,
+ createResolver,
+ defineNuxtModule,
+ addTypeTemplate,
+} from "@nuxt/kit";
+import type { NuxtModule } from "@nuxt/schema";
+import type { ModuleOptions } from "./types";
+import { createThemeInitScript } from "../utils/themeScript";
-export * from './types'
+export * from "./types";
const module: NuxtModule = defineNuxtModule({
- meta: {
- name: '@recloudstudio/ui',
- configKey: 'recloudUI',
- compatibility: {
- nuxt: '^4.0.0'
- }
+ meta: {
+ name: "@recloudstudio/ui",
+ configKey: "recloudUI",
+ compatibility: {
+ nuxt: "^4.0.0",
},
- defaults: {
- prefix: 'Re',
- injectTheme: true,
- themeStorageKey: 'recloud-theme',
- injectThemeScript: true
- },
- setup(options, nuxt) {
- const resolver = createResolver(import.meta.url)
+ },
+ defaults: {
+ prefix: "Re",
+ injectTheme: true,
+ themeStorageKey: "recloud-theme",
+ injectThemeScript: true,
+ },
+ setup(options, nuxt) {
+ const resolver = createResolver(import.meta.url);
- addTypeTemplate({
- filename: 'types/recloud-ui.d.ts',
- getContents: () => `declare module 'nuxt/schema' {
+ addTypeTemplate({
+ filename: "types/recloud-ui.d.ts",
+ getContents: () => `declare module 'nuxt/schema' {
interface NuxtConfig {
recloudUI?: import('@recloudstudio/ui/nuxt').ModuleOptions
}
@@ -32,34 +39,37 @@ const module: NuxtModule = defineNuxtModule({
recloudUI?: import('@recloudstudio/ui/nuxt').ModuleOptions
}
}
-export {}`
- })
+export {}`,
+ });
- if (options.injectTheme) {
- nuxt.options.css = nuxt.options.css || []
- nuxt.options.css.push(resolver.resolve('../style.css'))
- }
+ if (options.injectTheme) {
+ nuxt.options.css = nuxt.options.css || [];
+ nuxt.options.css.push(resolver.resolve("../style.css"));
+ }
- if (options.injectThemeScript) {
- nuxt.options.app.head.script ||= []
- nuxt.options.app.head.script.unshift({
- key: 'recloud-theme-init',
- innerHTML: createThemeInitScript(options.themeStorageKey),
- tagPosition: 'head'
- })
- }
+ if (options.injectThemeScript) {
+ nuxt.options.app.head.script ||= [];
+ nuxt.options.app.head.script.unshift({
+ key: "recloud-theme-init",
+ innerHTML: createThemeInitScript(options.themeStorageKey),
+ tagPosition: "head",
+ });
+ }
- addComponentExports({
- filePath: resolver.resolve('../index.js'),
- prefix: options.prefix,
- })
+ // 从源码加载时(monorepo dev)入口是 ../index.ts;发布包 dist 中为 ../index.js。
+ const libraryEntry = existsSync(resolver.resolve("../index.ts"))
+ ? "../index.ts"
+ : "../index.js";
+ addComponentExports({
+ filePath: resolver.resolve(libraryEntry),
+ prefix: options.prefix,
+ });
- addImportsSources({
- from: '@recloudstudio/ui',
- imports: ['useTheme', 'useToast']
- })
- }
- }
-)
+ addImportsSources({
+ from: "@recloudstudio/ui",
+ imports: ["useTheme", "useToast"],
+ });
+ },
+});
-export default module
+export default module;
diff --git a/packages/ui/src/nuxt/types.ts b/packages/ui/src/nuxt/types.ts
index d633900..859e463 100644
--- a/packages/ui/src/nuxt/types.ts
+++ b/packages/ui/src/nuxt/types.ts
@@ -3,24 +3,24 @@ export interface ModuleOptions {
* Prefix for components (e.g. 'Re' -> )
* @default 'Re'
*/
- prefix?: string
+ prefix?: string;
/**
* Whether to auto inject theme.css
* @default true
*/
- injectTheme?: boolean
+ injectTheme?: boolean;
/** Storage key shared by the SSR initialization script and `useTheme`. */
- themeStorageKey?: string
+ themeStorageKey?: string;
/** Apply saved theme attributes before Vue hydrates to prevent a color-mode flash. */
- injectThemeScript?: boolean
+ injectThemeScript?: boolean;
}
export const defaultOptions: ModuleOptions = {
- prefix: 'Re',
+ prefix: "Re",
injectTheme: true,
- themeStorageKey: 'recloud-theme',
- injectThemeScript: true
-}
+ themeStorageKey: "recloud-theme",
+ injectThemeScript: true,
+};
diff --git a/packages/ui/src/theme.css b/packages/ui/src/theme.css
index 049e050..834bbcb 100644
--- a/packages/ui/src/theme.css
+++ b/packages/ui/src/theme.css
@@ -19,7 +19,7 @@
--rc-radius-lg: 0.75rem;
--rc-shadow-none: none;
--rc-shadow-sm: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 1px rgb(15 23 42 / 0.04);
- --rc-shadow-md: 0 4px 10px rgb(15 23 42 / 0.10), 0 2px 4px rgb(15 23 42 / 0.05);
+ --rc-shadow-md: 0 4px 10px rgb(15 23 42 / 0.1), 0 2px 4px rgb(15 23 42 / 0.05);
--rc-shadow-lg: 0 16px 32px rgb(15 23 42 / 0.14), 0 6px 12px rgb(15 23 42 / 0.08);
/* Semantic tokens */
@@ -68,7 +68,14 @@
--brand-300: #417bdf;
--brand-600: #3069c9;
--brand-700: #1e63ce;
- --brand-gradient: linear-gradient(180deg, var(--brand-50) 0%, var(--brand-100) 25%, var(--brand-200) 50%, var(--brand-300) 75%, var(--brand-600) 100%);
+ --brand-gradient: linear-gradient(
+ 180deg,
+ var(--brand-50) 0%,
+ var(--brand-100) 25%,
+ var(--brand-200) 50%,
+ var(--brand-300) 75%,
+ var(--brand-600) 100%
+ );
--brand-gradient-compact: linear-gradient(180deg, var(--brand-50), var(--brand-600));
--brand-gradient-card: linear-gradient(180deg, var(--brand-200), var(--brand-600));
@@ -101,7 +108,7 @@
--border-subtle: var(--border-muted);
}
-[data-theme='dark'] {
+[data-theme="dark"] {
--background: #090e17;
--foreground: #f8fafc;
--card: #0f172a;
@@ -147,26 +154,99 @@
}
/* Palette values only override semantics; component APIs remain stable. */
-[data-theme-palette='ocean'] { --primary: #0284c7; --primary-hover: #0369a1; --ring: #0284c7; --info: #0284c7; }
-[data-theme-palette='violet'] { --primary: #7c3aed; --primary-hover: #6d28d9; --ring: #7c3aed; --info: #7c3aed; }
-[data-theme-palette='slate'] { --primary: #334155; --primary-hover: #1e293b; --ring: #475569; --info: #475569; }
-[data-theme='dark'][data-theme-palette='ocean'] { --primary: #38bdf8; --primary-hover: #7dd3fc; --ring: #38bdf8; --info: #38bdf8; }
-[data-theme='dark'][data-theme-palette='violet'] { --primary: #a78bfa; --primary-hover: #c4b5fd; --ring: #a78bfa; --info: #a78bfa; }
-[data-theme='dark'][data-theme-palette='slate'] { --primary: #cbd5e1; --primary-hover: #e2e8f0; --ring: #cbd5e1; --info: #cbd5e1; }
+[data-theme-palette="ocean"] {
+ --primary: #0284c7;
+ --primary-hover: #0369a1;
+ --ring: #0284c7;
+ --info: #0284c7;
+}
+[data-theme-palette="violet"] {
+ --primary: #7c3aed;
+ --primary-hover: #6d28d9;
+ --ring: #7c3aed;
+ --info: #7c3aed;
+}
+[data-theme-palette="slate"] {
+ --primary: #334155;
+ --primary-hover: #1e293b;
+ --ring: #475569;
+ --info: #475569;
+}
+[data-theme="dark"][data-theme-palette="ocean"] {
+ --primary: #38bdf8;
+ --primary-hover: #7dd3fc;
+ --ring: #38bdf8;
+ --info: #38bdf8;
+}
+[data-theme="dark"][data-theme-palette="violet"] {
+ --primary: #a78bfa;
+ --primary-hover: #c4b5fd;
+ --ring: #a78bfa;
+ --info: #a78bfa;
+}
+[data-theme="dark"][data-theme-palette="slate"] {
+ --primary: #cbd5e1;
+ --primary-hover: #e2e8f0;
+ --ring: #cbd5e1;
+ --info: #cbd5e1;
+}
-[data-theme-density='compact'] { --rc-control-height: 2rem; --rc-space-scale: 0.875; }
-[data-theme-density='comfortable'] { --rc-control-height: 2.5rem; --rc-space-scale: 1; }
-[data-theme-density='spacious'] { --rc-control-height: 2.75rem; --rc-space-scale: 1.125; }
-[data-theme-radius='none'] { --radius-sm: var(--rc-radius-none); --radius-md: var(--rc-radius-none); --radius-lg: var(--rc-radius-none); }
-[data-theme-radius='sm'] { --radius-sm: 0.25rem; --radius-md: var(--rc-radius-sm); --radius-lg: var(--rc-radius-md); }
-[data-theme-radius='lg'] { --radius-sm: var(--rc-radius-md); --radius-md: var(--rc-radius-lg); --radius-lg: 1rem; }
-[data-theme-shadow='none'] { --shadow-xs: none; --shadow-sm: none; --shadow-md: none; --shadow-lg: none; --shadow-xl: none; }
-[data-theme-shadow='md'] { --shadow-xs: var(--rc-shadow-md); --shadow-sm: var(--rc-shadow-md); --shadow-md: var(--rc-shadow-md); --shadow-lg: var(--rc-shadow-lg); --shadow-xl: var(--rc-shadow-lg); }
-[data-theme-shadow='lg'] { --shadow-xs: var(--rc-shadow-lg); --shadow-sm: var(--rc-shadow-lg); --shadow-md: var(--rc-shadow-lg); --shadow-lg: var(--rc-shadow-lg); --shadow-xl: var(--rc-shadow-lg); }
+[data-theme-density="compact"] {
+ --rc-control-height: 2rem;
+ --rc-space-scale: 0.875;
+}
+[data-theme-density="comfortable"] {
+ --rc-control-height: 2.5rem;
+ --rc-space-scale: 1;
+}
+[data-theme-density="spacious"] {
+ --rc-control-height: 2.75rem;
+ --rc-space-scale: 1.125;
+}
+[data-theme-radius="none"] {
+ --radius-sm: var(--rc-radius-none);
+ --radius-md: var(--rc-radius-none);
+ --radius-lg: var(--rc-radius-none);
+}
+[data-theme-radius="sm"] {
+ --radius-sm: 0.25rem;
+ --radius-md: var(--rc-radius-sm);
+ --radius-lg: var(--rc-radius-md);
+}
+[data-theme-radius="lg"] {
+ --radius-sm: var(--rc-radius-md);
+ --radius-md: var(--rc-radius-lg);
+ --radius-lg: 1rem;
+}
+[data-theme-shadow="none"] {
+ --shadow-xs: none;
+ --shadow-sm: none;
+ --shadow-md: none;
+ --shadow-lg: none;
+ --shadow-xl: none;
+}
+[data-theme-shadow="md"] {
+ --shadow-xs: var(--rc-shadow-md);
+ --shadow-sm: var(--rc-shadow-md);
+ --shadow-md: var(--rc-shadow-md);
+ --shadow-lg: var(--rc-shadow-lg);
+ --shadow-xl: var(--rc-shadow-lg);
+}
+[data-theme-shadow="lg"] {
+ --shadow-xs: var(--rc-shadow-lg);
+ --shadow-sm: var(--rc-shadow-lg);
+ --shadow-md: var(--rc-shadow-lg);
+ --shadow-lg: var(--rc-shadow-lg);
+ --shadow-xl: var(--rc-shadow-lg);
+}
@theme {
- --font-sans: 'Plus Jakarta Sans Variable', 'Plus Jakarta Sans', 'Segoe UI', -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans SC', 'Noto Sans CJK SC', sans-serif;
- --font-mono: 'JetBrains Mono', 'Sarasa Mono CL', 'Maple Mono NF CN', 'Noto Sans Mono CJK SC', ui-monospace, monospace;
+ --font-sans:
+ "Plus Jakarta Sans Variable", "Plus Jakarta Sans", "Segoe UI", -apple-system, "PingFang SC",
+ "Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC", "Noto Sans CJK SC", sans-serif;
+ --font-mono:
+ "JetBrains Mono", "Sarasa Mono CL", "Maple Mono NF CN", "Noto Sans Mono CJK SC", ui-monospace,
+ monospace;
--color-primary-50: var(--rc-blue-50);
--color-primary-100: var(--rc-blue-100);
--color-primary-200: var(--rc-blue-200);
@@ -196,9 +276,22 @@
--color-ui-error: var(--destructive);
}
-:where(html) { font-family: var(--font-sans); }
-:where(code, kbd, pre, samp) { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
-:where(.rc-tabular-nums, table, [data-numeric]) { font-variant-numeric: tabular-nums; }
-.rc-gradient-vertical { background-image: var(--brand-gradient); }
-.rc-gradient-compact { background-image: var(--brand-gradient-compact); }
-.rc-gradient-card { background-image: var(--brand-gradient-card); }
+:where(html) {
+ font-family: var(--font-sans);
+}
+:where(code, kbd, pre, samp) {
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+}
+:where(.rc-tabular-nums, table, [data-numeric]) {
+ font-variant-numeric: tabular-nums;
+}
+.rc-gradient-vertical {
+ background-image: var(--brand-gradient);
+}
+.rc-gradient-compact {
+ background-image: var(--brand-gradient-compact);
+}
+.rc-gradient-card {
+ background-image: var(--brand-gradient-card);
+}
diff --git a/packages/ui/src/utils/cn.test.ts b/packages/ui/src/utils/cn.test.ts
index 6fc4b6e..6932099 100644
--- a/packages/ui/src/utils/cn.test.ts
+++ b/packages/ui/src/utils/cn.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { cn } from "./cn";
describe("cn", () => {
- it("merges conflicting Tailwind classes", () => {
- expect(cn("px-2", false, "px-4", "text-sm")).toBe("px-4 text-sm");
- });
+ it("merges conflicting Tailwind classes", () => {
+ expect(cn("px-2", false, "px-4", "text-sm")).toBe("px-4 text-sm");
+ });
});
diff --git a/packages/ui/src/utils/theme.ts b/packages/ui/src/utils/theme.ts
index c6ec8b0..3ec8843 100644
--- a/packages/ui/src/utils/theme.ts
+++ b/packages/ui/src/utils/theme.ts
@@ -24,7 +24,8 @@ export const DARK_COLORS = {
} as const;
export const BRAND_GRADIENTS = {
- vertical: "linear-gradient(180deg, #C8E0FD 0%, #9BC5FE 25%, #70ACFE 50%, #417BDF 75%, #3069C9 100%)",
+ vertical:
+ "linear-gradient(180deg, #C8E0FD 0%, #9BC5FE 25%, #70ACFE 50%, #417BDF 75%, #3069C9 100%)",
compact: "linear-gradient(180deg, #C8E0FD 0%, #3069C9 100%)",
card: "linear-gradient(180deg, #70ACFE 0%, #3069C9 100%)",
} as const;
diff --git a/packages/ui/src/utils/themeScript.ts b/packages/ui/src/utils/themeScript.ts
index 6439418..1835e02 100644
--- a/packages/ui/src/utils/themeScript.ts
+++ b/packages/ui/src/utils/themeScript.ts
@@ -1,10 +1,20 @@
-import type { ThemePreferences } from '../composables/useTheme'
+import type { ThemePreferences } from "../composables/useTheme";
/**
* Inline this before application markup to apply persisted theme attributes before Vue hydrates.
* Nuxt users receive it automatically through the module when `injectTheme` is enabled.
*/
-export function createThemeInitScript(storageKey = 'recloud-theme', defaults: Partial = {}): string {
- const fallback = JSON.stringify({ mode: 'system', palette: 'recloud', density: 'comfortable', radius: 'md', shadow: 'sm', ...defaults })
- return `(function(){try{var fallback=${fallback};var saved=JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)})||'{}');var p=Object.assign({},fallback,saved);var mode=p.mode==='auto'?'system':p.mode;var dark=mode==='dark'||(mode==='system'&&matchMedia('(prefers-color-scheme: dark)').matches);var root=document.documentElement;root.classList.toggle('dark',dark);root.dataset.theme=dark?'dark':'light';root.dataset.themeMode=mode;root.dataset.themePalette=p.palette;root.dataset.themeDensity=p.density;root.dataset.themeRadius=p.radius;root.dataset.themeShadow=p.shadow}catch(e){}})();`
+export function createThemeInitScript(
+ storageKey = "recloud-theme",
+ defaults: Partial = {},
+): string {
+ const fallback = JSON.stringify({
+ mode: "system",
+ palette: "recloud",
+ density: "comfortable",
+ radius: "md",
+ shadow: "sm",
+ ...defaults,
+ });
+ return `(function(){try{var fallback=${fallback};var saved=JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)})||'{}');var p=Object.assign({},fallback,saved);var mode=p.mode==='auto'?'system':p.mode;var dark=mode==='dark'||(mode==='system'&&matchMedia('(prefers-color-scheme: dark)').matches);var root=document.documentElement;root.classList.toggle('dark',dark);root.dataset.theme=dark?'dark':'light';root.dataset.themeMode=mode;root.dataset.themePalette=p.palette;root.dataset.themeDensity=p.density;root.dataset.themeRadius=p.radius;root.dataset.themeShadow=p.shadow}catch(e){}})();`;
}
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 32cb8cd..5f4fc9d 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -16,6 +16,7 @@ export default defineConfig({
entry: {
index: resolve(__dirname, "src/index.ts"),
"icons/index": resolve(__dirname, "src/icons/index.ts"),
+ "markdown/index": resolve(__dirname, "src/markdown/index.ts"),
"nuxt/index": resolve(__dirname, "src/nuxt/index.ts"),
},
formats: ["es"],
@@ -26,9 +27,16 @@ export default defineConfig({
"@nuxt/kit",
"reka-ui",
"lucide-vue-next",
+ /^node:/,
/^shiki(\/.*)?$/,
"tailwind-merge",
"clsx",
+ "unified",
+ "remark-parse",
+ "remark-gfm",
+ "remark-directive",
+ "unist-util-visit",
+ "mdast-util-to-string",
],
output: {
chunkFileNames: "chunks/[name]-[hash].js",
diff --git a/turbo.json b/turbo.json
index f8960c5..adaa9cd 100644
--- a/turbo.json
+++ b/turbo.json
@@ -6,7 +6,6 @@
"outputs": ["dist/**", ".output/**", ".nuxt/**"]
},
"dev": {
- "dependsOn": ["^build"],
"cache": false,
"persistent": true
},