> = {
- text: {},
- 'github-stats': {
- username: 'octocat',
- name: 'The Octocat',
- publicRepositories: 42,
- followers: 4_321,
- following: 12,
- },
- 'github-langs': {
- languages: [
- { name: 'TypeScript', percentage: 54 },
- { name: 'JavaScript', percentage: 24 },
- { name: 'CSS', percentage: 14 },
- { name: 'Other', percentage: 8 },
- ],
- },
- 'github-commits': {
- username: 'octocat',
- commitsYear: 1_284,
- currentStreak: 12,
- longestStreak: 47,
- levels: Array.from({ length: 364 }, (_, day) => [0, 1, 2, 1, 3, 0, 4, 2, 1][(day * 7) % 9]),
- firstDayOfWeek: 0,
- },
- 'github-prs': { username: 'octocat', total: 214, merged: 176, open: 6, closed: 32 },
- 'github-status': {
- username: 'octocat',
- emoji: '🚀',
- message: 'Shipping new widgets',
- busy: false,
- },
- 'leetcode-stats': {
- username: 'your-profile',
- ranking: 18_240,
- contestRating: 1_726,
- solved: { all: 312, easy: 148, medium: 132, hard: 32 },
- },
-};
-
-const renderedData = (block: WidgetBlock, renderedBlocks?: RenderedBlock[]) =>
- renderedBlocks?.find((rendered) => rendered.id === block.id);
-
-const PreviewState = ({
- locale,
- source,
-}: {
- locale: WidgetLocale;
- source: 'GitHub' | 'LeetCode';
-}) => {
- const labels = widgetLabels(locale);
- return (
-
-
- @
-
-
- {labels.addUsername}
- {labels.addUsernameHint(source)}
-
-
- );
-};
-
-const WidgetBlockSkeleton = () => (
-
-);
-
-const numberFormatter = new Intl.NumberFormat('en-US');
-
-const formatNumber = (value: unknown, fallback = '—') =>
- typeof value === 'number' && Number.isFinite(value) ? numberFormatter.format(value) : fallback;
-
-type BlockData = {
- commitsYear?: number;
- currentStreak?: number;
- longestStreak?: number;
- levels?: number[];
- firstDayOfWeek?: number;
- total?: number;
- merged?: number;
- open?: number;
- closed?: number;
- emoji?: string | null;
- message?: string | null;
- busy?: boolean;
- username?: string;
- name?: string;
- avatarUrl?: string;
- publicRepositories?: number;
- followers?: number;
- following?: number;
- ranking?: number | null;
- contestRating?: number | null;
- solved?: { all?: number; easy?: number; medium?: number; hard?: number };
- languages?: { name: string; percentage: number }[];
-};
-
-type StatItem = { label: string; value: unknown };
-
-const StatsRow = ({ items, block }: { items: StatItem[]; block: WidgetBlock }) => {
- const { contentWidth, typography: t } = blockMetrics(getBlockLayout(block));
- const columnWidth = (contentWidth - t.columnGap * (items.length - 1)) / Math.max(items.length, 1);
- return (
-
- {items.map((item) => (
-
- {formatStatValue(item.value, columnWidth, t.statValue)}
- {item.label}
-
- ))}
-
- );
-};
-
-export const WidgetBlockContent = ({
- block,
- rendered,
- locale = 'en',
-}: {
- block: WidgetBlock;
- rendered?: RenderedBlock;
locale?: WidgetLocale;
-}) => {
- const labels = widgetLabels(locale);
- if (rendered?.error) return {rendered.error}
;
- const source = block.type.startsWith('github')
- ? 'GitHub'
- : block.type.startsWith('leetcode')
- ? 'LeetCode'
- : null;
- const username = typeof block.config.username === 'string' ? block.config.username.trim() : '';
- if (source && rendered?.data === undefined && !username) {
- return ;
- }
- if (source && rendered?.data === undefined && !rendered) {
- return ;
- }
- const data = (rendered?.data as BlockData | undefined) ?? (sampleData[block.type] as BlockData);
-
- if (block.type === 'text') {
- const align =
- block.config.align === 'center' || block.config.align === 'right'
- ? block.config.align
- : 'left';
- const text = typeof block.config.text === 'string' ? block.config.text.trim() : '';
- return (
-
- {text || labels.defaultText}
-
- );
- }
-
- if (block.type === 'github-stats') {
- const stats = [
- block.config.showRepositories !== false
- ? { label: labels.repositories, value: data.publicRepositories }
- : null,
- block.config.showFollowers !== false
- ? { label: labels.followers, value: data.followers }
- : null,
- block.config.showFollowing !== false
- ? { label: labels.following, value: data.following }
- : null,
- ].filter((item) => item !== null);
- return (
-
-
-
- {data.avatarUrl &&
}
-
-
- {data.name || labels.githubProfile}
- @{data.username || username || 'username'}
-
-
- {stats.length > 0 &&
}
-
- );
- }
-
- if (block.type === 'github-langs') {
- const languages = (data.languages ?? []).slice(0, 8);
- return (
-
-
- {labels.languages}
-
- {labels.top} {languages.length}
-
-
-
- {languages.map((language) => (
-
- ))}
-
-
- {languages.map((language) => (
-
-
- {language.name}
- {language.percentage}%
-
- ))}
-
-
- );
- }
-
- if (block.type === 'github-commits') {
- const stats = [
- { label: labels.commits, value: data.commitsYear },
- ...(block.config.showStreak !== false
- ? [
- { label: labels.currentStreak, value: data.currentStreak },
- { label: labels.longestStreak, value: data.longestStreak },
- ]
- : []),
- ];
- const columns = heatmapColumns(
- data.levels ?? [],
- heatmapWeeks(blockMetrics(getBlockLayout(block)).contentWidth),
- data.firstDayOfWeek ?? 0,
- );
- return (
-
-
- {labels.activity}
- @{data.username || username || 'username'}
-
-
- {block.config.showHeatmap !== false && (
-
- {columns.map((column, week) => (
-
- {column.map((level, weekday) => (
-
- ))}
-
- ))}
-
- )}
-
- );
- }
-
- if (block.type === 'github-prs') {
- const total = data.total ?? 0;
- const parts = [
- { key: 'merged', label: labels.merged, value: data.merged ?? 0 },
- { key: 'open', label: labels.open, value: data.open ?? 0 },
- { key: 'closed', label: labels.closed, value: data.closed ?? 0 },
- ];
- return (
-
-
- {labels.pullRequests}
- @{data.username || username || 'username'}
-
-
- {block.config.showBreakdown !== false && (
- <>
-
- {total > 0 &&
- parts.map((part) => (
-
- ))}
-
-
- {parts.map((part) => (
-
-
- {part.label}
- {total > 0 ? Math.round((part.value / total) * 100) : 0}%
-
- ))}
-
- >
- )}
-
- );
- }
-
- if (block.type === 'github-status') {
- return (
-
-
- {labels.status}
- @{data.username || username || 'username'}
-
- {!data.emoji && !data.message ? (
-
{labels.noStatus}
- ) : (
-
- {data.emoji &&
{data.emoji} }
- {data.message &&
{data.message}
}
- {data.busy &&
{labels.busy} }
-
- )}
-
- );
- }
-
- const solved = data.solved ?? {};
- const stats = [
- { label: labels.solved, value: solved.all },
- block.config.showRanking !== false ? { label: labels.ranking, value: data.ranking } : null,
- block.config.showContestRating !== false
- ? { label: labels.contestRating, value: data.contestRating }
- : null,
- ].filter((item) => item !== null);
- return (
-
-
- {labels.leetcodeProfile}
- @{data.username || username || 'username'}
-
-
-
-
- {labels.easy} {formatNumber(solved.easy, '0')}
-
-
- {labels.medium} {formatNumber(solved.medium, '0')}
-
-
- {labels.hard} {formatNumber(solved.hard, '0')}
-
-
-
- );
+ title?: string;
};
-type WidgetCanvasSkeletonProps = {
- locale?: WidgetLocale;
- width?: number;
- height?: number;
-};
-
-const skeletonLayouts: BlockLayout[] = [
- { x: 0, y: 0, width: 1, height: 1 },
- { x: 1, y: 0, width: 1, height: 1 },
-];
-
-export const WidgetCanvasSkeleton = ({
- locale = 'en',
- width = WIDGET_WIDTH,
- height = widgetDimensions(skeletonLayouts).height,
-}: WidgetCanvasSkeletonProps) => (
-
-
- {skeletonLayouts.map((layout) => (
-
-
-
- ))}
-
-
-);
-
/**
- * HTML widget renderer used by the editor, the public page and the iframe embed. It always
- * renders at the stored widget size; wrap it in `ScaledWidgetFrame` to fit smaller containers.
- * Geometry must stay in sync with the SVG export in `@shared/widget/WidgetCanvas`.
+ * The widget as shown on the public page and in iframes, at its stored size. Wrap it in
+ * `ScaledWidgetFrame` to fit smaller containers. Rendering is shared with the SVG export.
*/
export const WidgetCanvas = ({
blocks,
@@ -457,53 +35,51 @@ export const WidgetCanvas = ({
width = WIDGET_WIDTH,
height,
renderedBlocks,
- interactive = false,
- selectedBlockId,
- onSelectBlock,
locale = 'en',
-}: WidgetCanvasProps) => {
- const gridColumns = Math.max(1, Math.min(columns, MAX_GRID_COLUMNS));
- const layouts = blocks.map(getBlockLayout);
- const canvasHeight = height ?? widgetDimensions(layouts, width).height;
- const style = {
- ...canvasStyleVars(palette, { width, height: canvasHeight }),
- '--widget-columns': gridColumns,
- '--widget-rows': gridRows(layouts),
- } as CSSProperties;
+ title,
+}: WidgetCanvasProps) => (
+
+);
- const handleSelect = (event: MouseEvent, id: string) => {
- if (!interactive || !onSelectBlock) return;
- event.stopPropagation();
- onSelectBlock(id);
- };
+const skeletonLayouts: BlockLayout[] = [
+ { x: 0, y: 0, width: 1, height: 1 },
+ { x: 1, y: 0, width: 1, height: 1 },
+];
- return (
-
- {blocks.length === 0 ? (
-
{widgetLabels(locale).empty}
- ) : (
-
- {blocks.map((block, index) => (
-
handleSelect(event, block.id)}
- >
-
-
- ))}
-
- )}
-
- );
-};
+// Data blocks with a username and no data yet render as loading placeholders.
+const skeletonBlocks: WidgetBlock[] = skeletonLayouts.map((layout, index) => ({
+ id: `skeleton-${index}`,
+ type: 'github-stats',
+ position: index,
+ config: { username: 'loading', layout },
+}));
+
+export const WidgetCanvasSkeleton = ({
+ locale = 'en',
+ width = WIDGET_WIDTH,
+ height = widgetDimensions(skeletonLayouts).height,
+}: {
+ locale?: WidgetLocale;
+ width?: number;
+ height?: number;
+}) => (
+
+
+
+);
diff --git a/client/src/entities/widget/ui/WidgetSurface.module.css b/client/src/entities/widget/ui/WidgetSurface.module.css
new file mode 100644
index 0000000..b19561d
--- /dev/null
+++ b/client/src/entities/widget/ui/WidgetSurface.module.css
@@ -0,0 +1,20 @@
+.surface {
+ position: relative;
+ overflow: hidden;
+ border-radius: 28px;
+}
+
+.layer {
+ position: absolute;
+ inset: 0;
+ display: block;
+}
+
+.block {
+ position: absolute;
+}
+
+.blockSvg {
+ display: block;
+ overflow: visible;
+}
diff --git a/client/src/entities/widget/ui/WidgetSurface.tsx b/client/src/entities/widget/ui/WidgetSurface.tsx
new file mode 100644
index 0000000..93141e5
--- /dev/null
+++ b/client/src/entities/widget/ui/WidgetSurface.tsx
@@ -0,0 +1,126 @@
+import { useId, type CSSProperties, type ReactNode } from 'react';
+
+import type { PaletteId, PaletteMode, RenderedBlock, WidgetBlock } from '@/entities/widget/model';
+import {
+ BlockShell,
+ CanvasBackground,
+ CanvasEmptyState,
+ canvasBoxes,
+ canvasTokens,
+ type BlockBox,
+} from '@shared/widget/canvasParts';
+import { widgetLabels } from '@shared/widget/theme';
+import styles from '@/entities/widget/ui/WidgetSurface.module.css';
+
+export type SurfaceBlockSlot = { block: WidgetBlock; box: BlockBox; content: ReactNode };
+
+type WidgetSurfaceProps = {
+ blocks: WidgetBlock[];
+ renderedBlocks?: RenderedBlock[];
+ palette: PaletteId;
+ paletteMode?: PaletteMode;
+ width: number;
+ height: number;
+ columns?: number;
+ /** Row count override (the editor adds a spare drop row while dragging). */
+ rows?: number;
+ locale?: 'ru' | 'en';
+ emptyText?: string;
+ label?: string;
+ /** Wraps each positioned block, e.g. to add editor controls. Must return a keyed element. */
+ renderBlock?: (slot: SurfaceBlockSlot) => ReactNode;
+ /** Extra layers on top of the blocks (drop targets etc.). */
+ children?: ReactNode;
+};
+
+/**
+ * Browser rendering of a widget at its stored size, built from the same SVG parts as the
+ * `/image.svg` export: the background and each block are separate SVGs placed with the shared
+ * geometry, so the editor can attach DOM controls to individual blocks.
+ */
+export const WidgetSurface = ({
+ blocks,
+ renderedBlocks,
+ palette,
+ paletteMode = 'light',
+ width,
+ height,
+ columns,
+ rows,
+ locale = 'en',
+ emptyText,
+ label,
+ renderBlock,
+ children,
+}: WidgetSurfaceProps) => {
+ const idPrefix = `w${useId().replace(/[^a-z0-9]/gi, '')}`;
+ const tokens = canvasTokens(palette, paletteMode);
+ const boxes = canvasBoxes(blocks, { width, height, columns, rows });
+
+ return (
+
+
+
+ {blocks.length === 0 && (
+
+ )}
+
+ {blocks.map((block, index) => {
+ const box = boxes[index];
+ const content = (
+
+ item.id === block.id)}
+ box={{ x: 0, y: 0, width: box.width, height: box.height }}
+ tokens={tokens}
+ locale={locale}
+ idPrefix={`${idPrefix}-block`}
+ />
+
+ );
+ return renderBlock ? (
+ renderBlock({ block, box, content })
+ ) : (
+
+ {content}
+
+ );
+ })}
+ {children}
+
+ );
+};
diff --git a/client/src/pages/widget-editor/ui/EditorBlock.tsx b/client/src/pages/widget-editor/ui/EditorBlock.tsx
index f517fda..b5b4883 100644
--- a/client/src/pages/widget-editor/ui/EditorBlock.tsx
+++ b/client/src/pages/widget-editor/ui/EditorBlock.tsx
@@ -1,12 +1,10 @@
import { Grip, TrashBin } from '@gravity-ui/icons';
import { Icon } from '@gravity-ui/uikit';
-import type { PointerEvent } from 'react';
+import type { PointerEvent, ReactNode } from 'react';
-import { blockStyleVars, WidgetBlockContent } from '@/entities/widget';
-import type { RenderedBlock, WidgetBlock } from '@/entities/widget/model';
-import canvasStyles from '@/entities/widget/ui/WidgetCanvas.module.css';
+import type { WidgetBlock } from '@/entities/widget/model';
+import type { BlockBox } from '@shared/widget/canvasParts';
import { blockSizes, getLayout } from '@/pages/widget-editor/model/layout';
-import type { Locale } from '@/shared/locale/content';
import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css';
export type BlockPointerHandlers = {
@@ -18,8 +16,10 @@ export type BlockPointerHandlers = {
type EditorBlockProps = {
block: WidgetBlock;
- rendered?: RenderedBlock;
- locale: Locale;
+ /** Position inside the canvas, in widget pixels. */
+ box: BlockBox;
+ /** The block's SVG, rendered by WidgetSurface. */
+ children: ReactNode;
selected: boolean;
dragging: boolean;
removeLabel: string;
@@ -31,8 +31,8 @@ type EditorBlockProps = {
export const EditorBlock = ({
block,
- rendered,
- locale,
+ box,
+ children,
selected,
dragging,
removeLabel,
@@ -44,9 +44,9 @@ export const EditorBlock = ({
const layout = getLayout(block);
return (
))}
-
+ {children}
void;
};
-const gridStyle = { '--grid-cell-size': `${cellSize(WIDGET_WIDTH)}px` } as CSSProperties;
+const padding = canvasPadding(WIDGET_WIDTH);
/** The widget at its real size (scaled to fit), with drag, resize and selection controls. */
export const EditorCanvas = ({
@@ -47,10 +46,11 @@ export const EditorCanvas = ({
widget.blocks.length === 0
? widget.height
: widgetDimensions([{ x: 0, y: displayRows - 1, width: 1, height: 1 }]).height;
- const canvasStyle = {
- ...canvasStyleVars(widget.config.palette, { width: WIDGET_WIDTH }),
- '--widget-columns': MAX_COLUMNS,
- } as CSSProperties;
+ const cell = (x: number, y: number) =>
+ blockBox(
+ { x, y, width: 1, height: 1 },
+ { width: WIDGET_WIDTH, height: canvasHeight, rows: displayRows },
+ );
return (
- (
+
onSelectBlock(block.id)}
+ onRemove={() => onRemoveBlock(block.id)}
+ onResize={(width, height) => onResizeBlock(block.id, width, height)}
+ >
+ {content}
+
+ )}
>
- {widget.blocks.length === 0 ? (
-
- {locale === 'ru'
- ? 'Добавьте первый блок слева.'
- : 'Add your first block from the library.'}
-
- ) : (
-
-
- {draggingBlockId &&
- Array.from({ length: dropRows * MAX_COLUMNS }, (_, index) => {
- const x = index % MAX_COLUMNS;
- const y = Math.floor(index / MAX_COLUMNS);
- const isActive = dropCell?.x === x && dropCell.y === y;
- return (
-
- );
- })}
- {widget.blocks.map((block) => (
-
onSelectBlock(block.id)}
- onRemove={() => onRemoveBlock(block.id)}
- onResize={(width, height) => onResizeBlock(block.id, width, height)}
+ {/* Grid area used for pointer math; shows drop targets while dragging. */}
+
+ {draggingBlockId &&
+ Array.from({ length: dropRows * MAX_COLUMNS }, (_, index) => {
+ const x = index % MAX_COLUMNS;
+ const y = Math.floor(index / MAX_COLUMNS);
+ const box = cell(x, y);
+ const isActive = dropCell?.x === x && dropCell.y === y;
+ return (
+
- ))}
-
-
- )}
-
+ );
+ })}
+
+
);
};
diff --git a/client/src/pages/widget-editor/ui/WidgetEditorPage.module.css b/client/src/pages/widget-editor/ui/WidgetEditorPage.module.css
index e8ef168..48dbd97 100644
--- a/client/src/pages/widget-editor/ui/WidgetEditorPage.module.css
+++ b/client/src/pages/widget-editor/ui/WidgetEditorPage.module.css
@@ -208,11 +208,6 @@
padding: 8px 24px 32px;
}
-.editorSurface {
- display: flex;
- flex-direction: column;
-}
-
.gridCells,
.editorCanvas {
position: relative;
@@ -255,45 +250,31 @@
pointer-events: none;
}
-.gridLayoutHost {
- width: 100%;
- min-width: 0;
- overflow: visible;
-}
-.editorBlocks {
- --grid-cell-size: minmax(110px, auto);
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- grid-auto-rows: var(--grid-cell-size, minmax(110px, auto));
- align-items: stretch;
- gap: 18px;
- width: 100%;
- position: relative;
- z-index: 2;
- pointer-events: auto;
+/* Covers the grid area of the canvas (inside its padding); drop cells are placed absolutely. */
+.dropLayer {
+ position: absolute;
+ z-index: 1;
+ pointer-events: none;
}
.sortableBlock {
- position: relative;
- min-width: 0;
- height: 100%;
- min-height: 0;
- overflow: hidden;
+ position: absolute;
z-index: 2;
+ border-radius: 20px;
cursor: pointer;
+ transition: box-shadow 160ms ease;
+}
+
+.sortableBlock:hover,
+.selectedBlock {
+ box-shadow:
+ inset 0 0 0 1px color-mix(in srgb, var(--widget-accent) 66%, transparent),
+ 0 10px 30px color-mix(in srgb, var(--widget-accent) 12%, transparent);
}
.dropCell {
- min-width: 0;
- min-height: 0;
- aspect-ratio: 1;
+ position: absolute;
border: 1px dashed color-mix(in srgb, var(--widget-accent) 25%, transparent);
border-radius: 20px;
background: color-mix(in srgb, var(--widget-accent) 5%, transparent);
- cursor: copy;
- pointer-events: none;
- transition:
- background 150ms ease,
- border-color 150ms ease;
- z-index: 3;
}
.dropCellActive {
border-color: var(--widget-accent);
diff --git a/eslint.config.ts b/eslint.config.ts
index 51dc936..419579a 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -34,7 +34,7 @@ export default tseslint.config(
},
},
{
- files: ['server/**/*.ts', '*.config.js'],
+ files: ['server/**/*.ts', 'server/scripts/**/*.mjs', '*.config.js'],
languageOptions: {
globals: globals.node,
},
diff --git a/package-lock.json b/package-lock.json
index 556330f..7e8d3b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1347,44 +1347,6 @@
"url": "https://paulmillr.com/funding/"
}
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@@ -3106,33 +3068,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/anymatch": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
- "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/anymatch/node_modules/picomatch": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
- "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -3143,16 +3078,6 @@
"dequal": "^2.0.3"
}
},
- "node_modules/array-union": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
- "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
@@ -3236,19 +3161,6 @@
"require-from-string": "^2.0.2"
}
},
- "node_modules/binary-extensions": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
- "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/blueimp-md5": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
@@ -3293,9 +3205,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3305,19 +3217,6 @@
"node": "20 || >=22"
}
},
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.1.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -4068,19 +3967,6 @@
"wrappy": "1"
}
},
- "node_modules/dir-glob": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
- "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-type": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -4686,36 +4572,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.8"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4737,16 +4593,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fastq": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
- "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -4777,19 +4623,6 @@
"node": ">=16.0.0"
}
},
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -5044,19 +4877,6 @@
"node": ">= 0.4"
}
},
- "node_modules/get-tsconfig": {
- "version": "4.14.3",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz",
- "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "resolve-pkg-maps": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
- }
- },
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
@@ -5101,27 +4921,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/globby": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
- "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array-union": "^2.1.0",
- "dir-glob": "^3.0.1",
- "fast-glob": "^3.2.9",
- "ignore": "^5.2.0",
- "merge2": "^1.4.1",
- "slash": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -5343,9 +5142,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
- "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
+ "version": "10.7.3",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
+ "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -5360,19 +5159,6 @@
"node": ">= 0.10"
}
},
- "node_modules/is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "binary-extensions": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5412,16 +5198,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -6128,16 +5904,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -6148,33 +5914,6 @@
"node": ">= 0.6"
}
},
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=8.6"
- }
- },
- "node_modules/micromatch/node_modules/picomatch": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
- "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
@@ -6308,20 +6047,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
- "node_modules/mylas": {
- "version": "2.1.14",
- "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz",
- "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=16.0.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/raouldeheer"
- }
- },
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -6372,16 +6097,6 @@
"node": ">=18"
}
},
- "node_modules/normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/nypm": {
"version": "0.6.9",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz",
@@ -6597,16 +6312,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -6651,19 +6356,6 @@
"pathe": "^2.0.3"
}
},
- "node_modules/plimit-lit": {
- "version": "1.6.1",
- "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
- "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "queue-lit": "^1.5.1"
- },
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -6858,37 +6550,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/queue-lit": {
- "version": "1.5.2",
- "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz",
- "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -7166,16 +6827,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/resolve-pkg-maps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
- }
- },
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -7193,17 +6844,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -7265,30 +6905,6 @@
"node": ">= 18"
}
},
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -7536,16 +7152,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/slash": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
- "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/slice-ansi": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
@@ -7808,19 +7414,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -7879,102 +7472,6 @@
"typescript": ">=4.8.4"
}
},
- "node_modules/tsc-alias": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.9.2.tgz",
- "integrity": "sha512-VTWQGMv0xXCEyHDLpmV2DEvGYHMxwsyx87dZeou2ynkM0+WOFdHe+KWiRucavMPUEdQysr7xSu60Y/WY0R4YKA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chokidar": "^3.5.3",
- "commander": "^9.0.0",
- "get-tsconfig": "^4.10.0",
- "globby": "^11.0.4",
- "mylas": "^2.1.9",
- "normalize-path": "^3.0.0",
- "plimit-lit": "^1.2.6"
- },
- "bin": {
- "tsc-alias": "dist/bin/index.js"
- },
- "engines": {
- "node": ">=16.20.2"
- }
- },
- "node_modules/tsc-alias/node_modules/chokidar": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
- "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "engines": {
- "node": ">= 8.10.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/tsc-alias/node_modules/commander": {
- "version": "9.5.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
- "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || >=14"
- }
- },
- "node_modules/tsc-alias/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/tsc-alias/node_modules/picomatch": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
- "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/tsc-alias/node_modules/readdirp": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
- "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -8713,7 +8210,6 @@
"@types/supertest": "^6.0.3",
"prisma": "^6.19.0",
"supertest": "^7.1.4",
- "tsc-alias": "^1.9.1",
"tsx": "^4.21.0",
"vitest": "^4.0.16"
}
diff --git a/server/package.json b/server/package.json
index 98a6092..c1982d2 100644
--- a/server/package.json
+++ b/server/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
- "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
+ "build": "tsc -p tsconfig.build.json && node scripts/resolve-aliases.mjs",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --pretty false",
"test": "vitest run",
@@ -37,7 +37,6 @@
"@types/supertest": "^6.0.3",
"prisma": "^6.19.0",
"supertest": "^7.1.4",
- "tsc-alias": "^1.9.1",
"tsx": "^4.21.0",
"vitest": "^4.0.16"
}
diff --git a/server/scripts/resolve-aliases.mjs b/server/scripts/resolve-aliases.mjs
new file mode 100644
index 0000000..cbdc23c
--- /dev/null
+++ b/server/scripts/resolve-aliases.mjs
@@ -0,0 +1,47 @@
+// Rewrites the `@server/*` and `@shared/*` path aliases in compiled output to relative paths.
+// TypeScript leaves aliases as written, and Node can't resolve them at runtime. (Replaces
+// tsc-alias, whose dependency chain carried an unpatched `braces` advisory.)
+import { readdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const serverRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const outDir = path.join(serverRoot, 'dist', 'src');
+const aliases = { '@server/': outDir, '@shared/': path.join(outDir, 'shared') };
+
+// Static imports/exports and dynamic import() with a string literal.
+const specifier = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(@server\/|@shared\/)([^'"]+)\2/g;
+
+const filesIn = async (directory) => {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const nested = await Promise.all(
+ entries.map((entry) => {
+ const full = path.join(directory, entry.name);
+ return entry.isDirectory() ? filesIn(full) : [full];
+ }),
+ );
+ return nested.flat();
+};
+
+export const resolveAliases = (source, file) =>
+ source.replace(specifier, (_match, prefix, quote, alias, rest) => {
+ let relative = path.relative(path.dirname(file), path.join(aliases[alias], rest));
+ if (!relative.startsWith('.')) relative = `./${relative}`;
+ return `${prefix}${quote}${relative.split(path.sep).join('/')}${quote}`;
+ });
+
+const run = async () => {
+ const files = (await filesIn(outDir)).filter((file) => /\.(js|d\.ts)$/.test(file));
+ let changed = 0;
+ for (const file of files) {
+ const source = await readFile(file, 'utf8');
+ const output = resolveAliases(source, file);
+ if (output !== source) {
+ await writeFile(file, output);
+ changed += 1;
+ }
+ }
+ console.log(`resolve-aliases: rewrote ${changed} file(s)`);
+};
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) await run();
diff --git a/server/scripts/resolve-aliases.test.mjs b/server/scripts/resolve-aliases.test.mjs
new file mode 100644
index 0000000..5f6f086
--- /dev/null
+++ b/server/scripts/resolve-aliases.test.mjs
@@ -0,0 +1,33 @@
+import path from 'node:path';
+
+import { expect, it } from 'vitest';
+
+import { resolveAliases } from './resolve-aliases.mjs';
+
+const outDir = path.resolve('dist/src');
+
+it('rewrites server and shared aliases relative to the importing file', () => {
+ const file = path.join(outDir, 'services', 'widgetService.js');
+ const source = [
+ "import { prisma } from '@server/lib/prisma.js';",
+ "export { blockVariant } from '@shared/widget/blockSizes.js';",
+ "const theme = await import('@shared/widget/theme.js');",
+ "import 'node:crypto';",
+ ].join('\n');
+
+ expect(resolveAliases(source, file)).toBe(
+ [
+ "import { prisma } from '../lib/prisma.js';",
+ "export { blockVariant } from '../shared/widget/blockSizes.js';",
+ "const theme = await import('../shared/widget/theme.js');",
+ "import 'node:crypto';",
+ ].join('\n'),
+ );
+});
+
+it('uses ./ for files in the same directory', () => {
+ const file = path.join(outDir, 'app.js');
+ expect(resolveAliases("import { authRouter } from '@server/routes/auth.js';", file)).toBe(
+ "import { authRouter } from './routes/auth.js';",
+ );
+});
diff --git a/server/src/shared/widget/BlockContent.tsx b/server/src/shared/widget/BlockContent.tsx
new file mode 100644
index 0000000..6af77a2
--- /dev/null
+++ b/server/src/shared/widget/BlockContent.tsx
@@ -0,0 +1,569 @@
+// Content of one widget block, drawn in the block's content box (origin at its top-left).
+import {
+ BLOCK_LINE_HEIGHT,
+ HEATMAP_CELL,
+ HEATMAP_GAP,
+ TEXT_BLOCK_LINE_HEIGHT,
+ estimateTextWidth,
+ heatmapColumns,
+ heatmapWeeks,
+} from './geometry.js';
+import {
+ difficultyColors,
+ errorColor,
+ heatmapOpacity,
+ languageColor,
+ widgetLabels,
+ type PaletteTokens,
+ type WidgetLocale,
+} from './theme.js';
+import type { WidgetCanvasBlock, WidgetCanvasRenderedBlock } from './types.js';
+import {
+ MultilineText,
+ PreviewState,
+ StatsRow,
+ SvgText,
+ TitleRow,
+ asNumber,
+ asRecord,
+ asString,
+ baseline,
+ clamp,
+ fitText,
+ formatNumber,
+ imageUrl,
+ linesOf,
+ safeId,
+ statsRowHeight,
+ type BlockFrame,
+ type StatItem,
+} from './svgPrimitives.js';
+
+// Placeholder while live data loads (editor and public page; exports always have data).
+const BlockSkeleton = ({ frame, tokens }: { frame: BlockFrame; tokens: PaletteTokens }) => {
+ const line = (y: number, width: number, height = 10) => (
+
+ );
+ return (
+
+
+ {line(0, 0.68)}
+ {line(22, 0.46)}
+ {line(44, 0.3)}
+ {line(74, 0.26, 38)}
+
+ );
+};
+
+export const BlockContent = ({
+ block,
+ rendered,
+ tokens,
+ locale,
+ frame,
+ avatarDataUri,
+}: {
+ block: WidgetCanvasBlock;
+ rendered?: WidgetCanvasRenderedBlock;
+ tokens: PaletteTokens;
+ locale: WidgetLocale;
+ frame: BlockFrame;
+ avatarDataUri?: string;
+}) => {
+ const labels = widgetLabels(locale);
+ const { typography: t } = frame;
+ const config = asRecord(block.config);
+ const data = asRecord(rendered?.data);
+ const source = block.type.startsWith('github')
+ ? 'GitHub'
+ : block.type.startsWith('leetcode')
+ ? 'LeetCode'
+ : null;
+ const username = asString(config.username);
+
+ if (rendered?.error) {
+ return (
+
+ );
+ }
+
+ if (block.type === 'text') {
+ const align = config.align === 'center' || config.align === 'right' ? config.align : 'left';
+ const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
+ const x = align === 'center' ? frame.width / 2 : align === 'right' ? frame.width : 0;
+ const maxLines = Math.max(Math.floor(frame.height / (t.text * TEXT_BLOCK_LINE_HEIGHT)), 1);
+ return (
+
+ );
+ }
+
+ if (source && !username && !rendered?.data) {
+ return ;
+ }
+
+ if (!rendered?.data) return ;
+
+ if (block.type === 'github-stats') {
+ const avatarHref = avatarDataUri ?? imageUrl(data.avatarUrl);
+ const avatarId = `avatar-${safeId(block.id)}`;
+ const tightGap = clamp(frame.width / 100, 2, 3);
+ const textColumnHeight = t.heading * BLOCK_LINE_HEIGHT + tightGap + t.meta * BLOCK_LINE_HEIGHT;
+ const headingHeight = Math.max(t.avatar, textColumnHeight);
+ const avatarY = (headingHeight - t.avatar) / 2;
+ const textTop = (headingHeight - textColumnHeight) / 2;
+ const textX = t.avatar + t.headingGap;
+ const textWidth = frame.width - textX;
+ const stats = [
+ config.showRepositories !== false
+ ? { label: labels.repositories, value: data.publicRepositories }
+ : null,
+ config.showFollowers !== false ? { label: labels.followers, value: data.followers } : null,
+ config.showFollowing !== false ? { label: labels.following, value: data.following } : null,
+ ].filter((item): item is StatItem => item !== null);
+ return (
+ <>
+
+ {avatarHref && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ {stats.length > 0 && (
+
+ )}
+ >
+ );
+ }
+
+ if (block.type === 'github-langs') {
+ const languages = Array.isArray(data.languages)
+ ? data.languages
+ .filter((item): item is { name: string; percentage: number } => {
+ const value = asRecord(item);
+ return typeof value.name === 'string' && typeof value.percentage === 'number';
+ })
+ .slice(0, 8)
+ : [];
+ const barY = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const listTop = barY + 10 + t.sectionGap;
+ const columnWidth = (frame.width - t.columnGap) / 2;
+ const rowHeight = t.meta * BLOCK_LINE_HEIGHT;
+ const barClipId = `bar-${safeId(block.id)}`;
+ let barOffset = 0;
+ return (
+ <>
+
+
+
+
+
+
+ {languages.map((language) => {
+ const segmentWidth = (Math.max(language.percentage, 0) / 100) * frame.width;
+ const x = barOffset;
+ barOffset += segmentWidth;
+ return (
+
+ );
+ })}
+
+ {languages.map((language, index) => {
+ const x = (index % 2) * (columnWidth + t.columnGap);
+ const top = listTop + Math.floor(index / 2) * (rowHeight + t.rowGap);
+ const percent = `${language.percentage}%`;
+ return (
+
+
+
+
+
+ );
+ })}
+ >
+ );
+ }
+
+ if (block.type === 'github-commits') {
+ const stats: StatItem[] = [
+ { label: labels.commits, value: data.commitsYear },
+ ...(config.showStreak !== false
+ ? [
+ { label: labels.currentStreak, value: data.currentStreak },
+ { label: labels.longestStreak, value: data.longestStreak },
+ ]
+ : []),
+ ];
+ const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const heatmapTop = statsTop + statsRowHeight(t) + t.sectionGap;
+ const levels = Array.isArray(data.levels)
+ ? data.levels.filter((level): level is number => typeof level === 'number')
+ : [];
+ const columns = heatmapColumns(
+ levels,
+ heatmapWeeks(frame.width),
+ asNumber(data.firstDayOfWeek),
+ );
+ return (
+ <>
+
+
+ {config.showHeatmap !== false &&
+ columns.map((column, week) =>
+ column.map((level, weekday) =>
+ level === null ? null : (
+
+ ),
+ ),
+ )}
+ >
+ );
+ }
+
+ if (block.type === 'github-prs') {
+ const total = asNumber(data.total);
+ const parts = [
+ { label: labels.merged, value: asNumber(data.merged), fill: tokens.accent, opacity: 1 },
+ { label: labels.open, value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 },
+ { label: labels.closed, value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 },
+ ];
+ const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const barY = statsTop + statsRowHeight(t) + t.sectionGap;
+ const legendTop = barY + 10 + t.sectionGap;
+ const legendWidth = (frame.width - t.columnGap) / 2;
+ const legendRowHeight = t.meta * BLOCK_LINE_HEIGHT;
+ const barClipId = `prs-${safeId(block.id)}`;
+ let barOffset = 0;
+ return (
+ <>
+
+
+ {config.showBreakdown !== false && (
+ <>
+
+
+
+
+
+ {total > 0 &&
+ parts.map((part) => {
+ const width = (part.value / total) * frame.width;
+ const x = barOffset;
+ barOffset += width;
+ return (
+
+ );
+ })}
+
+ {parts.map((part, index) => {
+ // Same two-column list as the languages block: label left, percent right.
+ const x = (index % 2) * (legendWidth + t.columnGap);
+ const top = legendTop + Math.floor(index / 2) * (legendRowHeight + t.rowGap);
+ const percent = `${total > 0 ? Math.round((part.value / total) * 100) : 0}%`;
+ return (
+
+
+
+
+
+ );
+ })}
+ >
+ )}
+ >
+ );
+ }
+
+ if (block.type === 'github-status') {
+ const emoji = asString(data.emoji);
+ const message = asString(data.message);
+ const top = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const messageTop = emoji ? top + t.text * BLOCK_LINE_HEIGHT + t.headingGap : top;
+ const messageLines = message
+ ? linesOf(message, Math.floor(frame.width / (t.heading * 0.55)), 3)
+ : [];
+ const chipTop = messageTop + messageLines.length * t.heading * BLOCK_LINE_HEIGHT + t.headingGap;
+ const chipHeight = t.meta * BLOCK_LINE_HEIGHT + 8;
+ return (
+ <>
+
+ {!emoji && !message ? (
+
+ ) : (
+ <>
+ {emoji && (
+
+ )}
+
+ {data.busy === true && (
+
+
+
+
+ )}
+ >
+ )}
+ >
+ );
+ }
+
+ const solved = asRecord(data.solved);
+ const stats = [
+ { label: labels.solved, value: solved.all },
+ config.showRanking !== false ? { label: labels.ranking, value: data.ranking } : null,
+ config.showContestRating !== false
+ ? { label: labels.contestRating, value: data.contestRating }
+ : null,
+ ].filter((item): item is StatItem => item !== null);
+ const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const difficultyTop = statsTop + statsRowHeight(t) + t.sectionGap;
+ const difficultyWidth = (frame.width - t.columnGap * 2) / 3;
+ const difficulty = [
+ { label: labels.easy, value: solved.easy, color: difficultyColors.easy },
+ { label: labels.medium, value: solved.medium, color: difficultyColors.medium },
+ { label: labels.hard, value: solved.hard, color: difficultyColors.hard },
+ ];
+ return (
+ <>
+
+
+ {difficulty.map((item, index) => {
+ const x = index * (difficultyWidth + t.columnGap);
+ return (
+
+
+
+
+ );
+ })}
+ >
+ );
+};
diff --git a/server/src/shared/widget/WidgetCanvas.tsx b/server/src/shared/widget/WidgetCanvas.tsx
index f9a4871..98c028b 100644
--- a/server/src/shared/widget/WidgetCanvas.tsx
+++ b/server/src/shared/widget/WidgetCanvas.tsx
@@ -1,53 +1,18 @@
+// Widget as a single standalone SVG document (the `/image.svg` export).
+import { widgetLabels, type WidgetLocale } from './theme.js';
+import type { WidgetCanvasBlock, WidgetCanvasRenderedBlock } from './types.js';
import {
- BLOCK_BORDER,
- BLOCK_LINE_HEIGHT,
- BLOCK_PADDING,
- BLOCK_RADIUS,
- CANVAS_RADIUS,
- HEATMAP_CELL,
- HEATMAP_GAP,
- MAX_BLOCK_HEIGHT,
- MAX_GRID_COLUMNS,
- TEXT_BLOCK_LINE_HEIGHT,
- blockBox,
- blockContentWidth,
- blockTypography,
- estimateTextWidth,
- gridRows,
- heatmapColumns,
- heatmapWeeks,
- type BlockTypography,
- type GridLayout,
-} from './geometry.js';
-import {
- difficultyColors,
- errorColor,
- formatStatValue,
- heatmapOpacity,
- languageColor,
- paletteTokens,
- widgetLabels,
- type PaletteName,
- type PaletteTokens,
- type WidgetLocale,
-} from './theme.js';
+ BlockShell,
+ CanvasBackground,
+ CanvasEmptyState,
+ canvasBoxes,
+ canvasTokens,
+} from './canvasParts.js';
+import { clamp } from './svgPrimitives.js';
export { languageColor } from './theme.js';
-
-export type WidgetCanvasBlock = {
- id: string;
- type: string;
- position: number;
- config: unknown;
-};
-
-export type WidgetCanvasRenderedBlock = {
- id: string;
- type: string;
- position: number;
- data?: unknown;
- error?: string;
-};
+export { WIDGET_FONT_FAMILY } from './canvasParts.js';
+export type { WidgetCanvasBlock, WidgetCanvasRenderedBlock } from './types.js';
export type WidgetCanvasProps = {
title?: string;
@@ -64,826 +29,12 @@ export type WidgetCanvasProps = {
locale?: WidgetLocale;
};
-export const WIDGET_FONT_FAMILY =
- "Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif";
-
-const numberFormatter = new Intl.NumberFormat('en-US');
-
-const asRecord = (value: unknown): Record =>
- value && typeof value === 'object' ? (value as Record) : {};
-
-const asNumber = (value: unknown, fallback = 0) =>
- typeof value === 'number' && Number.isFinite(value) ? value : fallback;
-
-const asString = (value: unknown, fallback = '') =>
- typeof value === 'string' && value.trim() ? value.trim() : fallback;
-
-const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
-
-const mixHex = (left: string, right: string, leftWeight: number) => {
- const parse = (value: string) => Number.parseInt(value.replace('#', ''), 16);
- const leftValue = parse(left);
- const rightValue = parse(right);
- const weight = clamp(leftWeight, 0, 1);
- const channel = (shift: number) =>
- Math.round(
- ((leftValue >> shift) & 255) * weight + ((rightValue >> shift) & 255) * (1 - weight),
- );
- return `rgb(${channel(16)}, ${channel(8)}, ${channel(0)})`;
-};
-
-const formatNumber = (value: unknown, fallback = '—') =>
- typeof value === 'number' && Number.isFinite(value) ? numberFormatter.format(value) : fallback;
-
-const imageUrl = (value: unknown) => {
- const url = asString(value);
- return /^https?:\/\//i.test(url) ? url : null;
-};
-
-const safeId = (value: string) => value.replace(/[^a-z0-9_-]/gi, '-');
-
-const layoutOf = (block: WidgetCanvasBlock): GridLayout => {
- const value = asRecord(asRecord(block.config).layout);
- return {
- x: clamp(asNumber(value.x), 0, MAX_GRID_COLUMNS - 1),
- y: Math.max(0, asNumber(value.y, block.position)),
- width: clamp(asNumber(value.width, 1), 1, MAX_GRID_COLUMNS),
- height: clamp(asNumber(value.height, 1), 1, MAX_BLOCK_HEIGHT),
- };
-};
-
-// Inter vertical metrics: ascent 0.96875em, content area 1.2109em.
-const baseline = (top: number, size: number, lineHeight = BLOCK_LINE_HEIGHT) =>
- top + size * ((lineHeight - 1.2109) / 2 + 0.96875);
-
-// SVG has no text layout: cut text with an ellipsis where the HTML canvas would.
-const fitText = (value: string, width: number, size: number, weight = 500) => {
- if (estimateTextWidth(value, size, { weight }) <= width) return value;
- let end = value.length;
- while (end > 1 && estimateTextWidth(`${value.slice(0, end)}…`, size, { weight }) > width) {
- end -= 1;
- }
- return `${value.slice(0, end)}…`;
-};
-
-const linesOf = (value: string, maxLength: number, maxLines: number) => {
- const words = value.split(/\s+/).filter(Boolean);
- const lines: string[] = [];
- let current = '';
-
- for (const word of words) {
- const next = current ? `${current} ${word}` : word;
- if (current && next.length > maxLength) {
- lines.push(current);
- current = word;
- } else {
- current = next;
- }
- if (lines.length === maxLines) break;
- }
- if (lines.length < maxLines && current) lines.push(current);
- if (lines.length === maxLines && words.join(' ').length > lines.join(' ').length) {
- lines[maxLines - 1] = `${lines[maxLines - 1].slice(0, Math.max(maxLength - 1, 1))}…`;
- }
- return lines.length > 0 ? lines : [''];
-};
-
-type TextProps = {
- x: number;
- y: number;
- value: unknown;
- fill: string;
- size: number;
- weight?: number;
- anchor?: 'start' | 'middle' | 'end';
- opacity?: number;
- letterSpacing?: string;
-};
-
-const SvgText = ({
- x,
- y,
- value,
- fill,
- size,
- weight = 500,
- anchor = 'start',
- opacity,
- letterSpacing,
-}: TextProps) => (
-
- {String(value ?? '')}
-
-);
-
-const MultilineText = ({
- top,
- lineHeight,
- lines,
- ...text
-}: Omit & { top: number; lineHeight: number; lines: string[] }) => (
- <>
- {lines.map((line, index) => (
-
- ))}
- >
-);
-
-type BlockFrame = { width: number; height: number; typography: BlockTypography };
-
-type StatItem = { label: string; value: unknown };
-
-// Mirrors `.statsRow` / `.stat` in the HTML canvas: equal columns, value above label.
-const StatsRow = ({
- top,
- items,
- frame,
- tokens,
-}: {
- top: number;
- items: StatItem[];
- frame: BlockFrame;
- tokens: PaletteTokens;
-}) => {
- const { typography: t } = frame;
- const columnWidth = (frame.width - t.columnGap * (items.length - 1)) / Math.max(items.length, 1);
- const labelTop = top + t.statValue * BLOCK_LINE_HEIGHT + 3;
- return (
- <>
- {items.map((item, index) => {
- const x = index * (columnWidth + t.columnGap);
- return (
-
-
-
-
- );
- })}
- >
- );
-};
-
-const statsRowHeight = (t: BlockTypography) =>
- t.statValue * BLOCK_LINE_HEIGHT + 3 + t.meta * BLOCK_LINE_HEIGHT;
-
-// Mirrors `.blockTitleRow`: bold title on the left, muted meta on the right.
-const TitleRow = ({
- title,
- meta,
- frame,
- tokens,
-}: {
- title: string;
- meta: string;
- frame: BlockFrame;
- tokens: PaletteTokens;
-}) => {
- const { typography: t } = frame;
- const rowHeight = t.heading * BLOCK_LINE_HEIGHT;
- const metaTop = (rowHeight - t.meta * BLOCK_LINE_HEIGHT) / 2;
- // Like the HTML flex row: the title keeps its width, the meta shrinks (max 45%) with a gap of 12.
- const titleWidth = Math.min(estimateTextWidth(title, t.heading, { weight: 800 }), frame.width);
- const metaMaxWidth = Math.max(Math.min(frame.width * 0.45, frame.width - titleWidth - 12), 0);
- const metaText = fitText(meta, metaMaxWidth, t.meta);
- const metaWidth = metaText ? estimateTextWidth(metaText, t.meta) : 0;
- return (
- <>
-
-
- >
- );
-};
-
-// Mirrors `.previewState` in the HTML canvas.
-const PreviewState = ({
- source,
- frame,
- tokens,
- locale,
-}: {
- source: 'GitHub' | 'LeetCode';
- frame: BlockFrame;
- tokens: PaletteTokens;
- locale: WidgetLocale;
-}) => {
- const labels = widgetLabels(locale);
- const boxHeight = 76;
- return (
-
-
-
-
-
-
-
- );
-};
-
-const BlockContent = ({
- block,
- rendered,
- tokens,
- locale,
- frame,
- avatarDataUri,
-}: {
- block: WidgetCanvasBlock;
- rendered?: WidgetCanvasRenderedBlock;
- tokens: PaletteTokens;
- locale: WidgetLocale;
- frame: BlockFrame;
- avatarDataUri?: string;
-}) => {
- const labels = widgetLabels(locale);
- const { typography: t } = frame;
- const config = asRecord(block.config);
- const data = asRecord(rendered?.data);
- const source = block.type.startsWith('github')
- ? 'GitHub'
- : block.type.startsWith('leetcode')
- ? 'LeetCode'
- : null;
- const username = asString(config.username);
-
- if (rendered?.error) {
- return (
-
- );
- }
-
- if (block.type === 'text') {
- const align = config.align === 'center' || config.align === 'right' ? config.align : 'left';
- const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
- const x = align === 'center' ? frame.width / 2 : align === 'right' ? frame.width : 0;
- const maxLines = Math.max(Math.floor(frame.height / (t.text * TEXT_BLOCK_LINE_HEIGHT)), 1);
- return (
-
- );
- }
-
- if (source && !username && !rendered?.data) {
- return ;
- }
-
- if (!rendered?.data) {
- return (
-
- );
- }
-
- if (block.type === 'github-stats') {
- const avatarHref = avatarDataUri ?? imageUrl(data.avatarUrl);
- const avatarId = `avatar-${safeId(block.id)}`;
- const tightGap = clamp(frame.width / 100, 2, 3);
- const textColumnHeight = t.heading * BLOCK_LINE_HEIGHT + tightGap + t.meta * BLOCK_LINE_HEIGHT;
- const headingHeight = Math.max(t.avatar, textColumnHeight);
- const avatarY = (headingHeight - t.avatar) / 2;
- const textTop = (headingHeight - textColumnHeight) / 2;
- const textX = t.avatar + t.headingGap;
- const textWidth = frame.width - textX;
- const stats = [
- config.showRepositories !== false
- ? { label: labels.repositories, value: data.publicRepositories }
- : null,
- config.showFollowers !== false ? { label: labels.followers, value: data.followers } : null,
- config.showFollowing !== false ? { label: labels.following, value: data.following } : null,
- ].filter((item): item is StatItem => item !== null);
- return (
- <>
-
- {avatarHref && (
- <>
-
-
-
-
- >
- )}
-
-
- {stats.length > 0 && (
-
- )}
- >
- );
- }
-
- if (block.type === 'github-langs') {
- const languages = Array.isArray(data.languages)
- ? data.languages
- .filter((item): item is { name: string; percentage: number } => {
- const value = asRecord(item);
- return typeof value.name === 'string' && typeof value.percentage === 'number';
- })
- .slice(0, 8)
- : [];
- const barY = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const listTop = barY + 10 + t.sectionGap;
- const columnWidth = (frame.width - t.columnGap) / 2;
- const rowHeight = t.meta * BLOCK_LINE_HEIGHT;
- const barClipId = `bar-${safeId(block.id)}`;
- let barOffset = 0;
- return (
- <>
-
-
-
-
-
-
- {languages.map((language) => {
- const segmentWidth = (Math.max(language.percentage, 0) / 100) * frame.width;
- const x = barOffset;
- barOffset += segmentWidth;
- return (
-
- );
- })}
-
- {languages.map((language, index) => {
- const x = (index % 2) * (columnWidth + t.columnGap);
- const top = listTop + Math.floor(index / 2) * (rowHeight + t.rowGap);
- const percent = `${language.percentage}%`;
- return (
-
-
-
-
-
- );
- })}
- >
- );
- }
-
- if (block.type === 'github-commits') {
- const stats: StatItem[] = [
- { label: labels.commits, value: data.commitsYear },
- ...(config.showStreak !== false
- ? [
- { label: labels.currentStreak, value: data.currentStreak },
- { label: labels.longestStreak, value: data.longestStreak },
- ]
- : []),
- ];
- const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const heatmapTop = statsTop + statsRowHeight(t) + t.sectionGap;
- const levels = Array.isArray(data.levels)
- ? data.levels.filter((level): level is number => typeof level === 'number')
- : [];
- const columns = heatmapColumns(
- levels,
- heatmapWeeks(frame.width),
- asNumber(data.firstDayOfWeek),
- );
- return (
- <>
-
-
- {config.showHeatmap !== false &&
- columns.map((column, week) =>
- column.map((level, weekday) =>
- level === null ? null : (
-
- ),
- ),
- )}
- >
- );
- }
-
- if (block.type === 'github-prs') {
- const total = asNumber(data.total);
- const parts = [
- { label: labels.merged, value: asNumber(data.merged), fill: tokens.accent, opacity: 1 },
- { label: labels.open, value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 },
- { label: labels.closed, value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 },
- ];
- const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const barY = statsTop + statsRowHeight(t) + t.sectionGap;
- const legendTop = barY + 10 + t.sectionGap;
- const legendWidth = (frame.width - t.columnGap) / 2;
- const legendRowHeight = t.meta * BLOCK_LINE_HEIGHT;
- const barClipId = `prs-${safeId(block.id)}`;
- let barOffset = 0;
- return (
- <>
-
-
- {config.showBreakdown !== false && (
- <>
-
-
-
-
-
- {total > 0 &&
- parts.map((part) => {
- const width = (part.value / total) * frame.width;
- const x = barOffset;
- barOffset += width;
- return (
-
- );
- })}
-
- {parts.map((part, index) => {
- // Same two-column list as the languages block: label left, percent right.
- const x = (index % 2) * (legendWidth + t.columnGap);
- const top = legendTop + Math.floor(index / 2) * (legendRowHeight + t.rowGap);
- const percent = `${total > 0 ? Math.round((part.value / total) * 100) : 0}%`;
- return (
-
-
-
-
-
- );
- })}
- >
- )}
- >
- );
- }
-
- if (block.type === 'github-status') {
- const emoji = asString(data.emoji);
- const message = asString(data.message);
- const top = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const messageTop = emoji ? top + t.text * BLOCK_LINE_HEIGHT + t.headingGap : top;
- const messageLines = message
- ? linesOf(message, Math.floor(frame.width / (t.heading * 0.55)), 3)
- : [];
- const chipTop = messageTop + messageLines.length * t.heading * BLOCK_LINE_HEIGHT + t.headingGap;
- const chipHeight = t.meta * BLOCK_LINE_HEIGHT + 8;
- return (
- <>
-
- {!emoji && !message ? (
-
- ) : (
- <>
- {emoji && (
-
- )}
-
- {data.busy === true && (
-
-
-
-
- )}
- >
- )}
- >
- );
- }
-
- const solved = asRecord(data.solved);
- const stats = [
- { label: labels.solved, value: solved.all },
- config.showRanking !== false ? { label: labels.ranking, value: data.ranking } : null,
- config.showContestRating !== false
- ? { label: labels.contestRating, value: data.contestRating }
- : null,
- ].filter((item): item is StatItem => item !== null);
- const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const difficultyTop = statsTop + statsRowHeight(t) + t.sectionGap;
- const difficultyWidth = (frame.width - t.columnGap * 2) / 3;
- const difficulty = [
- { label: labels.easy, value: solved.easy, color: difficultyColors.easy },
- { label: labels.medium, value: solved.medium, color: difficultyColors.medium },
- { label: labels.hard, value: solved.hard, color: difficultyColors.hard },
- ];
- return (
- <>
-
-
- {difficulty.map((item, index) => {
- const x = index * (difficultyWidth + t.columnGap);
- return (
-
-
-
-
- );
- })}
- >
- );
-};
-
export const WidgetCanvas = ({
title = 'WidgeCode widget',
blocks,
palette = 'lavender',
paletteMode = 'light',
- columns = MAX_GRID_COLUMNS,
+ columns,
width = 600,
height = 400,
outputWidth,
@@ -894,22 +45,14 @@ export const WidgetCanvas = ({
}: WidgetCanvasProps) => {
const canvasWidth = clamp(Math.round(width), 280, 1600);
const canvasHeight = clamp(Math.round(height), 160, 1200);
- const svgWidth = outputWidth ?? canvasWidth;
- const svgHeight = outputHeight ?? canvasHeight;
- const paletteSet = paletteTokens[palette as PaletteName] ?? paletteTokens.lavender;
- const tokens = paletteMode === 'dark' ? paletteSet.dark : paletteSet.light;
- const gridColumns = clamp(Math.round(columns), 1, MAX_GRID_COLUMNS);
- const layouts = blocks.map(layoutOf);
- const rows = gridRows(layouts);
- const blockSurface = mixHex(tokens.surface, tokens.soft, 0.72);
- // CSS `radial-gradient(circle at 92% 2%, … 32%)` sizes to the farthest corner (bottom-left).
- const glowRadius = Math.hypot(canvasWidth * 0.92, canvasHeight * 0.98) * 0.32;
+ const tokens = canvasTokens(palette, paletteMode);
+ const boxes = canvasBoxes(blocks, { width: canvasWidth, height: canvasHeight, columns });
return (
{title}
Live developer statistics widget
-
-
-
-
-
-
-
-
-
-
-
-
-
- {blocks.length === 0 && (
-
- )}
- {blocks.map((block, index) => {
- const box = blockBox(layouts[index], {
- width: canvasWidth,
- height: canvasHeight,
- columns: gridColumns,
- rows,
- });
- const inset = BLOCK_BORDER + BLOCK_PADDING;
- const contentWidth = blockContentWidth(box.width);
- const frame = {
- width: contentWidth,
- height: Math.max(box.height - inset * 2, 0),
- typography: blockTypography(contentWidth),
- };
- const clipId = `block-${safeId(block.id)}`;
- return (
-
-
-
-
-
-
-
- item.id === block.id)}
- tokens={tokens}
- locale={locale}
- frame={frame}
- avatarDataUri={avatarDataUris?.[block.id]}
- />
-
-
-
- );
- })}
-
+
+ {blocks.length === 0 && (
+
+ )}
+ {blocks.map((block, index) => (
+ item.id === block.id)}
+ box={boxes[index]}
+ tokens={tokens}
+ locale={locale}
+ avatarDataUri={avatarDataUris?.[block.id]}
+ />
+ ))}
);
};
diff --git a/server/src/shared/widget/canvasParts.tsx b/server/src/shared/widget/canvasParts.tsx
new file mode 100644
index 0000000..9d3ca6b
--- /dev/null
+++ b/server/src/shared/widget/canvasParts.tsx
@@ -0,0 +1,179 @@
+// Pieces of a widget canvas. The SVG export composes them into one ; the browser places
+// the background and each block in their own (so the editor can attach controls to a
+// block) — same components, same geometry.
+import {
+ BLOCK_BORDER,
+ BLOCK_PADDING,
+ BLOCK_RADIUS,
+ CANVAS_RADIUS,
+ MAX_GRID_COLUMNS,
+ blockBox,
+ blockContentWidth,
+ blockTypography,
+ gridRows,
+} from './geometry.js';
+import { paletteTokens, type PaletteName, type PaletteTokens, type WidgetLocale } from './theme.js';
+import type { WidgetCanvasBlock, WidgetCanvasRenderedBlock } from './types.js';
+import { BlockContent } from './BlockContent.js';
+import { SvgText, clamp, layoutOf, mixHex, safeId } from './svgPrimitives.js';
+
+export const WIDGET_FONT_FAMILY =
+ "Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif";
+
+export const canvasTokens = (palette?: string, paletteMode?: string): PaletteTokens => {
+ const set = paletteTokens[palette as PaletteName] ?? paletteTokens.lavender;
+ return paletteMode === 'dark' ? set.dark : set.light;
+};
+
+export type BlockBox = { x: number; y: number; width: number; height: number };
+
+/**
+ * Pixel boxes of all blocks. `rows` overrides the row count derived from the blocks (the editor
+ * shows a spare drop row while dragging).
+ */
+export const canvasBoxes = (
+ blocks: WidgetCanvasBlock[],
+ canvas: { width: number; height: number; columns?: number; rows?: number },
+): BlockBox[] => {
+ const layouts = blocks.map(layoutOf);
+ const rows = canvas.rows ?? gridRows(layouts);
+ const columns = clamp(Math.round(canvas.columns ?? MAX_GRID_COLUMNS), 1, MAX_GRID_COLUMNS);
+ return layouts.map((layout) =>
+ blockBox(layout, { width: canvas.width, height: canvas.height, columns, rows }),
+ );
+};
+
+/** Canvas surface: gradient, accent glow and border. Ids are prefixed to stay unique per page. */
+export const CanvasBackground = ({
+ width,
+ height,
+ tokens,
+ idPrefix = 'widget',
+}: {
+ width: number;
+ height: number;
+ tokens: PaletteTokens;
+ idPrefix?: string;
+}) => {
+ // CSS `radial-gradient(circle at 92% 2%, … 32%)` sizes to the farthest corner (bottom-left).
+ const glowRadius = Math.hypot(width * 0.92, height * 0.98) * 0.32;
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const CanvasEmptyState = ({
+ width,
+ height,
+ tokens,
+ text,
+}: {
+ width: number;
+ height: number;
+ tokens: PaletteTokens;
+ text: string;
+}) => (
+
+
+
+);
+
+/** One block: surface, border and clipped content, drawn at `box` in the parent's coordinates. */
+export const BlockShell = ({
+ block,
+ rendered,
+ box,
+ tokens,
+ locale,
+ avatarDataUri,
+ idPrefix = 'block',
+}: {
+ block: WidgetCanvasBlock;
+ rendered?: WidgetCanvasRenderedBlock;
+ box: BlockBox;
+ tokens: PaletteTokens;
+ locale: WidgetLocale;
+ avatarDataUri?: string;
+ idPrefix?: string;
+}) => {
+ const inset = BLOCK_BORDER + BLOCK_PADDING;
+ const contentWidth = blockContentWidth(box.width);
+ const frame = {
+ width: contentWidth,
+ height: Math.max(box.height - inset * 2, 0),
+ typography: blockTypography(contentWidth),
+ };
+ const clipId = `${idPrefix}-${safeId(block.id)}`;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/server/src/shared/widget/svgPrimitives.tsx b/server/src/shared/widget/svgPrimitives.tsx
new file mode 100644
index 0000000..6e4a614
--- /dev/null
+++ b/server/src/shared/widget/svgPrimitives.tsx
@@ -0,0 +1,314 @@
+// Low-level SVG building blocks shared by every widget block renderer.
+import {
+ BLOCK_LINE_HEIGHT,
+ MAX_BLOCK_HEIGHT,
+ MAX_GRID_COLUMNS,
+ estimateTextWidth,
+ type BlockTypography,
+ type GridLayout,
+} from './geometry.js';
+import { formatStatValue, widgetLabels, type PaletteTokens, type WidgetLocale } from './theme.js';
+import type { WidgetCanvasBlock } from './types.js';
+
+export const WIDGET_FONT_FAMILY =
+ "Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif";
+
+export const numberFormatter = new Intl.NumberFormat('en-US');
+
+export const asRecord = (value: unknown): Record =>
+ value && typeof value === 'object' ? (value as Record) : {};
+
+export const asNumber = (value: unknown, fallback = 0) =>
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback;
+
+export const asString = (value: unknown, fallback = '') =>
+ typeof value === 'string' && value.trim() ? value.trim() : fallback;
+
+export const clamp = (value: number, min: number, max: number) =>
+ Math.min(Math.max(value, min), max);
+
+export const mixHex = (left: string, right: string, leftWeight: number) => {
+ const parse = (value: string) => Number.parseInt(value.replace('#', ''), 16);
+ const leftValue = parse(left);
+ const rightValue = parse(right);
+ const weight = clamp(leftWeight, 0, 1);
+ const channel = (shift: number) =>
+ Math.round(
+ ((leftValue >> shift) & 255) * weight + ((rightValue >> shift) & 255) * (1 - weight),
+ );
+ return `rgb(${channel(16)}, ${channel(8)}, ${channel(0)})`;
+};
+
+export const formatNumber = (value: unknown, fallback = '—') =>
+ typeof value === 'number' && Number.isFinite(value) ? numberFormatter.format(value) : fallback;
+
+export const imageUrl = (value: unknown) => {
+ const url = asString(value);
+ return /^https?:\/\//i.test(url) ? url : null;
+};
+
+export const safeId = (value: string) => value.replace(/[^a-z0-9_-]/gi, '-');
+
+export const layoutOf = (block: WidgetCanvasBlock): GridLayout => {
+ const value = asRecord(asRecord(block.config).layout);
+ return {
+ x: clamp(asNumber(value.x), 0, MAX_GRID_COLUMNS - 1),
+ y: Math.max(0, asNumber(value.y, block.position)),
+ width: clamp(asNumber(value.width, 1), 1, MAX_GRID_COLUMNS),
+ height: clamp(asNumber(value.height, 1), 1, MAX_BLOCK_HEIGHT),
+ };
+};
+
+// Inter vertical metrics: ascent 0.96875em, content area 1.2109em.
+export const baseline = (top: number, size: number, lineHeight = BLOCK_LINE_HEIGHT) =>
+ top + size * ((lineHeight - 1.2109) / 2 + 0.96875);
+
+// SVG has no text layout: cut text with an ellipsis where the HTML canvas would.
+export const fitText = (value: string, width: number, size: number, weight = 500) => {
+ if (estimateTextWidth(value, size, { weight }) <= width) return value;
+ let end = value.length;
+ while (end > 1 && estimateTextWidth(`${value.slice(0, end)}…`, size, { weight }) > width) {
+ end -= 1;
+ }
+ return `${value.slice(0, end)}…`;
+};
+
+export const linesOf = (value: string, maxLength: number, maxLines: number) => {
+ const words = value.split(/\s+/).filter(Boolean);
+ const lines: string[] = [];
+ let current = '';
+
+ for (const word of words) {
+ const next = current ? `${current} ${word}` : word;
+ if (current && next.length > maxLength) {
+ lines.push(current);
+ current = word;
+ } else {
+ current = next;
+ }
+ if (lines.length === maxLines) break;
+ }
+ if (lines.length < maxLines && current) lines.push(current);
+ if (lines.length === maxLines && words.join(' ').length > lines.join(' ').length) {
+ lines[maxLines - 1] = `${lines[maxLines - 1].slice(0, Math.max(maxLength - 1, 1))}…`;
+ }
+ return lines.length > 0 ? lines : [''];
+};
+
+export type TextProps = {
+ x: number;
+ y: number;
+ value: unknown;
+ fill: string;
+ size: number;
+ weight?: number;
+ anchor?: 'start' | 'middle' | 'end';
+ opacity?: number;
+ letterSpacing?: string;
+};
+
+export const SvgText = ({
+ x,
+ y,
+ value,
+ fill,
+ size,
+ weight = 500,
+ anchor = 'start',
+ opacity,
+ letterSpacing,
+}: TextProps) => (
+
+ {String(value ?? '')}
+
+);
+
+export const MultilineText = ({
+ top,
+ lineHeight,
+ lines,
+ ...text
+}: Omit & { top: number; lineHeight: number; lines: string[] }) => (
+ <>
+ {lines.map((line, index) => (
+
+ ))}
+ >
+);
+
+export type BlockFrame = { width: number; height: number; typography: BlockTypography };
+
+export type StatItem = { label: string; value: unknown };
+
+// Mirrors `.statsRow` / `.stat` in the HTML canvas: equal columns, value above label.
+export const StatsRow = ({
+ top,
+ items,
+ frame,
+ tokens,
+}: {
+ top: number;
+ items: StatItem[];
+ frame: BlockFrame;
+ tokens: PaletteTokens;
+}) => {
+ const { typography: t } = frame;
+ const columnWidth = (frame.width - t.columnGap * (items.length - 1)) / Math.max(items.length, 1);
+ const labelTop = top + t.statValue * BLOCK_LINE_HEIGHT + 3;
+ return (
+ <>
+ {items.map((item, index) => {
+ const x = index * (columnWidth + t.columnGap);
+ return (
+
+
+
+
+ );
+ })}
+ >
+ );
+};
+
+export const statsRowHeight = (t: BlockTypography) =>
+ t.statValue * BLOCK_LINE_HEIGHT + 3 + t.meta * BLOCK_LINE_HEIGHT;
+
+// Mirrors `.blockTitleRow`: bold title on the left, muted meta on the right.
+export const TitleRow = ({
+ title,
+ meta,
+ frame,
+ tokens,
+}: {
+ title: string;
+ meta: string;
+ frame: BlockFrame;
+ tokens: PaletteTokens;
+}) => {
+ const { typography: t } = frame;
+ const rowHeight = t.heading * BLOCK_LINE_HEIGHT;
+ const metaTop = (rowHeight - t.meta * BLOCK_LINE_HEIGHT) / 2;
+ // Like the HTML flex row: the title keeps its width, the meta shrinks (max 45%) with a gap of 12.
+ const titleWidth = Math.min(estimateTextWidth(title, t.heading, { weight: 800 }), frame.width);
+ const metaMaxWidth = Math.max(Math.min(frame.width * 0.45, frame.width - titleWidth - 12), 0);
+ const metaText = fitText(meta, metaMaxWidth, t.meta);
+ const metaWidth = metaText ? estimateTextWidth(metaText, t.meta) : 0;
+ return (
+ <>
+
+
+ >
+ );
+};
+
+// Mirrors `.previewState` in the HTML canvas.
+export const PreviewState = ({
+ source,
+ frame,
+ tokens,
+ locale,
+}: {
+ source: 'GitHub' | 'LeetCode';
+ frame: BlockFrame;
+ tokens: PaletteTokens;
+ locale: WidgetLocale;
+}) => {
+ const labels = widgetLabels(locale);
+ const boxHeight = 76;
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/server/src/shared/widget/types.ts b/server/src/shared/widget/types.ts
new file mode 100644
index 0000000..4a5a1d0
--- /dev/null
+++ b/server/src/shared/widget/types.ts
@@ -0,0 +1,16 @@
+// Block shapes accepted by the widget renderers (API rows and editor state both fit).
+
+export type WidgetCanvasBlock = {
+ id: string;
+ type: string;
+ position: number;
+ config: unknown;
+};
+
+export type WidgetCanvasRenderedBlock = {
+ id: string;
+ type: string;
+ position: number;
+ data?: unknown;
+ error?: string;
+};