From da825d256af96f0891b99d7fea68effc453ee3e0 Mon Sep 17 00:00:00 2001
From: RhenCloud
Date: Mon, 5 Oct 2026 21:41:53 +0800
Subject: [PATCH 01/17] feat(code-block): lazy loading, line numbers, a11y
Consolidate language metadata into a single registry with grammar loaders, load shiki grammars on demand with retryable failures, and keep the initial bundle free of grammar code.
Add an accessible scroll region with live copy announcements, gutter-based line numbers, collapse animation with reduced-motion support, and a scroll-aware fade for max-height limits.
Replace the removed languageIcons module with languages.ts and cover the registry and component contracts with vitest.
---
.changeset/lazy-code-blocks-improve.md | 5 +
.../src/components/code-block/CodeBlock.vue | 147 +++++------
.../components/code-block/codeBlock.test.ts | 52 ++++
.../src/components/code-block/highlighter.ts | 63 +++++
.../ui/src/components/code-block/index.ts | 7 +-
.../code-block/languageIcons.test.ts | 52 ----
.../components/code-block/languageIcons.ts | 126 ----------
.../components/code-block/languages.test.ts | 130 ++++++++++
.../ui/src/components/code-block/languages.ts | 231 ++++++++++++++++++
packages/ui/src/locale/messages.ts | 9 +
10 files changed, 570 insertions(+), 252 deletions(-)
create mode 100644 .changeset/lazy-code-blocks-improve.md
create mode 100644 packages/ui/src/components/code-block/codeBlock.test.ts
create mode 100644 packages/ui/src/components/code-block/highlighter.ts
delete mode 100644 packages/ui/src/components/code-block/languageIcons.test.ts
delete mode 100644 packages/ui/src/components/code-block/languageIcons.ts
create mode 100644 packages/ui/src/components/code-block/languages.test.ts
create mode 100644 packages/ui/src/components/code-block/languages.ts
diff --git a/.changeset/lazy-code-blocks-improve.md b/.changeset/lazy-code-blocks-improve.md
new file mode 100644
index 0000000..62531bf
--- /dev/null
+++ b/.changeset/lazy-code-blocks-improve.md
@@ -0,0 +1,5 @@
+---
+"@recloudstudio/ui": minor
+---
+
+Expand CodeBlock language support with on-demand syntax loading, resilient retries, accessible scrolling, adaptive line numbers, and smoother collapsed and height-limited states.
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 : '' }}
+
-
+
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..f141581
--- /dev/null
+++ b/packages/ui/src/markdown/compile.ts
@@ -0,0 +1,657 @@
+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, end] = 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/vite.config.ts b/packages/ui/vite.config.ts
index 82f24ff..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"],
@@ -30,6 +31,12 @@ export default defineConfig({
/^shiki(\/.*)?$/,
"tailwind-merge",
"clsx",
+ "unified",
+ "remark-parse",
+ "remark-gfm",
+ "remark-directive",
+ "unist-util-visit",
+ "mdast-util-to-string",
],
output: {
chunkFileNames: "chunks/[name]-[hash].js",
From 2db047519dc79cf77230cc7350163808f9874c1b Mon Sep 17 00:00:00 2001
From: RhenCloud
Date: Mon, 5 Oct 2026 23:31:08 +0800
Subject: [PATCH 09/17] docs(playground): document Markdown renderer and
catalog
---
apps/playground/nuxt.config.ts | 71 ++--
apps/playground/pages/components/markdown.vue | 121 +++++++
apps/playground/utils/catalog.ts | 330 ++++++++++++------
3 files changed, 377 insertions(+), 145 deletions(-)
create mode 100644 apps/playground/pages/components/markdown.vue
diff --git a/apps/playground/nuxt.config.ts b/apps/playground/nuxt.config.ts
index a778484..91a5140 100644
--- a/apps/playground/nuxt.config.ts
+++ b/apps/playground/nuxt.config.ts
@@ -1,47 +1,47 @@
-import { fileURLToPath } from 'node:url'
-import { defineNuxtConfig } from 'nuxt/config'
-import tailwindcss from '@tailwindcss/vite'
-import { createThemeInitScript } from '../../packages/ui/src/utils/themeScript'
+import { fileURLToPath } from "node:url";
+import { defineNuxtConfig } from "nuxt/config";
+import tailwindcss from "@tailwindcss/vite";
+import { createThemeInitScript } from "../../packages/ui/src/utils/themeScript";
// Dev 直连源码:模块、组件、组合式函数与图标都指向 packages/ui/src,
// 源码改动即时 HMR,无需先构建 dist。生产构建仍消费 dist(与发布产物一致)。
-const isDev = process.env.NODE_ENV === 'development'
+const isDev = process.env.NODE_ENV === "development";
const uiSrc = (path: string) =>
- fileURLToPath(new URL(`../../packages/ui/src/${path}`, import.meta.url))
+ fileURLToPath(new URL(`../../packages/ui/src/${path}`, import.meta.url));
export default defineNuxtConfig({
// 固定 buildDir:Nuxt 4 生产构建会在 .nuxt 已存在时自动切到 node_modules/.cache,
// 导致 tsconfig extends 在干净环境(CI/Pages)与本地行为不一致
- buildDir: '.nuxt',
- css: ['~/src/style.css'],
+ buildDir: ".nuxt",
+ css: ["~/src/style.css"],
modules: [
[
// Dev 加载 Nuxt 模块源码,组件自动注册随之指向 packages/ui/src。
- isDev ? uiSrc('nuxt/index.ts') : '@recloudstudio/ui/nuxt',
- { prefix: '', injectTheme: false }
- ]
+ isDev ? uiSrc("nuxt/index.ts") : "@recloudstudio/ui/nuxt",
+ { prefix: "", injectTheme: false },
+ ],
],
app: {
head: {
- title: 'ReCloud UI · ReCloud Studio 设计系统',
+ title: "ReCloud UI · ReCloud Studio 设计系统",
script: [
{
// 首屏应用完整的主题偏好,避免 Vue hydration 前的颜色、密度与圆角闪烁。
- innerHTML: createThemeInitScript()
- }
+ innerHTML: createThemeInitScript(),
+ },
],
link: [
- { rel: 'icon', type: 'image/svg+xml', href: '/icon.svg' },
- { rel: 'apple-touch-icon', href: '/icon.svg' },
+ { rel: "icon", type: "image/svg+xml", href: "/icon.svg" },
+ { rel: "apple-touch-icon", href: "/icon.svg" },
// 全站字体:Maple Mono NF CN(OFL-1.1,v7.9),由 ZeoSeven FontsAPI 按 unicode-range 分包提供。
// 四个字重同属 "Maple Mono NF CN" 族,对应 font-normal / medium / semibold / bold。
- { rel: 'preconnect', href: 'https://fontsapi.zeoseven.com', crossorigin: '' },
- ...['main', 'medium', 'semi-bold', 'bold'].map((style) => ({
- rel: 'stylesheet' as const,
- href: `https://fontsapi.zeoseven.com/442/${style}/result.css`
- }))
- ]
- }
+ { rel: "preconnect", href: "https://fontsapi.zeoseven.com", crossorigin: "" },
+ ...["main", "medium", "semi-bold", "bold"].map((style) => ({
+ rel: "stylesheet" as const,
+ href: `https://fontsapi.zeoseven.com/442/${style}/result.css`,
+ })),
+ ],
+ },
},
vite: {
plugins: [tailwindcss()],
@@ -50,23 +50,24 @@ export default defineNuxtConfig({
resolve: {
// 子路径必须排在包名之前:字符串别名按前缀匹配,顺序即优先级。
alias: {
- '@recloudstudio/ui/icons': uiSrc('icons/index.ts'),
- '@recloudstudio/ui': uiSrc('index.ts')
- }
+ "@recloudstudio/ui/icons": uiSrc("icons/index.ts"),
+ "@recloudstudio/ui/markdown": uiSrc("markdown/index.ts"),
+ "@recloudstudio/ui": uiSrc("index.ts"),
+ },
},
// Dev 下 SSR 同样走源码,避免服务端 external 到 dist 造成双实例。
- ssr: { noExternal: [/^@recloudstudio\/ui(\/.*)?$/] }
+ ssr: { noExternal: [/^@recloudstudio\/ui(\/.*)?$/] },
}
- : {})
+ : {}),
},
devtools: { enabled: true },
runtimeConfig: {
public: {
- cfPages: process.env.CF_PAGES || '',
- cfPagesBranch: process.env.CF_PAGES_BRANCH || '',
- cfPagesCommitSha: process.env.CF_PAGES_COMMIT_SHA || '',
- cfPagesUrl: process.env.CF_PAGES_URL || ''
- }
+ cfPages: process.env.CF_PAGES || "",
+ cfPagesBranch: process.env.CF_PAGES_BRANCH || "",
+ cfPagesCommitSha: process.env.CF_PAGES_COMMIT_SHA || "",
+ cfPagesUrl: process.env.CF_PAGES_URL || "",
+ },
},
- compatibilityDate: '2026-10-02'
-})
+ compatibilityDate: "2026-10-02",
+});
diff --git a/apps/playground/pages/components/markdown.vue b/apps/playground/pages/components/markdown.vue
new file mode 100644
index 0000000..689924a
--- /dev/null
+++ b/apps/playground/pages/components/markdown.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/playground/utils/catalog.ts b/apps/playground/utils/catalog.ts
index f2b4d4f..f66483d 100644
--- a/apps/playground/utils/catalog.ts
+++ b/apps/playground/utils/catalog.ts
@@ -1,160 +1,270 @@
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: "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: "落地页与产品首页主视觉横幅,支持居中、图文左右分栏及极简模式。",
+ },
+ ],
},
{
- 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,
+ };
}
From a1748e5241f27f1b6d353ac083115653cecbd9a7 Mon Sep 17 00:00:00 2001
From: RhenCloud
Date: Mon, 5 Oct 2026 23:31:18 +0800
Subject: [PATCH 10/17] fix(playground): remove max-width constraint on main
container
---
apps/playground/app.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/playground/app.vue b/apps/playground/app.vue
index 87a9eaa..6f98729 100644
--- a/apps/playground/app.vue
+++ b/apps/playground/app.vue
@@ -72,7 +72,7 @@
-
+
From 643e59fa88bd5f909ab9d932265acb0c890b2539 Mon Sep 17 00:00:00 2001
From: RhenCloud
Date: Mon, 5 Oct 2026 23:54:32 +0800
Subject: [PATCH 11/17] feat(ui): add Container, Section, and ThemeToggle
---
.changeset/calm-containers-toggle.md | 5 +
.../ui/src/components/container/Container.vue | 32 +++++
.../components/container/container.test.ts | 21 +++
packages/ui/src/components/container/index.ts | 2 +
packages/ui/src/components/container/types.ts | 29 ++++
.../ui/src/components/section/Section.vue | 85 +++++++++++
packages/ui/src/components/section/index.ts | 2 +
.../ui/src/components/section/section.test.ts | 24 ++++
packages/ui/src/components/section/types.ts | 59 ++++++++
.../components/theme-toggle/ThemeToggle.vue | 134 ++++++++++++++++++
.../ui/src/components/theme-toggle/index.ts | 2 +
.../theme-toggle/themeToggle.test.ts | 26 ++++
.../ui/src/components/theme-toggle/types.ts | 38 +++++
packages/ui/src/index.ts | 3 +
14 files changed, 462 insertions(+)
create mode 100644 .changeset/calm-containers-toggle.md
create mode 100644 packages/ui/src/components/container/Container.vue
create mode 100644 packages/ui/src/components/container/container.test.ts
create mode 100644 packages/ui/src/components/container/index.ts
create mode 100644 packages/ui/src/components/container/types.ts
create mode 100644 packages/ui/src/components/section/Section.vue
create mode 100644 packages/ui/src/components/section/index.ts
create mode 100644 packages/ui/src/components/section/section.test.ts
create mode 100644 packages/ui/src/components/section/types.ts
create mode 100644 packages/ui/src/components/theme-toggle/ThemeToggle.vue
create mode 100644 packages/ui/src/components/theme-toggle/index.ts
create mode 100644 packages/ui/src/components/theme-toggle/themeToggle.test.ts
create mode 100644 packages/ui/src/components/theme-toggle/types.ts
diff --git a/.changeset/calm-containers-toggle.md b/.changeset/calm-containers-toggle.md
new file mode 100644
index 0000000..f64d186
--- /dev/null
+++ b/.changeset/calm-containers-toggle.md
@@ -0,0 +1,5 @@
+---
+"@recloudstudio/ui": minor
+---
+
+Add Container, Section, and ThemeToggle components for unified responsive page layouts and ready-to-use theme toggles.
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/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/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("
-
+
点击切换语言环境,观察 Pagination、EmptyState、DataTable、FileUpload 与 TreeView 的文案即时响应。
@@ -98,7 +98,7 @@
-
+
使用方式
diff --git a/apps/playground/pages/components/markdown.vue b/apps/playground/pages/components/markdown.vue
index 689924a..f9137d2 100644
--- a/apps/playground/pages/components/markdown.vue
+++ b/apps/playground/pages/components/markdown.vue
@@ -1,11 +1,11 @@
-
-
-
+
-
-
+
-
+
diff --git a/apps/playground/pages/components/multi-select.vue b/apps/playground/pages/components/multi-select.vue
index e72a6cd..5c7a814 100644
--- a/apps/playground/pages/components/multi-select.vue
+++ b/apps/playground/pages/components/multi-select.vue
@@ -1,17 +1,17 @@
-
+
-
+
已选择:{{ regions.length ? regions.join('、') : '无' }}
-
+
-
+
-
+
-
+
diff --git a/apps/playground/pages/components/nav-tree.vue b/apps/playground/pages/components/nav-tree.vue
index bb5e053..16d5a58 100644
--- a/apps/playground/pages/components/nav-tree.vue
+++ b/apps/playground/pages/components/nav-tree.vue
@@ -1,8 +1,8 @@
diff --git a/apps/playground/pages/components/navbar.vue b/apps/playground/pages/components/navbar.vue
index 7def88a..a257e94 100644
--- a/apps/playground/pages/components/navbar.vue
+++ b/apps/playground/pages/components/navbar.vue
@@ -1,8 +1,8 @@
diff --git a/apps/playground/pages/components/navigation-menu.vue b/apps/playground/pages/components/navigation-menu.vue
index 82856da..0edfa46 100644
--- a/apps/playground/pages/components/navigation-menu.vue
+++ b/apps/playground/pages/components/navigation-menu.vue
@@ -1,8 +1,8 @@
-
+
-
+
{{ item.label }} 面板
-
+
-
+
diff --git a/apps/playground/pages/components/number-field.vue b/apps/playground/pages/components/number-field.vue
index 47d9fbb..3758cfb 100644
--- a/apps/playground/pages/components/number-field.vue
+++ b/apps/playground/pages/components/number-field.vue
@@ -1,15 +1,15 @@
diff --git a/apps/playground/pages/components/pagination.vue b/apps/playground/pages/components/pagination.vue
index 0fa1ff5..bd523c6 100644
--- a/apps/playground/pages/components/pagination.vue
+++ b/apps/playground/pages/components/pagination.vue
@@ -1,13 +1,13 @@
-
+
-
+
第 {{ page }} / 13 页
-
+
-
+
diff --git a/apps/playground/pages/components/pin-input.vue b/apps/playground/pages/components/pin-input.vue
index 403e7ae..92ba372 100644
--- a/apps/playground/pages/components/pin-input.vue
+++ b/apps/playground/pages/components/pin-input.vue
@@ -1,8 +1,8 @@
-
+
-
+
@@ -14,9 +14,9 @@
OTP 值:{{ otp.join('') || '—' }}
-
+
-
+
diff --git a/apps/playground/pages/components/popover.vue b/apps/playground/pages/components/popover.vue
index 2b41239..3aff2a8 100644
--- a/apps/playground/pages/components/popover.vue
+++ b/apps/playground/pages/components/popover.vue
@@ -1,8 +1,8 @@
-
+
-
+
@@ -13,9 +13,9 @@
Burst 20,000
-
+
-
+
diff --git a/apps/playground/pages/components/prev-next.vue b/apps/playground/pages/components/prev-next.vue
index c106f31..b9a083f 100644
--- a/apps/playground/pages/components/prev-next.vue
+++ b/apps/playground/pages/components/prev-next.vue
@@ -1,26 +1,26 @@
diff --git a/apps/playground/pages/components/progress.vue b/apps/playground/pages/components/progress.vue
index 7d0e8ad..4197f4d 100644
--- a/apps/playground/pages/components/progress.vue
+++ b/apps/playground/pages/components/progress.vue
@@ -1,17 +1,17 @@
diff --git a/apps/playground/pages/components/radio-group.vue b/apps/playground/pages/components/radio-group.vue
index 78e2630..11d8443 100644
--- a/apps/playground/pages/components/radio-group.vue
+++ b/apps/playground/pages/components/radio-group.vue
@@ -1,16 +1,16 @@
diff --git a/apps/playground/pages/components/rating.vue b/apps/playground/pages/components/rating.vue
index a8f8899..76cba20 100644
--- a/apps/playground/pages/components/rating.vue
+++ b/apps/playground/pages/components/rating.vue
@@ -1,8 +1,8 @@
-
+
-
+
@@ -13,9 +13,9 @@
客户满意度(只读)
-
+
-
+
diff --git a/apps/playground/pages/components/resizable-panel.vue b/apps/playground/pages/components/resizable-panel.vue
index 83f337c..1211258 100644
--- a/apps/playground/pages/components/resizable-panel.vue
+++ b/apps/playground/pages/components/resizable-panel.vue
@@ -1,15 +1,15 @@
-
+
-
+
部署日志
左侧宽度:{{ panelSize }}px
-
+
-
+
diff --git a/apps/playground/pages/components/scroll-area.vue b/apps/playground/pages/components/scroll-area.vue
index 97d412e..ef9a891 100644
--- a/apps/playground/pages/components/scroll-area.vue
+++ b/apps/playground/pages/components/scroll-area.vue
@@ -1,8 +1,8 @@
diff --git a/apps/playground/pages/components/search-input.vue b/apps/playground/pages/components/search-input.vue
index 366a26f..63dd086 100644
--- a/apps/playground/pages/components/search-input.vue
+++ b/apps/playground/pages/components/search-input.vue
@@ -1,11 +1,11 @@
-
- 提交 (@submit): {{ lastSubmit || '(无)' }}
-
+
-
-
+
-
-
+
- ({{ selectedItem.category }})
-
+
-
+
diff --git a/apps/playground/pages/components/section.vue b/apps/playground/pages/components/section.vue
index 98ee87b..9b012d7 100644
--- a/apps/playground/pages/components/section.vue
+++ b/apps/playground/pages/components/section.vue
@@ -1,11 +1,11 @@
-
-
-
+
-
-
+
-
+
diff --git a/apps/playground/pages/components/select.vue b/apps/playground/pages/components/select.vue
index cc9815c..e88b9f3 100644
--- a/apps/playground/pages/components/select.vue
+++ b/apps/playground/pages/components/select.vue
@@ -1,8 +1,8 @@
diff --git a/apps/playground/pages/components/separator.vue b/apps/playground/pages/components/separator.vue
index e2606d7..66975a8 100644
--- a/apps/playground/pages/components/separator.vue
+++ b/apps/playground/pages/components/separator.vue
@@ -1,8 +1,8 @@
-
+
-
+
基础信息
@@ -17,9 +17,9 @@
丢包 0%
-
+
-
+
diff --git a/apps/playground/pages/components/sheet.vue b/apps/playground/pages/components/sheet.vue
index dcb6bc6..ee309e5 100644
--- a/apps/playground/pages/components/sheet.vue
+++ b/apps/playground/pages/components/sheet.vue
@@ -1,8 +1,8 @@
-
+
-
+
@@ -19,9 +19,9 @@
-
+
-
+
diff --git a/apps/playground/pages/components/skeleton.vue b/apps/playground/pages/components/skeleton.vue
index 502ce90..bd3cac0 100644
--- a/apps/playground/pages/components/skeleton.vue
+++ b/apps/playground/pages/components/skeleton.vue
@@ -1,8 +1,8 @@
-
+
-
+
@@ -13,9 +13,9 @@
-
+
-
+
diff --git a/apps/playground/pages/components/slider.vue b/apps/playground/pages/components/slider.vue
index adebcf1..a09f966 100644
--- a/apps/playground/pages/components/slider.vue
+++ b/apps/playground/pages/components/slider.vue
@@ -1,8 +1,8 @@
-
+
-
+
@@ -13,9 +13,9 @@
-
+
-
+
diff --git a/apps/playground/pages/components/spinner.vue b/apps/playground/pages/components/spinner.vue
index e3ec1d4..e18d99b 100644
--- a/apps/playground/pages/components/spinner.vue
+++ b/apps/playground/pages/components/spinner.vue
@@ -1,17 +1,17 @@
-
+
-
+
校验中…
部署中…
同步中…
-
+
-
+
diff --git a/apps/playground/pages/components/stepper.vue b/apps/playground/pages/components/stepper.vue
index 77666f2..23da7a2 100644
--- a/apps/playground/pages/components/stepper.vue
+++ b/apps/playground/pages/components/stepper.vue
@@ -1,8 +1,8 @@
-
+
-
+
-
+
-
+
diff --git a/apps/playground/pages/components/steps.vue b/apps/playground/pages/components/steps.vue
index 33e7e06..542afdb 100644
--- a/apps/playground/pages/components/steps.vue
+++ b/apps/playground/pages/components/steps.vue
@@ -1,8 +1,8 @@
-
+
-
+
在工程根目录运行:
@@ -16,9 +16,9 @@
例如:引入 。
-
+
-
+
diff --git a/apps/playground/pages/components/switch.vue b/apps/playground/pages/components/switch.vue
index 26f6c8f..80d2449 100644
--- a/apps/playground/pages/components/switch.vue
+++ b/apps/playground/pages/components/switch.vue
@@ -1,17 +1,17 @@
diff --git a/apps/playground/pages/components/tabs.vue b/apps/playground/pages/components/tabs.vue
index 24bb89e..1ccbb52 100644
--- a/apps/playground/pages/components/tabs.vue
+++ b/apps/playground/pages/components/tabs.vue
@@ -1,8 +1,8 @@
diff --git a/apps/playground/pages/components/tag-input.vue b/apps/playground/pages/components/tag-input.vue
index e4cbccf..807a1d7 100644
--- a/apps/playground/pages/components/tag-input.vue
+++ b/apps/playground/pages/components/tag-input.vue
@@ -1,15 +1,15 @@
-
+
-
+
当前值:{{ tags.join(', ') || '—' }}
-
+
-
+
-
+
-
+
diff --git a/apps/playground/pages/components/textarea.vue b/apps/playground/pages/components/textarea.vue
index 6a38fc8..2ad8c89 100644
--- a/apps/playground/pages/components/textarea.vue
+++ b/apps/playground/pages/components/textarea.vue
@@ -1,12 +1,12 @@
diff --git a/apps/playground/pages/components/theme-toggle.vue b/apps/playground/pages/components/theme-toggle.vue
index ec97af9..6e7d3e5 100644
--- a/apps/playground/pages/components/theme-toggle.vue
+++ b/apps/playground/pages/components/theme-toggle.vue
@@ -1,11 +1,11 @@
-
-
-
+
-
点击查看 tooltip 或 aria-label 提示的三态轮换
-
+
-
-
+
-
+
diff --git a/apps/playground/pages/components/timeline.vue b/apps/playground/pages/components/timeline.vue
index 62a156a..768ed0f 100644
--- a/apps/playground/pages/components/timeline.vue
+++ b/apps/playground/pages/components/timeline.vue
@@ -1,15 +1,15 @@
-
-
+
+
-
-
+
+
{{ index + 1 }}
-
-
+
+
diff --git a/apps/playground/pages/components/toast.vue b/apps/playground/pages/components/toast.vue
index bc4a019..0df7afa 100644
--- a/apps/playground/pages/components/toast.vue
+++ b/apps/playground/pages/components/toast.vue
@@ -1,17 +1,17 @@
-
+
-
+
-
+
-
+
diff --git a/apps/playground/pages/components/toc.vue b/apps/playground/pages/components/toc.vue
index 2598e8e..7288bab 100644
--- a/apps/playground/pages/components/toc.vue
+++ b/apps/playground/pages/components/toc.vue
@@ -1,8 +1,8 @@
-
+
-
+
1. 系统介绍
@@ -24,9 +24,9 @@
-
+
-
+
diff --git a/apps/playground/pages/components/toggle.vue b/apps/playground/pages/components/toggle.vue
index c2cecef..d249dca 100644
--- a/apps/playground/pages/components/toggle.vue
+++ b/apps/playground/pages/components/toggle.vue
@@ -1,8 +1,8 @@
diff --git a/apps/playground/pages/components/toolbar.vue b/apps/playground/pages/components/toolbar.vue
index a902146..1608d8a 100644
--- a/apps/playground/pages/components/toolbar.vue
+++ b/apps/playground/pages/components/toolbar.vue
@@ -1,8 +1,8 @@
-
+
-
+
@@ -19,9 +19,9 @@
仅供屏幕阅读器的工具条说明
-
+
-
+
diff --git a/apps/playground/pages/components/tooltip.vue b/apps/playground/pages/components/tooltip.vue
index 49fccf6..128809b 100644
--- a/apps/playground/pages/components/tooltip.vue
+++ b/apps/playground/pages/components/tooltip.vue
@@ -1,17 +1,17 @@
-
+
-
+
-
+
-
+
diff --git a/apps/playground/pages/components/tree-view.vue b/apps/playground/pages/components/tree-view.vue
index 0aa9005..4278e31 100644
--- a/apps/playground/pages/components/tree-view.vue
+++ b/apps/playground/pages/components/tree-view.vue
@@ -1,12 +1,12 @@
-
- {{ expandedResources.join(', ') || '无' }}
-
+
- {{ selectedPods.join(', ') || '未选择' }}
-
+
-
-
+
-
已授权项数量:{{ grants.length }}
-
+
-
+
diff --git a/apps/playground/pages/components/visually-hidden.vue b/apps/playground/pages/components/visually-hidden.vue
index b4a97ee..6e69677 100644
--- a/apps/playground/pages/components/visually-hidden.vue
+++ b/apps/playground/pages/components/visually-hidden.vue
@@ -1,16 +1,16 @@
-
+
-
+
正在同步集群配置,请勿刷新页面
(上方文本对屏幕阅读器可见,视觉上隐藏)
-
+
-
+
diff --git a/apps/playground/pages/index.vue b/apps/playground/pages/index.vue
index f038431..a182269 100644
--- a/apps/playground/pages/index.vue
+++ b/apps/playground/pages/index.vue
@@ -34,13 +34,13 @@
-
-
+
+
-
+
组件总览
按使用场景分组,点击进入每个组件的文档与交互示例。
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/bun.lock b/bun.lock
index 0b74a14..342e247 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": {
@@ -1811,6 +1812,8 @@
"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=="],
@@ -1893,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=="],
@@ -1909,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/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.test.ts b/packages/ui/src/components/code-block/codeBlock.test.ts
index 387a73f..0051e5e 100644
--- a/packages/ui/src/components/code-block/codeBlock.test.ts
+++ b/packages/ui/src/components/code-block/codeBlock.test.ts
@@ -1,52 +1,52 @@
-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('./CodeBlock.vue', import.meta.url)), 'utf8')
+const source = readFileSync(fileURLToPath(new URL("./CodeBlock.vue", import.meta.url)), "utf8");
const highlighter = readFileSync(
- fileURLToPath(new URL('./highlighter.ts', import.meta.url)),
- 'utf8'
-)
+ fileURLToPath(new URL("./highlighter.ts", import.meta.url)),
+ "utf8",
+);
-describe('CodeBlock component contract', () => {
- it('keeps the actual scrolling region keyboard and screen-reader accessible', () => {
- expect(source).toContain(':tabindex="isCollapsed ? -1 : 0"')
- expect(source).toContain('role="region"')
- expect(source).toContain(':aria-label="regionLabel"')
- expect(source).toContain(':aria-hidden="isCollapsed || undefined"')
- expect(source).toContain('role="status" aria-live="polite"')
- expect(source).toContain('delete node.properties.tabindex')
- })
+describe("CodeBlock component contract", () => {
+ it("keeps the actual scrolling region keyboard and screen-reader accessible", () => {
+ expect(source).toContain(':tabindex="isCollapsed ? -1 : 0"');
+ expect(source).toContain('role="region"');
+ expect(source).toContain(':aria-label="regionLabel"');
+ expect(source).toContain(':aria-hidden="isCollapsed || undefined"');
+ expect(source).toContain('role="status" aria-live="polite"');
+ expect(source).toContain("delete node.properties.tabindex");
+ });
- it('uses adaptive line-number gutters that keep wrapped continuations out of the gutter', () => {
- expect(source).toContain('[padding-left:calc(2rem+var(--rc-ln-w))]')
- expect(source).toContain('[&_.line]:relative')
- expect(source).toContain('before:left-[calc(-1*(var(--rc-ln-w)+1rem))]')
- expect(source).toContain("style['--rc-ln-w']")
- expect(source).toContain('max(1.5rem, ')
- })
+ it("uses adaptive line-number gutters that keep wrapped continuations out of the gutter", () => {
+ expect(source).toContain("[padding-left:calc(2rem+var(--rc-ln-w))]");
+ expect(source).toContain("[&_.line]:relative");
+ expect(source).toContain("before:left-[calc(-1*(var(--rc-ln-w)+1rem))]");
+ expect(source).toContain("style['--rc-ln-w']");
+ expect(source).toContain("max(1.5rem, ");
+ });
- it('animates collapsible content without moving the header border', () => {
- expect(source).toContain('border-b px-3.5')
- expect(source).toContain("isCollapsed.value ? 'border-transparent'")
- expect(source).toContain("isCollapsed.value ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'")
- expect(source).toContain('transition-[grid-template-rows]')
- expect(source).not.toContain('v-show="!isCollapsed"')
- })
+ it("animates collapsible content without moving the header border", () => {
+ expect(source).toContain("border-b px-3.5");
+ expect(source).toContain("isCollapsed.value ? 'border-transparent'");
+ expect(source).toContain("isCollapsed.value ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'");
+ expect(source).toContain("transition-[grid-template-rows]");
+ expect(source).not.toContain('v-show="!isCollapsed"');
+ });
- it('shows a scroll-aware fade only while max-height content remains below', () => {
- expect(source).toContain('@scroll.passive="syncBottomFade"')
- expect(source).toContain('element.scrollHeight - element.scrollTop - element.clientHeight > 4')
+ it("shows a scroll-aware fade only while max-height content remains below", () => {
+ expect(source).toContain('@scroll.passive="syncBottomFade"');
+ expect(source).toContain("element.scrollHeight - element.scrollTop - element.clientHeight > 4");
expect(source).toContain(
- '[mask-image:linear-gradient(to_bottom,#000_calc(100%_-_2.5rem),transparent)]'
- )
- })
+ "[mask-image:linear-gradient(to_bottom,#000_calc(100%_-_2.5rem),transparent)]",
+ );
+ });
- it('loads the core and individual grammars lazily with retryable caches', () => {
- expect(highlighter).toContain('langs: []')
- expect(highlighter).toContain('loadCodeBlockGrammar(grammar)')
- expect(highlighter).toContain('highlighter.loadLanguage(language)')
- expect(highlighter).toContain('highlighterPromise = undefined')
- expect(highlighter).toContain('grammarPromises.delete(grammar)')
- })
-})
+ it("loads the core and individual grammars lazily with retryable caches", () => {
+ expect(highlighter).toContain("langs: []");
+ expect(highlighter).toContain("loadCodeBlockGrammar(grammar)");
+ expect(highlighter).toContain("highlighter.loadLanguage(language)");
+ expect(highlighter).toContain("highlighterPromise = undefined");
+ expect(highlighter).toContain("grammarPromises.delete(grammar)");
+ });
+});
diff --git a/packages/ui/src/components/code-block/index.ts b/packages/ui/src/components/code-block/index.ts
index 45a7aa2..8c13198 100644
--- a/packages/ui/src/components/code-block/index.ts
+++ b/packages/ui/src/components/code-block/index.ts
@@ -1,8 +1,8 @@
-export { default as CodeBlock } from './CodeBlock.vue'
-export type { CodeBlockProps } from './types'
+export { default as CodeBlock } from "./CodeBlock.vue";
+export type { CodeBlockProps } from "./types";
export {
DEFAULT_CODE_ICON,
LANGUAGE_ICONS,
extractExtension,
- resolveCodeBlockIcon
-} from './languages'
+ resolveCodeBlockIcon,
+} from "./languages";
diff --git a/packages/ui/src/components/code-block/languages.test.ts b/packages/ui/src/components/code-block/languages.test.ts
index 26c9126..c00a496 100644
--- a/packages/ui/src/components/code-block/languages.test.ts
+++ b/packages/ui/src/components/code-block/languages.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it } from "vitest";
import {
CODE_BLOCK_GRAMMARS,
CODE_BLOCK_GRAMMAR_IDS,
@@ -7,124 +7,120 @@ import {
LANGUAGE_ICONS,
extractExtension,
resolveCodeBlockGrammar,
- resolveCodeBlockIcon
-} from './languages'
+ resolveCodeBlockIcon,
+} from "./languages";
const LEGACY_LANGUAGE_KEYS = [
- 'bash',
- 'css',
- 'docker',
- 'dockerfile',
- 'go',
- 'golang',
- 'html',
- 'javascript',
- 'js',
- 'json',
- 'jsx',
- 'log',
- 'markdown',
- 'md',
- 'py',
- 'python',
- 'rs',
- 'rust',
- 'sh',
- 'shell',
- 'shellscript',
- 'sql',
- 'toml',
- 'ts',
- 'tsx',
- 'typescript',
- 'vue',
- 'vue-html',
- 'yaml',
- 'yml',
- 'zsh'
-]
+ "bash",
+ "css",
+ "docker",
+ "dockerfile",
+ "go",
+ "golang",
+ "html",
+ "javascript",
+ "js",
+ "json",
+ "jsx",
+ "log",
+ "markdown",
+ "md",
+ "py",
+ "python",
+ "rs",
+ "rust",
+ "sh",
+ "shell",
+ "shellscript",
+ "sql",
+ "toml",
+ "ts",
+ "tsx",
+ "typescript",
+ "vue",
+ "vue-html",
+ "yaml",
+ "yml",
+ "zsh",
+];
-describe('CodeBlock languages', () => {
- it('extracts extensions from filenames correctly', () => {
- expect(extractExtension('App.vue')).toBe('vue')
- expect(extractExtension('config.test.ts')).toBe('ts')
- expect(extractExtension('style.min.css')).toBe('css')
- expect(extractExtension('Dockerfile')).toBe('dockerfile')
- expect(extractExtension('README.md')).toBe('md')
- expect(extractExtension('file-without-ext')).toBe('')
- expect(extractExtension('')).toBe('')
- })
+describe("CodeBlock languages", () => {
+ it("extracts extensions from filenames correctly", () => {
+ expect(extractExtension("App.vue")).toBe("vue");
+ expect(extractExtension("config.test.ts")).toBe("ts");
+ expect(extractExtension("style.min.css")).toBe("css");
+ expect(extractExtension("Dockerfile")).toBe("dockerfile");
+ expect(extractExtension("README.md")).toBe("md");
+ expect(extractExtension("file-without-ext")).toBe("");
+ expect(extractExtension("")).toBe("");
+ });
- it('resolves icons from canonical ids and aliases', () => {
- expect(resolveCodeBlockIcon(undefined, 'ts', undefined)).toBe(LANGUAGE_ICONS.ts)
- expect(resolveCodeBlockIcon(undefined, 'typescript', undefined)).toBe(
- LANGUAGE_ICONS.typescript
- )
- expect(resolveCodeBlockIcon(undefined, 'vue', undefined)).toBe(LANGUAGE_ICONS.vue)
- expect(resolveCodeBlockIcon(undefined, 'bash', undefined)).toBe(LANGUAGE_ICONS.bash)
- expect(resolveCodeBlockIcon(undefined, 'python', undefined)).toBe(LANGUAGE_ICONS.python)
- expect(resolveCodeBlockIcon(undefined, 'rust', undefined)).toBe(LANGUAGE_ICONS.rust)
- expect(resolveCodeBlockIcon(undefined, 'json', undefined)).toBe(LANGUAGE_ICONS.json)
- expect(resolveCodeBlockIcon(undefined, 'sql', undefined)).toBe(LANGUAGE_ICONS.sql)
- })
+ it("resolves icons from canonical ids and aliases", () => {
+ expect(resolveCodeBlockIcon(undefined, "ts", undefined)).toBe(LANGUAGE_ICONS.ts);
+ expect(resolveCodeBlockIcon(undefined, "typescript", undefined)).toBe(
+ LANGUAGE_ICONS.typescript,
+ );
+ expect(resolveCodeBlockIcon(undefined, "vue", undefined)).toBe(LANGUAGE_ICONS.vue);
+ expect(resolveCodeBlockIcon(undefined, "bash", undefined)).toBe(LANGUAGE_ICONS.bash);
+ expect(resolveCodeBlockIcon(undefined, "python", undefined)).toBe(LANGUAGE_ICONS.python);
+ expect(resolveCodeBlockIcon(undefined, "rust", undefined)).toBe(LANGUAGE_ICONS.rust);
+ expect(resolveCodeBlockIcon(undefined, "json", undefined)).toBe(LANGUAGE_ICONS.json);
+ expect(resolveCodeBlockIcon(undefined, "sql", undefined)).toBe(LANGUAGE_ICONS.sql);
+ });
- it('resolves icons from filename extensions when language is absent', () => {
- expect(resolveCodeBlockIcon(undefined, undefined, 'deploy.sh')).toBe(LANGUAGE_ICONS.sh)
- expect(resolveCodeBlockIcon(undefined, undefined, 'schema.prisma.sql')).toBe(
- LANGUAGE_ICONS.sql
- )
- expect(resolveCodeBlockIcon(undefined, undefined, 'config.yaml')).toBe(
- LANGUAGE_ICONS.yaml
- )
- expect(resolveCodeBlockIcon(undefined, undefined, 'Dockerfile')).toBe(
- LANGUAGE_ICONS.dockerfile
- )
- })
+ it("resolves icons from filename extensions when language is absent", () => {
+ expect(resolveCodeBlockIcon(undefined, undefined, "deploy.sh")).toBe(LANGUAGE_ICONS.sh);
+ expect(resolveCodeBlockIcon(undefined, undefined, "schema.prisma.sql")).toBe(
+ LANGUAGE_ICONS.sql,
+ );
+ expect(resolveCodeBlockIcon(undefined, undefined, "config.yaml")).toBe(LANGUAGE_ICONS.yaml);
+ expect(resolveCodeBlockIcon(undefined, undefined, "Dockerfile")).toBe(
+ LANGUAGE_ICONS.dockerfile,
+ );
+ });
- it('respects explicit icon overrides', () => {
- expect(resolveCodeBlockIcon(false, 'ts', 'main.ts')).toBeNull()
+ it("respects explicit icon overrides", () => {
+ expect(resolveCodeBlockIcon(false, "ts", "main.ts")).toBeNull();
- const customIcon = 'lucide:file-code'
- expect(resolveCodeBlockIcon(customIcon, 'ts', 'main.ts')).toBe(customIcon)
+ const customIcon = "lucide:file-code";
+ expect(resolveCodeBlockIcon(customIcon, "ts", "main.ts")).toBe(customIcon);
- expect(resolveCodeBlockIcon(true, 'unknown-lang-xyz', 'data.unknown')).toBe(
- DEFAULT_CODE_ICON
- )
- expect(resolveCodeBlockIcon(undefined, 'unknown-lang-xyz', 'data.unknown')).toBeNull()
- })
+ expect(resolveCodeBlockIcon(true, "unknown-lang-xyz", "data.unknown")).toBe(DEFAULT_CODE_ICON);
+ expect(resolveCodeBlockIcon(undefined, "unknown-lang-xyz", "data.unknown")).toBeNull();
+ });
- it('resolves aliases, Vue snippets and unknown languages to loadable grammars', () => {
- expect(resolveCodeBlockGrammar('ts', 'const value = 1')).toBe('typescript')
- expect(resolveCodeBlockGrammar('bash', 'echo hello')).toBe('shellscript')
- expect(resolveCodeBlockGrammar('dockerfile', 'FROM oven/bun')).toBe('docker')
- expect(resolveCodeBlockGrammar('vue', '')).toBe('vue')
- expect(resolveCodeBlockGrammar('vue', '')).toBe('vue-html')
- expect(resolveCodeBlockGrammar('unknown', 'plain text')).toBe('log')
- expect(resolveCodeBlockGrammar('', 'plain text')).toBe('log')
- })
+ it("resolves aliases, Vue snippets and unknown languages to loadable grammars", () => {
+ expect(resolveCodeBlockGrammar("ts", "const value = 1")).toBe("typescript");
+ expect(resolveCodeBlockGrammar("bash", "echo hello")).toBe("shellscript");
+ expect(resolveCodeBlockGrammar("dockerfile", "FROM oven/bun")).toBe("docker");
+ expect(resolveCodeBlockGrammar("vue", "")).toBe("vue");
+ expect(resolveCodeBlockGrammar("vue", "")).toBe("vue-html");
+ expect(resolveCodeBlockGrammar("unknown", "plain text")).toBe("log");
+ expect(resolveCodeBlockGrammar("", "plain text")).toBe("log");
+ });
- it('keeps every registered grammar paired with exactly one lazy loader', () => {
- expect([...CODE_BLOCK_GRAMMARS].sort()).toEqual([...CODE_BLOCK_GRAMMAR_IDS].sort())
- })
+ it("keeps every registered grammar paired with exactly one lazy loader", () => {
+ expect([...CODE_BLOCK_GRAMMARS].sort()).toEqual([...CODE_BLOCK_GRAMMAR_IDS].sort());
+ });
- it('loads a registered grammar in the format accepted by shiki', async () => {
- const language = loadCodeBlockGrammar('python')
- expect(language).not.toBeNull()
- if (!language) throw new Error('Python grammar loader is missing')
+ it("loads a registered grammar in the format accepted by shiki", async () => {
+ const language = loadCodeBlockGrammar("python");
+ expect(language).not.toBeNull();
+ if (!language) throw new Error("Python grammar loader is missing");
- const { createHighlighterCore } = await import('shiki/core')
- const { createJavaScriptRegexEngine } = await import('shiki/engine/javascript')
+ const { createHighlighterCore } = await import("shiki/core");
+ const { createJavaScriptRegexEngine } = await import("shiki/engine/javascript");
const highlighter = await createHighlighterCore({
themes: [],
langs: [],
- engine: createJavaScriptRegexEngine()
- })
- await highlighter.loadLanguage(language)
+ engine: createJavaScriptRegexEngine(),
+ });
+ await highlighter.loadLanguage(language);
- expect(highlighter.getLoadedLanguages()).toEqual(expect.arrayContaining(['python', 'py']))
- })
+ expect(highlighter.getLoadedLanguages()).toEqual(expect.arrayContaining(["python", "py"]));
+ });
- it('preserves every previously published language icon key', () => {
- expect(Object.keys(LANGUAGE_ICONS)).toEqual(expect.arrayContaining(LEGACY_LANGUAGE_KEYS))
- })
-})
+ it("preserves every previously published language icon key", () => {
+ expect(Object.keys(LANGUAGE_ICONS)).toEqual(expect.arrayContaining(LEGACY_LANGUAGE_KEYS));
+ });
+});
diff --git a/packages/ui/src/components/code-block/languages.ts b/packages/ui/src/components/code-block/languages.ts
index 4c4ebba..567ef9d 100644
--- a/packages/ui/src/components/code-block/languages.ts
+++ b/packages/ui/src/components/code-block/languages.ts
@@ -1,45 +1,45 @@
-import type { LanguageInput } from 'shiki/core'
+import type { LanguageInput } from "shiki/core";
-import type { IconSource } from '../icon'
+import type { IconSource } from "../icon";
/* -------------------------------------------------------------------------- */
/* Inline SVG icons */
/* -------------------------------------------------------------------------- */
-const ICON_TYPESCRIPT = ``
+const ICON_TYPESCRIPT = ``;
-const ICON_JAVASCRIPT = ``
+const ICON_JAVASCRIPT = ``;
-const ICON_VUE = ``
+const ICON_VUE = ``;
-const ICON_HTML = ``
+const ICON_HTML = ``;
-const ICON_CSS = ``
+const ICON_CSS = ``;
-const ICON_JSON = ``
+const ICON_JSON = ``;
-const ICON_SHELL = ``
+const ICON_SHELL = ``;
-const ICON_LOG = ``
+const ICON_LOG = ``;
-const ICON_PYTHON = ``
+const ICON_PYTHON = ``;
-const ICON_RUST = ``
+const ICON_RUST = ``;
-const ICON_GO = ``
+const ICON_GO = ``;
-const ICON_MARKDOWN = ``
+const ICON_MARKDOWN = ``;
-const ICON_YAML = ``
+const ICON_YAML = ``;
-const ICON_TOML = ``
+const ICON_TOML = ``;
-const ICON_SQL = ``
+const ICON_SQL = ``;
-const ICON_DOCKER = ``
+const ICON_DOCKER = ``;
/** Generic fallback shown when `icon` is `true` but no language can be matched. */
-export const DEFAULT_CODE_ICON = ``
+export const DEFAULT_CODE_ICON = ``;
/* -------------------------------------------------------------------------- */
/* Single language registry */
@@ -47,11 +47,11 @@ export const DEFAULT_CODE_ICON = `