chore: add monorepo tools (#9196)

This commit is contained in:
liuyi
2024-12-24 15:29:48 +08:00
committed by GitHub
parent e3a8b63e38
commit 2443935830
116 changed files with 9390 additions and 1466 deletions

View File

@@ -1,13 +0,0 @@
# Bump BlockSuite
To update BlockSuite, run following command in project root:
```sh
node scripts/bump-blocksuite.js
```
For network issue, try setting proxy environment variables:
```sh
export http_proxy=http://127.0.0.1:7890
```

View File

@@ -1,176 +0,0 @@
import { execSync } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Clipboard } from '@napi-rs/clipboard';
import {
FetchOptions,
ProxyOptions,
RemoteCallbacks,
Repository,
Sort,
} from '@napi-rs/simple-git';
import chalk from 'chalk';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import corePackage from '../../packages/frontend/core/package.json' with { type: 'json' };
const clipboard = new Clipboard();
const oldHash = corePackage.dependencies['@blocksuite/affine'].split('-').pop();
const info = await fetch('https://registry.npmjs.org/@blocksuite/affine').then(
res => res.json()
);
const latestVersion = info['dist-tags'].latest;
const latestHash = latestVersion.split('-').pop();
const latestGitHead = info.versions[latestHash].gitHead;
const oldGitHead = info.versions[oldHash].gitHead;
if (oldHash === latestHash) {
console.info(chalk.greenBright('Already updated'));
process.exit(0);
}
if (process.env.http_proxy) {
setGlobalDispatcher(new ProxyAgent(process.env.http_proxy));
}
console.info(`Upgrade blocksuite from ${oldHash} -> ${latestHash}`);
const blockSuiteDeps = execSync(`yarn info -A --name-only --json`, {
encoding: 'utf8',
});
const blocksuiteDepsList = blockSuiteDeps
.split('\n')
.map(s => s.trim())
.filter(Boolean)
.map(s => s.substring(1, s.length - 1))
.filter(
s => s.startsWith('@blocksuite') && !s.startsWith('@blocksuite/icons')
)
.map(s => s.split('@npm').at(0));
for (const pkg of blocksuiteDepsList) {
const command = `yarn up ${pkg}@${latestVersion}`;
console.info(chalk.bgCyan(`Executing ${command}`));
execSync(command, {
stdio: 'inherit',
});
}
console.info(`Upgrade complete`);
const repo = new Repository(
join(fileURLToPath(import.meta.url), '..', '..', '..', '..', 'BlockSuite')
);
const remote = repo.remoteAnonymous(
'https://github.com/toeverything/BlockSuite.git'
);
remote.fetch(
['master'],
new FetchOptions().proxyOptions(new ProxyOptions().auto()).remoteCallback(
new RemoteCallbacks().transferProgress(progress => {
if (progress.totalDeltas && progress.totalObjects) {
console.log(
`${(
(progress.receivedObjects / progress.totalObjects) * 50 +
(progress.indexedDeltas / progress.totalDeltas) * 50
).toFixed(2)}%`
);
}
})
)
);
const commits = {
Features: [],
Bugfix: [],
Refactor: [],
Misc: [],
};
if (!latestGitHead) {
console.info('latestGitHead is not found');
console.info('Skip generating changelog');
process.exit(0);
}
for (const oid of repo
.revWalk()
.push(latestGitHead)
.setSorting(Sort.Time & Sort.Topological)) {
if (oid.startsWith(oldGitHead)) {
break;
}
const commit = repo.findCommit(oid);
const summary = commit.summary();
if (summary.startsWith('feat')) {
commits.Features.push(commit);
} else if (summary.startsWith('fix')) {
commits.Bugfix.push(commit);
} else if (summary.startsWith('refactor')) {
commits.Refactor.push(commit);
} else {
commits.Misc.push(commit);
}
}
clipboard.setText(await formatCommits(commits));
console.info(`Changelog copied to clipboard`);
async function formatCommits(commits) {
return `## Features
${await Promise.all(commits.Features.map(format)).then(commits =>
commits.join('\n')
)}
## Bugfix
${await Promise.all(commits.Bugfix.map(format)).then(commits =>
commits.join('\n')
)}
## Refactor
${await Promise.all(commits.Refactor.map(format)).then(commits =>
commits.join('\n')
)}
## Misc
${await Promise.all(commits.Misc.map(format)).then(commits =>
commits.join('\n')
)}
`;
/**
* @param {import('./index').Commit} commit
* @returns string
*/
async function format(commit) {
const summary = commit.summary();
const match = summary.match(/\(#(\d+)\)/);
if (match) {
const [_, pull] = match;
const pullInfo = await fetch(
`https://api.github.com/repos/toeverything/BlockSuite/pulls/${pull}`,
{
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
'X-GitHub-Api-Version': '2022-11-28',
},
}
)
.then(res => res.json())
.catch(() => ({ user: {} }));
const {
user: { login },
} = pullInfo;
return `- https://github.com/toeverything/BlockSuite/pull/${pull} @${login}`;
}
return `- ${summary}`;
}
}

View File

@@ -1,14 +0,0 @@
{
"name": "@affine/bump-blocksuite",
"version": "0.18.0",
"type": "module",
"main": "index.js",
"private": true,
"description": "Generate changelog from blocksuite version change",
"dependencies": {
"@napi-rs/clipboard": "^1.1.2",
"@napi-rs/simple-git": "^0.1.19",
"chalk": "^5.3.0",
"undici": "^7.1.0"
}
}

113
tools/cli/README.md Normal file
View File

@@ -0,0 +1,113 @@
# AFFiNE Monorepo Cli
## Start
```bash
yarn affine -h
```
### Run build command defined in package.json
```bash
yarn affine i18n build
# or
yarn build -p i18n
```
### Run dev command defined in package.json
```bash
yarn affine web dev
# or
yarn dev -p i18n
```
### Clean
```bash
yarn affine clean --dist --rust
# clean node_modules
yarn affine clean --node-modules
```
### Init
> Generate files that make the monorepo work properly, the per project codegen will not be included anymore
```bash
yarn affine init
```
## Tricks
### Define scripts to run a .ts files without `--loader ts-node/esm/transpile-only`
`affine run` will automatically inject `ts-node`'s transpile service(swc used) for your scripts
```json
{
"name": "@affine/demo"
"scripts": {
"dev": "node ./dev.ts"
}
}
```
```bash
affine @affine/demo dev
```
### Short your key presses
```bash
# af is also available for running the scripts
yarn af web build
```
#### by custom shell script
> personally, I use 'af'
create file `af` in the root of AFFiNE project with the following content
```bash
#!/usr/bin/env sh
./tools/scripts/bin/runner.js affine.ts $@
```
or on windows:
```cmd
node "./tools/cli/bin/runner.js" affine.ts %*
```
and give it executable permission
```bash
chmod a+x ./af
# now you can run scripts with simply
./af web build
```
if you want to go further, but for vscode(or other forks) only, add the following to your `.vscode/settings.json`
```json
{
"terminal.integrated.env.osx": {
"PATH": "${env:PATH}:${cwd}"
},
"terminal.integrated.env.linux": {
"PATH": "${env:PATH}:${cwd}"
},
"terminal.integrated.env.windows": {
"PATH": "${env:PATH};${cwd}"
}
}
```
restart all the integrated terminals and now you get:
```bash
af web build
```

68
tools/cli/bin/runner.js Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const scriptsFolder = join(fileURLToPath(import.meta.url), '..', '..');
const scriptsSrcFolder = join(scriptsFolder, 'src');
const projectRoot = join(scriptsFolder, '..', '..');
const loader = join(scriptsFolder, 'register.js');
const [node, _self, file, ...options] = process.argv;
if (!file) {
console.error(`Please provide a file to run, e.g. 'run src/index.{js/ts}'`);
process.exit(1);
}
const fileLocationCandidates = new Set([
process.cwd(),
scriptsSrcFolder,
projectRoot,
]);
const lookups = [];
/**
* @type {string | undefined}
*/
let scriptLocation;
for (const location of fileLocationCandidates) {
const fileCandidates = [file, `${file}.js`, `${file}.ts`];
for (const candidate of fileCandidates) {
const candidateLocation = join(location, candidate);
if (existsSync(candidateLocation)) {
scriptLocation = candidateLocation;
break;
}
lookups.push(candidateLocation);
}
}
if (!scriptLocation) {
console.error(
`File ${file} not found, please make sure the first parameter passed to 'run' script is a valid js or ts file.`
);
console.error(`Searched locations: `);
lookups.forEach(location => {
console.error(` - ${location}`);
});
process.exit(1);
}
const nodeOptions = [];
if (
scriptLocation.endsWith('.ts') ||
scriptLocation.startsWith(scriptsFolder)
) {
nodeOptions.unshift(`--import=${pathToFileURL(loader)}`);
} else {
nodeOptions.unshift('--experimental-specifier-resolution=node');
}
spawn(node, [...nodeOptions, scriptLocation, ...options], {
stdio: 'inherit',
}).on('exit', code => {
process.exit(code);
});

14
tools/cli/hooks.js Normal file
View File

@@ -0,0 +1,14 @@
import { create, createEsmHooks, register } from 'ts-node';
const service = create({
experimentalSpecifierResolution: 'node',
esm: true,
transpileOnly: true,
swc: true,
});
register(service);
const hooks = createEsmHooks(service);
export const resolve = hooks.resolve;
export const load = hooks.load;

View File

@@ -1,33 +1,39 @@
{
"name": "@affine/cli",
"name": "@affine-tools/cli",
"version": "0.0.1",
"type": "module",
"private": true,
"devDependencies": {
"@affine/env": "workspace:*",
"@affine/templates": "workspace:*",
"bin": {
"r": "./bin/runner.js"
},
"exports": {
"./loader": "./loader.js"
},
"scripts": {
"affine": "r ./src/affine.ts"
},
"dependencies": {
"@affine-tools/utils": "workspace:*",
"@aws-sdk/client-s3": "^3.709.0",
"@blocksuite/affine": "workspace:*",
"@clack/core": "^0.4.0",
"@clack/prompts": "^0.9.0",
"@magic-works/i18n-codegen": "^0.6.1",
"@napi-rs/simple-git": "^0.1.19",
"@perfsee/webpack": "^1.13.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@sentry/webpack-plugin": "^2.22.7",
"@types/mime-types": "^2.1.4",
"@types/webpack-env": "^1.18.5",
"@vanilla-extract/webpack-plugin": "^2.3.15",
"autoprefixer": "^10.4.20",
"clipanion": "^3.2.1",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"cssnano": "^7.0.6",
"dotenv": "^16.4.7",
"html-webpack-plugin": "^5.6.3",
"lodash-es": "^4.17.21",
"mime-types": "^2.1.35",
"mini-css-extract-plugin": "^2.9.2",
"postcss": "^8.4.49",
"postcss-loader": "^8.1.1",
"prettier": "^3.4.2",
"react-refresh": "^0.16.0",
"source-map-loader": "^5.0.0",
"style-loader": "^4.0.0",
@@ -35,14 +41,16 @@
"tailwindcss": "^3.4.16",
"terser-webpack-plugin": "^5.3.10",
"ts-node": "^10.9.2",
"vite": "^6.0.3",
"typanion": "^3.14.0",
"typescript": "^5.5.4",
"webpack": "^5.97.1",
"webpack-dev-server": "^5.2.0",
"webpack-merge": "^6.0.1"
},
"scripts": {
"bundle": "node --loader ts-node/esm/transpile-only.mjs ./src/bin/build.ts",
"dev": "node --loader ts-node/esm/transpile-only.mjs ./src/bin/dev.ts"
},
"version": "0.18.0"
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.17.10",
"@types/webpack-env": "^1.18.5"
}
}

3
tools/cli/register.js Normal file
View File

@@ -0,0 +1,3 @@
import { register } from 'node:module';
register('./hooks.js', import.meta.url);

32
tools/cli/src/affine.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Workspace } from '@affine-tools/utils/workspace';
import { Cli } from 'clipanion';
import { BuildCommand } from './build';
import { BundleCommand } from './bundle';
import { CleanCommand } from './clean';
import { CodegenCommand } from './codegen';
import type { CliContext } from './context';
import { DevCommand } from './dev';
import { RunCommand } from './run';
const cli = new Cli<CliContext>({
binaryName: 'affine',
binaryVersion: '0.0.0',
binaryLabel: 'AFFiNE Monorepo Tools',
enableColors: true,
enableCapture: true,
});
cli.register(RunCommand);
cli.register(CodegenCommand);
cli.register(CleanCommand);
cli.register(BuildCommand);
cli.register(DevCommand);
cli.register(BundleCommand);
await cli.runExit(process.argv.slice(2), {
workspace: new Workspace(),
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
});

View File

@@ -1,66 +0,0 @@
import { spawn } from 'node:child_process';
import webpack from 'webpack';
import { getCwdFromDistribution } from '../config/cwd.cjs';
import type { BuildFlags } from '../config/index.js';
import { createWebpackConfig } from '../webpack/webpack.config.js';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const buildType = process.env.BUILD_TYPE_OVERRIDE || process.env.BUILD_TYPE;
if (process.env.BUILD_TYPE_OVERRIDE) {
process.env.BUILD_TYPE = process.env.BUILD_TYPE_OVERRIDE;
}
const getChannel = () => {
switch (buildType) {
case 'canary':
case 'beta':
case 'stable':
case 'internal':
return buildType;
case '':
throw new Error('BUILD_TYPE is not set');
default: {
throw new Error(
`BUILD_TYPE must be one of canary, beta, stable, internal, received [${buildType}]`
);
}
}
};
let entry: BuildFlags['entry'];
const { DISTRIBUTION = 'web' } = process.env;
const cwd = getCwdFromDistribution(DISTRIBUTION);
if (DISTRIBUTION === 'desktop') {
entry = { app: './index.tsx', shell: './shell/index.tsx' };
}
const flags = {
distribution: DISTRIBUTION as BuildFlags['distribution'],
mode: 'production',
channel: getChannel(),
coverage: process.env.COVERAGE === 'true',
entry,
static: false,
} satisfies BuildFlags;
spawn('yarn', ['workspace', '@affine/i18n', 'build'], {
stdio: 'inherit',
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
webpack(createWebpackConfig(cwd!, flags), (err, stats) => {
if (err) {
console.error(err);
process.exit(1);
}
if (stats?.hasErrors()) {
console.error(stats.toString('errors-only'));
process.exit(1);
}
});

View File

@@ -1,146 +0,0 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import * as p from '@clack/prompts';
import { config } from 'dotenv';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import { getCwdFromDistribution, projectRoot } from '../config/cwd.cjs';
import type { BuildFlags } from '../config/index.js';
import { createWebpackConfig } from '../webpack/webpack.config.js';
const flags: BuildFlags = {
distribution:
(process.env.DISTRIBUTION as BuildFlags['distribution']) ?? 'web',
mode: 'development',
static: false,
channel: 'canary',
coverage: process.env.COVERAGE === 'true',
};
const files = ['.env', '.env.local'];
for (const file of files) {
if (existsSync(join(projectRoot, file))) {
config({
path: join(projectRoot, file),
});
console.log(`${file} loaded`);
break;
}
}
const buildFlags = process.argv.includes('--static')
? { ...flags, static: true }
: ((await p.group(
{
distribution: () =>
p.select({
message: 'Distribution',
options: [
{
value: 'web',
},
{
value: 'desktop',
},
{
value: 'admin',
},
{
value: 'mobile',
},
{
value: 'ios',
},
],
initialValue: 'web',
}),
mode: () =>
p.select({
message: 'Mode',
options: [
{
value: 'development',
},
{
value: 'production',
},
],
initialValue: 'development',
}),
channel: () =>
p.select({
message: 'Channel',
options: [
{
value: 'canary',
},
{
value: 'beta',
},
{
value: 'stable',
},
],
initialValue: 'canary',
}),
coverage: () =>
p.confirm({
message: 'Enable coverage',
initialValue: process.env.COVERAGE === 'true',
}),
},
{
onCancel: () => {
p.cancel('Operation cancelled.');
process.exit(0);
},
}
)) as BuildFlags);
flags.distribution = buildFlags.distribution;
flags.mode = buildFlags.mode;
flags.channel = buildFlags.channel;
flags.coverage = buildFlags.coverage;
flags.static = buildFlags.static;
flags.entry = undefined;
const cwd = getCwdFromDistribution(flags.distribution);
process.env.DISTRIBUTION = flags.distribution;
if (flags.distribution === 'desktop') {
flags.entry = {
app: join(cwd, 'index.tsx'),
shell: join(cwd, 'shell/index.tsx'),
};
}
console.info(flags);
if (!flags.static) {
spawn('yarn', ['workspace', '@affine/i18n', 'dev'], {
stdio: 'inherit',
shell: true,
});
}
try {
// @ts-expect-error no types
await import('@affine/templates/build-edgeless');
const config = createWebpackConfig(cwd, flags);
if (flags.static) {
config.watch = false;
}
const compiler = webpack(config);
// Start webpack
const devServer = new WebpackDevServer(config.devServer, compiler);
await devServer.start();
} catch (error) {
console.error('Error during build:', error);
process.exit(1);
}

15
tools/cli/src/build.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PackageCommand } from './command';
export class BuildCommand extends PackageCommand {
static override paths = [['build'], ['b']];
async execute() {
const args = ['affine build', this.package];
if (this.deps) {
args.push('--deps');
}
await this.cli.run(args);
}
}

93
tools/cli/src/bundle.ts Normal file
View File

@@ -0,0 +1,93 @@
import webpack, { type Compiler, type Configuration } from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import { merge } from 'webpack-merge';
import { Option, PackageCommand } from './command';
import { createWebpackConfig } from './webpack';
function getChannel() {
const channel = process.env.BUILD_TYPE ?? 'canary';
switch (channel) {
case 'canary':
case 'beta':
case 'stable':
case 'internal':
return channel;
default: {
throw new Error(
`BUILD_TYPE must be one of canary, beta, stable, internal, received [${channel}]`
);
}
}
}
export class BundleCommand extends PackageCommand {
static override paths = [['bundle'], ['webpack'], ['pack'], ['bun']];
// bundle is not able to run with deps
override deps = false;
dev = Option.Boolean('--dev,-d', false, {
description: 'Run in Development mode',
});
async execute() {
this.logger.info(`Packing package ${this.package}...`);
const config = await this.getConfig();
const compiler = webpack(config);
if (this.dev) {
await this.start(compiler, config.devServer);
} else {
await this.build(compiler);
}
}
async getConfig() {
let config = createWebpackConfig(this.workspace.getPackage(this.package), {
mode: this.dev ? 'development' : 'production',
channel: getChannel(),
});
let configOverride: Configuration | undefined;
const overrideConfigPath = this.workspace
.getPackage(this.package)
.join('webpack.config.js');
if (overrideConfigPath.isFile()) {
const override = await import(overrideConfigPath.value);
configOverride = override.config ?? override.default;
}
if (configOverride) {
config = merge(config, configOverride);
}
return config;
}
async start(compiler: Compiler, config: Configuration['devServer']) {
const devServer = new WebpackDevServer(config, compiler);
await devServer.start();
}
async build(compiler: Compiler) {
compiler.run((error, stats) => {
if (error) {
console.error(error);
process.exit(1);
}
if (stats) {
if (stats.hasErrors()) {
console.error(stats.toString('errors-only'));
process.exit(1);
} else {
console.log(stats.toString('minimal'));
}
}
});
}
}

71
tools/cli/src/clean.ts Normal file
View File

@@ -0,0 +1,71 @@
import { rmSync } from 'node:fs';
import { exec } from '@affine-tools/utils/process';
import { Command, Option } from './command';
export class CleanCommand extends Command {
static override paths = [['clean']];
cleanDist = Option.Boolean('--dist', false);
cleanRustTarget = Option.Boolean('--rust', false);
cleanNodeModules = Option.Boolean('--node-modules', false);
all = Option.Boolean('--all,-a', false);
async execute() {
this.logger.info('Cleaning Workspace...');
if (this.all || this.cleanNodeModules) {
this.doCleanNodeModules();
}
if (this.all || this.cleanDist) {
this.doCleanDist();
}
if (this.all || this.cleanRustTarget) {
this.doCleanRust();
}
}
doCleanNodeModules() {
this.logger.info('Cleaning node_modules...');
const rootNodeModules = this.workspace.join('node_modules');
if (rootNodeModules.isDirectory()) {
this.logger.info(`Cleaning ${rootNodeModules}`);
rmSync(rootNodeModules.value, { recursive: true });
}
this.workspace.forEach(pkg => {
const nodeModules = pkg.nodeModulesPath;
if (nodeModules.isDirectory()) {
this.logger.info(`Cleaning ${nodeModules}`);
rmSync(nodeModules.value, { recursive: true });
}
});
this.logger.info('node_modules cleaned');
}
doCleanDist() {
this.logger.info('Cleaning dist...');
this.workspace.forEach(pkg => {
if (pkg.distPath.isDirectory()) {
this.logger.info(`Cleaning ${pkg.distPath}`);
rmSync(pkg.distPath.value, { recursive: true });
}
if (pkg.libPath.isDirectory()) {
this.logger.info(`Cleaning ${pkg.libPath}`);
rmSync(pkg.libPath.value, { recursive: true });
}
});
this.logger.info('dist cleaned');
}
doCleanRust() {
exec('', 'cargo clean');
}
}

67
tools/cli/src/codegen.ts Executable file
View File

@@ -0,0 +1,67 @@
import { readFileSync, writeFileSync } from 'node:fs';
import type { Path } from '@affine-tools/utils/path';
import { type BuiltInParserName, format } from 'prettier';
import { Command } from './command';
export class CodegenCommand extends Command {
static override paths = [['init'], ['i'], ['codegen']];
async execute() {
this.logger.info('Generating Workspace configs');
await this.generateWorkspaceFiles();
this.logger.info('Workspace configs generated');
}
async generateWorkspaceFiles() {
const filesToGenerate: [Path, () => string, BuiltInParserName?][] = [
[
this.workspace.join('tsconfig.project.json'),
this.workspace.genProjectTsConfig.bind(this.workspace),
'json',
],
[
this.workspace
.getPackage('@affine-tools/utils')
.join('src/workspace.gen.ts'),
this.workspace.genWorkspaceInfo.bind(this.workspace),
'typescript',
],
[this.workspace.join('oxlint.json'), this.genOxlintConfig, 'json'],
];
for (const [path, content, formatter] of filesToGenerate) {
this.logger.info(`Output: ${path}`);
let file = content();
if (formatter) {
file = await this.format(file, formatter);
}
writeFileSync(path.value, file);
}
}
format(content: string, parser: BuiltInParserName) {
const config = JSON.parse(
readFileSync(this.workspace.join('.prettierrc').value, 'utf-8')
);
return format(content, { parser, ...config });
}
genOxlintConfig = () => {
const json = JSON.parse(
readFileSync(this.workspace.join('oxlint.json').value, 'utf-8')
);
const ignoreList = readFileSync(
this.workspace.join('.prettierignore').value,
'utf-8'
)
.split('\n')
.filter(line => line.trim() && !line.startsWith('#'));
json['ignorePatterns'] = ignoreList;
return JSON.stringify(json, null, 2);
};
}

71
tools/cli/src/command.ts Normal file
View File

@@ -0,0 +1,71 @@
import { AliasToPackage } from '@affine-tools/utils/distribution';
import { Logger } from '@affine-tools/utils/logger';
import { type PackageName, Workspace } from '@affine-tools/utils/workspace';
import { Command as BaseCommand, Option } from 'clipanion';
import * as t from 'typanion';
import type { CliContext } from './context';
export abstract class Command extends BaseCommand<CliContext> {
get logger() {
// @ts-expect-error hack: Get the command name
return new Logger(this.constructor.paths[0][0]);
}
get workspace() {
return this.context.workspace;
}
}
export abstract class PackageCommand extends Command {
protected availablePackageNameArgs = (
Workspace.PackageNames as string[]
).concat(Array.from(AliasToPackage.keys()));
protected packageNameValidator = t.isOneOf(
this.availablePackageNameArgs.map(k => t.isLiteral(k))
);
protected packageNameOrAlias = Option.String('--package,-p', {
required: true,
validator: this.packageNameValidator,
description: 'The package name or alias to be run with',
});
get package(): PackageName {
return (
AliasToPackage.get(this.packageNameOrAlias as any) ??
(this.packageNameOrAlias as PackageName)
);
}
deps = Option.Boolean('--deps', false, {
description:
'Execute the same command in workspace dependencies, if defined.',
});
}
export abstract class PackagesCommand extends Command {
protected availablePackageNameArgs = (
Workspace.PackageNames as string[]
).concat(Array.from(AliasToPackage.keys()));
protected packageNameValidator = t.isOneOf(
this.availablePackageNameArgs.map(k => t.isLiteral(k))
);
protected packageNamesOrAliases = Option.Array('--package,-p', {
required: true,
validator: t.isArray(this.packageNameValidator),
});
get packages() {
return this.packageNamesOrAliases.map(
name => AliasToPackage.get(name as any) ?? name
);
}
deps = Option.Boolean('--deps', false, {
description:
'Execute the same command in workspace dependencies, if defined.',
});
}
export { Option };

View File

@@ -1,38 +0,0 @@
// @ts-check
const { join } = require('node:path');
const projectRoot = join(__dirname, '../../../..');
module.exports.projectRoot = projectRoot;
/**
*
* @param {string | undefined} distribution
* @returns string
*/
module.exports.getCwdFromDistribution = function getCwdFromDistribution(
distribution
) {
switch (distribution) {
case 'web':
case undefined:
case null:
return join(projectRoot, 'packages/frontend/apps/web');
case 'desktop':
return join(projectRoot, 'packages/frontend/apps/electron/renderer');
case 'admin':
return join(projectRoot, 'packages/frontend/admin');
case 'mobile':
return join(projectRoot, 'packages/frontend/apps/mobile');
case 'ios':
return join(projectRoot, 'packages/frontend/apps/ios');
case 'android':
return join(projectRoot, 'packages/frontend/apps/android');
default: {
throw new Error(
'DISTRIBUTION must be one of web, desktop, admin, mobile'
);
}
}
};

View File

@@ -1,9 +0,0 @@
export type BuildFlags = {
distribution: 'web' | 'desktop' | 'admin' | 'mobile' | 'ios' | 'android';
mode: 'development' | 'production';
channel: 'stable' | 'beta' | 'canary' | 'internal';
static: boolean;
coverage?: boolean;
localBlockSuite?: string;
entry?: string | { [key: string]: string };
};

6
tools/cli/src/context.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { Workspace } from '@affine-tools/utils/workspace';
import type { BaseContext } from 'clipanion';
export interface CliContext extends BaseContext {
workspace: Workspace;
}

15
tools/cli/src/dev.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PackageCommand } from './command';
export class DevCommand extends PackageCommand {
static override paths = [['dev'], ['d']];
async execute() {
const args = [this.package, 'dev'];
if (this.deps) {
args.push('--deps');
}
await this.cli.run(args);
}
}

135
tools/cli/src/run.ts Normal file
View File

@@ -0,0 +1,135 @@
import { Path } from '@affine-tools/utils/path';
import { execAsync } from '@affine-tools/utils/process';
import type { PackageName } from '@affine-tools/utils/workspace';
import { Option, PackageCommand } from './command';
interface RunScriptOptions {
includeDependencies?: boolean;
waitDependencies?: boolean;
}
const currentDir = Path.dir(import.meta.url);
const ignoreLoaderScripts = [
'vitest',
'vite',
'ts-node',
'prisma',
'cap',
'tsc',
/electron(?!-)/,
];
export class RunCommand extends PackageCommand {
static override paths = [[], ['run'], ['r']];
static override usage = PackageCommand.Usage({
description: 'AFFiNE Monorepo scripts',
details: `
\`affine web <script>\` Run any script defined in package's package.json
\`affine codegen\` Generate the required files if there are any package added or removed
\`affine clean\` Clean the output files of ts, cargo, webpack, etc.
\`affine bundle\` Bundle the packages
\`affine build\` A proxy for <-p package>'s \`build\` script
\`affine dev\` A proxy for <-p package>'s \`dev\` script
`,
examples: [
[`See detail of each command`, '$0 -h'],
[
`Run custom 'xxx' script defined in @affine/web's package.json`,
'$0 web xxx',
],
[`Run 'codegen' for workspace`, '$0 codegen'],
[`Clean tsbuild and dist under each package`, '$0 clean --ts --dist'],
[`Clean node_modules under each package`, '$0 clean --node-modules'],
[`Clean everything`, '$0 clean --all'],
[`Run 'build' script for @affine/web`, '$0 build -p web'],
[
`Run 'build' script for @affine/web with all deps prebuild before`,
'$0 build -p web --deps',
],
],
});
// we use positional arguments instead of options
protected override packageNameOrAlias: string = Option.String({
required: true,
validator: this.packageNameValidator,
});
args = Option.Proxy({ name: 'args', required: 1 });
async execute() {
await this.run(this.package, this.args, {
includeDependencies: this.deps,
waitDependencies: true,
});
}
async run(name: PackageName, args: string[], opts: RunScriptOptions = {}) {
opts = { includeDependencies: false, ...opts };
const pkg = this.workspace.getPackage(name);
const script = args[0];
const pkgScript = pkg.scripts[script];
let isPackageJsonScript = false;
let isAFFiNEScript = false;
if (pkgScript) {
isPackageJsonScript = true;
isAFFiNEScript = pkgScript.startsWith('affine ');
} else {
isAFFiNEScript = script.startsWith('affine ');
}
if (isPackageJsonScript && opts.includeDependencies) {
this.logger.info(
`Running [${script}] script in dependencies of ${pkg.name}...`
);
await Promise.all(
pkg.deps.map(dep => {
this.logger.info(`Running [${script}] script in ${dep.name}...`);
return this.run(dep.name, args, opts);
})
);
}
if (isPackageJsonScript) {
this.logger.info(`Running [${script}] script in ${pkg.name}...`);
}
if (isAFFiNEScript) {
await this.cli.run([
...pkgScript.split(' ').slice(1),
...args.slice(1),
'-p',
pkg.name,
]);
} else {
const script = pkgScript ?? args[0];
// very simple test for auto ts/mjs scripts
const isLoaderRequired = !ignoreLoaderScripts.some(ignore =>
new RegExp(ignore).test(script)
);
await execAsync(name, ['yarn', ...args], {
cwd: pkg.path.value,
...(isLoaderRequired
? {
env: {
NODE_OPTIONS: `--import=${currentDir.join('../register.js').toFileUrl()}`,
},
}
: {}),
});
}
}
}

View File

@@ -1,26 +0,0 @@
import { spawn } from 'node:child_process';
import { resolve } from 'node:path';
import { build } from 'vite';
import { projectRoot } from '../config/cwd.cjs';
const infraFilePath = resolve(
projectRoot,
'packages',
'infra',
'vite.config.ts'
);
export const buildInfra = async () => {
await build({
configFile: infraFilePath,
});
};
export const watchInfra = async () => {
spawn('vite', ['build', '--watch'], {
cwd: resolve(projectRoot, 'packages/common/infra'),
shell: true,
stdio: 'inherit',
});
};

View File

@@ -0,0 +1,197 @@
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import type { BUILD_CONFIG_TYPE } from '@affine/env/global';
import { Path, ProjectRoot } from '@affine-tools/utils/path';
import { Repository } from '@napi-rs/simple-git';
import HTMLPlugin from 'html-webpack-plugin';
import once from 'lodash-es/once';
import type { Compiler, WebpackPluginInstance } from 'webpack';
import webpack from 'webpack';
import type { BuildFlags } from './types.js';
export const getPublicPath = (
flags: BuildFlags,
BUILD_CONFIG: BUILD_CONFIG_TYPE
) => {
const { BUILD_TYPE } = process.env;
if (typeof process.env.PUBLIC_PATH === 'string') {
return process.env.PUBLIC_PATH;
}
if (
flags.mode === 'development' ||
BUILD_CONFIG.distribution === 'desktop' ||
BUILD_CONFIG.distribution === 'ios' ||
BUILD_CONFIG.distribution === 'android'
) {
return '/';
}
switch (BUILD_TYPE) {
case 'stable':
return 'https://prod.affineassets.com/';
case 'beta':
return 'https://beta.affineassets.com/';
default:
return 'https://dev.affineassets.com/';
}
};
const DESCRIPTION = `There can be more than Notion and Miro. AFFiNE is a next-gen knowledge base that brings planning, sorting and creating all together.`;
const gitShortHash = once(() => {
const { GITHUB_SHA } = process.env;
if (GITHUB_SHA) {
return GITHUB_SHA.substring(0, 9);
}
const repo = new Repository(ProjectRoot.path);
const shortSha = repo.head().target()?.substring(0, 9);
if (shortSha) {
return shortSha;
}
const sha = execSync(`git rev-parse --short HEAD`, {
encoding: 'utf-8',
}).trim();
return sha;
});
const currentDir = Path.dir(import.meta.url);
function getHTMLPluginOptions(
flags: BuildFlags,
BUILD_CONFIG: BUILD_CONFIG_TYPE
) {
const publicPath = getPublicPath(flags, BUILD_CONFIG);
const cdnOrigin = publicPath.startsWith('/')
? undefined
: new URL(publicPath).origin;
const templateParams = {
GIT_SHORT_SHA: gitShortHash(),
DESCRIPTION,
PRECONNECT: cdnOrigin
? `<link rel="preconnect" href="${cdnOrigin}" />`
: '',
VIEWPORT_FIT: BUILD_CONFIG.isMobileEdition ? 'cover' : 'auto',
};
return {
template: currentDir.join('template.html').toString(),
inject: 'body',
filename: 'index.html',
minify: false,
templateParameters: templateParams,
chunks: ['app'],
} satisfies HTMLPlugin.Options;
}
export function createShellHTMLPlugin(
flags: BuildFlags,
BUILD_CONFIG: BUILD_CONFIG_TYPE
) {
const htmlPluginOptions = getHTMLPluginOptions(flags, BUILD_CONFIG);
return new HTMLPlugin({
...htmlPluginOptions,
chunks: ['shell'],
filename: `shell.html`,
});
}
export function createHTMLPlugins(
flags: BuildFlags,
BUILD_CONFIG: BUILD_CONFIG_TYPE
): WebpackPluginInstance[] {
const publicPath = getPublicPath(flags, BUILD_CONFIG);
const globalErrorHandler = [
'js/global-error-handler.js',
readFileSync(currentDir.join('./error-handler.js').toString(), 'utf-8'),
];
const htmlPluginOptions = getHTMLPluginOptions(flags, BUILD_CONFIG);
return [
{
apply(compiler: Compiler) {
compiler.hooks.compilation.tap(
'assets-manifest-plugin',
compilation => {
HTMLPlugin.getHooks(compilation).beforeAssetTagGeneration.tap(
'assets-manifest-plugin',
arg => {
if (
!BUILD_CONFIG.isElectron &&
!compilation.getAsset(globalErrorHandler[0])
) {
compilation.emitAsset(
globalErrorHandler[0],
new webpack.sources.RawSource(globalErrorHandler[1])
);
arg.assets.js.unshift(
arg.assets.publicPath + globalErrorHandler[0]
);
}
if (!compilation.getAsset('assets-manifest.json')) {
compilation.emitAsset(
globalErrorHandler[0],
new webpack.sources.RawSource(globalErrorHandler[1])
);
compilation.emitAsset(
`assets-manifest.json`,
new webpack.sources.RawSource(
JSON.stringify(
{
...arg.assets,
js: arg.assets.js.map(file =>
file.substring(arg.assets.publicPath.length)
),
css: arg.assets.css.map(file =>
file.substring(arg.assets.publicPath.length)
),
gitHash:
htmlPluginOptions.templateParameters.GIT_SHORT_SHA,
description:
htmlPluginOptions.templateParameters.DESCRIPTION,
},
null,
2
)
),
{
immutable: false,
}
);
}
return arg;
}
);
}
);
},
},
new HTMLPlugin({
...htmlPluginOptions,
publicPath,
meta: {
'env:publicPath': publicPath,
},
}),
// selfhost html
new HTMLPlugin({
...htmlPluginOptions,
meta: {
'env:isSelfHosted': 'true',
'env:publicPath': '/',
},
filename: 'selfhost.html',
templateParameters: {
...htmlPluginOptions.templateParameters,
PRECONNECT: '',
},
}),
];
}

View File

@@ -1,32 +1,33 @@
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import type { BUILD_CONFIG_TYPE } from '@affine/env/global';
import { getBuildConfig } from '@affine-tools/utils/build-config';
import { ProjectRoot } from '@affine-tools/utils/path';
import type { Package } from '@affine-tools/utils/workspace';
import { PerfseePlugin } from '@perfsee/webpack';
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import { sentryWebpackPlugin } from '@sentry/webpack-plugin';
import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin';
import CopyPlugin from 'copy-webpack-plugin';
import { compact } from 'lodash-es';
import compact from 'lodash-es/compact';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import webpack from 'webpack';
import type { Configuration as DevServerConfiguration } from 'webpack-dev-server';
import { projectRoot } from '../config/cwd.cjs';
import type { BuildFlags } from '../config/index.js';
import { productionCacheGroups } from './cache-group.js';
import { createHTMLPlugins, createShellHTMLPlugin } from './html-plugin.js';
import { WebpackS3Plugin } from './s3-plugin.js';
import type { BuildFlags } from './types';
const require = createRequire(import.meta.url);
const cssnano = require('cssnano');
const IN_CI = !!process.env.CI;
export const rootPath = join(fileURLToPath(import.meta.url), '..', '..');
export const workspaceRoot = join(rootPath, '..', '..', '..');
const OptimizeOptionOptions: (
buildFlags: BuildFlags
) => webpack.Configuration['optimization'] = buildFlags => ({
minimize: buildFlags.mode === 'production',
flags: BuildFlags
) => webpack.Configuration['optimization'] = flags => ({
minimize: flags.mode === 'production',
minimizer: [
new TerserPlugin({
minify: TerserPlugin.swcMinify,
@@ -60,71 +61,48 @@ const OptimizeOptionOptions: (
},
});
export const getPublicPath = (buildFlags: BuildFlags) => {
const { BUILD_TYPE } = process.env;
if (typeof process.env.PUBLIC_PATH === 'string') {
return process.env.PUBLIC_PATH;
}
export function createWebpackConfig(
pkg: Package,
flags: BuildFlags
): webpack.Configuration {
const buildConfig = getBuildConfig(pkg, flags);
if (
buildFlags.mode === 'development' ||
process.env.COVERAGE ||
buildFlags.distribution === 'desktop' ||
buildFlags.distribution === 'ios' ||
buildFlags.distribution === 'android'
) {
return '/';
}
switch (BUILD_TYPE) {
case 'stable':
return 'https://prod.affineassets.com/';
case 'beta':
return 'https://beta.affineassets.com/';
default:
return 'https://dev.affineassets.com/';
}
};
export const createConfiguration: (
cwd: string,
buildFlags: BuildFlags,
buildConfig: BUILD_CONFIG_TYPE
) => webpack.Configuration = (cwd, buildFlags, buildConfig) => {
const config = {
name: 'affine',
// to set a correct base path for the source map
context: cwd,
context: pkg.path.value,
experiments: {
topLevelAwait: true,
outputModule: false,
syncWebAssembly: true,
},
entry: {
app: pkg.entry ?? './src/index.tsx',
},
output: {
environment: {
module: true,
dynamicImport: true,
},
filename:
buildFlags.mode === 'production'
flags.mode === 'production'
? 'js/[name].[contenthash:8].js'
: 'js/[name].js',
// In some cases webpack will emit files starts with "_" which is reserved in web extension.
chunkFilename: pathData =>
pathData.chunk?.name?.endsWith?.('worker')
? 'js/[name].[contenthash:8].js'
: buildFlags.mode === 'production'
: flags.mode === 'production'
? 'js/chunk.[name].[contenthash:8].js'
: 'js/chunk.[name].js',
assetModuleFilename:
buildFlags.mode === 'production'
flags.mode === 'production'
? 'assets/[name].[contenthash:8][ext][query]'
: '[name].[contenthash:8][ext]',
devtoolModuleFilenameTemplate: 'webpack://[namespace]/[resource-path]',
hotUpdateChunkFilename: 'hot/[id].[fullhash].js',
hotUpdateMainFilename: 'hot/[runtime].[fullhash].json',
path: join(cwd, 'dist'),
clean: buildFlags.mode === 'production',
path: pkg.distPath.value,
clean: flags.mode === 'production',
globalObject: 'globalThis',
// NOTE(@forehalo): always keep it '/'
publicPath: '/',
@@ -132,10 +110,10 @@ export const createConfiguration: (
},
target: ['web', 'es2022'],
mode: buildFlags.mode,
mode: flags.mode,
devtool:
buildFlags.mode === 'production'
flags.mode === 'production'
? 'source-map'
: 'eval-cheap-module-source-map',
@@ -147,14 +125,13 @@ export const createConfiguration: (
},
extensions: ['.js', '.ts', '.tsx'],
alias: {
yjs: join(workspaceRoot, 'node_modules', 'yjs'),
lit: join(workspaceRoot, 'node_modules', 'lit'),
'@preact/signals-core': join(
workspaceRoot,
yjs: ProjectRoot.join('node_modules', 'yjs').value,
lit: ProjectRoot.join('node_modules', 'lit').value,
'@preact/signals-core': ProjectRoot.join(
'node_modules',
'@preact',
'signals-core'
),
).value,
},
},
@@ -230,7 +207,7 @@ export const createConfiguration: (
transform: {
react: {
runtime: 'automatic',
refresh: buildFlags.mode === 'development' && {
refresh: flags.mode === 'development' && {
refreshReg: '$RefreshReg$',
refreshSig: '$RefreshSig$',
emitFullSignatures: true,
@@ -263,7 +240,7 @@ export const createConfiguration: (
{
test: /\.css$/,
use: [
buildFlags.mode === 'development'
flags.mode === 'development'
? 'style-loader'
: MiniCssExtractPlugin.loader,
{
@@ -280,7 +257,25 @@ export const createConfiguration: (
loader: 'postcss-loader',
options: {
postcssOptions: {
config: join(rootPath, 'webpack', 'postcss.config.cjs'),
plugins: [
cssnano({
preset: [
'default',
{
convertValues: false,
},
],
}),
].concat(
pkg.join('tailwind.config.js').exists()
? [
require('tailwindcss')(
require(pkg.join('tailwind.config.js').path)
),
'autoprefixer',
]
: []
),
},
},
},
@@ -292,7 +287,7 @@ export const createConfiguration: (
},
plugins: compact([
IN_CI ? null : new webpack.ProgressPlugin({ percentBy: 'entries' }),
buildFlags.mode === 'development'
flags.mode === 'development'
? new ReactRefreshWebpackPlugin({
overlay: false,
esModule: true,
@@ -305,7 +300,7 @@ export const createConfiguration: (
}),
new VanillaExtractPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(buildFlags.mode),
'process.env.NODE_ENV': JSON.stringify(flags.mode),
'process.env.CAPTCHA_SITE_KEY': JSON.stringify(
process.env.CAPTCHA_SITE_KEY
),
@@ -323,24 +318,19 @@ export const createConfiguration: (
{} as Record<string, string>
),
}),
buildFlags.distribution === 'admin'
buildConfig.isAdmin
? null
: new CopyPlugin({
patterns: [
{
// copy the shared public assets into dist
from: join(
workspaceRoot,
'packages',
'frontend',
'core',
'public'
),
to: join(cwd, 'dist'),
from: pkg.workspace.getPackage('@affine/core').join('public')
.value,
to: pkg.distPath.value,
},
],
}),
buildFlags.mode === 'production' &&
flags.mode === 'production' &&
(buildConfig.isWeb || buildConfig.isMobileWeb || buildConfig.isAdmin) &&
process.env.R2_SECRET_ACCESS_KEY
? new WebpackS3Plugin()
@@ -349,14 +339,12 @@ export const createConfiguration: (
stats: {
errorDetails: true,
},
optimization: OptimizeOptionOptions(buildFlags),
optimization: OptimizeOptionOptions(flags),
devServer: {
host: '0.0.0.0',
allowedHosts: 'all',
hot: buildFlags.static ? false : 'only',
liveReload: !buildFlags.static,
hot: true,
liveReload: true,
client: {
overlay: process.env.DISABLE_DEV_OVERLAY === 'true' ? false : undefined,
},
@@ -374,20 +362,10 @@ export const createConfiguration: (
},
static: [
{
directory: join(
projectRoot,
'packages',
'frontend',
'core',
'public'
),
directory: pkg.workspace.getPackage('@affine/core').join('public')
.value,
publicPath: '/',
watch: !buildFlags.static,
},
{
directory: join(cwd, 'public'),
publicPath: '/',
watch: !buildFlags.static,
watch: true,
},
],
proxy: [
@@ -404,7 +382,7 @@ export const createConfiguration: (
} as DevServerConfiguration,
} satisfies webpack.Configuration;
if (buildFlags.mode === 'production' && process.env.PERFSEE_TOKEN) {
if (flags.mode === 'production' && process.env.PERFSEE_TOKEN) {
config.plugins.push(
new PerfseePlugin({
project: 'affine-toeverything',
@@ -412,7 +390,7 @@ export const createConfiguration: (
);
}
if (buildFlags.mode === 'development') {
if (flags.mode === 'development') {
config.optimization = {
...config.optimization,
minimize: false,
@@ -456,5 +434,11 @@ export const createConfiguration: (
);
}
config.plugins = config.plugins.concat(createHTMLPlugins(flags, buildConfig));
if (buildConfig.isElectron) {
config.plugins.push(createShellHTMLPlugin(flags, buildConfig));
}
return config;
};
}

View File

@@ -1,47 +0,0 @@
const { join } = require('node:path');
const cssnano = require('cssnano');
const tailwindcss = require('tailwindcss');
const autoprefixer = require('autoprefixer');
const { getCwdFromDistribution } = require('../config/cwd.cjs');
const projectCwd = getCwdFromDistribution(process.env.DISTRIBUTION);
const twConfig = (function () {
try {
const config = require(`${projectCwd}/tailwind.config.js`);
const { content } = config;
if (Array.isArray(content)) {
config.content = content.map(c =>
c.startsWith(projectCwd) ? c : join(projectCwd, c)
);
}
return config;
} catch {
return null;
}
})();
module.exports = function (context) {
const plugins = [
cssnano({
preset: [
'default',
{
convertValues: false,
},
],
}),
];
if (twConfig) {
plugins.push(tailwindcss(twConfig), autoprefixer());
}
return {
from: context.from,
plugins,
to: context.to,
};
};

View File

@@ -15,9 +15,7 @@ export class WebpackS3Plugin implements WebpackPluginInstance {
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});

View File

@@ -0,0 +1,4 @@
export interface BuildFlags {
mode: 'development' | 'production';
channel: 'stable' | 'beta' | 'canary' | 'internal';
}

View File

@@ -1,183 +0,0 @@
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { BuildFlags } from '@affine/cli/config';
import { Repository } from '@napi-rs/simple-git';
import HTMLPlugin from 'html-webpack-plugin';
import { once } from 'lodash-es';
import type { Compiler } from 'webpack';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import {
createConfiguration,
getPublicPath,
rootPath,
workspaceRoot,
} from './config.js';
import { getBuildConfig } from './runtime-config.js';
const DESCRIPTION = `There can be more than Notion and Miro. AFFiNE is a next-gen knowledge base that brings planning, sorting and creating all together.`;
const gitShortHash = once(() => {
const { GITHUB_SHA } = process.env;
if (GITHUB_SHA) {
return GITHUB_SHA.substring(0, 9);
}
const repo = new Repository(workspaceRoot);
const shortSha = repo.head().target()?.substring(0, 9);
if (shortSha) {
return shortSha;
}
const sha = execSync(`git rev-parse --short HEAD`, {
encoding: 'utf-8',
}).trim();
return sha;
});
export function createWebpackConfig(cwd: string, flags: BuildFlags) {
console.log('build flags', flags);
const runtimeConfig = getBuildConfig(flags);
console.log('BUILD_CONFIG', runtimeConfig);
const config = createConfiguration(cwd, flags, runtimeConfig);
const entry =
typeof flags.entry === 'string' || !flags.entry
? {
app: flags.entry ?? resolve(cwd, 'src/index.tsx'),
}
: flags.entry;
const publicPath = getPublicPath(flags);
const cdnOrigin = publicPath.startsWith('/')
? undefined
: new URL(publicPath).origin;
const globalErrorHandler = [
'js/global-error-handler.js',
readFileSync(
join(workspaceRoot, 'tools/cli/src/webpack/error-handler.js'),
'utf-8'
),
];
const templateParams = {
GIT_SHORT_SHA: gitShortHash(),
DESCRIPTION,
PRECONNECT: cdnOrigin
? `<link rel="preconnect" href="${cdnOrigin}" />`
: '',
VIEWPORT_FIT:
flags.distribution === 'mobile' ||
flags.distribution === 'ios' ||
flags.distribution === 'android'
? 'cover'
: 'auto',
};
const createHTMLPlugins = (entryName: string) => {
const htmlPluginOptions = {
template: join(rootPath, 'webpack', 'template.html'),
inject: 'body',
filename: 'index.html',
minify: false,
templateParameters: templateParams,
chunks: [entryName],
} satisfies HTMLPlugin.Options;
if (entryName === 'app') {
return [
{
apply(compiler: Compiler) {
compiler.hooks.compilation.tap(
'assets-manifest-plugin',
compilation => {
HTMLPlugin.getHooks(compilation).beforeAssetTagGeneration.tap(
'assets-manifest-plugin',
arg => {
if (
flags.distribution !== 'desktop' &&
!compilation.getAsset(globalErrorHandler[0])
) {
compilation.emitAsset(
globalErrorHandler[0],
new webpack.sources.RawSource(globalErrorHandler[1])
);
arg.assets.js.unshift(
arg.assets.publicPath + globalErrorHandler[0]
);
}
if (!compilation.getAsset('assets-manifest.json')) {
compilation.emitAsset(
globalErrorHandler[0],
new webpack.sources.RawSource(globalErrorHandler[1])
);
compilation.emitAsset(
`assets-manifest.json`,
new webpack.sources.RawSource(
JSON.stringify(
{
...arg.assets,
js: arg.assets.js.map(file =>
file.substring(arg.assets.publicPath.length)
),
css: arg.assets.css.map(file =>
file.substring(arg.assets.publicPath.length)
),
gitHash: templateParams.GIT_SHORT_SHA,
description: templateParams.DESCRIPTION,
},
null,
2
)
),
{
immutable: false,
}
);
}
return arg;
}
);
}
);
},
},
new HTMLPlugin({
...htmlPluginOptions,
publicPath,
meta: {
'env:publicPath': publicPath,
},
}),
// selfhost html
new HTMLPlugin({
...htmlPluginOptions,
meta: {
'env:isSelfHosted': 'true',
'env:publicPath': '/',
},
filename: 'selfhost.html',
templateParameters: {
...htmlPluginOptions.templateParameters,
PRECONNECT: '',
},
}),
];
} else {
return [
new HTMLPlugin({
...htmlPluginOptions,
filename: `${entryName}.html`,
}),
];
}
};
return merge(config, {
entry,
plugins: Object.keys(entry).map(createHTMLPlugins).flat(),
});
}

View File

@@ -2,11 +2,12 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"allowJs": true,
"module": "ESNext",
"moduleResolution": "Node",
"outDir": "lib"
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src", "package.json"],
"references": [{ "path": "../../packages/common/env" }]
"include": ["./src"],
"references": [
{ "path": "../../packages/common/env" },
{ "path": "../utils" }
]
}

View File

@@ -14,12 +14,9 @@
"ios",
"android",
"docs",
"storybook",
"component",
"workspace",
"env",
"graphql",
"cli",
"hooks",
"i18n",
"native",
@@ -27,7 +24,8 @@
"debug",
"nbstore",
"infra",
"editor"
"editor",
"tools"
]
]
}

View File

@@ -1,8 +1,7 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Package } from '@affine-tools/utils/workspace';
import {
androidpublisher_v3,
auth as google_auth,
@@ -55,13 +54,10 @@ export async function fetchVersionCode(applicationId: string): Promise<number> {
}
const versionCodeRegexPattern = /(versionCode(?:\s|=)*)(.*)/;
const gradlePath = join(
fileURLToPath(import.meta.url),
'..',
'..',
'..',
'packages/frontend/apps/android/App/app/build.gradle'
);
const gradlePath = new Package('@affine/android').join(
'App/app/build.gradle'
).value;
let gradleVersionCode = 0;

View File

@@ -9,6 +9,7 @@
"bump": "node --import @oxc-node/core/register index.ts"
},
"dependencies": {
"@affine-tools/utils": "workspace:*",
"@googleapis/androidpublisher": "^22.0.0",
"@oxc-node/core": "^0.0.16"
},

View File

@@ -6,5 +6,6 @@
"module": "ESNext",
"moduleResolution": "Bundler"
},
"exclude": ["dist"]
"exclude": ["dist"],
"include": ["index.ts"]
}

21
tools/utils/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@affine-tools/utils",
"version": "0.0.1",
"type": "module",
"private": true,
"exports": {
"./path": "./src/path.ts",
"./workspace": "./src/workspace.ts",
"./process": "./src/process.ts",
"./logger": "./src/logger.ts",
"./build-config": "./src/build-config.ts",
"./distribution": "./src/distribution.ts"
},
"dependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.17.10",
"chalk": "^5.3.0",
"lodash-es": "^4.17.21",
"typescript": "^5.5.4"
}
}

View File

@@ -1,31 +1,45 @@
import type { BUILD_CONFIG_TYPE } from '@affine/env/global';
import type { Package } from '@affine-tools/utils/workspace';
import packageJson from '../../package.json' with { type: 'json' };
import type { BuildFlags } from '../config';
import { PackageToDistribution } from './distribution';
export interface BuildFlags {
channel: 'stable' | 'beta' | 'internal' | 'canary';
mode: 'development' | 'production';
}
export function getBuildConfig(
pkg: Package,
buildFlags: BuildFlags
): BUILD_CONFIG_TYPE {
const distribution = PackageToDistribution.get(pkg.name);
if (!distribution) {
throw new Error(`Distribution for ${pkg.name} is not found`);
}
export function getBuildConfig(buildFlags: BuildFlags): BUILD_CONFIG_TYPE {
const buildPreset: Record<BuildFlags['channel'], BUILD_CONFIG_TYPE> = {
get stable() {
return {
debug: buildFlags.mode === 'development',
distribution: buildFlags.distribution,
distribution,
isDesktopEdition: (
['web', 'desktop', 'admin'] as BuildFlags['distribution'][]
).includes(buildFlags.distribution),
['web', 'desktop', 'admin'] as BUILD_CONFIG_TYPE['distribution'][]
).includes(distribution),
isMobileEdition: (
['mobile', 'ios', 'android'] as BuildFlags['distribution'][]
).includes(buildFlags.distribution),
isElectron: buildFlags.distribution === 'desktop',
isWeb: buildFlags.distribution === 'web',
isMobileWeb: buildFlags.distribution === 'mobile',
isIOS: buildFlags.distribution === 'ios',
isAndroid: buildFlags.distribution === 'android',
isAdmin: buildFlags.distribution === 'admin',
['mobile', 'ios', 'android'] as BUILD_CONFIG_TYPE['distribution'][]
).includes(distribution),
isElectron: distribution === 'desktop',
isWeb: distribution === 'web',
isMobileWeb: distribution === 'mobile',
isIOS: distribution === 'ios',
isAndroid: distribution === 'android',
isAdmin: distribution === 'admin',
appBuildType: 'stable' as const,
serverUrlPrefix: 'https://app.affine.pro',
appVersion: packageJson.version,
editorVersion: packageJson.devDependencies['@blocksuite/affine'],
appVersion: pkg.version,
editorVersion: pkg.dependencies['@blocksuite/affine'],
githubUrl: 'https://github.com/toeverything/AFFiNE',
changelogUrl: 'https://affine.pro/what-is-new',
downloadUrl: 'https://affine.pro/download',

View File

@@ -0,0 +1,30 @@
import type { BUILD_CONFIG_TYPE } from '@affine/env/global';
import { PackageList, type PackageName } from './workspace.gen';
export const PackageToDistribution = new Map<
PackageName,
BUILD_CONFIG_TYPE['distribution']
>([
['@affine/admin', 'admin'],
['@affine/web', 'web'],
['@affine/electron', 'desktop'],
['@affine/mobile', 'mobile'],
['@affine/ios', 'ios'],
['@affine/android', 'android'],
]);
export const AliasToPackage = new Map<string, PackageName>([
['admin', '@affine/admin'],
['web', '@affine/web'],
['desktop', '@affine/electron'],
['electron', '@affine/electron'],
['mobile', '@affine/mobile'],
['ios', '@affine/ios'],
['android', '@affine/android'],
['server', '@affine/server'],
['gql', '@affine/graphql'],
...PackageList.map(
pkg => [pkg.name.split('/').pop()!, pkg.name] as [string, PackageName]
),
]);

46
tools/utils/src/logger.ts Normal file
View File

@@ -0,0 +1,46 @@
import chalk from 'chalk';
import identity from 'lodash-es/identity';
export const newLineSeparator = /\r\n|[\n\r\x85\u2028\u2029]/g;
interface StringLike {
toString: () => string;
}
export class Logger {
log = this.getLineLogger(console.log.bind(console));
info = this.getLineLogger(console.info.bind(console), chalk.blue);
warn = this.getLineLogger(
console.warn.bind(console),
chalk.bgHex('#322b08').hex('#fadea6')
);
error = this.getLineLogger(
console.error.bind(console),
chalk.bgHex('#250201').hex('#ef8784')
);
success = this.getLineLogger(console.log.bind(console), chalk.green);
constructor(private readonly tag: string = '') {}
getLineLogger(
logLine: (...line: string[]) => void,
color: (...text: string[]) => string = identity
) {
return (...args: StringLike[]) => {
args.forEach(arg => {
arg
.toString()
.split(newLineSeparator)
.forEach(line => {
if (line.length !== 0) {
if (this.tag) {
logLine(color(`[${this.tag}] ${line}`));
} else {
logLine(color(line));
}
}
});
});
};
}
}

View File

@@ -0,0 +1,81 @@
import { readFileSync } from 'node:fs';
import { parse } from 'node:path';
import { type Path, ProjectRoot } from './path';
import type { CommonPackageJsonContent, YarnWorkspaceItem } from './types';
import type { Workspace } from './workspace';
import { PackageList, type PackageName } from './workspace.gen';
export function readPackageJson(path: Path): CommonPackageJsonContent {
const content = readFileSync(path.join('package.json').toString(), 'utf-8');
return JSON.parse(content);
}
export class Package {
readonly name: PackageName;
readonly packageJson: CommonPackageJsonContent;
readonly dirname: string;
readonly path: Path;
readonly srcPath: Path;
readonly nodeModulesPath: Path;
readonly libPath: Path;
readonly distPath: Path;
readonly version: string;
readonly isTsProject: boolean;
readonly workspaceDependencies: string[];
readonly deps: Package[] = [];
private _workspace: Workspace | null = null;
get entry() {
return this.packageJson.main || this.packageJson.exports?.['.'];
}
get dependencies() {
return this.packageJson.dependencies || {};
}
get devDependencies() {
return this.packageJson.devDependencies || {};
}
get workspace() {
if (!this._workspace) {
throw new Error('Workspace is not initialized');
}
return this._workspace;
}
private set workspace(workspace: Workspace) {
this._workspace = workspace;
}
constructor(name: PackageName, meta?: YarnWorkspaceItem) {
this.name = name as PackageName;
meta ??= PackageList.find(item => item.name === name)!;
// parse paths
this.path = ProjectRoot.join(meta.location);
this.dirname = parse(meta.location).name;
this.srcPath = this.path.join('src');
this.libPath = this.path.join('lib');
this.distPath = this.path.join('dist');
this.nodeModulesPath = this.path.join('node_modules');
// parse workspace
const packageJson = readPackageJson(this.path);
this.packageJson = packageJson;
this.version = packageJson.version;
this.workspaceDependencies = meta.workspaceDependencies;
this.isTsProject = this.path.join('tsconfig.json').isFile();
}
get scripts() {
return this.packageJson.scripts || {};
}
join(...paths: string[]) {
return this.path.join(...paths);
}
}

49
tools/utils/src/path.ts Normal file
View File

@@ -0,0 +1,49 @@
import { existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
export class Path {
static dir(url: string) {
return new Path(join(fileURLToPath(url), '..'));
}
get value() {
return this.path;
}
get relativePath() {
return './' + this.path.slice(ProjectRoot.path.length).replace(/\\/g, '/');
}
constructor(public readonly path: string) {}
join(...paths: string[]) {
return new Path(join(this.path, ...paths));
}
toString() {
return this.path;
}
exists() {
return existsSync(this.path);
}
stats() {
return statSync(this.path);
}
isFile() {
return this.exists() && this.stats().isFile();
}
isDirectory() {
return this.exists() && this.stats().isDirectory();
}
toFileUrl() {
return pathToFileURL(this.path);
}
}
export const ProjectRoot = Path.dir(import.meta.url).join('../../../');

View File

@@ -0,0 +1,99 @@
import {
type ChildProcess,
execSync,
spawn as RawSpawn,
type SpawnOptions,
} from 'node:child_process';
import { Logger } from './logger';
const children = new Set<ChildProcess>();
export function spawn(
tag: string,
cmd: string | string[],
options: SpawnOptions = {}
) {
cmd = typeof cmd === 'string' ? cmd.split(' ') : cmd;
const isYarnSpawn = cmd[0] === 'yarn';
const spawnOptions: SpawnOptions = {
stdio: isYarnSpawn
? ['inherit', 'inherit', 'inherit']
: ['inherit', 'pipe', 'pipe'],
shell: true,
...options,
env: { ...process.env, ...options.env },
};
const logger = new Logger(tag);
logger.info(cmd.join(' '));
const childProcess = RawSpawn(cmd[0], cmd.slice(1), spawnOptions);
children.add(childProcess);
const drain = (_code: number | null, signal: any) => {
children.delete(childProcess);
// don't run repeatedly if this is the error event
if (signal === undefined) {
childProcess.removeListener('exit', drain);
}
};
childProcess.stdout?.on('data', chunk => {
logger.log(chunk);
});
childProcess.stderr?.on('data', chunk => {
logger.error(chunk);
});
childProcess.once('error', e => {
logger.error(e.toString());
children.delete(childProcess);
});
childProcess.once('exit', (code, signal) => {
if (code !== 0) {
logger.error('Finished with non-zero exit code.');
}
drain(code, signal);
});
return childProcess;
}
export function execAsync(
tag: string,
cmd: string | string[],
options?: SpawnOptions
): Promise<void> {
return new Promise((resolve, reject) => {
const childProcess = spawn(tag, cmd, options);
childProcess.once('error', e => {
reject(e);
});
childProcess.once('exit', code => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Child process exits with non-zero code ${code}`));
}
});
});
}
export function exec(
tag: string,
cmd: string,
{ silent }: { silent: boolean } = { silent: false }
): string {
const logger = new Logger(tag);
!silent && logger.info(cmd);
const result = execSync(cmd, { encoding: 'utf8' }).trim();
!silent && logger.log(result);
return result;
}

20
tools/utils/src/types.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface YarnWorkspaceItem {
name: string;
location: string;
workspaceDependencies: string[];
// we don't need it
mismatchedWorkspaceDependencies?: string[];
}
export interface CommonPackageJsonContent {
name: string;
version: string;
private?: boolean;
dependencies?: { [key: string]: string };
devDependencies?: { [key: string]: string };
scripts?: { [key: string]: string };
main?: string;
exports?: {
[key: string]: string;
};
}

View File

@@ -0,0 +1,512 @@
// Auto generated content
// DO NOT MODIFY THIS FILE MANUALLY
export const PackageList = [
{
location: 'blocksuite/affine/all',
name: '@blocksuite/affine',
workspaceDependencies: [
'blocksuite/framework/block-std',
'blocksuite/blocks',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/presets',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/block-embed',
name: '@blocksuite/affine-block-embed',
workspaceDependencies: [
'blocksuite/affine/block-surface',
'blocksuite/affine/components',
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/block-list',
name: '@blocksuite/affine-block-list',
workspaceDependencies: [
'blocksuite/affine/components',
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/block-paragraph',
name: '@blocksuite/affine-block-paragraph',
workspaceDependencies: [
'blocksuite/affine/components',
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/block-surface',
name: '@blocksuite/affine-block-surface',
workspaceDependencies: [
'blocksuite/affine/components',
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/components',
name: '@blocksuite/affine-components',
workspaceDependencies: [
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/data-view',
name: '@blocksuite/data-view',
workspaceDependencies: [
'blocksuite/affine/components',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/model',
name: '@blocksuite/affine-model',
workspaceDependencies: [
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/shared',
name: '@blocksuite/affine-shared',
workspaceDependencies: [
'blocksuite/affine/model',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/widget-scroll-anchoring',
name: '@blocksuite/affine-widget-scroll-anchoring',
workspaceDependencies: [
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
],
},
{
location: 'blocksuite/blocks',
name: '@blocksuite/blocks',
workspaceDependencies: [
'blocksuite/affine/block-embed',
'blocksuite/affine/block-list',
'blocksuite/affine/block-paragraph',
'blocksuite/affine/block-surface',
'blocksuite/affine/components',
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/affine/widget-scroll-anchoring',
'blocksuite/framework/block-std',
'blocksuite/affine/data-view',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/framework/block-std',
name: '@blocksuite/block-std',
workspaceDependencies: [
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/framework/global',
name: '@blocksuite/global',
workspaceDependencies: [],
},
{
location: 'blocksuite/framework/inline',
name: '@blocksuite/inline',
workspaceDependencies: ['blocksuite/framework/global'],
},
{
location: 'blocksuite/framework/store',
name: '@blocksuite/store',
workspaceDependencies: [
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/sync',
],
},
{
location: 'blocksuite/framework/sync',
name: '@blocksuite/sync',
workspaceDependencies: ['blocksuite/framework/global'],
},
{
location: 'blocksuite/playground',
name: '@blocksuite/playground',
workspaceDependencies: [
'blocksuite/affine/components',
'blocksuite/affine/model',
'blocksuite/framework/block-std',
'blocksuite/blocks',
'blocksuite/affine/data-view',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/presets',
'blocksuite/framework/store',
'blocksuite/framework/sync',
],
},
{
location: 'blocksuite/presets',
name: '@blocksuite/presets',
workspaceDependencies: [
'blocksuite/affine/block-surface',
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/blocks',
'blocksuite/framework/global',
'blocksuite/framework/inline',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/tests-legacy',
name: '@blocksuite/legacy-e2e',
workspaceDependencies: [
'blocksuite/affine/model',
'blocksuite/framework/block-std',
'blocksuite/framework/global',
'blocksuite/presets',
],
},
{
location: 'docs/reference',
name: '@affine/docs',
workspaceDependencies: [],
},
{
location: 'packages/backend/native',
name: '@affine/server-native',
workspaceDependencies: [],
},
{
location: 'packages/backend/server',
name: '@affine/server',
workspaceDependencies: ['tests/kit', 'packages/backend/native'],
},
{
location: 'packages/common/debug',
name: '@affine/debug',
workspaceDependencies: [],
},
{
location: 'packages/common/env',
name: '@affine/env',
workspaceDependencies: ['blocksuite/affine/all'],
},
{
location: 'packages/common/infra',
name: '@toeverything/infra',
workspaceDependencies: [
'packages/common/debug',
'packages/common/env',
'packages/frontend/templates',
'blocksuite/affine/all',
],
},
{
location: 'packages/common/nbstore',
name: '@affine/nbstore',
workspaceDependencies: [
'packages/common/infra',
'packages/frontend/electron-api',
'packages/frontend/graphql',
],
},
{
location: 'packages/frontend/admin',
name: '@affine/admin',
workspaceDependencies: [
'packages/frontend/component',
'packages/frontend/core',
'packages/frontend/graphql',
'packages/common/infra',
],
},
{
location: 'packages/frontend/apps/android',
name: '@affine/android',
workspaceDependencies: [
'packages/frontend/component',
'packages/frontend/core',
'packages/frontend/i18n',
'blocksuite/affine/all',
],
},
{
location: 'packages/frontend/apps/electron',
name: '@affine/electron',
workspaceDependencies: [
'tests/kit',
'tools/utils',
'packages/frontend/component',
'packages/frontend/core',
'packages/frontend/electron-api',
'packages/frontend/i18n',
'packages/frontend/native',
'packages/common/nbstore',
'blocksuite/affine/all',
'packages/common/infra',
],
},
{
location: 'packages/frontend/apps/ios',
name: '@affine/ios',
workspaceDependencies: [
'packages/frontend/component',
'packages/frontend/core',
'packages/frontend/i18n',
'blocksuite/affine/all',
],
},
{
location: 'packages/frontend/apps/mobile',
name: '@affine/mobile',
workspaceDependencies: [
'packages/frontend/component',
'packages/frontend/core',
'packages/frontend/i18n',
'blocksuite/affine/all',
],
},
{
location: 'packages/frontend/apps/web',
name: '@affine/web',
workspaceDependencies: [
'packages/frontend/component',
'packages/frontend/core',
'packages/frontend/i18n',
],
},
{
location: 'packages/frontend/component',
name: '@affine/component',
workspaceDependencies: [
'packages/common/debug',
'packages/frontend/electron-api',
'packages/frontend/graphql',
'packages/frontend/i18n',
'blocksuite/affine/all',
],
},
{
location: 'packages/frontend/core',
name: '@affine/core',
workspaceDependencies: [
'packages/frontend/component',
'packages/common/debug',
'packages/frontend/electron-api',
'packages/common/env',
'packages/frontend/graphql',
'packages/frontend/i18n',
'packages/frontend/templates',
'packages/frontend/track',
'blocksuite/affine/all',
],
},
{
location: 'packages/frontend/electron-api',
name: '@affine/electron-api',
workspaceDependencies: [],
},
{
location: 'packages/frontend/graphql',
name: '@affine/graphql',
workspaceDependencies: ['packages/common/env'],
},
{
location: 'packages/frontend/i18n',
name: '@affine/i18n',
workspaceDependencies: ['packages/common/debug'],
},
{
location: 'packages/frontend/native',
name: '@affine/native',
workspaceDependencies: [],
},
{
location: 'packages/frontend/templates',
name: '@affine/templates',
workspaceDependencies: [],
},
{
location: 'packages/frontend/track',
name: '@affine/track',
workspaceDependencies: ['packages/common/debug'],
},
{
location: 'tests/affine-cloud',
name: '@affine-test/affine-cloud',
workspaceDependencies: ['tests/kit'],
},
{
location: 'tests/affine-cloud-copilot',
name: '@affine-test/affine-cloud-copilot',
workspaceDependencies: ['tests/kit'],
},
{
location: 'tests/affine-desktop',
name: '@affine-test/affine-desktop',
workspaceDependencies: ['tests/kit', 'packages/frontend/electron-api'],
},
{
location: 'tests/affine-desktop-cloud',
name: '@affine-test/affine-desktop-cloud',
workspaceDependencies: ['tests/kit'],
},
{
location: 'tests/affine-local',
name: '@affine-test/affine-local',
workspaceDependencies: ['tests/kit'],
},
{
location: 'tests/affine-mobile',
name: '@affine-test/affine-mobile',
workspaceDependencies: ['tests/kit'],
},
{
location: 'tests/kit',
name: '@affine-test/kit',
workspaceDependencies: [],
},
{
location: 'tools/@types/env',
name: '@types/affine__env',
workspaceDependencies: ['packages/common/env'],
},
{
location: 'tools/changelog',
name: '@affine/changelog',
workspaceDependencies: [],
},
{
location: 'tools/cli',
name: '@affine-tools/cli',
workspaceDependencies: ['tools/utils'],
},
{
location: 'tools/commitlint',
name: '@affine/commitlint-config',
workspaceDependencies: [],
},
{
location: 'tools/copilot-result',
name: '@affine/copilot-result',
workspaceDependencies: [],
},
{
location: 'tools/playstore-auto-bump',
name: '@affine/playstore-auto-bump',
workspaceDependencies: ['tools/utils'],
},
{
location: 'tools/utils',
name: '@affine-tools/utils',
workspaceDependencies: [],
},
{
location: 'tools/workers',
name: '@affine/workers',
workspaceDependencies: [],
},
];
export type PackageName =
| '@blocksuite/affine'
| '@blocksuite/affine-block-embed'
| '@blocksuite/affine-block-list'
| '@blocksuite/affine-block-paragraph'
| '@blocksuite/affine-block-surface'
| '@blocksuite/affine-components'
| '@blocksuite/data-view'
| '@blocksuite/affine-model'
| '@blocksuite/affine-shared'
| '@blocksuite/affine-widget-scroll-anchoring'
| '@blocksuite/blocks'
| '@blocksuite/block-std'
| '@blocksuite/global'
| '@blocksuite/inline'
| '@blocksuite/store'
| '@blocksuite/sync'
| '@blocksuite/playground'
| '@blocksuite/presets'
| '@blocksuite/legacy-e2e'
| '@affine/docs'
| '@affine/server-native'
| '@affine/server'
| '@affine/debug'
| '@affine/env'
| '@toeverything/infra'
| '@affine/nbstore'
| '@affine/admin'
| '@affine/android'
| '@affine/electron'
| '@affine/ios'
| '@affine/mobile'
| '@affine/web'
| '@affine/component'
| '@affine/core'
| '@affine/electron-api'
| '@affine/graphql'
| '@affine/i18n'
| '@affine/native'
| '@affine/templates'
| '@affine/track'
| '@affine-test/affine-cloud'
| '@affine-test/affine-cloud-copilot'
| '@affine-test/affine-desktop'
| '@affine-test/affine-desktop-cloud'
| '@affine-test/affine-local'
| '@affine-test/affine-mobile'
| '@affine-test/kit'
| '@types/affine__env'
| '@affine/changelog'
| '@affine-tools/cli'
| '@affine/commitlint-config'
| '@affine/copilot-result'
| '@affine/playstore-auto-bump'
| '@affine-tools/utils'
| '@affine/workers';

View File

@@ -0,0 +1,204 @@
import once from 'lodash-es/once';
import { Logger } from './logger';
import { Package, readPackageJson } from './package';
import { ProjectRoot } from './path';
import { exec } from './process';
import type { CommonPackageJsonContent, YarnWorkspaceItem } from './types';
import { PackageList, type PackageName } from './workspace.gen';
class CircularDependenciesError extends Error {
constructor(public currentName: string) {
super('Circular dependencies error');
}
}
class ForbiddenPackageRefError extends Error {
constructor(
public currentName: string,
public refName: string
) {
super(
`Public package cannot reference private package. Found '${refName}' in dependencies of '${currentName}'`
);
}
}
export class Workspace {
static PackageNames: PackageName[] = PackageList.map(
p => p.name
) as PackageName[];
readonly packages: Package[];
readonly packageJson: CommonPackageJsonContent;
private readonly logger = new Logger('AFFiNE');
readonly path = ProjectRoot;
get version() {
return this.packageJson.version;
}
get devDependencies() {
return this.packageJson.devDependencies ?? {};
}
get dependencies() {
return this.packageJson.dependencies ?? {};
}
constructor() {
this.packageJson = readPackageJson(ProjectRoot);
const packages = new Map<string, Package>();
for (const meta of PackageList) {
try {
const pkg = new Package(meta.name as PackageName, meta);
// @ts-expect-error internal api
pkg.workspace = this;
packages.set(meta.location, pkg);
} catch (e) {
this.logger.error(e as Error);
}
}
const building = new Set<string>();
try {
packages.forEach(pkg => this.buildDeps(pkg, packages, building));
} catch (e) {
if (e instanceof CircularDependenciesError) {
const inProcessPackages = Array.from(building);
const circle = inProcessPackages
.slice(inProcessPackages.indexOf(e.currentName))
.concat(e.currentName);
this.logger.error(
`Circular dependencies found: \n ${circle.join(' -> ')}`
);
process.exit(1);
}
throw e;
}
this.packages = Array.from(packages.values());
}
tryGetPackage(name: PackageName) {
return this.packages.find(p => p.name === name);
}
getPackage(name: PackageName) {
const pkg = this.tryGetPackage(name);
if (!pkg) {
throw new Error(`Cannot find package with name '${name}'`);
}
return pkg;
}
join(...paths: string[]) {
return this.path.join(...paths);
}
buildDeps(
pkg: Package,
packages: Map<string, Package>,
building: Set<string>
) {
if (pkg.deps.length) {
return;
}
building.add(pkg.name);
// @ts-expect-error workspace is the builder for package deps
pkg.deps = pkg.workspaceDependencies
.map(relativeDepPath => {
const dep = packages.get(relativeDepPath);
if (!dep) {
this.logger.error(
`Cannot find package at ${relativeDepPath}. While build dependencies of ${pkg.name}`
);
return null;
}
if (building.has(dep.name)) {
throw new CircularDependenciesError(pkg.name);
}
if (!pkg.packageJson.private && dep.packageJson.private) {
throw new ForbiddenPackageRefError(pkg.name, dep.name);
}
this.buildDeps(dep, packages, building);
return dep;
})
.filter(Boolean) as Package[];
building.delete(pkg.name);
}
yarnList = once(() => {
const output = exec('', 'yarn workspaces list -v --json', { silent: true });
let packageList = JSON.parse(
`[${output.trim().replace(/\r\n|\n/g, ',')}]`
) as YarnWorkspaceItem[];
packageList.forEach(p => {
p.location = p.location.replaceAll(/\\/g, '/');
delete p['mismatchedWorkspaceDependencies'];
});
// ignore root package
return packageList.filter(p => p.location !== '.');
});
genWorkspaceInfo() {
const list = this.yarnList();
const names = list.map(p => p.name);
const content = [
'// Auto generated content',
'// DO NOT MODIFY THIS FILE MANUALLY',
`export const PackageList = ${JSON.stringify(list, null, 2)}`,
'',
`export type PackageName = ${names.map(n => `'${n}'`).join(' | ')}`,
];
return content.join('\n');
}
genProjectTsConfig() {
const content = [
'// Auto generated content',
'// DO NOT MODIFY THIS FILE MANUALLY',
'{',
' "compilerOptions": {',
' "noEmit": true',
' },',
' "include": [],',
' "references": [',
this.packages
.filter(p => p.isTsProject)
.map(p => ` { "path": "${p.path.relativePath}" }`)
.join(',\n'),
' ]',
'}',
'',
];
return content.join('\n');
}
forEach(callback: (pkg: Package) => void) {
this.packages.forEach(callback);
}
}
export { Package, type PackageName };

16
tools/utils/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"moduleResolution": "bundler",
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src"],
"references": [
{
"path": "../../packages/common/env"
}
]
}