snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

32
common/debugger/README.md Normal file
View File

@@ -0,0 +1,32 @@
# @univerjs/debugger
> [!CAUTION]
> NEVER use this plugin in your production environment!
## Package Overview
| Package Name | UMD Namespace | Version | License | Downloads | Contains CSS | Contains i18n locales |
| --- | --- | --- | --- | --- | :---: | :---: |
| `@univerjs/debugger` | `UniverDebugger` | [![][npm-version-shield]][npm-version-link] | ![][npm-license-shield] | ![][npm-downloads-shield] | ⭕️ | ❌ |
## Introduction
> This plugin provides a lot of utilities to help you debug Univer.
## Usage
### Installation
```shell
# Using npm
npm install @univerjs/debugger
# Using pnpm
pnpm add @univerjs/debugger
```
<!-- Links -->
[npm-version-shield]: https://img.shields.io/npm/v/@univerjs/debugger?style=flat-square
[npm-version-link]: https://npmjs.com/package/@univerjs/debugger
[npm-license-shield]: https://img.shields.io/npm/l/@univerjs/debugger?style=flat-square
[npm-downloads-shield]: https://img.shields.io/npm/dm/@univerjs/debugger?style=flat-square

View File

@@ -0,0 +1,54 @@
{
"name": "@univerjs/debugger",
"version": "1.0.0-rc.0",
"private": true,
"description": "",
"author": "DreamNum <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [],
"exports": {
".": "./src/index.ts",
"./*": "./src/*"
},
"main": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/docs-drawing-ui": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-drawing-ui": "workspace:*",
"@univerjs/themes": "workspace:*",
"@univerjs/ui": "workspace:*",
"@univerjs/watermark": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vue": "^3.5.42"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,68 @@
export const FloatButton = () => {
const divStyle = {
width: '100px',
height: '30px',
backgroundColor: '#fff',
border: '1px solid #ccc',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center' as const,
};
const clickHandler = () => {
console.warn('click');
};
return (
<div style={divStyle} onClick={clickHandler}>
FloatButton
</div>
);
};
export const AIButton = () => {
const divStyle = {
width: '80px',
height: '50px',
backgroundColor: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center' as const,
borderRadius: '25px',
border: 'none',
color: 'white',
cursor: 'pointer',
transition: 'all 0.3s ease',
background: 'linear-gradient(90deg, #00C9FF 0%, #92FE9D 50%, #00C9FF 100%)',
backgroundSize: '200% auto',
animation: 'gradient 3s linear infinite',
':hover': {
transform: 'translateY(-2px)',
boxShadow: '0 10px 20px rgba(0, 201, 255, 0.3)',
},
};
const clickHandler = () => {
console.warn('click');
};
return (
<button type="button" style={divStyle} onClick={clickHandler}>
AI
<style>
{`
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 201, 255, 0.3);
}
`}
</style>
</button>
);
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
import { borderClassName, clsx } from '@univerjs/design';
export const RangeLoading = () => {
return (
<div
className={clsx(`
univer-flex univer-size-full univer-origin-top-left univer-items-center univer-justify-center
univer-bg-gray-0
dark:!univer-bg-gray-900
`, borderClassName)}
>
Loading...
</div>
);
};

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue';
const state = ref<'sun' | 'moon'>('sun');
function toggleState() {
state.value = state.value === 'sun' ? 'moon' : 'sun';
}
</script>
<template>
<div @click="toggleState">
<span v-if="state === 'sun'">🌞</span>
<span v-if="state === 'moon'">🌚</span>
</div>
</template>

View File

@@ -0,0 +1,47 @@
export class CounterComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const container = document.createElement('div');
container.setAttribute('class', 'counter-container');
const countDisplay = document.createElement('span');
countDisplay.setAttribute('class', 'count-display');
countDisplay.textContent = '0';
const a = document.createElement('a');
a.textContent = '🍔';
a.addEventListener('click', (e) => {
e.preventDefault();
const currentCount = Number.parseInt(countDisplay.textContent || '0', 10);
countDisplay.textContent = (currentCount + 1).toString();
});
const style = document.createElement('style');
style.textContent = `
.counter-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.count-display {
position: relative;
top: 2px;
font-size: 10px;
}
a {
font-size: 10px;
}`;
shadow.appendChild(style);
shadow.appendChild(container);
container.appendChild(countDisplay);
container.appendChild(a);
}
}

View File

@@ -0,0 +1,25 @@
import type { ILanguagePack, LocaleType, UniverInstanceType } from '@univerjs/core';
import type { MenuConfig } from '@univerjs/ui';
export const DEBUGGER_PLUGIN_CONFIG_KEY = 'debugger.config';
export const configSymbol = Symbol(DEBUGGER_PLUGIN_CONFIG_KEY);
export type UniverDebuggerLocaleLoader = (locale: LocaleType) => ILanguagePack | Promise<ILanguagePack>;
export interface IUniverDebuggerConfig {
menu?: MenuConfig;
fab?: boolean;
fabEntryUnitType: UniverInstanceType;
localeLoader: UniverDebuggerLocaleLoader;
performanceMonitor?: {
enabled: boolean;
};
}
export const defaultPluginConfig: Pick<IUniverDebuggerConfig, 'fab' | 'performanceMonitor'> = {
fab: true,
performanceMonitor: {
enabled: true,
},
};

View File

@@ -0,0 +1,44 @@
import { Disposable, Inject } from '@univerjs/core';
import { ComponentManager } from '@univerjs/ui';
import { AIButton, FloatButton } from '../components/FloatButton';
import { ImageDemo } from '../components/Image';
import { RangeLoading } from '../components/RangeLoading';
// @ts-ignore
// import VueComponent from '../components/VueComponent.vue';
// import { CounterComponent } from '../components/WebComponent';
import { WatermarkPanel } from '../views/watermark/WatermarkPanel';
import { WatermarkPanelFooter } from '../views/watermark/WatermarkPanelFooter';
export const WATERMARK_PANEL = 'WATERMARK_PANEL';
export const WATERMARK_PANEL_FOOTER = 'WATERMARK_PANEL_FOOTER';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager
) {
super();
this._registerComponents();
}
private _registerComponents(): void {
([
['ImageDemo', ImageDemo],
['RangeLoading', RangeLoading],
['FloatButton', FloatButton],
['AIButton', AIButton],
[WATERMARK_PANEL, WatermarkPanel],
[WATERMARK_PANEL_FOOTER, WatermarkPanelFooter],
] as const).forEach(([key, component]) => {
this.disposeWithMe(this._componentManager.register(key, component));
});
// this.disposeWithMe(this._componentManager.register('VueComponent', VueComponent, {
// framework: 'vue3',
// }));
// this.disposeWithMe(this._componentManager.register('counter-component', CounterComponent, {
// framework: 'web-component',
// }));
}
}

View File

@@ -0,0 +1,62 @@
import type { IUniverDebuggerConfig } from '../config/config';
import { Disposable, IConfigService, Inject, Injector } from '@univerjs/core';
import {
BuiltInUIPart,
connectInjector,
IMenuManagerService,
IUIPartsService,
// MenuItemType,
// RibbonStartGroup,
} from '@univerjs/ui';
import { DEBUGGER_PLUGIN_CONFIG_KEY } from '../config/config';
import { Fab } from '../views/Fab';
import { RecordController } from './local-save/record.controller';
export class DebuggerController extends Disposable {
constructor(
@Inject(Injector) private readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService
) {
super();
this._initCustomComponents();
// this._menuManagerService.mergeMenu({
// [RibbonStartGroup.OTHERS]: {
// EMOJI: {
// order: 9999,
// menuItemFactory: () => {
// return {
// id: 'EMOJI',
// icon: 'VueComponent',
// type: MenuItemType.BUTTON,
// };
// },
// },
// COUNTER: {
// order: 9999,
// menuItemFactory: () => {
// return {
// id: 'COUNTER',
// icon: 'counter-component',
// type: MenuItemType.BUTTON,
// };
// },
// },
// },
// });
this._injector.add([RecordController]);
}
private _initCustomComponents(): void {
const configs = this._configService.getConfig<IUniverDebuggerConfig>(DEBUGGER_PLUGIN_CONFIG_KEY);
if (configs?.fab) {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(Fab, this._injector))
);
}
}
}

View File

@@ -0,0 +1,59 @@
import { CommandType, ICommandService, Inject } from '@univerjs/core';
import { Observable } from 'rxjs';
export class RecordController {
constructor(@Inject(ICommandService) private _commandService: ICommandService) {
// empty
}
record() {
return new Observable<{ type: 'start' } | { type: 'finish'; data: Blob }>((subscribe) => {
navigator.mediaDevices.getDisplayMedia({ video: true }).then((stream) => {
subscribe.next({ type: 'start' });
const mime = MediaRecorder.isTypeSupported('video/webm; codecs=vp9')
? 'video/webm; codecs=vp9'
: 'video/webm';
const mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
const chunks: Blob[] = [];
mediaRecorder.addEventListener('dataavailable', function (e) {
chunks.push(e.data);
});
mediaRecorder.addEventListener('stop', function () {
const blob = new Blob(chunks, { type: chunks[0].type });
subscribe.next({ type: 'finish', data: blob });
subscribe.complete();
});
mediaRecorder.start();
});
});
}
startSaveCommands() {
type SecondsString = string & {};
type CommandString = string & {};
type TypeString = string & {};
type ParamsString = string & {};
const result: Array<[SecondsString, CommandString, TypeString, ParamsString]> = [];
const startTime = performance.now();
const disposable = this._commandService.beforeCommandExecuted((commandInfo) => {
try {
result.push([
String((performance.now() - startTime) / 1000),
commandInfo.id,
String(commandInfo.type || CommandType.COMMAND),
JSON.stringify(commandInfo.params || ''),
]);
} catch (err) {
console.error(`${commandInfo.id} unable to serialize`);
console.error(err);
}
});
return () => {
disposable.dispose();
return result;
};
}
}

View File

@@ -0,0 +1,8 @@
export function createDownloadElement(fileName: string, blob: Blob) {
const a = document.createElement('a');
a.download = fileName;
a.href = window.URL.createObjectURL(blob);
return () => {
a.click();
};
}

View File

@@ -0,0 +1,60 @@
import type { Nullable } from '@univerjs/core';
import type { Subscription } from 'rxjs';
import { Inject, IUniverInstanceService, LifecycleService, LifecycleStages, RxDisposable } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { distinctUntilChanged, filter, take, takeUntil } from 'rxjs';
export class PerformanceMonitorController extends RxDisposable {
private _containerElement: HTMLDivElement | null;
private _currentUnitSub: Nullable<Subscription>;
constructor(
@Inject(LifecycleService) lifecycleService: LifecycleService,
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
lifecycleService.subscribeWithPrevious()
.pipe(
filter((stage) => stage === LifecycleStages.Rendered),
take(1)
)
.subscribe(() => this._listenDocumentTypeChange());
}
override dispose(): void {
super.dispose();
this._disposeCurrentObserver();
}
private _disposeCurrentObserver(): void {
this._currentUnitSub?.unsubscribe();
this._currentUnitSub = null;
}
private _listenDocumentTypeChange() {
this._instanceService.focused$.pipe(takeUntil(this.dispose$), distinctUntilChanged()).subscribe((unitId) => {
this._disposeCurrentObserver();
if (unitId) {
this._listenToRenderer(unitId);
}
});
}
private _listenToRenderer(unitId: string) {
const renderer = this._renderManagerService.getRenderUnitById(unitId);
if (renderer) {
const { engine } = renderer;
this._currentUnitSub = engine.endFrame$.subscribe(() => {
if (!this._containerElement) {
this._containerElement = document.querySelector('[data-u-comp=debugger-fps]');
} else {
this._containerElement.textContent = `FPS: ${Math.round(engine.getFps()).toString()}`;
}
});
}
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,4 @@
import './global.css';
export type { IUniverDebuggerConfig, UniverDebuggerLocaleLoader } from './config/config';
export { UniverDebuggerPlugin } from './plugin';

View File

@@ -0,0 +1,66 @@
import type { Dependency } from '@univerjs/core';
import type { IUniverDebuggerConfig } from './config/config';
import { IConfigService, Inject, Injector, merge, Plugin, registerDependencies, touchDependencies } from '@univerjs/core';
import pkg from '../package.json';
import { DEBUGGER_PLUGIN_CONFIG_KEY, defaultPluginConfig } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DebuggerController } from './controllers/debugger.controller';
import { PerformanceMonitorController } from './controllers/performance-monitor.controller';
export class UniverDebuggerPlugin extends Plugin {
static override pluginName = 'UNIVER_DEBUGGER_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
private _debuggerController!: DebuggerController;
constructor(
private readonly _config: IUniverDebuggerConfig,
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DEBUGGER_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies: Dependency[] = [
[DebuggerController],
[ComponentsController],
];
if (this._config.performanceMonitor?.enabled !== false) {
dependencies.push([PerformanceMonitorController]);
}
registerDependencies(this._injector, dependencies);
}
override onReady(): void {
touchDependencies(this._injector, [
[ComponentsController],
[DebuggerController],
]);
}
override onRendered(): void {
touchDependencies(this._injector, [
[PerformanceMonitorController],
]);
}
getDebuggerController() {
this._debuggerController = this._injector.get(DebuggerController);
return this._debuggerController;
}
}

View File

@@ -0,0 +1,116 @@
import type { IDropdownMenuProps } from '@univerjs/design';
import type { IUniverDebuggerConfig } from '../config/config';
import { IConfigService, UniverInstanceType } from '@univerjs/core';
import { borderClassName, clsx, DropdownMenu } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
import { DEBUGGER_PLUGIN_CONFIG_KEY } from '../config/config';
import { useCellContent } from './use-cell-content';
import { useDarkMode } from './use-dark-mode';
import { useDialog } from './use-dialog';
import { useDispose } from './use-dispose';
import { useEditable } from './use-editable';
import { useFloatingDom } from './use-floating-dom';
import { useLocale } from './use-locale';
import { useMessage } from './use-message';
import { useNotification } from './use-notification';
import { useRTL } from './use-rtl';
import { useSidebar } from './use-sidebar';
import { useSnapshot } from './use-snapshot';
import { useTheme } from './use-theme';
import { useUnits } from './use-units';
import { useUser } from './use-user';
import { useWatermark } from './use-watermark';
export function Fab() {
const configService = useDependency(IConfigService);
const configs = configService.getConfig<IUniverDebuggerConfig>(DEBUGGER_PLUGIN_CONFIG_KEY);
const performanceMonitor = configs?.performanceMonitor;
const fabEntryUnitType = configs?.fabEntryUnitType;
const locale = useLocale();
const rtl = useRTL();
const darkMode = useDarkMode();
const theme = useTheme();
const watermark = useWatermark();
const notification = useNotification();
const message = useMessage();
const dialog = useDialog();
const sidebar = useSidebar();
const floatingDom = useFloatingDom(fabEntryUnitType);
const cellContent = useCellContent(fabEntryUnitType);
const units = useUnits();
const snapshot = useSnapshot();
const editable = useEditable();
const user = useUser();
const dispose = useDispose();
const globalItems = [locale, rtl, darkMode, theme];
const lightweightItems = [
...globalItems,
watermark,
];
const commonDebugItems = [
watermark,
{ type: 'separator' as const },
notification,
message,
dialog,
sidebar,
{ type: 'separator' as const },
snapshot,
editable,
dispose,
];
const sheetItems = [
...globalItems,
watermark,
{ type: 'separator' as const },
notification,
message,
dialog,
sidebar,
{ type: 'separator' as const },
floatingDom,
cellContent,
units,
snapshot,
editable,
user,
dispose,
];
const items: IDropdownMenuProps['items'] = fabEntryUnitType === UniverInstanceType.UNIVER_BASE || fabEntryUnitType === UniverInstanceType.UNIVER_SLIDE
? lightweightItems
: fabEntryUnitType === UniverInstanceType.UNIVER_DOC
? [...globalItems, ...commonDebugItems, floatingDom].filter(Boolean) as IDropdownMenuProps['items']
: sheetItems.filter(Boolean) as IDropdownMenuProps['items'];
return (
<div
data-u-comp="debugger-fab"
className={`
univer-fixed univer-bottom-12 univer-right-8 univer-z-[9999] univer-flex univer-select-none
univer-flex-col univer-items-center univer-gap-1
`}
>
<DropdownMenu align="end" items={items}>
<button
className={clsx(`
univer-flex univer-size-9 univer-cursor-pointer univer-items-center univer-justify-center
univer-rounded-full univer-bg-gray-0 univer-text-base univer-text-gray-900 univer-shadow
univer-outline-none univer-transition-shadow
hover:univer-ring-1 hover:univer-ring-primary-400
dark:!univer-bg-gray-900 dark:!univer-text-gray-200
`, borderClassName)}
type="button"
>
🏗️
</button>
</DropdownMenu>
{performanceMonitor?.enabled && (
<span data-u-comp="debugger-fps" className="univer-text-xs univer-text-gray-400" />
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export const TEST_EDITOR_CONTAINER_COMPONENT = 'TestEditorContainer';

View File

@@ -0,0 +1,28 @@
import { ILogService, IUniverInstanceService, ObjectMatrix, Range, UniverInstanceType } from '@univerjs/core';
import { getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets';
import { useDependency } from '@univerjs/ui';
export function useCellContent(fabEntryUnitType?: UniverInstanceType) {
const logService = useDependency(ILogService);
const selectionManagerService = fabEntryUnitType === UniverInstanceType.UNIVER_SHEET ? useDependency(SheetsSelectionsService) : null;
const univerInstanceService = useDependency(IUniverInstanceService);
const onSelect = () => {
if (!selectionManagerService) return;
const selections = selectionManagerService.getCurrentSelections();
const target = getSheetCommandTarget(univerInstanceService);
const matrix = new ObjectMatrix();
selections.forEach((selection) => {
Range.foreach(selection.range, (row, col) => {
matrix.setValue(row, col, target?.worksheet.getCell(row, col));
});
});
logService.debug('cell-content', matrix);
};
return {
type: 'item' as const,
children: '🗒️ Console cell content',
onSelect,
};
}

View File

@@ -0,0 +1,31 @@
import { ThemeService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
import { useEffect } from 'react';
export function useDarkMode() {
const themeService = useDependency(ThemeService);
useEffect(() => {
const darkMode = localStorage.getItem('local.darkMode');
if (darkMode === 'dark') {
document.documentElement.classList.add('univer-dark');
themeService.setDarkMode(true);
} else if (darkMode === 'system') {
themeService.setDarkMode(false);
}
}, []);
const onSelect = () => {
const darkMode = themeService.darkMode ? 'light' : 'dark';
themeService.setDarkMode(darkMode === 'dark');
localStorage.setItem('local.darkMode', darkMode);
};
return {
type: 'item' as const,
children: '🌓 Toggle dark mode',
onSelect,
};
}

View File

@@ -0,0 +1,89 @@
import { IConfirmService } from '@univerjs/core';
import { IDialogService, useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Open dialog',
value: 'dialog',
},
{
label: 'Draggable dialog',
value: 'draggable',
},
{
label: 'Open confirm',
value: 'confirm',
},
];
export function useDialog() {
const dialogService = useDependency(IDialogService);
const confirmService = useDependency(IConfirmService);
const onSelect = (value: string) => {
if (value === 'draggable') {
dialogService.open({
id: 'draggable',
children: { title: 'Draggable Dialog Content' },
title: { title: 'Draggable Dialog' },
draggable: true,
preservePositionOnDestroy: true,
width: 350,
onClose() {
// dialogService.close('draggable');
},
onOpenChange(open: boolean) {
if (!open) {
dialogService.close('draggable');
}
},
});
} else if (value === 'dialog') {
dialogService.open({
id: 'dialog1',
children: { title: 'Dialog Content' },
footer: { title: 'Dialog Footer' },
title: { title: 'Dialog Title' },
draggable: false,
onClose() {
// dialogService.close('dialog1');
},
onOpenChange(open: boolean) {
if (!open) {
dialogService.close('dialog1');
}
},
});
} else if (value === 'confirm') {
confirmService.open({
id: 'confirm1',
children: { title: 'Confirm Content' },
title: { title: 'Confirm Title' },
confirmText: 'hello',
cancelText: 'world',
onClose() {
confirmService.close('confirm1');
},
});
confirmService.open({
id: 'confirm2',
children: { title: 'Confirm2 Content' },
title: { title: 'Confirm2 Title' },
onClose() {
confirmService.close('confirm2');
},
});
}
};
return {
type: 'subItem' as const,
children: '💬 Dialog & Confirm',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,42 @@
import type { Univer } from '@univerjs/core';
import { IUniverInstanceService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Dispose Univer',
value: 'univer',
},
{
label: 'Dispose current unit',
value: 'unit',
},
];
export function useDispose() {
const univerInstanceService = useDependency(IUniverInstanceService);
const onSelect = (value: string) => {
if (value === 'univer') {
const debuggerWindow = window as typeof window & { univer?: Univer };
debuggerWindow.univer?.dispose();
Reflect.deleteProperty(debuggerWindow, 'univer');
Reflect.deleteProperty(debuggerWindow, 'univerAPI');
} else if (value === 'unit') {
const focused = univerInstanceService.getFocusedUnit();
if (!focused) return false;
return univerInstanceService.disposeUnit(focused.getUnitId());
}
};
return {
type: 'subItem' as const,
children: '🗑️ Dispose',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,48 @@
import { IPermissionService, IUniverInstanceService } from '@univerjs/core';
import { getSheetCommandTarget, WorkbookEditablePermission, WorksheetEditPermission } from '@univerjs/sheets';
import { useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Change workbook editable',
value: 'univer',
},
{
label: 'Change worksheet editable',
value: 'sheet',
},
];
export function useEditable() {
const univerInstanceService = useDependency(IUniverInstanceService);
const permissionService = useDependency(IPermissionService);
const onSelect = (value: string) => {
const target = getSheetCommandTarget(univerInstanceService);
if (!target) {
return false;
}
const { workbook, worksheet, unitId, subUnitId } = target;
if (!workbook || !worksheet) {
return false;
}
if (value === 'sheet') {
const editable = permissionService.getPermissionPoint(new WorksheetEditPermission(unitId, subUnitId).id);
permissionService.updatePermissionPoint(new WorksheetEditPermission(unitId, subUnitId).id, !editable);
} else {
const unitId = workbook!.getUnitId();
const editable = permissionService.getPermissionPoint(new WorkbookEditablePermission(unitId).id);
permissionService.updatePermissionPoint(new WorkbookEditablePermission(unitId).id, !editable);
}
};
return {
type: 'subItem' as const,
children: '✍️ Editable',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,50 @@
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocFloatDomController } from '@univerjs/docs-drawing-ui';
import { SheetCanvasFloatDomManagerService } from '@univerjs/sheets-drawing-ui';
import { useDependency } from '@univerjs/ui';
export function useFloatingDom(entryUnitType?: UniverInstanceType) {
const univerInstanceService = useDependency(IUniverInstanceService);
const floatDomService = entryUnitType === UniverInstanceType.UNIVER_SHEET ? useDependency(SheetCanvasFloatDomManagerService) : null;
const floatDomController = entryUnitType === UniverInstanceType.UNIVER_DOC ? useDependency(DocFloatDomController) : null;
const onSelect = () => {
if (entryUnitType === UniverInstanceType.UNIVER_SHEET) {
const currentSheet = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET);
if (!currentSheet) return;
floatDomService?.addFloatDomToPosition({
allowTransform: true,
initPosition: {
startX: 200,
endX: 400,
startY: 200,
endY: 400,
},
componentKey: 'ImageDemo',
data: {
aa: '128',
},
});
} else if (entryUnitType === UniverInstanceType.UNIVER_DOC) {
const currentDoc = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC);
if (!currentDoc) return;
floatDomController?.insertFloatDom({
allowTransform: true,
componentKey: 'ImageDemo',
data: {
aa: '128',
},
}, {
height: 300,
});
}
};
return {
type: 'item' as const,
children: '☁️ Create floating DOM',
onSelect,
};
}

View File

@@ -0,0 +1,78 @@
import type { IUniverDebuggerConfig } from '../config/config';
import { IConfigService, LocaleService, LocaleType } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
import { useCallback, useEffect } from 'react';
import { DEBUGGER_PLUGIN_CONFIG_KEY } from '../config/config';
const localeLabels: Record<LocaleType, string> = {
[LocaleType.AR_SA]: 'العربية',
[LocaleType.CA_ES]: 'Català',
[LocaleType.DE_DE]: 'Deutsch',
[LocaleType.EN_US]: 'English',
[LocaleType.ES_ES]: 'Español',
[LocaleType.FA_IR]: 'فارسی',
[LocaleType.FR_FR]: 'Français',
[LocaleType.ID_ID]: 'Bahasa Indonesia',
[LocaleType.IT_IT]: 'Italiano',
[LocaleType.JA_JP]: '日本語',
[LocaleType.KO_KR]: '한국어',
[LocaleType.PL_PL]: 'Polski',
[LocaleType.PT_BR]: 'Português (Brasil)',
[LocaleType.RU_RU]: 'Русский',
[LocaleType.SK_SK]: 'Slovenčina',
[LocaleType.VI_VN]: 'Tiếng Việt',
[LocaleType.ZH_CN]: '简体中文',
[LocaleType.ZH_HK]: '繁體中文(香港)',
[LocaleType.ZH_TW]: '繁體中文(台灣)',
};
export function useLocale() {
const configService = useDependency(IConfigService);
const localeService = useDependency(LocaleService);
const configs = configService.getConfig<IUniverDebuggerConfig>(DEBUGGER_PLUGIN_CONFIG_KEY);
const localeLoader = configs?.localeLoader;
const loadLocales = useCallback(async (value: string) => {
const locale = value as LocaleType;
if (!localeLoader) {
throw new Error('[UniverDebuggerPlugin]: localeLoader is required.');
}
const localePack = await localeLoader(locale);
localeService.load({
[locale]: localePack,
});
}, [localeLoader, localeService]);
useEffect(() => {
const locale = localStorage.getItem('local.locale');
if (locale) {
loadLocales(locale).then(() => {
localeService.setLocale(locale as LocaleType);
});
}
}, [loadLocales, localeService]);
const onSelect = async (value: string) => {
await loadLocales(value);
localeService.setLocale(value as LocaleType);
localStorage.setItem('local.locale', value);
};
return {
type: 'subItem' as const,
children: '🌐 Languages',
options: Object.values(LocaleType).map((locale) => ({
type: 'radio' as const,
value: localeService.getCurrentLocale(),
options: [{
label: localeLabels[locale],
value: locale,
}],
onSelect,
})),
};
}

View File

@@ -0,0 +1,43 @@
import { MessageType } from '@univerjs/design';
import { IMessageService, useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Open message',
value: '',
},
{
label: 'Open loading message',
value: 'loading',
},
];
export function useMessage() {
const messageService = useDependency(IMessageService);
const onSelect = (value: string) => {
if (value === 'loading') {
messageService.show({
type: MessageType.Loading,
content: 'Loading message',
duration: 3000,
});
} else {
messageService.show({
type: MessageType.Success,
content: 'Demo message',
duration: 1500,
});
}
};
return {
type: 'subItem' as const,
children: '✉️ Message',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,43 @@
import type { INotificationOptions } from '@univerjs/ui';
import { INotificationService, useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Notification success',
value: 'success',
},
{
label: 'Notification info',
value: 'info',
},
{
label: 'Notification warning',
value: 'warning',
},
{
label: 'Notification error',
value: 'error',
},
];
export function useNotification() {
const notificationService = useDependency(INotificationService);
const onSelect = (value: string) => {
notificationService.show({
type: value as INotificationOptions['type'],
content: 'Lorem Ipusm dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
title: 'Notification Title',
});
};
return {
type: 'subItem' as const,
children: '🔔 Notification',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,29 @@
import { LocaleService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
import { useEffect } from 'react';
export function useRTL() {
const localeService = useDependency(LocaleService);
useEffect(() => {
const dir = localStorage.getItem('local.direction');
if (dir === 'rtl' || dir === 'ltr') {
localeService.setDirection(dir);
}
}, [localeService]);
const onSelect = () => {
const current = localeService.getDirection();
const nextDirection = current === 'rtl' ? 'ltr' : 'rtl';
localeService.setDirection(nextDirection);
localStorage.setItem('local.direction', nextDirection);
};
return {
type: 'item' as const,
children: '↔️ Toggle RTL',
onSelect,
};
}

View File

@@ -0,0 +1,41 @@
import { ISidebarService, useDependency } from '@univerjs/ui';
import { TEST_EDITOR_CONTAINER_COMPONENT } from './test-editor/component-name';
const menu = [
{
label: 'Open sidebar',
value: 'open',
},
{
label: 'Close sidebar',
value: 'close',
},
];
export function useSidebar() {
const sidebarService = useDependency(ISidebarService);
const onSelect = (value: string) => {
if (value === 'open') {
sidebarService.open({
header: { title: 'Sidebar title' },
children: { label: TEST_EDITOR_CONTAINER_COMPONENT },
footer: { title: 'Sidebar Footer' },
onClose: () => {
},
});
} else if (value === 'close') {
sidebarService.close();
}
};
return {
type: 'subItem' as const,
children: '🧩 Sidebar',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,128 @@
/* eslint-disable node/prefer-global/process */
import type { DocumentDataModel, IStyleData, IWorkbookData, Workbook } from '@univerjs/core';
import { IResourceLoaderService, IUniverInstanceService, ObjectMatrix, UniverInstanceType } from '@univerjs/core';
import { ILocalFileService, useDependency } from '@univerjs/ui';
import { RecordController } from '../controllers/local-save/record.controller';
const menu = [
{
label: 'Save workbook',
value: 'workbook',
},
{
label: 'Save worksheet',
value: 'sheet',
},
{
label: 'Record',
value: 'record',
},
{
label: 'Load snapshot',
value: 'load',
},
];
export interface ISaveSnapshotParams {
value: 'sheet' | 'workbook' | 'record';
}
const filterStyle = (workbookData: IWorkbookData) => {
const sheets = workbookData.sheets;
const cacheStyle: Record<string, IStyleData> = {};
Object.keys(sheets).forEach((sheetId) => {
const sheet = sheets[sheetId];
new ObjectMatrix(sheet.cellData).forValue((_r, _c, value) => {
const s = value?.s;
if (s && typeof s === 'string') {
const style = workbookData.styles[s];
if (style) {
cacheStyle[s] = style;
}
}
});
});
workbookData.styles = cacheStyle;
return workbookData;
};
export function useSnapshot() {
const univerInstanceService = useDependency(IUniverInstanceService);
const resourceLoaderService = useDependency(IResourceLoaderService);
const localFileService = useDependency(ILocalFileService);
const recordController = useDependency(RecordController);
const onSelect = async (value: string) => {
const preName = new Date().toLocaleString();
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) {
const doc = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)!;
const snapshot = resourceLoaderService.saveUnit(doc.getUnitId());
if (process.env.NODE_ENV === 'production') {
const gitHash = process.env.GIT_COMMIT_HASH;
const gitBranch = process.env.GIT_REF_NAME;
const buildTime = process.env.BUILD_TIME;
(snapshot as any).__env__ = { gitHash, gitBranch, buildTime };
}
const text = JSON.stringify(snapshot, null, 2);
localFileService.downloadFile(new Blob([text]), `${preName} snapshot.json`);
return true;
}
const worksheet = workbook.getActiveSheet();
if (!worksheet) {
return false;
}
const snapshot = resourceLoaderService.saveUnit<ReturnType<typeof workbook.getSnapshot>>(workbook.getUnitId())!;
if (process.env.NODE_ENV === 'production') {
const gitHash = process.env.GIT_COMMIT_HASH;
const gitBranch = process.env.GIT_REF_NAME;
const buildTime = process.env.BUILD_TIME;
(snapshot as any).__env__ = { gitHash, gitBranch, buildTime };
}
if (value === 'sheet') {
const sheetId = worksheet.getSheetId();
const sheet = snapshot.sheets[sheetId];
snapshot.sheets = { [sheetId]: sheet };
snapshot.sheetOrder = [sheetId];
const text = JSON.stringify(filterStyle(snapshot), null, 2);
localFileService.downloadFile(new Blob([text]), `${preName} snapshot.json`);
} else if (value === 'workbook') {
const text = JSON.stringify(filterStyle(snapshot), null, 2);
localFileService.downloadFile(new Blob([text]), `${preName} snapshot.json`);
} else if (value === 'record') {
let endCommands = () => [];
recordController.record().subscribe((v) => {
if (v.type === 'start') {
endCommands = recordController.startSaveCommands() as any;
}
if (v.type === 'finish') {
const commands = endCommands();
localFileService.downloadFile(v.data, `${preName} video.webm`);
localFileService.downloadFile(new Blob([JSON.stringify(commands, null, 2)]), `${preName} commands.json`);
}
});
} else if (value === 'load') {
const snapshotFile = await localFileService.openFile({ multiple: false, accept: '.json' });
if (snapshotFile.length !== 1) return false;
const text = await snapshotFile[0].text();
univerInstanceService.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, JSON.parse(text));
return true;
}
};
return {
type: 'subItem' as const,
children: '📷 Snapshot',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,77 @@
import { ThemeService } from '@univerjs/core';
import {
blueTheme,
darkBlueTheme,
greenTheme,
orangeTheme,
purpleTheme,
redTheme,
yellowTheme,
} from '@univerjs/themes';
import { useDependency } from '@univerjs/ui';
import { useEffect } from 'react';
const themes = [
{
label: '🟢',
value: greenTheme,
},
{
label: '🔵',
value: blueTheme,
},
{
label: '🔵🌑',
value: darkBlueTheme,
},
{
label: '🟠',
value: orangeTheme,
},
{
label: '🔴',
value: redTheme,
},
{
label: '🟣',
value: purpleTheme,
},
{
label: '🟡',
value: yellowTheme,
},
];
export function useTheme() {
const themeService = useDependency(ThemeService);
useEffect(() => {
const themeKey = localStorage.getItem('local.theme');
const theme = themes.find((theme) => theme.label === themeKey);
if (theme) {
themeService.setTheme(theme.value);
}
}, [themeService]);
const onSelect = (value: string) => {
localStorage.setItem('local.theme', value);
const theme = themes.find((theme) => theme.label === value);
if (theme) {
themeService.setTheme(theme.value);
}
};
return {
type: 'subItem' as const,
children: '🎨 Themes',
options: themes.map((theme) => ({
type: 'item' as const,
children: theme.label,
onSelect: () => onSelect(theme.label),
})),
};
}

View File

@@ -0,0 +1,50 @@
import type { Workbook } from '@univerjs/core';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useState } from 'react';
const defaultMenu = [
{
label: 'Create another sheet',
value: 'create',
},
];
export function useUnits() {
const univerInstanceService = useDependency(IUniverInstanceService);
const [menu, setMenu] = useState<{ label: string; value: string }[]>([...defaultMenu]);
const unitAdded = useObservable(univerInstanceService.unitAdded$);
const unitDisposed = useObservable(univerInstanceService.unitDisposed$);
useEffect(() => {
const sheets = univerInstanceService.getAllUnitsForType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const options = sheets.map((sheet) => ({
label: sheet.name || sheet.getUnitId(),
value: sheet.getUnitId(),
}));
setMenu([
...defaultMenu,
...(options as any[]),
]);
}, [unitAdded, unitDisposed]);
const onSelect = (value: string) => {
if (value === 'create') {
univerInstanceService.createUnit(UniverInstanceType.UNIVER_SHEET, {});
} else {
if (!univerInstanceService.getUnit(value)) return false;
univerInstanceService.setCurrentUnitForType(value);
}
};
return {
type: 'subItem' as const,
children: '🪸 Units',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,54 @@
import type { Workbook } from '@univerjs/core';
import { createDefaultUser, IPermissionService, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core';
import { WorkbookManageCollaboratorPermission } from '@univerjs/sheets';
import { useDependency } from '@univerjs/ui';
enum UnitRole {
Reader = 0,
Editor = 1,
Owner = 2,
UNRECOGNIZED = -1,
}
const menu = [
{
label: 'Owner',
value: UnitRole.Owner,
},
{
label: 'Editor',
value: UnitRole.Editor,
},
{
label: 'Reader',
value: UnitRole.Reader,
},
];
export function useUser() {
const univerInstanceService = useDependency(IUniverInstanceService);
const userManagerService = useDependency(UserManagerService);
const permissionService = useDependency(IPermissionService);
const onSelect = (value: UnitRole) => {
userManagerService.setCurrentUser(createDefaultUser(value));
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const unitId = workbook.getUnitId();
if (value === UnitRole.Owner) {
permissionService.updatePermissionPoint(new WorkbookManageCollaboratorPermission(unitId).id, true);
} else {
permissionService.updatePermissionPoint(new WorkbookManageCollaboratorPermission(unitId).id, false);
}
};
return {
type: 'subItem' as const,
children: '👥 Change user\'s role',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,22 @@
import { ISidebarService, useDependency } from '@univerjs/ui';
import { WATERMARK_PANEL, WATERMARK_PANEL_FOOTER } from '../controllers/components.controller';
export function useWatermark() {
const sidebarService = useDependency(ISidebarService);
const onSelect = () => {
sidebarService.open({
header: { title: 'Watermark' },
children: { label: WATERMARK_PANEL },
footer: { label: WATERMARK_PANEL_FOOTER },
onClose: () => { },
width: 330,
});
};
return {
type: 'item' as const,
children: '🌊 Watermark Settings',
onSelect,
};
}

View File

@@ -0,0 +1,224 @@
import type { IImageWatermarkConfig } from '@univerjs/engine-render';
import { Button, Checkbox, InputNumber } from '@univerjs/design';
import { ILocalFileService, useDependency } from '@univerjs/ui';
import { WATERMARK_IMAGE_ALLOW_IMAGE_LIST } from '@univerjs/watermark';
interface IWatermarkImageSettingProps {
config?: IImageWatermarkConfig;
onChange: (config: IImageWatermarkConfig) => void;
}
export function WatermarkImageSetting({ config, onChange }: IWatermarkImageSettingProps) {
const fileOpenService = useDependency(ILocalFileService);
if (!config) return null;
const handleUpdateImageUrl = async () => {
const files = await fileOpenService.openFile({
multiple: false,
accept: WATERMARK_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','),
});
const fileLength = files.length;
if (fileLength === 0) {
return false;
}
const file = files[0];
const reader = new FileReader();
reader.onload = function (event) {
if (event.target?.result) {
const base64String = event.target.result;
const img = new Image();
img.onload = function () {
onChange({ ...config, url: base64String as string, width: Math.max(20, img.width), height: Math.max(img.height, 20), originRatio: img.width / img.height });
};
img.src = base64String as string;
}
};
reader.readAsDataURL(file);
};
return (
<div className="univer-grid univer-gap-2">
<div className="univer-text-gray-400">Image</div>
<div className="univer-mb-4 univer-grid univer-gap-1">
<Button
className="univer-mb-2"
onClick={handleUpdateImageUrl}
>
{config.url ? 'Replace Image' : 'Upload Image'}
</Button>
<div className="univer-flex univer-gap-2 univer-text-center">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Opacity</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.opacity}
max={1}
min={0}
step={0.05}
onChange={(val) => {
if (val != null) {
onChange({ ...config, opacity: Number.parseFloat(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Keep Ratio</div>
<Checkbox
className="univer-justify-center univer-self-baseline"
checked={config.maintainAspectRatio}
onChange={(val) => {
if (val === true) {
onChange({ ...config, maintainAspectRatio: val as boolean, height: Math.round(config.width / config.originRatio) });
} else {
onChange({ ...config, maintainAspectRatio: val as boolean });
}
}}
/>
</div>
</div>
</div>
<div className="univer-grid univer-gap-2 univer-text-center">
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Width</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.width}
min={20}
onChange={(val) => {
if (val != null) {
const newWidth = Math.max(20, Number.parseInt(val.toString()));
if (config.maintainAspectRatio) {
onChange({ ...config, width: newWidth, height: Math.round(newWidth / config.originRatio) });
} else {
onChange({ ...config, width: newWidth });
}
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Height</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.height}
min={20}
onChange={(val) => {
if (val != null) {
const newHeight = Math.max(20, Number.parseInt(val.toString()));
if (config.maintainAspectRatio) {
onChange({ ...config, height: newHeight, width: Math.round(newHeight * config.originRatio) });
} else {
onChange({ ...config, height: Number.parseInt(val.toString()) });
}
}
}}
/>
</div>
</div>
</div>
<div className="univer-text-gray-400">Layout Settings</div>
<div className="univer-grid univer-gap-2 univer-text-center">
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Rotate</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.rotate}
max={360}
min={-360}
onChange={(val) => {
if (val != null) {
onChange({ ...config, rotate: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Repeat</div>
<Checkbox
className="univer-justify-center univer-self-baseline"
checked={config.repeat}
onChange={(val) => onChange({ ...config, repeat: val as boolean })}
/>
</div>
</div>
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Horizontal Spacing</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.spacingX}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, spacingX: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Vertical Spacing</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.spacingY}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, spacingY: Number.parseInt(val.toString()) });
}
}}
/>
</div>
</div>
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Horizontal Start Position</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.x}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, x: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Vertical Start Position</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.y}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, y: Number.parseInt(val.toString()) });
}
}}
/>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
import type { IWatermarkConfig, IWatermarkConfigWithType } from '@univerjs/engine-render';
import { ILocalStorageService } from '@univerjs/core';
import { Select } from '@univerjs/design';
import { IWatermarkTypeEnum, UNIVER_WATERMARK_STORAGE_KEY } from '@univerjs/engine-render';
import { useDependency, useObservable } from '@univerjs/ui';
import { WatermarkImageBaseConfig, WatermarkService, WatermarkTextBaseConfig } from '@univerjs/watermark';
import { useCallback, useEffect, useState } from 'react';
import { WatermarkImageSetting } from './WatermarkImageSetting';
import { WatermarkTextSetting } from './WatermarkTextSetting';
export function WatermarkPanel() {
const [watermarkType, setWatermarkType] = useState<IWatermarkTypeEnum>(IWatermarkTypeEnum.Text);
const [config, setConfig] = useState<IWatermarkConfig>();
const watermarkService = useDependency(WatermarkService);
const localStorageService = useDependency(ILocalStorageService);
const _refresh = useObservable(watermarkService.refresh$);
function handleConfigChange(config: IWatermarkConfig, type?: IWatermarkTypeEnum) {
setConfig(config);
watermarkService.updateWatermarkConfig({ type: type ?? watermarkType, config });
}
const getWatermarkConfig = useCallback(async () => {
const watermarkConfig = await localStorageService.getItem<IWatermarkConfigWithType>(UNIVER_WATERMARK_STORAGE_KEY);
if (watermarkConfig) {
setWatermarkType(watermarkConfig.type);
setConfig(watermarkConfig.config);
} else {
setConfig({ text: WatermarkTextBaseConfig });
}
}, []);
useEffect(() => {
getWatermarkConfig();
}, [_refresh, getWatermarkConfig]);
return (
<div className="univer-grid univer-gap-3 univer-text-sm">
{/* Watermark type */}
<div className="univer-grid univer-gap-2">
<div className="univer-text-gray-400">Type</div>
<Select
value={watermarkType}
options={[
{ label: 'Text', value: IWatermarkTypeEnum.Text },
{ label: 'Image', value: IWatermarkTypeEnum.Image },
]}
onChange={(v) => {
setWatermarkType(v as IWatermarkTypeEnum);
if (v === IWatermarkTypeEnum.Text) {
handleConfigChange({ text: WatermarkTextBaseConfig }, IWatermarkTypeEnum.Text);
} else if (v === IWatermarkTypeEnum.Image) {
handleConfigChange({ image: WatermarkImageBaseConfig }, IWatermarkTypeEnum.Image);
}
}}
/>
</div>
<div className="univer-grid univer-gap-2">
{watermarkType === IWatermarkTypeEnum.Text && <WatermarkTextSetting config={config?.text} onChange={(v) => handleConfigChange({ text: v })} />}
{watermarkType === IWatermarkTypeEnum.Image && <WatermarkImageSetting config={config?.image} onChange={(v) => handleConfigChange({ image: v })} />}
</div>
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { Button } from '@univerjs/design';
import { IWatermarkTypeEnum } from '@univerjs/engine-render';
import { IClipboardInterfaceService, ISidebarService, useDependency } from '@univerjs/ui';
import { WatermarkService, WatermarkTextBaseConfig } from '@univerjs/watermark';
export function WatermarkPanelFooter() {
const sidebarService = useDependency(ISidebarService);
const watermarkService = useDependency(WatermarkService);
const clipboardService = useDependency(IClipboardInterfaceService);
return (
<div className="univer-flex univer-items-center univer-justify-between">
<a
className="univer-text-sm univer-text-primary-600 univer-underline"
onClick={() => {
watermarkService.updateWatermarkConfig({
type: IWatermarkTypeEnum.Text,
config: { text: WatermarkTextBaseConfig },
});
watermarkService.refresh();
}}
>
Cancel Watermark
</a>
<div className="univer-flex univer-items-center univer-gap-2">
<Button
onClick={async () => {
const watermarkConfig = await watermarkService.getWatermarkConfig();
let config;
if (watermarkConfig?.type === IWatermarkTypeEnum.Text) {
config = watermarkConfig.config.text;
} else if (watermarkConfig?.type === IWatermarkTypeEnum.Image) {
config = watermarkConfig.config.image;
}
clipboardService.writeText(JSON.stringify(config));
}}
>
Copy Config
</Button>
<Button
onClick={async () => {
const watermarkConfig = await watermarkService.getWatermarkConfig();
if (watermarkConfig?.type === IWatermarkTypeEnum.Text && !watermarkConfig.config.text?.content) {
watermarkService.deleteWatermarkConfig();
} else if (watermarkConfig?.type === IWatermarkTypeEnum.Image && !watermarkConfig.config.image?.url) {
watermarkService.deleteWatermarkConfig();
}
sidebarService.close();
}}
>
Close Panel
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,205 @@
import type { ITextWatermarkConfig } from '@univerjs/engine-render';
import { Checkbox, clsx, ColorPicker, Dropdown, Input, InputNumber, Select } from '@univerjs/design';
import { BoldIcon, FontColorDoubleIcon, ItalicIcon } from '@univerjs/icons';
interface IWatermarkTextSettingProps {
config?: ITextWatermarkConfig;
onChange: (config: ITextWatermarkConfig) => void;
}
export function WatermarkTextSetting(props: IWatermarkTextSettingProps) {
const { config, onChange } = props;
if (!config) return null;
return (
<div className="univer-grid univer-gap-2">
<div className="univer-text-gray-400">Style Settings</div>
<div className="univer-mb-4 univer-grid univer-gap-1">
<div>Content</div>
<Input
value={config.content}
onChange={(val) => onChange({ ...config, content: val })}
placeholder="Enter text"
/>
</div>
<div className="univer-grid univer-gap-4">
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-gap-1">
<div>Font Size</div>
<InputNumber
value={config.fontSize}
max={72}
min={12}
onChange={(val) => {
if (val != null) {
onChange({ ...config, fontSize: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-gap-1">
<div>Direction</div>
<Select
value={config.direction}
options={[
{ label: 'Left to Right', value: 'ltr' },
{ label: 'Right to Left', value: 'rtl' },
]}
onChange={(v) => onChange({ ...config, direction: v as 'ltr' | 'rtl' })}
/>
</div>
<div className="univer-grid univer-gap-1">
<div>Opacity</div>
<InputNumber
max={1}
min={0}
step={0.05}
value={config.opacity}
onChange={(val) => {
if (val != null) {
onChange({ ...config, opacity: Number.parseFloat(val.toString()) });
}
}}
/>
</div>
</div>
<div
className={`
univer-flex univer-justify-around univer-gap-4
[&_a]:univer-flex [&_a]:univer-size-6 [&_a]:univer-items-center [&_a]:univer-justify-center
[&_a]:univer-rounded
`}
>
<Dropdown
overlay={(
<div className="univer-rounded-lg univer-p-4">
<ColorPicker value={config.color} onChange={(val) => onChange({ ...config, color: val })} />
</div>
)}
>
<a
className={`
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`}
>
<FontColorDoubleIcon extend={{ colorChannel1: config.color ?? '#2c53f1' }} />
</a>
</Dropdown>
<a
className={clsx(`
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`, {
'univer-bg-gray-200 dark:!univer-bg-gray-600': config.bold,
})}
onClick={() => { onChange({ ...config, bold: !config.bold }); }}
>
<BoldIcon />
</a>
<a
className={clsx(`
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`, {
'univer-bg-gray-200 dark:!univer-bg-gray-600': config.italic,
})}
onClick={() => { onChange({ ...config, italic: !config.italic }); }}
>
<ItalicIcon />
</a>
</div>
</div>
{/* Layout */}
<div className="univer-text-gray-400">Layout Settings</div>
<div className="univer-grid univer-gap-2 univer-text-center">
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Rotate</div>
<InputNumber
value={config.rotate}
max={360}
min={-360}
onChange={(val) => {
if (val != null) {
onChange({ ...config, rotate: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Repeat</div>
<Checkbox
className="univer-justify-center univer-self-baseline"
checked={config.repeat}
onChange={(val) => onChange({ ...config, repeat: val as boolean })}
/>
</div>
</div>
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-gap-1">
<div>Horizontal Spacing</div>
<InputNumber
value={config.spacingX}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, spacingX: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-gap-1">
<div>Vertical Spacing</div>
<InputNumber
value={config.spacingY}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, spacingY: Number.parseInt(val.toString()) });
}
}}
/>
</div>
</div>
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-gap-1">
<div>Horizontal Start Position</div>
<InputNumber
value={config.x}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, x: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-gap-1">
<div>Vertical Start Position</div>
<InputNumber
value={config.y}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, y: Number.parseInt(val.toString()) });
}
}}
/>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,11 @@
import type { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

88
common/shared/bin/index.mts Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env -S node --experimental-strip-types
import type { IBuildOptions } from '../tsdown/types.ts';
import process from 'node:process';
import { buildPresetPackage, preparePresetPackage } from '../preset-build/index.ts';
import { build } from '../tsdown/index.ts';
interface IPresetCliBuildOptions {
cleanup?: boolean;
tsdownConfigPath?: string;
}
const argvs = process.argv.slice(2);
const [command, ...args] = argvs;
function collectOptionValues(argv: string[], optionName: string) {
const values: string[] = [];
for (let index = 0; index < argv.length; index++) {
if (argv[index] !== optionName) {
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith('--')) {
continue;
}
values.push(...next.split(',').map((value) => value.trim()).filter(Boolean));
index++;
}
return [...new Set(values)];
}
if (command === 'preset') {
const [presetCommand, ...presetArgs] = args;
if (!presetCommand) {
throw new Error('Missing preset subcommand. Expected "prepare" or "build".');
}
if (presetCommand === 'prepare') {
preparePresetPackage();
} else if (presetCommand === 'build') {
const options: IPresetCliBuildOptions = {};
if (presetArgs.includes('--cleanup')) {
options.cleanup = true;
}
const configIdx = presetArgs.indexOf('--config');
if (configIdx !== -1 && presetArgs[configIdx + 1]) {
options.tsdownConfigPath = presetArgs[configIdx + 1];
}
// eslint-disable-next-line antfu/no-top-level-await
await buildPresetPackage(options);
} else {
throw new Error(`Unknown preset subcommand "${presetCommand}". Expected "prepare" or "build".`);
}
}
if (command === 'build') {
const options: IBuildOptions = {};
if (args.includes('--skipUMD')) {
options.skipUMD = true;
}
if (args.includes('--cleanup')) {
options.cleanup = true;
}
if (args.includes('--nodeFirst')) {
options.nodeFirst = true;
}
const ignorePackages = collectOptionValues(args, '--ignorePackages');
if (ignorePackages.length > 0) {
options.ignorePackages = ignorePackages;
}
const configIdx = args.indexOf('--config');
if (configIdx !== -1 && args[configIdx + 1]) {
options.tsdownConfigPath = args[configIdx + 1];
}
// eslint-disable-next-line antfu/no-top-level-await
await build(options);
}

View File

@@ -0,0 +1,76 @@
import fs from 'node:fs';
interface IResolveArgs {
importer: string;
path: string;
}
interface ILoadArgs {
path: string;
}
interface IPluginBuild {
onResolve(
options: { filter: RegExp; namespace?: string },
callback: (args: IResolveArgs) => unknown
): void;
onLoad(
options: { filter: RegExp; namespace?: string },
callback: (args: ILoadArgs) => unknown
): void;
}
interface IEsbuildPlugin {
name: string;
setup: (build: IPluginBuild) => void;
}
export function ignoreGlobalCssPlugin(): IEsbuildPlugin {
return {
name: 'ignore-global-css',
setup(build: IPluginBuild) {
build.onResolve({ filter: /\/global\.css$/ }, (args: IResolveArgs) => {
if (args.importer.includes('packages')) {
return {
path: args.path,
namespace: 'ignore-global-css',
pluginData: {
importer: args.importer,
},
};
}
});
build.onLoad({ filter: /\/global\.css$/, namespace: 'ignore-global-css' }, () => {
return { contents: '' };
});
},
};
}
export function removeClassnameNewlinesPlugin(): IEsbuildPlugin {
return {
name: 'remove-classname-newlines',
setup(build: IPluginBuild) {
build.onLoad({ filter: /\.(tsx)$/ }, (args: ILoadArgs) => {
const source = fs.readFileSync(args.path, 'utf8');
const transformedSource = source.replace(
/className\s*=\s*{([^}]*?)}/gs,
(_match: string, classNameValue: string) => {
const cleanedValue = classNameValue.replace(/`([^`]*?)`/gs, (_templateMatch: string, templateContent: string) => {
return `\`${templateContent.replace(/\s*\n\s*/g, ' ').trim()}\``;
});
return `className={${cleanedValue.trim()}}`;
}
);
return {
contents: transformedSource,
loader: 'tsx',
};
});
},
};
}

View File

@@ -0,0 +1,80 @@
import type { Rules } from '@antfu/eslint-config';
import type { ESLint, Linter } from 'eslint';
import antfu from '@antfu/eslint-config';
import reactHooks from 'eslint-plugin-react-hooks';
import {
facadePreset,
headerPreset,
indexEntryPreset,
noBarrelImportPreset,
penetratingPreset,
specPreset,
tailwindcssPreset,
typescriptPreset,
univerSourcePreset,
} from './presets';
import { baseRules } from './rules';
export interface IUniverEslintConfigOptions {
ignores?: string[];
header?: boolean;
noFacadeImportsOutsideFacade?: {
ignore?: string[];
};
rules?: Partial<Rules>;
}
export const createUniverEslintConfig = (options: IUniverEslintConfigOptions = {}) => {
const configs: Linter.Config[] = [
{
files: ['**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'],
plugins: {
'react-hooks': reactHooks as unknown as ESLint.Plugin,
},
rules: {
...baseRules,
...options.rules,
},
},
penetratingPreset(),
typescriptPreset(),
indexEntryPreset(),
univerSourcePreset({
noFacadeImportsOutsideFacade: options.noFacadeImportsOutsideFacade,
}),
facadePreset(),
noBarrelImportPreset(),
tailwindcssPreset(),
specPreset(),
];
if (options.header ?? true) {
configs.push(headerPreset());
}
return antfu(
{
ignores: options.ignores,
stylistic: {
indent: 4,
semi: true,
},
e18e: false,
regexp: false,
react: true,
pnpm: false,
yaml: {
overrides: {
'yaml/indent': ['error', 4, { indicatorValueIndent: 2 }],
},
},
markdown: false,
typescript: true,
formatters: {
css: true,
html: true,
},
},
...configs
);
};

View File

@@ -0,0 +1,14 @@
export { createUniverEslintConfig } from './config';
export type { IUniverEslintConfigOptions } from './config';
export {
facadePreset,
headerPreset,
indexEntryPreset,
noBarrelImportPreset,
penetratingPreset,
specPreset,
tailwindcssPreset,
typescriptPreset,
univerSourcePreset,
} from './presets';
export { baseRules } from './rules';

View File

@@ -0,0 +1,54 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { describe, it } from 'vitest';
import noExternalImportsInFacade from './no-external-imports-in-facade';
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});
describe('no-external-imports-in-facade', () => {
it('explains the facade package-entry migration path', () => {
ruleTester.run('no-external-imports-in-facade', noExternalImportsInFacade, {
valid: [
{
code: 'import { PublicCommand } from "@univerjs/example";',
filename: '/repo/packages/example/src/facade/f-univer.ts',
},
{
code: 'import { FThing } from "./f-thing";',
filename: '/repo/packages/example/src/facade/f-univer.ts',
},
],
invalid: [
{
code: 'import { PrivateCommand } from "../commands/private.command";',
filename: '/repo/packages/example/src/facade/f-univer.ts',
errors: [
{
message: 'Facade files are published as a separate package entry. Do not import package-internal runtime modules through "../commands/private.command"; it can duplicate module singletons when consumers import both the package root and the facade entry. Move the needed symbol to the package root export if necessary, then import it through the package name. Type-only imports should also use a public package entry.',
},
],
},
],
});
});
});

View File

@@ -0,0 +1,72 @@
import type { Rule } from 'eslint';
import path from 'node:path';
function getImportSourceValue(node: Rule.Node): string | null {
if (!('source' in node)) {
return null;
}
const source = (node as { source?: { value?: unknown } }).source;
if (!source || typeof source.value !== 'string') {
return null;
}
return source.value;
}
function getRuleFilename(context: Rule.RuleContext): string {
const filenameFromProperty = (context as { filename?: unknown }).filename;
if (typeof filenameFromProperty === 'string' && filenameFromProperty) {
return filenameFromProperty;
}
const getFilename = (context as { getFilename?: () => string }).getFilename;
return typeof getFilename === 'function' ? getFilename.call(context) : '';
}
const rule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Disallow imports from outside facade directory in facade files',
},
messages: {
noExternalImports: 'Facade files are published as a separate package entry. Do not import package-internal runtime modules through "{{importPath}}"; it can duplicate module singletons when consumers import both the package root and the facade entry. Move the needed symbol to the package root export if necessary, then import it through the package name. Type-only imports should also use a public package entry.',
},
},
create(context) {
const filename = getRuleFilename(context);
const normalizedPath = filename.split(path.sep).join('/');
const isFacadeFile = normalizedPath.includes('/facade/');
if (!isFacadeFile) {
return {};
}
const currentDir = path.dirname(filename);
return {
ImportDeclaration(node: Rule.Node) {
const importPath = getImportSourceValue(node);
if (!importPath || !importPath.startsWith('.')) {
return;
}
const resolvedPath = path.resolve(currentDir, importPath).split(path.sep).join('/');
if (!resolvedPath.includes('/facade/')) {
context.report({
node,
messageId: 'noExternalImports',
data: { importPath },
});
}
},
};
},
};
export default rule;

View File

@@ -0,0 +1,137 @@
import type { Rule } from 'eslint';
import path from 'node:path';
interface IRuleOptions { ignore?: unknown[] }
function getImportSourceValue(node: Rule.Node): string | null {
if (!('source' in node)) {
return null;
}
const source = (node as { source?: { value?: unknown } }).source;
if (!source || typeof source.value !== 'string') {
return null;
}
return source.value;
}
function normalizePath(filePath: string): string {
return filePath.split(path.sep).join('/');
}
function shouldIgnoreFile(normalizedFilePath: string, ignorePaths: readonly unknown[]): boolean {
return ignorePaths.some((ignorePath: unknown) => {
if (typeof ignorePath !== 'string') {
return false;
}
const normalizedIgnorePath = normalizePath(ignorePath).replace(/^\.\//, '');
if (!normalizedIgnorePath) {
return false;
}
return normalizedFilePath === normalizedIgnorePath
|| normalizedFilePath.endsWith(`/${normalizedIgnorePath}`)
|| normalizedFilePath.includes(normalizedIgnorePath);
});
}
function getRuleFilename(context: Rule.RuleContext): string {
const filenameFromProperty = (context as { filename?: unknown }).filename;
if (typeof filenameFromProperty === 'string' && filenameFromProperty) {
return filenameFromProperty;
}
const getFilename = (context as { getFilename?: () => string }).getFilename;
return typeof getFilename === 'function' ? getFilename.call(context) : '';
}
const rule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Disallow imports containing facade in non-facade files',
},
schema: [
{
type: 'object',
properties: {
ignore: {
type: 'array',
items: {
type: 'string',
},
},
},
additionalProperties: false,
},
],
messages: {
noFacadeImports: 'Imports containing "facade" are not allowed in non-facade files: "{{importPath}}"',
},
},
create(context) {
const filename = getRuleFilename(context);
const normalizedPath = normalizePath(filename);
const [ruleOptions = {} as IRuleOptions] = context.options as [IRuleOptions?];
const ignorePaths = Array.isArray(ruleOptions.ignore) ? ruleOptions.ignore : [];
if (shouldIgnoreFile(normalizedPath, ignorePaths)) {
return {};
}
const isInPackages = normalizedPath.includes('/packages/');
const isInFacade = normalizedPath.includes('/facade/');
if (!isInPackages || isInFacade) {
return {};
}
// get parent dir
const parentDirMatch = normalizedPath.match(/\/([^/]+)\/packages\//);
if (!parentDirMatch) {
return {};
}
return {
ImportDeclaration(node: Rule.Node) {
const importPath = getImportSourceValue(node);
// Check if the import path contains 'facade'
if (importPath?.includes('facade')) {
context.report({
node,
messageId: 'noFacadeImports',
data: { importPath },
});
}
},
ExportNamedDeclaration(node: Rule.Node) {
const exportPath = getImportSourceValue(node);
if (exportPath?.includes('facade')) {
context.report({
node,
messageId: 'noFacadeImports',
data: { importPath: exportPath },
});
}
},
ExportAllDeclaration(node: Rule.Node) {
const exportPath = getImportSourceValue(node);
if (exportPath?.includes('facade')) {
context.report({
node,
messageId: 'noFacadeImports',
data: { importPath: exportPath },
});
}
},
};
},
};
export default rule;

View File

@@ -0,0 +1,122 @@
import type { Rule, SourceCode } from 'eslint';
import path from 'node:path';
type ImportOrExportSpecifier = Rule.Node & {
importKind?: 'type' | 'value';
exportKind?: 'type' | 'value';
};
function normalizePath(filePath: string): string {
return filePath.split(path.sep).join('/');
}
function getRuleFilename(context: Rule.RuleContext): string {
const filenameFromProperty = (context as { filename?: unknown }).filename;
if (typeof filenameFromProperty === 'string' && filenameFromProperty) {
return filenameFromProperty;
}
const getFilename = (context as { getFilename?: () => string }).getFilename;
return typeof getFilename === 'function' ? getFilename.call(context) : '';
}
function isSrcIndexFile(context: Rule.RuleContext): boolean {
return normalizePath(getRuleFilename(context)).endsWith('/src/index.ts');
}
function isInlineTypeSpecifier(specifier: ImportOrExportSpecifier, kind: 'import' | 'export'): boolean {
return kind === 'import' ? specifier.importKind === 'type' : specifier.exportKind === 'type';
}
function trimInlineTypeKeyword(specifierText: string): string {
return specifierText.replace(/^type\s+/, '');
}
function buildReplacement(
sourceCode: SourceCode,
node: Rule.Node,
kind: 'import' | 'export'
): string | null {
const specifiers = (node as { specifiers?: ImportOrExportSpecifier[] }).specifiers;
if (!specifiers?.length) {
return null;
}
const typeSpecifiers = specifiers.filter((specifier) => isInlineTypeSpecifier(specifier, kind));
const valueSpecifiers = specifiers.filter((specifier) => !isInlineTypeSpecifier(specifier, kind));
if (typeSpecifiers.length === 0 || valueSpecifiers.length === 0) {
return null;
}
const nodeText = sourceCode.getText(node);
const openingBraceIndex = nodeText.indexOf('{');
const closingBraceIndex = nodeText.lastIndexOf('}');
if (openingBraceIndex === -1 || closingBraceIndex === -1 || closingBraceIndex < openingBraceIndex) {
return null;
}
const suffixWithSemicolon = nodeText.slice(closingBraceIndex + 1);
const hasSemicolon = suffixWithSemicolon.trimEnd().endsWith(';');
const suffix = hasSemicolon ? suffixWithSemicolon.replace(/;\s*$/, '') : suffixWithSemicolon;
const semicolon = hasSemicolon ? ';' : '';
const valueSpecifierText = valueSpecifiers.map((specifier) => sourceCode.getText(specifier)).join(', ');
const typeSpecifierText = typeSpecifiers
.map((specifier) => trimInlineTypeKeyword(sourceCode.getText(specifier)))
.join(', ');
return `${kind} { ${valueSpecifierText} }${suffix}${semicolon}\n${kind} type { ${typeSpecifierText} }${suffix}${semicolon}`;
}
const rule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow mixing inline type specifiers with value specifiers in src/index.ts',
},
fixable: 'code',
messages: {
noMixedExportSpecifiers: 'Do not mix type and value export specifiers in src/index.ts.',
noMixedImportSpecifiers: 'Do not mix type and value import specifiers in src/index.ts.',
},
},
create(context) {
if (!isSrcIndexFile(context)) {
return {};
}
return {
ImportDeclaration(node: Rule.Node) {
const replacement = buildReplacement(context.sourceCode, node, 'import');
if (!replacement) {
return;
}
context.report({
node,
messageId: 'noMixedImportSpecifiers',
fix(fixer) {
return fixer.replaceText(node, replacement);
},
});
},
ExportNamedDeclaration(node: Rule.Node) {
const replacement = buildReplacement(context.sourceCode, node, 'export');
if (!replacement) {
return;
}
context.report({
node,
messageId: 'noMixedExportSpecifiers',
fix(fixer) {
return fixer.replaceText(node, replacement);
},
});
},
};
},
};
export default rule;

View File

@@ -0,0 +1,95 @@
import type { Rule } from 'eslint';
import path from 'node:path';
function getImportSourceValue(node: Rule.Node): string | null {
if (!('source' in node)) {
return null;
}
const source = (node as { source?: { value?: unknown } }).source;
if (!source || typeof source.value !== 'string') {
return null;
}
return source.value;
}
function getRuleFilename(context: Rule.RuleContext): string {
const filenameFromProperty = (context as { filename?: unknown }).filename;
if (typeof filenameFromProperty === 'string' && filenameFromProperty) {
return filenameFromProperty;
}
const getFilename = (context as { getFilename?: () => string }).getFilename;
return typeof getFilename === 'function' ? getFilename.call(context) : '';
}
const rule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Disallow self package imports in packages directory except facade',
},
messages: {
noSelfImport: 'Package cannot import itself: "{{importPath}}" in {{packageName}}',
},
},
create(context) {
const filename = getRuleFilename(context);
const normalizedPath = filename.split(path.sep).join('/');
const isInPackages = normalizedPath.includes('/packages/');
const isInFacade = normalizedPath.includes('/facade/');
if (!isInPackages || isInFacade) {
return {};
}
// get parent dir
const parentDirMatch = normalizedPath.match(/\/([^/]+)\/packages\//);
if (!parentDirMatch) {
return {};
}
const parentDir = parentDirMatch[1];
const packagePrefix = parentDir === 'univer'
? '@univerjs/' :
parentDir === 'univer-pro'
? '@univerjs-pro/' :
null;
if (!packagePrefix) {
return {};
}
// get package name
const packageMatch = normalizedPath.match(/\/packages\/([^/]+)/);
if (!packageMatch) {
return {};
}
const packageName = packageMatch[1];
const possiblePackageName = `${packagePrefix}${packageName}`;
return {
ImportDeclaration(node: Rule.Node) {
const importPath = getImportSourceValue(node);
if (importPath && importPath === possiblePackageName) {
context.report({
node,
messageId: 'noSelfImport',
data: {
importPath,
packageName: importPath,
},
});
}
},
};
},
};
export default rule;

View File

@@ -0,0 +1,296 @@
import type { ESLint, Linter } from 'eslint';
import os from 'node:os';
import path from 'node:path';
import { fixupPluginRules } from '@eslint/compat';
import typescriptParser from '@typescript-eslint/parser';
import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss';
import header from 'eslint-plugin-header';
import barrel from 'eslint-plugin-no-barrel-import';
import penetrating from 'eslint-plugin-no-penetrating-import';
import noExternalImportsInFacade from './plugins/no-external-imports-in-facade';
import noFacadeImportsOutsideFacade from './plugins/no-facade-imports-outside-facade';
import noMixedTypeSpecifiersInIndex from './plugins/no-mixed-type-specifiers-in-index';
import noSelfPackageImports from './plugins/no-self-package-imports';
const univerPlugin = {
rules: {
'no-external-imports-in-facade': noExternalImportsInFacade,
'no-self-package-imports': noSelfPackageImports,
'no-facade-imports-outside-facade': noFacadeImportsOutsideFacade,
'no-mixed-type-specifiers-in-index': noMixedTypeSpecifiersInIndex,
},
} satisfies ESLint.Plugin;
/**
* TypeScript preset configuration for ESLint.
* Applies TypeScript-specific rules and custom Univer rules.
*
* @returns ESLint configuration object for TypeScript files
*/
export const typescriptPreset = (): Linter.Config => {
return {
files: ['**/*.{ts,tsx,mts,cts}'],
rules: {
'ts/naming-convention': [
'warn',
// Interfaces' names should start with a capital 'I'.
{
selector: 'interface',
format: ['PascalCase'],
custom: {
regex: '^I[A-Z0-9]',
match: true,
},
},
// Private fields of a class should start with an underscore '_'.
{
selector: ['classMethod', 'classProperty'],
modifiers: ['private'],
format: ['camelCase'],
leadingUnderscore: 'require',
},
],
// 'ts/consistent-type-exports': 'warn',
},
languageOptions: {
parser: typescriptParser,
},
};
};
/**
* Index entry preset configuration for ESLint.
* Keeps type-only imports and exports separate from value imports and exports.
*
* @returns ESLint configuration object for package entry files
*/
export const indexEntryPreset = (): Linter.Config => {
return {
files: ['**/src/index.ts'],
rules: {
'univer/no-mixed-type-specifiers-in-index': 'error',
},
plugins: {
univer: univerPlugin,
},
languageOptions: {
parser: typescriptParser,
},
};
};
/**
* Univer source code preset configuration.
* Enforces package structure rules for source files.
*
* @returns ESLint configuration object for Univer source files
*/
export const univerSourcePreset = (options: {
noFacadeImportsOutsideFacade?: {
ignore?: string[];
};
} = {}): Linter.Config => {
return {
files: ['**/*.ts', '**/*.tsx'],
ignores: [
'**/__tests__/**/*',
'**/*.spec.ts',
'**/*.test.ts',
],
rules: {
'univer/no-self-package-imports': 'error',
'univer/no-facade-imports-outside-facade': ['error', {
ignore: options.noFacadeImportsOutsideFacade?.ignore ?? [],
}],
},
plugins: {
univer: univerPlugin,
},
languageOptions: {
parser: typescriptParser,
},
};
};
/**
* Facade preset configuration for ESLint.
* Enforces strict typing and import rules for facade files.
*
* @returns ESLint configuration object for facade files
*/
export const facadePreset = (): Linter.Config => {
return {
files: ['**/src/facade/**/*.ts'],
ignores: [
'**/__tests__/**/*',
'**/*.spec.ts',
'**/*.test.ts',
],
rules: {
'ts/explicit-function-return-type': 'error',
'univer/no-external-imports-in-facade': 'error',
},
plugins: {
univer: univerPlugin,
},
};
};
/**
* Tailwind CSS preset configuration for ESLint.
* Enforces Tailwind CSS class usage and formatting rules.
*
* @returns ESLint configuration object for files using Tailwind CSS
*/
export const tailwindcssPreset = (): Linter.Config => {
const isWindows = os.platform() === 'win32';
const lineBreakStyle = isWindows ? 'windows' : 'unix';
return {
files: ['**/*.tsx'],
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'better-tailwindcss': eslintPluginBetterTailwindcss,
},
settings: {
'better-tailwindcss': {
tailwindConfig: path.resolve(__dirname, '../tailwind/tailwind.config.ts'),
},
},
rules: {
// enable all recommended rules to error
...eslintPluginBetterTailwindcss.configs['recommended-error'].rules,
'better-tailwindcss/enforce-consistent-line-wrapping': ['error', {
printWidth: 120,
group: 'newLine',
lineBreakStyle,
}],
'better-tailwindcss/no-unknown-classes': 'off',
'better-tailwindcss/no-conflicting-classes': 'off',
},
};
};
/**
* Test/spec files preset configuration for ESLint.
* Relaxed rules for test files to allow more flexible testing patterns.
*
* @returns ESLint configuration object for test files
*/
export const specPreset = (): Linter.Config => {
return {
files: [
'**/*.spec.ts',
'**/__tests__/**/*.ts',
],
rules: {
'ts/explicit-function-return-type': 'off',
},
};
};
/**
* Penetrating import preset configuration for ESLint.
* Prevents direct imports from internal package directories.
*
* @returns ESLint configuration object for preventing penetrating imports
*/
export const penetratingPreset = (): Linter.Config => {
return {
// Not penetrating for source files
files: ['**/*.ts', '**/*.tsx'],
plugins: {
penetrating: fixupPluginRules(penetrating),
},
ignores: [
'**/__tests__/**/*',
'**/__testing__/**/*',
'examples/**/*',
],
rules: {
'penetrating/no-penetrating-import': 2,
},
};
};
/**
* No barrel import preset configuration for ESLint.
* Prevents barrel imports and enforces code complexity limits.
*
* @returns ESLint configuration object for preventing barrel imports
*/
export const noBarrelImportPreset = (): Linter.Config => {
return {
files: ['**/*.ts', '**/*.tsx'],
ignores: [
'packages/engine-render/src/components/docs/**/*.ts',
'**/*.tsx',
'**/*.d.ts',
'**/tsdown.config.ts',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.test.ts',
'**/*.test.tsx',
], // do not check test files
plugins: {
barrel: fixupPluginRules(barrel),
},
rules: {
'barrel/no-barrel-import': 2,
complexity: ['warn', { max: 20 }],
'max-lines-per-function': ['warn', 80],
},
};
};
/**
* Header preset configuration for ESLint.
* Enforces Apache License 2.0 header in package and preset source files.
*
* @returns ESLint configuration object for enforcing file headers
*/
export const headerPreset = (): Linter.Config => {
header.rules.header.meta.schema = false;
return {
files: ['{packages,presets}/**/*.{ts,tsx}'],
ignores: [
'**/*.d.ts',
'**/vitest.config.ts',
'**/vitest.workspace.ts',
],
plugins: {
header: fixupPluginRules(header),
},
rules: {
'header/header': [
2,
'block',
[
'*',
' * Copyright 2023-present DreamNum Co., Ltd.',
' *',
' * Licensed under the Apache License, Version 2.0 (the "License");',
' * you may not use this file except in compliance with the License.',
' * You may obtain a copy of the License at',
' *',
' * http://www.apache.org/licenses/LICENSE-2.0',
' *',
' * Unless required by applicable law or agreed to in writing, software',
' * distributed under the License is distributed on an "AS IS" BASIS,',
' * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.',
' * See the License for the specific language governing permissions and',
' * limitations under the License.',
' ',
],
2,
],
},
};
};

View File

@@ -0,0 +1,201 @@
import type { Rules } from '@antfu/eslint-config';
/**
* Base ESLint rules configuration for Univer project.
* These rules apply to all TypeScript and JavaScript files.
*/
const qualityRules: Partial<Rules> = {
// Code style and formatting rules
curly: ['error', 'multi-line'],
'antfu/if-newline': 'off',
'no-param-reassign': ['warn'],
'eol-last': ['error', 'always'],
'no-empty-function': 'off',
};
const typescriptRules: Partial<Rules> = {
// TypeScript specific rules
'ts/no-explicit-any': 'warn',
'ts/no-redeclare': 'off',
'ts/method-signature-style': 'off',
};
const styleRules: Partial<Rules> = {
// Style and formatting rules
'style/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'style/brace-style': ['warn', '1tbs', { allowSingleLine: true }],
'style/jsx-first-prop-new-line': ['warn', 'multiline'],
'style/arrow-parens': ['error', 'always'],
'style/spaced-comment': 'off',
'style/indent-binary-ops': 'off',
'style/operator-linebreak': 'off',
'style/indent': ['error', 4, {
ObjectExpression: 'first',
SwitchCase: 1,
ignoreComments: true,
}],
'style/quotes': ['warn', 'single', { avoidEscape: true }],
'style/jsx-closing-tag-location': 'warn',
'style/jsx-curly-newline': ['warn', { multiline: 'forbid', singleline: 'forbid' }],
'style/jsx-wrap-multilines': 'warn',
'style/quote-props': ['warn', 'as-needed'],
'style/jsx-curly-brace-presence': 'warn',
'style/multiline-ternary': 'warn',
// Comma and punctuation rules
'style/comma-dangle': ['error', {
arrays: 'always-multiline',
objects: 'always-multiline',
imports: 'always-multiline',
exports: 'always-multiline',
enums: 'always-multiline',
functions: 'never',
}],
};
const filenameRules: Partial<Rules> = {
// Filename conventions
'unicorn/filename-case': [
'error',
{
cases: {
kebabCase: true,
pascalCase: true,
},
ignore: [
'^README-(\w+)?\\.md$',
'^[a-z]{2}-[A-Z]{2}\.ts$',
'^__tests__$',
'^__testing__$',
'^FUNDING.yml$',
'^bug_report.yml$',
'^bug_report.zh-CN.yml$',
'^feature_request.yml$',
'^feature_request.zh-CN.yml$',
],
},
],
};
const reactRules: Partial<Rules> = {
// JSX and React rules
'style/jsx-self-closing-comp': ['error', {
component: true,
html: true,
}],
'react-refresh/only-export-components': 'off',
'react/no-unstable-context-value': 'warn',
'react/no-unstable-default-props': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/no-create-ref': 'warn',
'react/rules-of-hooks': 'off',
'react/static-components': 'off',
'react/dom-no-render': 'off',
'react/unsupported-syntax': 'off',
'react/jsx-no-key-after-spread': 'off',
'react/use-memo': 'warn',
'react-hooks/static-components': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/refs': 'off',
'react-hooks/immutability': 'warn',
'react-hooks/use-memo': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/globals': 'warn',
};
const importRules: Partial<Rules> = {
// Import and export rules
'sort-imports': [
'error',
{
allowSeparatedGroups: false,
ignoreCase: true,
ignoreDeclarationSort: true,
ignoreMemberSort: false,
memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
},
],
'perfectionist/sort-imports': 'warn',
'perfectionist/sort-named-imports': 'off',
'perfectionist/sort-named-exports': 'warn',
// Code quality rules
'accessor-pairs': 'warn',
'react-hooks/exhaustive-deps': 'off',
'react-hooks/rules-of-hooks': 'off',
'jsdoc/tag-lines': 'off',
// Restricted imports for consistency
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'clsx',
message: 'Please use `import { clsx } from \'@univerjs/design\'` instead.',
},
],
},
],
};
const disabledDebatableRules: Partial<Rules> = {
// IMPORTANT: To ensure compatibility, some features of React 19 will be disabled.
'react/no-forward-ref': 'off',
'react/no-context-provider': 'off',
'react-dom/no-render': 'off',
'react/no-use-context': 'off',
// Debatable rules - currently disabled
'test/prefer-lowercase-title': 'off',
'antfu/top-level-function': 'off',
'unicorn/no-new-array': 'off',
'unicorn/prefer-includes': 'off',
'prefer-arrow-callback': 'off',
'no-restricted-globals': 'off',
'unicorn/prefer-string-starts-ends-with': 'warn',
};
const migrationWarningRules: Partial<Rules> = {
// Compatibility rules for legacy code - set to warn for gradual migration
'unused-imports/no-unused-vars': 'warn',
'ts/no-restricted-types': 'warn',
'ts/no-wrapper-object-types': 'warn',
'ts/no-empty-object-type': 'warn',
'ts/no-unsafe-function-type': 'warn',
'ts/no-unused-expressions': 'warn',
'unicorn/prefer-dom-node-text-content': 'warn',
'unicorn/prefer-number-properties': 'warn',
'no-prototype-builtins': 'warn',
'eslint-comments/no-unlimited-disable': 'off',
'ts/prefer-ts-expect-error': 'off',
'ts/ban-ts-comment': 'off',
'ts/no-duplicate-enum-values': 'off',
'no-cond-assign': 'warn',
'ts/no-use-before-define': 'warn',
'test/no-identical-title': 'warn',
'ts/no-non-null-asserted-optional-chain': 'warn',
'no-restricted-syntax': 'warn',
'prefer-regex-literals': 'warn',
'ts/no-this-alias': 'warn',
'prefer-promise-reject-errors': 'warn',
'no-new': 'warn',
'unicorn/error-message': 'warn',
'ts/prefer-literal-enum-member': 'warn',
'no-control-regex': 'warn',
'ts/no-import-type-side-effects': 'warn',
'unicorn/number-literal-case': 'warn',
'unicorn/prefer-type-error': 'warn',
};
export const baseRules: Partial<Rules> = {
...qualityRules,
...typescriptRules,
...styleRules,
...filenameRules,
...reactRules,
...importRules,
...disabledDebatableRules,
...migrationWarningRules,
};

3
common/shared/eslint/third-party.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module 'eslint-plugin-header';
declare module 'eslint-plugin-no-barrel-import';
declare module 'eslint-plugin-no-penetrating-import';

View File

@@ -0,0 +1,61 @@
import { existsSync, readdirSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const LOCALE_FILE_NAME_PATTERN = /^[a-z]{2}-[A-Z]{2}\.ts$/;
export interface IDiscoverUniverUiLocalesOptions {
rootDir?: string;
uiPackageDir?: string;
}
function getCandidateUiPackageDirs(rootDir: string): string[] {
const dirs: string[] = [];
let current = path.resolve(rootDir);
while (true) {
dirs.push(
path.join(current, 'node_modules/@univerjs/ui'),
path.join(current, 'packages/ui'),
path.join(current, 'submodules/univer/packages/ui')
);
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return dirs;
}
function resolveUniverUiPackageDir(options: IDiscoverUniverUiLocalesOptions): string {
if (options.uiPackageDir) {
return path.resolve(options.uiPackageDir);
}
const rootDir = options.rootDir ?? process.cwd();
const uiPackageDir = getCandidateUiPackageDirs(rootDir).find((candidate) => existsSync(path.join(candidate, 'src/locale')));
if (!uiPackageDir) {
throw new Error(`Cannot find @univerjs/ui src/locale from ${path.resolve(rootDir)}`);
}
return uiPackageDir;
}
export function discoverUniverUiLocales(options: IDiscoverUniverUiLocalesOptions = {}): string[] {
const uiPackageDir = resolveUniverUiPackageDir(options);
const localeDir = path.join(uiPackageDir, 'src/locale');
if (!existsSync(localeDir)) {
throw new Error(`Cannot find @univerjs/ui locale directory: ${localeDir}`);
}
return readdirSync(localeDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && LOCALE_FILE_NAME_PATTERN.test(entry.name))
.map((entry) => path.basename(entry.name, '.ts'))
.sort((left, right) => left.localeCompare(right));
}

View File

@@ -0,0 +1,59 @@
{
"name": "@univerjs-infra/shared",
"type": "module",
"version": "1.0.0-rc.0",
"private": true,
"description": "Some infrastructures for univerjs",
"author": "DreamNum <developer@univer.ai>",
"license": "Apache-2.0",
"homepage": "https://github.com/dream-num/univer",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer.git"
},
"keywords": [],
"exports": {
"./eslint": "./eslint/index.ts",
"./tsconfigs/*": "./tsconfigs/*.json",
"./vitest": "./vitest/index.ts",
"./esbuild": "./esbuild/index.ts",
"./tailwind": "./tailwind/tailwind.config.ts",
"./postcss": "./postcss/postcss.config.mjs",
"./tsdown": "./tsdown/index.ts",
"./locale": "./locale/index.ts",
"./preset-build": "./preset-build/index.ts"
},
"main": "bin/index.mts",
"bin": {
"univer-cli": "bin/index.mts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tsdown/css": "^0.23.0",
"@typescript-eslint/parser": "^8.65.0",
"@vitest/coverage-istanbul": "^5.0.0",
"autoprefixer": "^10.6.0",
"eslint-plugin-better-tailwindcss": "^4.7.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-no-barrel-import": "^0.0.2",
"eslint-plugin-no-penetrating-import": "^0.0.1",
"happy-dom": "20.14.5",
"javascript-obfuscator": "^5.7.0",
"postcss-preset-env": "^11.5.3",
"postcss-replace": "^2.0.1",
"sort-keys": "^6.0.1",
"tailwind-scrollbar": "^3",
"tailwindcss": "3.4.18",
"tsdown": "^0.23.0",
"unplugin-vue": "^7.2.0",
"vitest": "^5.0.0"
},
"devDependencies": {
"@univerjs/icons": "1.38.0",
"@univerjs/icons-svg": "^1.38.0",
"typescript": "^6.0.3",
"vue-tsc": "^3.3.11"
}
}

View File

@@ -0,0 +1,26 @@
import autoprefixer from 'autoprefixer';
import env from 'postcss-preset-env';
import replace from 'postcss-replace';
import tailwindcss from 'tailwindcss';
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: [
tailwindcss,
autoprefixer,
env({
features: {
'color-functional-notation': true,
'hexadecimal-alpha-notation': true,
},
}),
replace({
pattern: /(--tw|\*)/g,
data: {
'--tw': '--univer-tw',
},
}),
],
};
export default config;

View File

@@ -0,0 +1 @@
export const PRESET_LOCALE_SOURCE_DIR = 'src/locales';

View File

@@ -0,0 +1,49 @@
import type { IEntryConfig } from '../tsdown/types.ts';
import { existsSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { PRESET_LOCALE_SOURCE_DIR } from './constants.ts';
function getPresetLocaleEntries(packageDir: string): IEntryConfig[] {
const localeDir = path.join(packageDir, PRESET_LOCALE_SOURCE_DIR);
if (!existsSync(localeDir)) {
return [];
}
const entries: IEntryConfig[] = [];
for (const fileName of readdirSync(localeDir).sort((left, right) => left.localeCompare(right))) {
const fullPath = path.join(localeDir, fileName);
if (statSync(fullPath).isDirectory() || !fileName.endsWith('.ts') || !fileName.includes('-')) {
continue;
}
entries.push({
key: `locales/${fileName.replace(/\.ts$/, '')}`,
path: fullPath,
type: 'locale',
});
}
return entries;
}
export function getPresetModuleEntries(packageDir: string): IEntryConfig[] {
const entries: IEntryConfig[] = [{
key: 'index',
path: path.join(packageDir, 'src/index.ts'),
type: 'index',
}];
const workerEntry = path.join(packageDir, 'src/worker.ts');
if (existsSync(workerEntry)) {
entries.push({
key: 'worker',
path: workerEntry,
type: 'worker',
});
}
return [...entries, ...getPresetLocaleEntries(packageDir)];
}

View File

@@ -0,0 +1,130 @@
import type { TModuleFormat } from '../tsdown/configs/module.ts';
import type { IPackageJson } from '../tsdown/types.ts';
import type { IPresetBuildOptions, IPresetPackageJson } from './types.ts';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import { mergeConfig, build as tsdownBuild } from 'tsdown';
import { createModuleConfig } from '../tsdown/configs/module.ts';
import { BUILD_OUTPUT_DIRECTORIES, CLEANUP_DIRECTORIES } from '../tsdown/constants.ts';
import { createBaseConfig, createInputPlugins } from '../tsdown/utils/base-config.ts';
import { getPresetModuleEntries } from './entries.ts';
import { generatePresetLocales } from './locale.ts';
import { buildPresetStyles } from './style.ts';
export { generatePresetLocales } from './locale.ts';
export type { IPresetBuildOptions } from './types.ts';
function readPackageJson(packageDir: string): IPresetPackageJson {
return JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as IPresetPackageJson;
}
function getExternalPackages(packageJson: IPresetPackageJson) {
return [...new Set([
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
'react',
'react-dom',
'rxjs',
])]
.sort((left, right) => left.localeCompare(right))
.flatMap((packageName) => [packageName, `${packageName}/*`]);
}
async function loadUserConfig(options: IPresetBuildOptions, packageDir: string) {
if (!options.tsdownConfigPath) {
return null;
}
const configPath = path.resolve(packageDir, options.tsdownConfigPath);
let userConfig = (await import(pathToFileURL(configPath).href)).default;
if (typeof userConfig === 'function') {
userConfig = await userConfig();
}
return userConfig;
}
function mergeUserConfig(configs: any[], userConfig: any) {
if (!userConfig) {
return configs;
}
return configs.map((config) => mergeConfig(config, userConfig));
}
export function createPresetModuleEntryGroups(entries: ReturnType<typeof getPresetModuleEntries>) {
const primaryEntries = entries.filter((entry) => entry.type === 'index' || entry.type === 'locale');
const isolatedEntries = entries.filter((entry) => entry.type !== 'index' && entry.type !== 'locale');
return [
...(primaryEntries.length > 0 ? [primaryEntries] : []),
...isolatedEntries.map((entry) => [entry]),
];
}
export function removePresetOutputs(packageDir = process.cwd()) {
for (const dir of CLEANUP_DIRECTORIES) {
const targetDir = path.resolve(packageDir, dir);
if (existsSync(targetDir)) {
rmSync(targetDir, { force: true, recursive: true });
}
}
}
export function preparePresetPackage(packageDir = process.cwd()) {
const packageJson = readPackageJson(packageDir);
if (packageJson.name === '@univerjs/presets') {
return [];
}
return generatePresetLocales({ packageDir });
}
export async function buildPresetPackage(options: IPresetBuildOptions = {}) {
const packageDir = process.cwd();
const packageJson = readPackageJson(packageDir);
if (options.cleanup) {
removePresetOutputs(packageDir);
}
const externalPackages = getExternalPackages(packageJson);
const baseConfig = createBaseConfig({
entries: [],
externalPackages,
facadeExternalPackages: externalPackages,
packageDir,
packageJson: packageJson as IPackageJson,
plugins: [],
});
const plugins = createInputPlugins(packageDir);
const userConfig = await loadUserConfig(options, packageDir);
const moduleFormats: TModuleFormat[] = ['esm', 'cjs'];
const moduleConfigs = createPresetModuleEntryGroups(getPresetModuleEntries(packageDir)).flatMap((entries) => {
return moduleFormats.map((format) => createModuleConfig({
baseConfig,
enableObfuscation: false,
entries,
externalPackages,
facadeExternalPackages: externalPackages,
format,
outDir: BUILD_OUTPUT_DIRECTORIES[format],
packageDir,
plugins,
}));
});
for (const config of mergeUserConfig(moduleConfigs, userConfig)) {
await tsdownBuild(config);
}
await buildPresetStyles({
packageDir,
packageJson,
});
}

View File

@@ -0,0 +1,98 @@
import type { IGeneratePresetLocalesOptions, IPresetPackageJson } from './types.ts';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { discoverUniverUiLocales } from '../locale/index.ts';
import { PRESET_LOCALE_SOURCE_DIR } from './constants.ts';
function readPackageJson(packageDir: string): IPresetPackageJson {
return JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as IPresetPackageJson;
}
function convertImportNameFromPackageName(name: string) {
return name
.replace(/^@univerjs(?:-[^/]+)?\//, 'univerjs')
.replace(/[^a-zA-Z0-9_$]/g, '');
}
function compareStrings(left: string, right: string) {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
return 0;
}
function getDependencyNames(pkg: IPresetPackageJson) {
return Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith('@univerjs'))
.sort(compareStrings);
}
function getDependencyLocaleSubpath(packageDir: string, dependencyName: string, locale: string) {
const dependencyDir = path.join(packageDir, 'node_modules', dependencyName);
if (
existsSync(path.join(dependencyDir, 'lib/es/locale', `${locale}.js`))
|| existsSync(path.join(dependencyDir, 'src/locale', `${locale}.ts`))
) {
return 'locale';
}
if (
existsSync(path.join(dependencyDir, 'lib/es/locales', `${locale}.js`))
|| existsSync(path.join(dependencyDir, 'src/locales', `${locale}.ts`))
) {
return 'locales';
}
return null;
}
export function generatePresetLocales(options: IGeneratePresetLocalesOptions): string[] {
const { packageDir } = options;
const pkg = readPackageJson(packageDir);
const dependencyNames = getDependencyNames(pkg);
const localeDir = path.join(packageDir, PRESET_LOCALE_SOURCE_DIR);
rmSync(localeDir, { force: true, recursive: true });
mkdirSync(localeDir, { recursive: true });
const generated: string[] = [];
for (const locale of discoverUniverUiLocales({ rootDir: packageDir })) {
const dependenciesWithLocale = dependencyNames
.map((dependencyName) => ({
dependencyName,
localeSubpath: getDependencyLocaleSubpath(packageDir, dependencyName, locale),
}))
.filter((dependency): dependency is { dependencyName: string; localeSubpath: 'locale' | 'locales' } => dependency.localeSubpath !== null);
if (dependenciesWithLocale.length === 0) {
continue;
}
let content = '';
for (const { dependencyName, localeSubpath } of dependenciesWithLocale) {
content += `import ${convertImportNameFromPackageName(dependencyName)} from '${dependencyName}/${localeSubpath}/${locale}';\n`;
}
content += '\nexport default Object.assign(\n';
content += ' {},\n';
for (const { dependencyName } of dependenciesWithLocale) {
content += ` ${convertImportNameFromPackageName(dependencyName)},\n`;
}
content += ');\n';
writeFileSync(path.join(localeDir, `${locale}.ts`), content);
generated.push(locale);
}
return generated;
}

View File

@@ -0,0 +1,58 @@
import type { IPresetPackageJson } from './types.ts';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
function getDependencyDir(packageDir: string, dependencyName: string) {
return path.join(packageDir, 'node_modules', dependencyName);
}
function getBuiltStyleDependencyCssFiles(packageDir: string, packageJson: IPresetPackageJson) {
return Object.keys(packageJson.dependencies ?? {})
.flatMap((dependencyName) => {
if (!dependencyName.startsWith('@univerjs')) {
return [];
}
const dependencyDir = getDependencyDir(packageDir, dependencyName);
const sourceGlobalCss = path.join(dependencyDir, 'src/global.css');
const builtCss = path.join(dependencyDir, 'lib/index.css');
if (!existsSync(sourceGlobalCss)) {
return [];
}
if (!existsSync(builtCss)) {
throw new Error(`Missing built CSS for ${dependencyName}. Build dependency packages before building preset CSS.`);
}
return [builtCss];
});
}
function writePresetCss(packageDir: string, cssFiles: string[]) {
const outputFile = path.join(packageDir, 'lib/index.css');
mkdirSync(path.dirname(outputFile), { recursive: true });
const content = cssFiles
.map((cssFile) => readFileSync(cssFile, 'utf8').trimEnd())
.filter(Boolean)
.join('\n');
writeFileSync(outputFile, `${content}\n`);
}
export async function buildPresetStyles(options: {
packageDir: string;
packageJson: IPresetPackageJson;
}) {
const { packageDir, packageJson } = options;
const cssFiles = getBuiltStyleDependencyCssFiles(packageDir, packageJson);
if (cssFiles.length === 0) {
rmSync(path.join(packageDir, 'lib/index.css'), { force: true });
return;
}
writePresetCss(packageDir, cssFiles);
}

View File

@@ -0,0 +1,15 @@
export interface IPresetBuildOptions {
cleanup?: boolean;
tsdownConfigPath?: string;
}
export interface IPresetPackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
name: string;
peerDependencies?: Record<string, string>;
}
export interface IGeneratePresetLocalesOptions {
packageDir: string;
}

View File

@@ -0,0 +1,90 @@
/* eslint-disable */
/**
* Starting from Univer 0.6.0, with the support for React 19[^1], UMD users may need additional adaptation.
* It is recommended to migrate to module script[^2] and a more modern build system.
*
* [^1]: [Module Script] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#applying_the_module_to_your_html
* [^2]: [support for React 19] https://github.com/dream-num/univer/pull/4247
*/
/**
* Fix `Uncaught TypeError: client.createRoot is not a function`
* If using UMD React < 18, you might need the following code.
*/
(function (global) {
'use strict';
if (!global.ReactDOM) {
throw new Error('ReactDOM must be loaded before ReactCreateRoot.');
}
const ReactDOM = global.ReactDOM;
if (!ReactDOM.createRoot) {
ReactDOM.createRoot = function (container) {
return {
render: (element) => {
ReactDOM.render(element, container);
},
};
};
}
})(this);
/**
* Fix `Uncaught TypeError: jsxRuntime.jsx is not a function`
* If using UMD React, you might need the following code.
* Reference: https://unpkg.com/react@18.3.1/cjs/react-jsx-runtime.production.min.js
*/
(function (global) {
'use strict';
if (!global.React) {
throw new Error('React must be loaded before ReactJSXRuntime.');
}
const React = global.React;
if (!React.jsx || !React.jsxs) {
const REACT_ELEMENT_TYPE = Symbol.for('react.element');
const hasOwnProperty = Object.prototype.hasOwnProperty;
const RESERVED_PROPS = {
key: true,
ref: true,
__self: true,
__source: true,
};
const ReactCurrentOwner = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner;
function createReactElement(type, config, maybeKey) {
const props = {};
let key = null;
let ref = null;
if (maybeKey !== undefined) {
key = `${maybeKey}`;
}
if (config.key !== undefined) {
key = `${config.key}`;
}
if (config.ref !== undefined) {
ref = config.ref;
}
for (var propName in config) {
if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
props[propName] = config[propName];
}
}
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (var propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
return {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
ref,
props,
_owner: ReactCurrentOwner.current,
};
}
React.jsx = createReactElement;
React.jsxs = createReactElement;
}
})(this);

View File

@@ -0,0 +1,78 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import tailwindConfig, { createTailwindContent } from '../tailwind.config';
const roots: string[] = [];
function makePackageRoot() {
const root = mkdtempSync(path.join(os.tmpdir(), 'univer-tailwind-content-'));
roots.push(root);
writeFileSync(path.join(root, 'tailwind.config.ts'), '');
return root;
}
function writeJson(filePath: string, value: unknown) {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(value, null, 4)}\n`);
}
function writeGlobalCss(packageRoot: string, dependencyName: string) {
const cssPath = path.join(packageRoot, 'node_modules', dependencyName, 'src/global.css');
mkdirSync(path.dirname(cssPath), { recursive: true });
writeFileSync(cssPath, '');
}
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { force: true, recursive: true });
}
});
describe('createTailwindContent', () => {
it('limits hover styles to devices that support hover', () => {
expect(tailwindConfig.future).toMatchObject({ hoverOnlyWhenSupported: true });
});
it('returns current package source content by default', () => {
const packageRoot = makePackageRoot();
writeJson(path.join(packageRoot, 'package.json'), {
name: '@univerjs/preset-demo',
dependencies: {
'@univerjs/design': 'workspace:*',
},
});
writeGlobalCss(packageRoot, '@univerjs/design');
expect(createTailwindContent(path.join(packageRoot, 'tailwind.config.ts'))).toEqual([
'./src/**/*.{js,ts,jsx,tsx}',
]);
});
it('includes Univer dependencies with global styles when requested', () => {
const packageRoot = makePackageRoot();
writeJson(path.join(packageRoot, 'package.json'), {
name: '@univerjs/preset-demo',
dependencies: {
'@univerjs/sheets-ui': 'workspace:*',
'@univerjs/design': 'workspace:*',
'@univerjs/core': 'workspace:*',
'@univerjs-pro/sheets-chart-ui': 'workspace:*',
react: '^18.3.1',
},
});
writeGlobalCss(packageRoot, '@univerjs/sheets-ui');
writeGlobalCss(packageRoot, '@univerjs/design');
writeGlobalCss(packageRoot, '@univerjs-pro/sheets-chart-ui');
expect(createTailwindContent(path.join(packageRoot, 'tailwind.config.ts'), {
includeStyleDependencies: true,
})).toEqual([
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@univerjs/design/src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@univerjs/sheets-ui/src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@univerjs-pro/sheets-chart-ui/src/**/*.{js,ts,jsx,tsx}',
]);
});
});

View File

@@ -0,0 +1,237 @@
import type { Config } from 'tailwindcss';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import scrollbar from 'tailwind-scrollbar';
const SOURCE_CONTENT = './src/**/*.{js,ts,jsx,tsx}';
const DEPENDENCY_SOURCE_CONTENT = './node_modules/{name}/src/**/*.{js,ts,jsx,tsx}';
interface ITailwindContentOptions {
includeStyleDependencies?: boolean;
}
interface IPackageJson {
dependencies?: Record<string, string>;
}
function getPackageDir(configUrl: string) {
const configPath = configUrl.startsWith('file:')
? fileURLToPath(configUrl)
: configUrl;
return path.dirname(configPath);
}
function getPackageJson(packageDir: string): IPackageJson {
const packageJsonPath = path.join(packageDir, 'package.json');
if (!existsSync(packageJsonPath)) {
return {};
}
return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as IPackageJson;
}
function getDependencyDir(packageDir: string, dependencyName: string) {
return path.join(packageDir, 'node_modules', dependencyName);
}
function compareDependencyName(left: string, right: string) {
const leftScope = left.startsWith('@univerjs-pro/') ? 1 : 0;
const rightScope = right.startsWith('@univerjs-pro/') ? 1 : 0;
if (leftScope !== rightScope) {
return leftScope - rightScope;
}
return left.localeCompare(right);
}
export function createTailwindContent(configUrl: string, options: ITailwindContentOptions = {}) {
const content = [SOURCE_CONTENT];
if (!options.includeStyleDependencies) {
return content;
}
const packageDir = getPackageDir(configUrl);
const packageJson = getPackageJson(packageDir);
const dependencyNames = Object.keys(packageJson.dependencies ?? {})
.filter((dependencyName) => {
if (!dependencyName.startsWith('@univerjs')) {
return false;
}
return existsSync(path.join(getDependencyDir(packageDir, dependencyName), 'src/global.css'));
})
.sort(compareDependencyName);
return [
...content,
...dependencyNames.map((dependencyName) => DEPENDENCY_SOURCE_CONTENT.replace('{name}', dependencyName)),
];
}
const config: Omit<Config, 'content'> = {
prefix: 'univer-',
darkMode: 'selector',
future: {
hoverOnlyWhenSupported: true,
},
corePlugins: {
preflight: false,
},
theme: {
extend: {
colors: {
primary: {
50: 'var(--univer-primary-50)',
100: 'var(--univer-primary-100)',
200: 'var(--univer-primary-200)',
300: 'var(--univer-primary-300)',
400: 'var(--univer-primary-400)',
500: 'var(--univer-primary-500)',
600: 'var(--univer-primary-600)',
700: 'var(--univer-primary-700)',
800: 'var(--univer-primary-800)',
900: 'var(--univer-primary-900)',
},
gray: {
0: 'var(--univer-gray-0)',
50: 'var(--univer-gray-50)',
100: 'var(--univer-gray-100)',
200: 'var(--univer-gray-200)',
300: 'var(--univer-gray-300)',
400: 'var(--univer-gray-400)',
500: 'var(--univer-gray-500)',
600: 'var(--univer-gray-600)',
700: 'var(--univer-gray-700)',
800: 'var(--univer-gray-800)',
900: 'var(--univer-gray-900)',
1000: 'var(--univer-gray-1000)',
},
blue: {
50: 'var(--univer-blue-50)',
100: 'var(--univer-blue-100)',
200: 'var(--univer-blue-200)',
300: 'var(--univer-blue-300)',
400: 'var(--univer-blue-400)',
500: 'var(--univer-blue-500)',
600: 'var(--univer-blue-600)',
700: 'var(--univer-blue-700)',
800: 'var(--univer-blue-800)',
900: 'var(--univer-blue-900)',
},
red: {
50: 'var(--univer-red-50)',
100: 'var(--univer-red-100)',
200: 'var(--univer-red-200)',
300: 'var(--univer-red-300)',
400: 'var(--univer-red-400)',
500: 'var(--univer-red-500)',
600: 'var(--univer-red-600)',
700: 'var(--univer-red-700)',
800: 'var(--univer-red-800)',
900: 'var(--univer-red-900)',
},
orange: {
50: 'var(--univer-orange-50)',
100: 'var(--univer-orange-100)',
200: 'var(--univer-orange-200)',
300: 'var(--univer-orange-300)',
400: 'var(--univer-orange-400)',
500: 'var(--univer-orange-500)',
600: 'var(--univer-orange-600)',
700: 'var(--univer-orange-700)',
800: 'var(--univer-orange-800)',
900: 'var(--univer-orange-900)',
},
yellow: {
50: 'var(--univer-yellow-50)',
100: 'var(--univer-yellow-100)',
200: 'var(--univer-yellow-200)',
300: 'var(--univer-yellow-300)',
400: 'var(--univer-yellow-400)',
500: 'var(--univer-yellow-500)',
600: 'var(--univer-yellow-600)',
700: 'var(--univer-yellow-700)',
800: 'var(--univer-yellow-800)',
900: 'var(--univer-yellow-900)',
},
green: {
50: 'var(--univer-green-50)',
100: 'var(--univer-green-100)',
200: 'var(--univer-green-200)',
300: 'var(--univer-green-300)',
400: 'var(--univer-green-400)',
500: 'var(--univer-green-500)',
600: 'var(--univer-green-600)',
700: 'var(--univer-green-700)',
800: 'var(--univer-green-800)',
900: 'var(--univer-green-900)',
},
jiqing: {
50: 'var(--univer-jiqing-50)',
100: 'var(--univer-jiqing-100)',
200: 'var(--univer-jiqing-200)',
300: 'var(--univer-jiqing-300)',
400: 'var(--univer-jiqing-400)',
500: 'var(--univer-jiqing-500)',
600: 'var(--univer-jiqing-600)',
700: 'var(--univer-jiqing-700)',
800: 'var(--univer-jiqing-800)',
900: 'var(--univer-jiqing-900)',
},
indigo: {
50: 'var(--univer-indigo-50)',
100: 'var(--univer-indigo-100)',
200: 'var(--univer-indigo-200)',
300: 'var(--univer-indigo-300)',
400: 'var(--univer-indigo-400)',
500: 'var(--univer-indigo-500)',
600: 'var(--univer-indigo-600)',
700: 'var(--univer-indigo-700)',
800: 'var(--univer-indigo-800)',
900: 'var(--univer-indigo-900)',
},
purple: {
50: 'var(--univer-purple-50)',
100: 'var(--univer-purple-100)',
200: 'var(--univer-purple-200)',
300: 'var(--univer-purple-300)',
400: 'var(--univer-purple-400)',
500: 'var(--univer-purple-500)',
600: 'var(--univer-purple-600)',
700: 'var(--univer-purple-700)',
800: 'var(--univer-purple-800)',
900: 'var(--univer-purple-900)',
},
pink: {
50: 'var(--univer-pink-50)',
100: 'var(--univer-pink-100)',
200: 'var(--univer-pink-200)',
300: 'var(--univer-pink-300)',
400: 'var(--univer-pink-400)',
500: 'var(--univer-pink-500)',
600: 'var(--univer-pink-600)',
700: 'var(--univer-pink-700)',
800: 'var(--univer-pink-800)',
900: 'var(--univer-pink-900)',
},
},
boxShadow: {
sm: '0px 1px 2px 0px rgba(30, 40, 77, 0.08)',
md: '0px 1px 6px -2px rgba(30, 40, 77, 0.08), 0px 2px 6px -1px rgba(30, 40, 77, 0.10)',
lg: '0px 4px 6px 0px rgba(30, 40, 77, 0.05), 0px 10px 15px -3px rgba(30, 40, 77, 0.10)',
xl: '0px 10px 10px 0px rgba(30, 40, 77, 0.04), 0px 20px 24px -5px rgba(30, 40, 77, 0.10)',
'2xl': '0px 24px 50px -12px rgba(30, 40, 77, 0.24)',
},
},
},
plugins: [
scrollbar,
],
};
export default config;

View File

@@ -0,0 +1,20 @@
{
"extends": "./tsconfigs/base.json",
"compilerOptions": {
"rootDir": ".",
"skipLibCheck": true
},
"include": [
"bin/**/*.mts",
"esbuild/**/*.ts",
"eslint/**/*.ts",
"eslint/**/*.d.ts",
"locale/**/*.ts",
"tailwind/**/*.ts",
"tsdown/**/*.ts",
"vitest/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"types": ["node"],
"target": "ESNext",
"jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable", "WebWorker"],
"useDefineForClassFields": true,
"experimentalDecorators": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"strict": true,
"strictPropertyInitialization": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": false,
"noEmit": true,
"noImplicitOverride": true,
"isolatedModules": true,
"skipLibCheck": true,
"stripInternal": true
}
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
}
}

View File

@@ -0,0 +1,58 @@
import type { UserConfig } from 'tsdown';
import type { IEntryConfig } from '../types.ts';
import { defineConfig } from 'tsdown';
import { createCssNoopInputOptions } from '../plugins/css-noop.ts';
import { createOutputAliasPlugin } from '../plugins/output-alias.ts';
import { createOutputObfuscatorPlugin } from '../plugins/output-obfuscator.ts';
export type TModuleFormat = 'cjs' | 'esm';
export interface ICreateModuleConfigOptions {
baseConfig: Partial<UserConfig>;
enableObfuscation: boolean;
entries: IEntryConfig[];
externalPackages: string[];
facadeExternalPackages: string[];
format: TModuleFormat;
obfuscatorIgnorePatterns?: RegExp[];
outDir: string;
packageDir: string;
plugins: any[];
}
/**
* Creates the common ESM/CJS bundle config for a single package entry.
*/
export function createModuleConfig(options: ICreateModuleConfigOptions): UserConfig {
const { baseConfig, enableObfuscation, entries, externalPackages, facadeExternalPackages, format, obfuscatorIgnorePatterns, outDir, packageDir, plugins } = options;
const hasFacadeEntry = entries.some((entry) => entry.type === 'facade');
const hasIndexEntry = entries.some((entry) => entry.type === 'index');
const neverBundle = hasFacadeEntry ? facadeExternalPackages : externalPackages;
const copyToRoot = format === 'esm';
const keepRootIndexCss = hasIndexEntry && format === 'esm';
return defineConfig({
...baseConfig,
deps: {
neverBundle,
},
dts: false,
entry: Object.fromEntries(entries.map((entry) => [entry.key, entry.path])),
format,
inputOptions: keepRootIndexCss ? baseConfig.inputOptions : createCssNoopInputOptions(baseConfig.inputOptions),
outputOptions: {
codeSplitting: true,
minify: enableObfuscation,
},
outDir,
plugins: [
...plugins,
...(enableObfuscation ? [createOutputObfuscatorPlugin(obfuscatorIgnorePatterns)] : []),
createOutputAliasPlugin({
copyToRoot,
keepRootIndexCss,
packageDir,
}),
],
});
}

View File

@@ -0,0 +1,132 @@
import type { UserConfig } from 'tsdown';
import type { IEntryConfig } from '../types.ts';
import { defineConfig } from 'tsdown';
import { peerDepsMap } from '../data/peer-deps.ts';
import { createCssNoopInputOptions } from '../plugins/css-noop.ts';
import { createOutputAliasPlugin } from '../plugins/output-alias.ts';
import { createOutputObfuscatorPlugin } from '../plugins/output-obfuscator.ts';
export interface ICreateUmdConfigOptions {
baseConfig: Partial<UserConfig>;
enableObfuscation: boolean;
entry: IEntryConfig;
obfuscatorIgnorePatterns?: RegExp[];
outDir: string;
packageDir: string;
packageName: string;
plugins: any[];
}
const UMD_GLOBALS: Record<string, string> = Object.fromEntries(
Object.entries(peerDepsMap).map(([source, value]) => [source, value.global])
);
const CSS_UMD_GLOBAL = 'UniverCssNoop';
function convertLibNameFromPackageName(name: string) {
return name
.replace(/^@(univerjs(?:-pro)?)\//, (_, matchedPrefix) => {
return matchedPrefix === 'univerjs-pro' ? 'univer-pro-' : 'univer-';
})
.replace('/facade', '-facade')
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
}
/**
* Maps externals to their browser globals in UMD builds.
*/
function resolveUmdGlobal(source: string) {
if (source.endsWith('.css')) {
return null;
}
if (source in UMD_GLOBALS) {
return UMD_GLOBALS[source];
}
if (source.startsWith('@univerjs')) {
if (source === '@univerjs/icons') {
return null;
}
const localeMatch = source.match(/^(@univerjs(?:-pro)?\/[^/]+)\/(?:locale|locales)\/([^/]+)$/);
if (localeMatch) {
return `${convertLibNameFromPackageName(localeMatch[1])}${convertLibNameFromPackageName(localeMatch[2])}`;
}
return convertLibNameFromPackageName(source);
}
return null;
}
function resolveOutputGlobal(source: string) {
const global = resolveUmdGlobal(source);
if (global !== null) {
return global;
}
if (source.endsWith('.css')) {
return CSS_UMD_GLOBAL;
}
return convertLibNameFromPackageName(source);
}
/**
* Produces a stable UMD global name for primary, facade and locale entries.
*/
function getGlobalName(packageName: string, entryKey: string) {
const name = convertLibNameFromPackageName(packageName);
if (entryKey === 'facade') {
return `${name}Facade`;
}
if (entryKey.startsWith('locale/') || entryKey.startsWith('locales/')) {
const localeKey = entryKey.split('/')[1];
return `${name}${convertLibNameFromPackageName(localeKey)}`;
}
return name;
}
/**
* Creates the browser-oriented UMD bundle config for a single package entry.
*/
export function createUmdConfig(options: ICreateUmdConfigOptions): UserConfig {
const { baseConfig, enableObfuscation, entry, obfuscatorIgnorePatterns, outDir, packageDir, packageName, plugins } = options;
return defineConfig({
...baseConfig,
deps: {
alwaysBundle: [/.*/],
neverBundle: (source: string) => Boolean(resolveUmdGlobal(source)),
onlyBundle: false,
},
dts: false,
entry: { [entry.key]: entry.path },
format: 'umd',
globalName: getGlobalName(packageName, entry.key),
inputOptions: createCssNoopInputOptions(baseConfig.inputOptions),
outDir,
outputOptions: {
entryFileNames: '[name].js',
globals: (source: string) => resolveOutputGlobal(source),
minify: true,
},
platform: 'browser',
plugins: [
...plugins,
...(enableObfuscation ? [createOutputObfuscatorPlugin(obfuscatorIgnorePatterns)] : []),
createOutputAliasPlugin({
copyToRoot: false,
keepRootIndexCss: false,
packageDir,
}),
],
});
}

View File

@@ -0,0 +1,16 @@
export const BUILD_OUTPUT_ROOT = 'dist';
export const BUILD_OUTPUT_DIRECTORIES = {
cjs: 'lib/cjs',
esm: 'lib/es',
umd: 'lib/umd',
} as const;
export const CLEANUP_DIRECTORIES = ['dist', 'lib', 'coverage'] as const;
export const DEFAULT_BROWSER_TARGET = 'chrome88';
export const DEFAULT_ENTRY_FILE = 'src/index.ts';
export const FACADE_ENTRY_FILE = 'src/facade/index.ts';
export const LOCALE_DIRECTORY = 'src/locale';
export const SOURCE_DIRECTORY = 'src';
export const TYPE_BUILD_CONFIG_FILE = 'tsconfig.tsdown.build.json';
export const TYPE_BUILD_EXCLUDES = ['src/**/__tests__/**', 'src/**/*.spec.ts', 'src/**/*.test.ts'];

View File

@@ -0,0 +1,47 @@
export const peerDepsMap = {
react: {
global: 'React',
name: 'react',
version: '^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc',
},
'react/jsx-runtime': {
global: 'React',
name: 'react',
version: 'react',
},
'react-dom': {
global: 'ReactDOM',
name: 'react-dom',
version: '^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc',
},
'react-dom/client': {
global: 'ReactDOM',
name: 'react-dom',
version: 'react-dom',
},
rxjs: {
global: 'rxjs',
name: 'rxjs',
version: '>=7.0.0',
},
'rxjs/operators': {
global: 'rxjs.operators',
name: 'rxjs',
version: 'rxjs',
},
'@wendellhu/redi': {
global: '@wendellhu/redi',
name: '@wendellhu/redi',
version: '1.1.3',
},
'@wendellhu/redi/react-bindings': {
global: '@wendellhu/redi/react-bindings',
name: '@wendellhu/redi',
version: '@wendellhu/redi',
},
vue: {
global: 'Vue',
name: 'vue',
version: '>=3.0.0',
},
} as const;

View File

@@ -0,0 +1,142 @@
import type { TModuleFormat } from './configs/module.ts';
import type { IBuildContext, IBuildOptions } from './types.ts';
import { existsSync, rmSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import { mergeConfig, build as tsdownBuild } from 'tsdown';
import { createModuleConfig } from './configs/module.ts';
import { createUmdConfig } from './configs/umd.ts';
import { BUILD_OUTPUT_DIRECTORIES, BUILD_OUTPUT_ROOT, CLEANUP_DIRECTORIES } from './constants.ts';
import { createBaseConfig, createInputOptions, createInputPlugins } from './utils/base-config.ts';
import { cleanupPackageJson } from './utils/cleanup-pkg.ts';
import { getEntries } from './utils/entries.ts';
import { removeCssArtifacts } from './utils/files.ts';
import { createExternalPackages, readPackageJson } from './utils/package.ts';
import { emitPublishPackageJson } from './utils/publish-manifest.ts';
export { DEFAULT_BROWSER_TARGET } from './constants.ts';
/**
* Builds the shared context consumed by all output format factories.
*/
function createBuildContext(packageDir: string, options: IBuildOptions): IBuildContext {
const packageJson = readPackageJson(packageDir);
const externalPackages = createExternalPackages(packageJson, options.ignorePackages);
return {
entries: getEntries(packageDir),
externalPackages,
facadeExternalPackages: [...externalPackages, packageJson.name, `${packageJson.name}/*`],
inputOptions: createInputOptions(options),
packageDir,
packageJson,
plugins: createInputPlugins(packageDir),
};
}
/**
* Expands the package context into all required tsdown configs.
*/
function createModuleEntryGroups(entries: IBuildContext['entries']) {
const primaryEntries = entries.filter((entry) => entry.type === 'index' || entry.type === 'locale');
const isolatedEntries = entries.filter((entry) => entry.type !== 'index' && entry.type !== 'locale');
return [
...(primaryEntries.length > 0 ? [primaryEntries] : []),
...isolatedEntries.map((entry) => [entry]),
];
}
export function createConfigs(context: IBuildContext, options: IBuildOptions) {
const baseConfig = createBaseConfig(context);
const moduleFormats: TModuleFormat[] = ['esm', 'cjs'];
const enableObfuscation = context.packageJson.name.startsWith('@univerjs-pro/');
const moduleConfigs = createModuleEntryGroups(context.entries).flatMap((entries) => {
return moduleFormats.map((format) => createModuleConfig({
baseConfig,
enableObfuscation,
entries,
externalPackages: context.externalPackages,
facadeExternalPackages: context.facadeExternalPackages,
format,
obfuscatorIgnorePatterns: options.obfuscatorIgnorePatterns,
outDir: BUILD_OUTPUT_DIRECTORIES[format],
packageDir: context.packageDir,
plugins: context.plugins,
}));
});
if (options.skipUMD) {
return moduleConfigs;
}
const umdConfigs = context.entries.map((entry) => createUmdConfig({
baseConfig,
enableObfuscation,
entry,
obfuscatorIgnorePatterns: options.obfuscatorIgnorePatterns,
outDir: BUILD_OUTPUT_DIRECTORIES.umd,
packageDir: context.packageDir,
packageName: context.packageJson.name,
plugins: context.plugins,
}));
return [...moduleConfigs, ...umdConfigs];
}
export function remove() {
const cwd = process.cwd();
for (const dir of CLEANUP_DIRECTORIES) {
const targetDir = path.resolve(cwd, dir);
if (existsSync(targetDir)) {
rmSync(targetDir, { force: true, recursive: true });
}
}
}
export async function build(options: IBuildOptions = {}) {
const packageDir = process.cwd();
if (options.cleanup) {
remove();
}
removeCssArtifacts(path.join(packageDir, BUILD_OUTPUT_ROOT));
/**
* If we specifically have a tsdown config path provided, we will use that and ignore any default configs. However, if no specific config is provided, we will look for default configs in the package root (tsdown.config.mjs, tsdown.config.ts, tsdown.config.js) and merge any found obfuscatorIgnorePatterns into the build context. This allows users to specify obfuscation ignore patterns without needing to provide a full custom config.
*/
if (!options.tsdownConfigPath) {
const configExtensions = ['.mjs', '.ts', '.js'];
for (const ext of configExtensions) {
const defaultConfigPath = path.resolve(packageDir, `tsdown.config${ext}`);
if (existsSync(defaultConfigPath)) {
const { default: userConfigModule } = await import(pathToFileURL(defaultConfigPath).href);
if (userConfigModule.obfuscatorIgnorePatterns) {
options.obfuscatorIgnorePatterns = userConfigModule.obfuscatorIgnorePatterns;
}
break;
}
}
}
const context = createBuildContext(packageDir, options);
let configs = createConfigs(context, options);
if (options.tsdownConfigPath) {
const configPath = path.resolve(packageDir, options.tsdownConfigPath);
let userConfig = (await import(pathToFileURL(configPath).href)).default;
if (typeof userConfig === 'function') {
userConfig = await userConfig();
}
configs = configs.map((config) => mergeConfig(config, userConfig));
}
await Promise.all(configs.map((config) => tsdownBuild(config)));
cleanupPackageJson(packageDir, context.packageJson);
emitPublishPackageJson(packageDir);
}

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import { cleanupClassNameTemplateWhitespace } from './class-name-whitespace-cleanup';
describe('cleanupClassNameTemplateWhitespace', () => {
it('should normalize whitespace for clsx template arguments', () => {
const sourceCode = `
const value = clsx(\`
univer-flex univer-size-6 univer-cursor-pointer univer-items-center univer-justify-center
univer-justify-self-center univer-rounded
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
\`, {
active: true,
});
`;
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx')).toContain(
'clsx("univer-flex univer-size-6 univer-cursor-pointer univer-items-center univer-justify-center univer-justify-self-center univer-rounded hover:univer-bg-gray-100 dark:hover:!univer-bg-gray-700", {'
);
});
it('should normalize whitespace for conditional clsx template arguments', () => {
const sourceCode = `
const value = clsx(
"univer-relative univer-transition-all univer-duration-150",
isDraggingItem && "univer-opacity-0",
dragOverId === itemId && !isDraggingItem && \`
univer-bg-primary-50/60
dark:!univer-bg-primary-900/20
univer-rounded univer-border univer-border-primary-200
dark:!univer-border-primary-700
\`
);
`;
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx')).toContain(
'dragOverId === itemId && !isDraggingItem && "univer-bg-primary-50/60 dark:!univer-bg-primary-900/20 univer-rounded univer-border univer-border-primary-200 dark:!univer-border-primary-700"'
);
});
it('should normalize whitespace for className template literals', () => {
const sourceCode = `
const value = (
<div
className={\`
univer-box-border univer-grid univer-grid-cols-5 univer-gap-2 univer-text-gray-600
dark:!univer-text-gray-200
\`}
/>
);
`;
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx')).toContain(
'className={"univer-box-border univer-grid univer-grid-cols-5 univer-gap-2 univer-text-gray-600 dark:!univer-text-gray-200"}'
);
});
it('should normalize whitespace for className string literals', () => {
const sourceCode = `
const value = (
<button
className="
univer-flex univer-cursor-pointer univer-items-center univer-justify-center
univer-border-none
hover:univer-opacity-70
"
/>
);
`;
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx')).toContain(
'className="univer-flex univer-cursor-pointer univer-items-center univer-justify-center univer-border-none hover:univer-opacity-70"'
);
});
it('should normalize whitespace for compiled className properties', () => {
const sourceCode = `
jsx("button", {
className: "\\n univer-flex univer-cursor-pointer univer-items-center univer-justify-center\\n univer-border-none\\n hover:univer-opacity-70\\n ",
type: "button",
});
`;
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.js')).toContain(
'className: "univer-flex univer-cursor-pointer univer-items-center univer-justify-center univer-border-none hover:univer-opacity-70"'
);
});
it('should normalize whitespace inside cva definitions', () => {
const sourceCode = `
const buttonVariants = cva(
\`
univer-box-border univer-inline-flex univer-cursor-pointer
disabled:univer-pointer-events-none
\`,
{
variants: {
variant: {
primary: \`
univer-border-primary-600 univer-bg-primary-600 univer-text-gray-0
hover:univer-bg-primary-500
\`,
},
},
compoundVariants: [{
className: \`
univer-gap-1 univer-rounded
dark:!univer-bg-gray-700
\`,
}],
}
);
`;
const cleanedCode = cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx');
expect(cleanedCode).toContain(
'cva(\n "univer-box-border univer-inline-flex univer-cursor-pointer disabled:univer-pointer-events-none",'
);
expect(cleanedCode).toContain(
'primary: "univer-border-primary-600 univer-bg-primary-600 univer-text-gray-0 hover:univer-bg-primary-500"'
);
expect(cleanedCode).toContain(
'className: "univer-gap-1 univer-rounded dark:!univer-bg-gray-700"'
);
});
it('should not touch unrelated template literals', () => {
const sourceCode = 'const message = ` hello\\n world `;';
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.ts')).toBe(sourceCode);
});
});

View File

@@ -0,0 +1,175 @@
import path from 'node:path';
import * as ts from 'typescript';
interface ITextEdit {
end: number;
start: number;
text: string;
}
const SUPPORTED_SCRIPT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
function stripQueryAndHash(filePath: string) {
return filePath.replace(/[?#].*$/, '');
}
function shouldProcessFile(filePath: string) {
const normalizedPath = stripQueryAndHash(filePath);
if (normalizedPath.includes('/node_modules/')) {
return false;
}
return SUPPORTED_SCRIPT_EXTENSIONS.has(path.extname(normalizedPath));
}
function getScriptKind(filePath: string) {
switch (path.extname(stripQueryAndHash(filePath))) {
case '.jsx':
return ts.ScriptKind.JSX;
case '.tsx':
return ts.ScriptKind.TSX;
case '.js':
return ts.ScriptKind.JS;
default:
return ts.ScriptKind.TS;
}
}
function normalizeClassNameWhitespace(value: string) {
return value.replace(/\s+/g, ' ').trim();
}
function mayContainCleanupTarget(sourceCode: string) {
return /(?:className|clsx\s*\(|cva\s*\()/.test(sourceCode)
&& /(?:\s{2,}|\\[nr])/.test(sourceCode);
}
function isClsxIdentifier(node: ts.Expression) {
return ts.isIdentifier(node) && node.text === 'clsx';
}
function isCvaIdentifier(node: ts.Expression) {
return ts.isIdentifier(node) && node.text === 'cva';
}
function isStaticStringLiteral(node: ts.Node): node is ts.NoSubstitutionTemplateLiteral | ts.StringLiteral {
return ts.isNoSubstitutionTemplateLiteral(node) || ts.isStringLiteral(node);
}
function isClassNameIdentifier(name: ts.PropertyName | ts.JsxAttributeName) {
return ts.isIdentifier(name) && name.text === 'className';
}
function isClsxArgument(node: ts.Expression) {
let current: ts.Node = node;
while (current.parent) {
if (ts.isCallExpression(current.parent)) {
return current.parent.arguments.includes(current as ts.Expression)
&& isClsxIdentifier(current.parent.expression);
}
current = current.parent;
}
return false;
}
function isClassNameJsxAttributeValue(node: ts.Expression) {
if (ts.isJsxAttribute(node.parent)) {
return node.parent.initializer === node && isClassNameIdentifier(node.parent.name);
}
return ts.isJsxExpression(node.parent)
&& node.parent.expression === node
&& ts.isJsxAttribute(node.parent.parent)
&& isClassNameIdentifier(node.parent.parent.name);
}
function isClassNamePropertyValue(node: ts.Expression) {
return ts.isPropertyAssignment(node.parent)
&& node.parent.initializer === node
&& (
isClassNameIdentifier(node.parent.name)
|| (ts.isStringLiteral(node.parent.name) && node.parent.name.text === 'className')
);
}
function isInsideCvaCall(node: ts.Node) {
let current: ts.Node | undefined = node.parent;
while (current) {
if (ts.isCallExpression(current) && isCvaIdentifier(current.expression)) {
return true;
}
current = current.parent;
}
return false;
}
function isClassNameWhitespaceTarget(node: ts.NoSubstitutionTemplateLiteral | ts.StringLiteral) {
return isClsxArgument(node)
|| isClassNameJsxAttributeValue(node)
|| isClassNamePropertyValue(node)
|| isInsideCvaCall(node);
}
function applyTextEdits(sourceCode: string, edits: ITextEdit[]) {
return edits
.sort((left, right) => right.start - left.start)
.reduce((code, edit) => `${code.slice(0, edit.start)}${edit.text}${code.slice(edit.end)}`, sourceCode);
}
export function cleanupClassNameTemplateWhitespace(sourceCode: string, filePath: string) {
if (!shouldProcessFile(filePath) || !mayContainCleanupTarget(sourceCode)) {
return sourceCode;
}
const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
const edits: ITextEdit[] = [];
function visit(node: ts.Node) {
if (isStaticStringLiteral(node) && isClassNameWhitespaceTarget(node)) {
const normalized = normalizeClassNameWhitespace(node.text);
if (normalized !== node.text) {
edits.push({
end: node.end,
start: node.getStart(sourceFile),
text: JSON.stringify(normalized),
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (edits.length === 0) {
return sourceCode;
}
return applyTextEdits(sourceCode, edits);
}
export function createClassNameWhitespaceCleanupPlugin() {
return {
name: 'class-name-whitespace-cleanup',
transform(sourceCode: string, id: string) {
const cleanedCode = cleanupClassNameTemplateWhitespace(sourceCode, id);
if (cleanedCode === sourceCode) {
return null;
}
return {
code: cleanedCode,
map: null,
};
},
};
}

View File

@@ -0,0 +1,95 @@
import type { UserConfig } from 'tsdown';
const CSS_NOOP_PLUGIN_NAME = 'univer-css-noop';
const CSS_IMPORT_RE = /\.(?:css|less|sass|scss|styl|stylus)(?:$|\?)/;
const CSS_NOOP_ID_PREFIX = '\0univer-css-noop:';
const CSS_NOOP_ID_RE = /^\0univer-css-noop:/;
type TInputOptions = NonNullable<UserConfig['inputOptions']>;
type TInputOptionsFunction = Extract<TInputOptions, (...args: any[]) => any>;
type TInputOptionsObject = Exclude<TInputOptions, (...args: any[]) => any>;
function toPluginArray(plugins: unknown): any[] {
return Array.isArray(plugins)
? plugins
: plugins
? [plugins]
: [];
}
function withoutCssNoopPlugin(plugins: any[]) {
return plugins.filter((plugin) => !plugin || plugin.name !== CSS_NOOP_PLUGIN_NAME);
}
export function createCssNoopPlugin() {
let nextId = 0;
return {
name: CSS_NOOP_PLUGIN_NAME,
resolveId: {
filter: { id: CSS_IMPORT_RE },
handler() {
return `${CSS_NOOP_ID_PREFIX}${nextId++}`;
},
},
load: {
filter: { id: CSS_NOOP_ID_RE },
handler() {
return {
code: 'export default {};',
moduleSideEffects: false,
moduleType: 'js',
};
},
},
};
}
function prependCssNoopPlugin(plugins: any[]) {
return [
createCssNoopPlugin(),
...withoutCssNoopPlugin(plugins),
];
}
export function createCssNoopInputOptions(inputOptions?: TInputOptions): TInputOptionsFunction {
return (async (defaultOptions: { plugins?: unknown }, ...args: any[]) => {
const defaultPlugins = toPluginArray(defaultOptions.plugins);
if (!inputOptions) {
return {
...defaultOptions,
plugins: prependCssNoopPlugin(defaultPlugins),
};
}
if (typeof inputOptions === 'function') {
const defaultOptionsWithCssNoop = {
...defaultOptions,
plugins: prependCssNoopPlugin(defaultPlugins),
};
const resolvedOptions = await (inputOptions as any)(defaultOptionsWithCssNoop, ...args);
if (!resolvedOptions) {
return defaultOptionsWithCssNoop;
}
return {
...defaultOptionsWithCssNoop,
...resolvedOptions,
plugins: prependCssNoopPlugin(toPluginArray(resolvedOptions.plugins ?? defaultOptionsWithCssNoop.plugins)),
};
}
const inputOptionsObject = inputOptions as TInputOptionsObject & { plugins?: unknown };
return {
...defaultOptions,
...inputOptionsObject,
plugins: prependCssNoopPlugin([
...toPluginArray(inputOptionsObject.plugins),
...defaultPlugins,
]),
};
}) as TInputOptionsFunction;
}

View File

@@ -0,0 +1,58 @@
import { cpSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
import path from 'node:path';
interface IBundleOutput {
fileName?: string;
}
export interface ICreateOutputAliasPluginOptions {
copyToRoot: boolean;
keepRootIndexCss: boolean;
packageDir: string;
}
export function createOutputAliasPlugin(options: ICreateOutputAliasPluginOptions) {
const { copyToRoot, keepRootIndexCss, packageDir } = options;
return {
name: 'create-output-alias',
writeBundle(outputOptions: { dir?: string }, bundle: Record<string, IBundleOutput>) {
if (!outputOptions.dir) {
return;
}
const outDir = path.resolve(packageDir, outputOptions.dir);
const rootDir = path.resolve(outDir, '..');
for (const output of Object.values(bundle)) {
if (!output.fileName || output.fileName.endsWith('.map')) {
continue;
}
const sourcePath = path.join(outDir, output.fileName);
if (output.fileName.endsWith('.css')) {
if (keepRootIndexCss) {
const rootCssPath = path.join(rootDir, 'index.css');
mkdirSync(path.dirname(rootCssPath), { recursive: true });
cpSync(sourcePath, rootCssPath);
}
if (existsSync(sourcePath)) {
unlinkSync(sourcePath);
}
continue;
}
if (!copyToRoot) {
continue;
}
const targetPath = path.join(rootDir, output.fileName);
mkdirSync(path.dirname(targetPath), { recursive: true });
cpSync(sourcePath, targetPath);
}
},
};
}

View File

@@ -0,0 +1,42 @@
import type { ObfuscatorOptions } from 'javascript-obfuscator';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import JavaScriptObfuscator from 'javascript-obfuscator';
interface IBundleOutput {
fileName?: string;
}
export function createOutputObfuscatorPlugin(ignorePatterns?: RegExp[]) {
const obfuscationOptions: ObfuscatorOptions = {
ignoreImports: true,
};
return {
name: 'create-output-obfuscator',
writeBundle(outputOptions: { dir?: string }, bundle: Record<string, IBundleOutput>) {
if (!outputOptions.dir) {
return;
}
const outDir = outputOptions.dir;
for (const output of Object.values(bundle)) {
const fileName = output.fileName;
if (!fileName || !fileName.endsWith('.js') || fileName.endsWith('.map')) {
continue;
}
if (ignorePatterns?.some((pattern) => pattern.test(fileName))) {
continue;
}
const targetPath = path.resolve(outDir, fileName);
const sourceCode = readFileSync(targetPath, 'utf8');
const obfuscatedCode = JavaScriptObfuscator.obfuscate(sourceCode, obfuscationOptions).getObfuscatedCode();
writeFileSync(targetPath, obfuscatedCode, 'utf8');
}
},
};
}

View File

@@ -0,0 +1,36 @@
import type { UserConfig } from 'tsdown';
export interface IBuildOptions {
skipUMD?: boolean;
cleanup?: boolean;
ignorePackages?: string[];
nodeFirst?: boolean;
tsdownConfigPath?: string;
obfuscatorIgnorePatterns?: RegExp[];
}
export type TEntryType = 'facade' | 'index' | 'locale' | 'worker';
export interface IEntryConfig {
key: string;
path: string;
type: TEntryType;
}
export interface IPackageJson {
[key: string]: unknown;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
name: string;
peerDependencies?: Record<string, string>;
}
export interface IBuildContext {
entries: IEntryConfig[];
externalPackages: string[];
facadeExternalPackages: string[];
inputOptions?: NonNullable<UserConfig['inputOptions']>;
packageDir: string;
packageJson: IPackageJson;
plugins: any[];
}

View File

@@ -0,0 +1,55 @@
import type { UserConfig } from 'tsdown';
import type { IBuildContext, IBuildOptions } from '../types.ts';
import path from 'node:path';
import vue from 'unplugin-vue/rolldown';
import { DEFAULT_BROWSER_TARGET } from '../constants.ts';
import { createClassNameWhitespaceCleanupPlugin } from '../plugins/class-name-whitespace-cleanup.ts';
import { hasSourceFiles } from './files.ts';
/**
* Resolves extra bundler plugins based on the package source layout.
*/
export function createInputPlugins(packageDir: string): any[] {
return [
createClassNameWhitespaceCleanupPlugin(),
...(hasSourceFiles(packageDir, '.vue') ? [vue()] : []),
];
}
/**
* Builds resolver conditions for packages that prefer Node-first exports.
*/
export function createInputOptions(options: IBuildOptions): IBuildContext['inputOptions'] {
if (!options.nodeFirst) {
return undefined;
}
return {
resolve: {
conditionNames: ['node', 'default'],
},
};
}
/**
* Creates the shared tsdown base config used by all output formats.
*/
export function createBaseConfig(context: IBuildContext): Partial<UserConfig> {
return {
clean: false,
css: {
transformer: 'postcss' as const,
},
cwd: context.packageDir,
define: {
'process.env.BUILD_TIMESTAMP': JSON.stringify(Math.floor(Date.now() / 1000)),
'process.env.NODE_ENV': JSON.stringify('production'),
'process.env.WS_NO_BUFFER_UTIL': JSON.stringify(true),
},
inputOptions: context.inputOptions,
outExtensions: () => ({ js: '.js' }),
report: true,
target: DEFAULT_BROWSER_TARGET,
tsconfig: path.join(context.packageDir, 'tsconfig.json'),
} satisfies Partial<UserConfig>;
}

View File

@@ -0,0 +1,442 @@
/* eslint-disable max-lines-per-function */
import type { IPackageJson } from '../types.ts';
import fs from 'node:fs';
import path from 'node:path';
import sortKeys from 'sort-keys';
import * as ts from 'typescript';
import { peerDepsMap } from '../data/peer-deps.ts';
type StringMap = Record<string, string>;
type PeerDepValue = (typeof peerDepsMap)[keyof typeof peerDepsMap] & { optional?: boolean };
type CleanupPackageJson = IPackageJson & {
dependencies?: StringMap;
devDependencies?: StringMap;
exports?: Record<string, unknown>;
optionalDependencies?: StringMap;
peerDependencies?: StringMap;
publishConfig?: {
access: string;
exports: Record<string, unknown>;
main: string;
module: string;
};
};
interface IDerivedDependencyGroups {
dependencies: StringMap;
devDependencies: StringMap;
optionalDependencies: StringMap;
peerDependencies: StringMap;
}
const SOURCE_EXTS = new Set(['.ts', '.tsx']);
const TEST_FILE_RE = /\.(spec|test)\.[cm]?tsx?$/;
const IMMUTABLE_MANAGED_DEPENDENCIES = new Set(['@univerjs/icons', '@univerjs/icons-svg']);
function filterPackageName(packageName: string): string {
if (packageName.startsWith('@univerjs/')) {
return packageName.split('/').slice(0, 2).join('/');
} else if (packageName.startsWith('@univerjs-pro/')) {
return packageName.split('/').slice(0, 2).join('/');
} else {
return packageName;
}
}
function isManagedUniverPackage(packageName: string) {
return packageName.startsWith('@univerjs/') || packageName.startsWith('@univerjs-pro/');
}
function shouldPreserveExistingDependency(packageName: string) {
return !isManagedUniverPackage(packageName) || IMMUTABLE_MANAGED_DEPENDENCIES.has(packageName);
}
function mergePreservingNonUniver(existing: StringMap | undefined, next: StringMap) {
const preserved = Object.fromEntries(
Object.entries(existing ?? {}).filter(([name]) => shouldPreserveExistingDependency(name))
) as StringMap;
return sortKeys({ ...preserved, ...next });
}
function assignDependencyGroup(
pkg: CleanupPackageJson,
key: 'dependencies' | 'devDependencies' | 'optionalDependencies',
next: StringMap
) {
const merged = mergePreservingNonUniver(pkg[key], next);
if (Object.keys(merged).length === 0) {
delete pkg[key];
return;
}
pkg[key] = merged;
}
function isTestSourceFile(filePath: string) {
const normalized = filePath.split(path.sep).join('/');
if (normalized.includes('/__tests__/')) {
return true;
}
return TEST_FILE_RE.test(normalized);
}
function parseModuleSpecifiersFromCode(code: string, filePath: string) {
const runtime = new Set<string>();
const typeOnly = new Set<string>();
const kind = path.extname(filePath) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true, kind);
function add(spec: string, isType: boolean) {
if (!spec || spec.startsWith('\0') || spec.startsWith('.') || spec.startsWith('/')) {
return;
}
(isType ? typeOnly : runtime).add(spec);
}
function addFromImportLike(moduleSpecifier: ts.Expression | undefined, isType: boolean) {
if (moduleSpecifier && ts.isStringLiteralLike(moduleSpecifier)) {
add(moduleSpecifier.text, isType);
}
}
for (const stmt of sourceFile.statements) {
if (ts.isImportDeclaration(stmt)) {
const spec = stmt.moduleSpecifier;
const clause = stmt.importClause;
if (!clause) {
addFromImportLike(spec, false);
continue;
}
if (clause.isTypeOnly) {
addFromImportLike(spec, true);
continue;
}
let hasValue = Boolean(clause.name);
if (clause.namedBindings) {
if (ts.isNamespaceImport(clause.namedBindings)) {
hasValue = true;
} else if (ts.isNamedImports(clause.namedBindings)) {
const allTypeOnly = clause.namedBindings.elements.length > 0
&& clause.namedBindings.elements.every((el) => el.isTypeOnly);
const anyValue = clause.namedBindings.elements.some((el) => !el.isTypeOnly);
if (anyValue) {
hasValue = true;
} else if (allTypeOnly && !hasValue) {
addFromImportLike(spec, true);
continue;
}
}
}
addFromImportLike(spec, !hasValue);
continue;
}
if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) {
const spec = stmt.moduleSpecifier;
if (stmt.isTypeOnly) {
addFromImportLike(spec, true);
continue;
}
if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
const allTypeOnly = stmt.exportClause.elements.length > 0
&& stmt.exportClause.elements.every((el) => el.isTypeOnly);
addFromImportLike(spec, allTypeOnly);
continue;
}
addFromImportLike(spec, false);
continue;
}
}
function visit(node: ts.Node) {
if (ts.isImportTypeNode(node)) {
const arg = node.argument;
if (ts.isLiteralTypeNode(arg) && ts.isStringLiteralLike(arg.literal)) {
add(arg.literal.text, true);
}
}
if (ts.isCallExpression(node) && node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0])) {
const callee = node.expression;
if (callee.kind === ts.SyntaxKind.ImportKeyword) {
add(node.arguments[0].text, false);
} else if (ts.isIdentifier(callee) && callee.text === 'require') {
add(node.arguments[0].text, false);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return { runtime, typeOnly };
}
function walkSourceFiles(dir: string, files: string[] = []) {
if (!fs.existsSync(dir)) {
return files;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'lib' || entry.name === 'coverage') {
continue;
}
walkSourceFiles(fullPath, files);
continue;
}
if (SOURCE_EXTS.has(path.extname(entry.name))) {
files.push(fullPath);
}
}
return files;
}
function collectBareSpecifiersFromSource(packageDir: string) {
const runtimeValueSpecifiers = new Set<string>();
const runtimeTypeSpecifiers = new Set<string>();
const testSpecifiers = new Set<string>();
const srcDir = path.resolve(packageDir, 'src');
for (const file of walkSourceFiles(srcDir)) {
const code = fs.readFileSync(file, 'utf8');
const isTest = isTestSourceFile(file);
const { runtime, typeOnly } = parseModuleSpecifiersFromCode(code, file);
for (const spec of runtime) {
if (isTest) {
testSpecifiers.add(spec);
} else {
runtimeValueSpecifiers.add(spec);
}
}
for (const spec of typeOnly) {
if (isTest) {
testSpecifiers.add(spec);
} else {
runtimeTypeSpecifiers.add(spec);
}
}
}
return {
runtimeValue: [...runtimeValueSpecifiers],
runtimeType: [...runtimeTypeSpecifiers],
test: [...testSpecifiers],
};
}
function resolvePeerDepVersion(value: PeerDepValue): { name: string; version: string; optional: boolean } | null {
let version = value.version;
let name = value.name;
const optional = Boolean(value.optional);
const visited = new Set<string>();
while (version in peerDepsMap) {
if (visited.has(version)) {
return null;
}
visited.add(version);
const next = peerDepsMap[version as keyof typeof peerDepsMap] as PeerDepValue;
name = next.name;
version = next.version;
}
return { name, optional, version };
}
function resolveUniverDependencyVersion(name: string) {
if (IMMUTABLE_MANAGED_DEPENDENCIES.has(name)) {
return undefined;
}
return 'workspace:*';
}
function deriveDependencyGroups(packageDir: string, packageJson: IPackageJson): IDerivedDependencyGroups {
const declaredDependencies = packageJson.dependencies ?? {};
const declaredOptionalDependencies = (packageJson as CleanupPackageJson).optionalDependencies ?? {};
const peerDeps: StringMap = {};
const deps: StringMap = {};
const optionalDeps: StringMap = {};
const devDeps: StringMap = {};
const { runtimeValue, runtimeType, test } = collectBareSpecifiersFromSource(packageDir);
const runtimeUniver = new Set<string>();
const runtimeTypeOnlyUniver = new Set<string>();
const testOnlyUniver = new Set<string>();
for (const source of runtimeValue) {
if (Object.prototype.hasOwnProperty.call(peerDepsMap, source)) {
const value = peerDepsMap[source as keyof typeof peerDepsMap] as PeerDepValue;
const resolved = resolvePeerDepVersion(value);
if (!resolved) {
continue;
}
// Local package manifests win over the shared peer-dependency registry.
if (resolved.name in declaredDependencies || resolved.name in declaredOptionalDependencies) {
continue;
}
if (resolved.optional) {
optionalDeps[resolved.name] = resolved.version;
} else {
peerDeps[resolved.name] = resolved.version;
}
continue;
}
if (source.startsWith('@univerjs')) {
runtimeUniver.add(filterPackageName(source));
}
}
for (const source of runtimeType) {
if (source.startsWith('@univerjs')) {
runtimeTypeOnlyUniver.add(filterPackageName(source));
}
}
for (const source of test) {
if (source.startsWith('@univerjs')) {
testOnlyUniver.add(filterPackageName(source));
}
}
for (const name of runtimeUniver) {
if (name === packageJson.name) {
continue;
}
const dependencyVersion = resolveUniverDependencyVersion(name);
if (dependencyVersion) {
deps[name] = dependencyVersion;
}
}
for (const name of new Set([...testOnlyUniver, ...runtimeTypeOnlyUniver])) {
if (runtimeUniver.has(name) || name === packageJson.name) {
continue;
}
const dependencyVersion = resolveUniverDependencyVersion(name);
if (dependencyVersion) {
devDeps[name] = dependencyVersion;
}
}
const declaredDevDependencies: Record<string, string> = (packageJson as CleanupPackageJson).devDependencies ?? {};
if ('react' in declaredDevDependencies && !('react' in peerDeps)) {
const reactPeerDep = peerDepsMap.react;
if (reactPeerDep) {
peerDeps.react = reactPeerDep.version;
}
}
return {
dependencies: deps,
devDependencies: devDeps,
optionalDependencies: optionalDeps,
peerDependencies: peerDeps,
};
}
function applyPublishManifest(pkg: CleanupPackageJson, packageDir: string) {
const hasLocales = fs.existsSync(path.resolve(packageDir, 'src/locale'));
const hasFacade = fs.existsSync(path.resolve(packageDir, 'src/facade/index.ts'));
pkg.publishConfig = {
access: 'public',
main: './lib/es/index.js',
module: './lib/es/index.js',
exports: {
'.': {
import: './lib/es/index.js',
require: './lib/cjs/index.js',
types: './lib/types/index.d.ts',
},
'./*': {
import: './lib/es/*',
require: './lib/cjs/*',
types: './lib/types/index.d.ts',
},
},
};
pkg.exports ||= {};
if (hasLocales) {
pkg.exports['./locale/*'] = './src/locale/*.ts';
pkg.publishConfig.exports['./locale/*'] = {
import: './lib/es/locale/*.js',
require: './lib/cjs/locale/*.js',
types: './lib/types/locale/*.d.ts',
};
}
if (hasFacade) {
pkg.exports['./facade'] = './src/facade/index.ts';
pkg.publishConfig.exports['./facade'] = {
import: './lib/es/facade.js',
require: './lib/cjs/facade.js',
types: './lib/types/facade/index.d.ts',
};
pkg.publishConfig.exports['./lib/facade'] = pkg.publishConfig.exports['./facade'];
}
pkg.publishConfig.exports['./lib/*'] = './lib/*';
}
function assignPeerDependencies(pkg: CleanupPackageJson, peerDeps: StringMap) {
const managedPeerDepNames = [...new Set(Object.values(peerDepsMap).map((value) => value.name))];
const existing = { ...pkg.peerDependencies };
for (const key of managedPeerDepNames) {
delete existing[key];
}
const merged = Object.keys(peerDeps).length > 0 ? sortKeys({ ...existing, ...peerDeps }) : sortKeys(existing);
if (Object.keys(merged).length === 0) {
delete pkg.peerDependencies;
return;
}
pkg.peerDependencies = merged;
}
function readJsonFile<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
}
function writeJsonFile(filePath: string, value: unknown) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 4)}\n`);
}
export function cleanupPackageJson(packageDir: string, packageJson: IPackageJson) {
const pkgPath = path.resolve(packageDir, 'package.json');
const pkg = readJsonFile<CleanupPackageJson>(pkgPath);
const dependencyGroups = deriveDependencyGroups(packageDir, packageJson);
applyPublishManifest(pkg, packageDir);
assignDependencyGroup(pkg, 'optionalDependencies', dependencyGroups.optionalDependencies);
assignPeerDependencies(pkg, dependencyGroups.peerDependencies);
assignDependencyGroup(pkg, 'dependencies', dependencyGroups.dependencies);
// This rewrite only owns @univerjs-managed entries; unrelated dev deps stay intact.
assignDependencyGroup(pkg, 'devDependencies', dependencyGroups.devDependencies);
writeJsonFile(pkgPath, pkg);
}

View File

@@ -0,0 +1,45 @@
import type { IEntryConfig } from '../types.ts';
import { existsSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { DEFAULT_ENTRY_FILE, FACADE_ENTRY_FILE, LOCALE_DIRECTORY } from '../constants.ts';
/**
* Collects all canonical build entries for a package.
*/
export function getEntries(packageDir: string): IEntryConfig[] {
const entries: IEntryConfig[] = [{
key: 'index',
path: path.join(packageDir, DEFAULT_ENTRY_FILE),
type: 'index',
}];
const facadeEntry = path.join(packageDir, FACADE_ENTRY_FILE);
if (existsSync(facadeEntry)) {
entries.push({
key: 'facade',
path: facadeEntry,
type: 'facade',
});
}
const localeDir = path.join(packageDir, LOCALE_DIRECTORY);
if (!existsSync(localeDir)) {
return entries;
}
for (const fileName of readdirSync(localeDir).sort((left, right) => left.localeCompare(right))) {
const fullPath = path.join(localeDir, fileName);
if (statSync(fullPath).isDirectory() || !fileName.endsWith('.ts') || !fileName.includes('-')) {
continue;
}
entries.push({
key: `locale/${fileName.replace(/\.ts$/, '')}`,
path: fullPath,
type: 'locale',
});
}
return entries;
}

View File

@@ -0,0 +1,60 @@
import { existsSync, readdirSync, unlinkSync } from 'node:fs';
import path from 'node:path';
import { SOURCE_DIRECTORY } from '../constants.ts';
/**
* Walks the package source tree and returns whether a given file suffix exists.
*/
export function hasSourceFiles(packageDir: string, extension: string) {
const sourceDir = path.join(packageDir, SOURCE_DIRECTORY);
if (!existsSync(sourceDir)) {
return false;
}
const stack = [sourceDir];
while (stack.length > 0) {
const currentDir = stack.pop()!;
const entries = readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (entry.name.endsWith(extension)) {
return true;
}
}
}
return false;
}
/**
* Removes emitted css artifacts before a new build so alias copies stay deterministic.
*/
export function removeCssArtifacts(dir: string) {
if (!existsSync(dir)) {
return;
}
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
removeCssArtifacts(fullPath);
continue;
}
if (entry.name.endsWith('.css')) {
unlinkSync(fullPath);
}
}
}

View File

@@ -0,0 +1,42 @@
import type { IPackageJson } from '../types.ts';
import { readFileSync } from 'node:fs';
import path from 'node:path';
function getProductionDependencyNames(packageJson: IPackageJson) {
const names = new Set([
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
]);
const devDeps = packageJson.devDependencies ?? {};
if ('react' in devDeps) {
names.add('react');
}
return [...names].sort((left, right) => left.localeCompare(right));
}
function normalizeIgnoredPackages(ignorePackages: string[] = []) {
return [...new Set(ignorePackages)].sort((left, right) => left.localeCompare(right));
}
/**
* Reads the package manifest used to derive dependency externalization rules.
*/
export function readPackageJson(packageDir: string): IPackageJson {
return JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf-8')) as IPackageJson;
}
/**
* Produces the external package allowlist including subpath imports.
*/
export function createExternalPackages(packageJson: IPackageJson, ignorePackages: string[] = []) {
const ignoredPackages = normalizeIgnoredPackages(ignorePackages);
return [...new Set([
...getProductionDependencyNames(packageJson),
...ignoredPackages,
])]
.sort((left, right) => left.localeCompare(right))
.flatMap((packageName) => [packageName, `${packageName}/*`]);
}

View File

@@ -0,0 +1,65 @@
import type { IPackageJson } from '../types.ts';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { readPackageJson } from './package.ts';
const ROOT_MANIFEST_FIELDS = [
'author',
'bugs',
'dependencies',
'description',
'engines',
'funding',
'homepage',
'keywords',
'license',
'name',
'optionalDependencies',
'peerDependencies',
'peerDependenciesMeta',
'repository',
'sideEffects',
'type',
'version',
] as const;
function pickRootManifest(pkg: IPackageJson) {
const manifest: Record<string, unknown> = {};
for (const field of ROOT_MANIFEST_FIELDS) {
if (pkg[field] !== undefined) {
manifest[field] = pkg[field];
}
}
return manifest;
}
/**
* Creates the publish-time package manifest inside the dist directory.
*/
export function emitPublishPackageJson(packageDir: string) {
const pkg = readPackageJson(packageDir);
const publishConfig = pkg.publishConfig as Record<string, unknown> | undefined;
const publishDirectory = typeof publishConfig?.directory === 'string' ? publishConfig.directory : null;
if (!publishConfig || !publishDirectory) {
return;
}
const distDir = path.join(packageDir, publishDirectory);
const manifest = {
...pickRootManifest(pkg),
} as Record<string, unknown>;
for (const [key, value] of Object.entries(publishConfig)) {
if (key === 'access' || key === 'directory') {
continue;
}
manifest[key] = value;
}
mkdirSync(distDir, { recursive: true });
writeFileSync(path.join(distDir, 'package.json'), `${JSON.stringify(manifest, null, 4)}\n`);
}

View File

@@ -0,0 +1,61 @@
import { createRequire } from 'node:module';
import process from 'node:process';
import { defineConfig, mergeConfig } from 'vitest/config';
const require = createRequire(import.meta.url);
const coverageProviderModule = require.resolve('@vitest/coverage-istanbul');
const isCI = process.env.CI === 'true';
export default function createConfig(options?: any) {
return defineConfig(mergeConfig({
test: {
testTimeout: 30_000,
hookTimeout: 30_000,
css: {
modules: {
classNameStrategy: 'non-scoped',
},
},
environment: 'happy-dom',
coverage: {
reporter: isCI ? ['json', 'lcovonly'] : ['html'],
provider: 'custom',
// `customProviderModule` expects a file path. Using a bare
// package name makes Vitest resolve it as a relative path from
// each package root (e.g. `packages/foo/@vitest/...`) and fail.
// Resolve it from this shared package's deps instead.
customProviderModule: coverageProviderModule,
include: ['src/**/*.{ts,tsx}'],
exclude: [
'coverage/**',
'dist/**',
'**/[.]**',
'**/*.d.ts',
'**/virtual:*',
'**/__x00__*',
'cypress/**',
'test?(s)/**',
'test?(-*).?(c|m)[jt]s?(x)',
'**/*{.,-}{test,spec}?(-d).?(c|m)[jt]s?(x)',
'**/__test?(s)__/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
'**/vitest.{workspace,projects}.[jt]s?(on)',
'**/.{eslint,mocha,prettier}rc.{?(c|m)js,yml}',
'lib/**',
'src/locale/**',
'**/*.stories.tsx',
'**/*/tailwind.config.ts',
'packages/protocol/**',
'packages/slides/**',
'packages/slides-ui/**',
'**/src/menu/**',
'**/src/plugin.ts',
'**/src/mobile-plugin.ts',
'**/src/facade/index.ts',
'**/src/config/config.ts',
'**/src/controllers/components.controller.ts',
],
},
},
}, options ?? {}));
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,120 @@
import type { StorybookConfig } from '@storybook/react-webpack5';
import type { StoriesEntry } from 'storybook/internal/types';
import { existsSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
const __dirname = pathToFileURL(new URL('.', import.meta.url).pathname).pathname;
const tsconfigPathsPlugin = new TsconfigPathsPlugin({
configFile: resolve(__dirname, '../tsconfig.storybook.json'),
extensions: ['.ts', '.tsx', '.js'],
});
const config: StorybookConfig = {
stories: async () => {
const rootPaths = ['../../../packages'];
const isSubmodules = __dirname.includes('submodules');
if (isSubmodules) {
rootPaths.push('../../../../../packages');
}
const stories: StoriesEntry[] | PromiseLike<StoriesEntry[]> = [];
rootPaths.forEach((rootPath) => {
const rootDir = resolve(__dirname, rootPath);
if (existsSync(rootDir)) {
readdirSync(rootDir).forEach((pkg) => {
const pkgPath = resolve(rootDir, pkg, 'package.json');
const srcDir = resolve(rootDir, pkg, 'src');
if (existsSync(pkgPath) && existsSync(srcDir)) {
stories.push({
directory: `${rootPath}/${pkg}/src`,
files: '**/*.stories.@(js|jsx|mjs|ts|tsx)',
titlePrefix: pkg,
});
}
});
}
});
return stories;
},
addons: [
'@storybook/addon-links',
'@chromatic-com/storybook',
'@storybook/addon-webpack5-compiler-swc',
{
name: '@storybook/addon-styling-webpack',
options: {
rules: [
{
test: /\.css$/,
sideEffects: true,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
{
loader: 'postcss-loader',
options: {
implementation: 'postcss',
},
},
],
},
],
},
},
'@storybook/addon-docs',
],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
swc: () => {
return {
isModule: true,
module: {
type: 'es6',
noInterop: true,
},
env: {
target: 'es2022',
},
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
decorators: true,
decoratorsBeforeExport: true,
dynamicImport: true,
},
transform: {
react: {
runtime: 'automatic',
},
legacyDecorator: true,
decoratorMetadata: true,
},
loose: true,
},
};
},
docs: {
defaultName: 'documentatie',
},
async webpackFinal(config) {
if (config.resolve) {
config.resolve.plugins = [tsconfigPathsPlugin];
}
return config;
},
};
export default config;

View File

@@ -0,0 +1,140 @@
import type { Preview } from '@storybook/react';
import type { Theme } from '@univerjs/themes';
import {
CommandService,
ConfigService,
ContextService,
DesktopLogService,
ErrorService,
ICommandService,
IConfigService,
IContextService,
ILocalStorageService,
ILogService,
Injector,
IPermissionService,
IResourceManagerService,
IUndoRedoService,
IUniverInstanceService,
LifecycleService,
LocaleService,
LocaleType,
LocalUndoRedoService,
PermissionService,
ResourceManagerService,
ThemeService,
UniverInstanceService,
} from '@univerjs/core';
import { ConfigProvider } from '@univerjs/design';
import enUS from '@univerjs/design/locale/en-US';
import zhCN from '@univerjs/design/locale/zh-CN';
import { defaultTheme, greenTheme } from '@univerjs/themes';
import { DesktopLocalStorageService, RediContext, ThemeSwitcherService } from '@univerjs/ui';
import { useEffect, useMemo } from 'react';
import './global.css';
export const themes: Record<string, Theme> = {
default: defaultTheme,
green: greenTheme,
};
const preview: Preview = {
parameters: {
// actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
backgrounds: {
values: [
{ name: 'Light', value: '#F9FAFB' },
],
default: 'Light',
},
},
globalTypes: {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'default',
toolbar: {
icon: 'cog',
items: Object.keys(themes),
},
},
i18n: {
name: 'i18n',
description: 'Global theme for components',
defaultValue: LocaleType.ZH_CN,
toolbar: {
icon: 'globe',
items: [LocaleType.ZH_CN, LocaleType.EN_US],
},
},
darkMode: {
name: 'darkMode',
description: 'Dark mode',
defaultValue: 'light',
toolbar: {
icon: 'sun',
items: ['light', 'dark'],
},
},
},
decorators: [(Story, context) => {
// themeInstance.setTheme(document.body, themes[context.globals.theme]);
const designLocale = context.globals.i18n === LocaleType.ZH_CN ? zhCN.design : enUS.design;
if (context.globals.darkMode === 'dark') {
document.body.classList.add('univer-dark');
} else {
document.body.classList.remove('univer-dark');
}
const rediContext = useMemo(() => {
const injector = new Injector([
[ThemeSwitcherService],
[IUniverInstanceService, { useClass: UniverInstanceService }],
[ErrorService],
[LocaleService],
[ThemeService],
[LifecycleService],
[ILogService, { useClass: DesktopLogService, lazy: true }],
[ICommandService, { useClass: CommandService, lazy: true }],
[IUndoRedoService, { useClass: LocalUndoRedoService, lazy: true }],
[IConfigService, { useClass: ConfigService }],
[IContextService, { useClass: ContextService }],
[IResourceManagerService, { useClass: ResourceManagerService, lazy: true }],
[IPermissionService, { useClass: PermissionService }],
// services
[ILocalStorageService, { useClass: DesktopLocalStorageService, lazy: true }],
]);
injector.get(LocaleService).setLocale(context.globals.i18n);
return {
injector,
};
}, []);
useEffect(() => {
const theme = themes[context.globals.theme];
rediContext.injector.get(ThemeSwitcherService).injectThemeToHead(theme);
}, [context.globals.theme]);
return (
<RediContext.Provider value={rediContext}>
<ConfigProvider locale={designLocale} mountContainer={document.body}>
<Story />
</ConfigProvider>
</RediContext.Provider>
);
}],
};
export default preview;

View File

@@ -0,0 +1,45 @@
{
"name": "@univerjs/storybook",
"version": "1.0.0-rc.0",
"private": true,
"description": "Some infrastructures for univerjs",
"author": "DreamNum <developer@univer.ai>",
"license": "Apache-2.0",
"homepage": "https://github.com/dream-num/univer",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer.git"
},
"keywords": [],
"scripts": {
"dev:storybook": "storybook dev -p 6006 --no-open",
"build:storybook": "storybook build"
},
"dependencies": {
"@chromatic-com/storybook": "^5.2.1",
"@storybook/addon-docs": "^10.4.6",
"@storybook/addon-links": "^10.4.6",
"@storybook/addon-styling-webpack": "^3.0.2",
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
"@storybook/icons": "^2.1.0",
"@storybook/react": "^10.4.6",
"@storybook/react-webpack5": "^10.4.6",
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/themes": "workspace:*",
"@univerjs/ui": "workspace:*",
"css-loader": "^7.1.4",
"postcss-loader": "^8.2.1",
"storybook": "^10.4.6",
"storybook-addon-swc": "^1.2.0",
"style-loader": "^4.0.0",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"typescript": "^6.0.3"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"tailwindcss": "3.4.18",
"tailwindcss-animate": "^1.0.7"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,31 @@
import type { Config } from 'tailwindcss';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import preset from '@univerjs-infra/shared/tailwind';
import animate from 'tailwindcss-animate';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packagesDir = fs.readdirSync(path.resolve(__dirname, '../../packages')).map((dir) => path.resolve(__dirname, `../../packages/${dir}`));
const tailwindProjects = packagesDir.reduce((acc, dir) => {
const tailwindConfig = path.resolve(dir, 'tailwind.config.ts');
if (fs.existsSync(tailwindConfig)) {
acc.push(`${dir}/src/**/*.{js,ts,jsx,tsx}`);
}
return acc;
}, [] as string[]);
const config: Config = {
presets: [preset],
content: [
'.storybook/**/*.{js,ts,jsx,tsx}',
...tailwindProjects,
],
plugins: [
animate,
],
};
export default config;

View File

@@ -0,0 +1,20 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"useDefineForClassFields": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"rootDir": ".",
"module": "esnext",
"moduleResolution": "bundler",
"allowJs": true,
"declaration": false,
"importHelpers": true,
"allowSyntheticDefaultImports": true
},
"include": [
".storybook/**/*",
"tailwind.config.ts"
],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"target": "es6",
"useDefineForClassFields": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"moduleResolution": "node"
},
"include": [
"src/**/*.stories.ts",
"src/**/*.stories.js",
"src/**/*.stories.jsx",
"src/**/*.stories.tsx",
"src/**/*.stories.mdx",
".storybook/**/*"
],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}