snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
1
tools/@types/assets/__all.d.ts
vendored
Normal file
1
tools/@types/assets/__all.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.css';
|
||||
7
tools/@types/assets/package.json
Normal file
7
tools/@types/assets/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@types/assets",
|
||||
"private": true,
|
||||
"types": "./__all.d.ts",
|
||||
"type": "module",
|
||||
"version": "0.27.5"
|
||||
}
|
||||
41
tools/@types/build-config/__all.d.ts
vendored
Normal file
41
tools/@types/build-config/__all.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
declare interface BUILD_CONFIG_TYPE {
|
||||
debug: boolean;
|
||||
distribution: 'web' | 'desktop' | 'admin' | 'mobile' | 'ios' | 'android';
|
||||
/**
|
||||
* 'web' | 'desktop' | 'admin'
|
||||
*/
|
||||
isDesktopEdition: boolean;
|
||||
/**
|
||||
* 'mobile'
|
||||
*/
|
||||
isMobileEdition: boolean;
|
||||
|
||||
isElectron: boolean;
|
||||
isWeb: boolean;
|
||||
/**
|
||||
* 'desktop' | 'ios' | 'android'
|
||||
*/
|
||||
isNative: boolean;
|
||||
isMobileWeb: boolean;
|
||||
isIOS: boolean;
|
||||
isAndroid: boolean;
|
||||
isAdmin: boolean;
|
||||
|
||||
appVersion: string;
|
||||
editorVersion: string;
|
||||
appBuildType: 'stable' | 'beta' | 'internal' | 'canary';
|
||||
|
||||
githubUrl: string;
|
||||
changelogUrl: string;
|
||||
pricingUrl: string;
|
||||
downloadUrl: string;
|
||||
discordUrl: string;
|
||||
requestLicenseUrl: string;
|
||||
// see: tools/workers
|
||||
imageProxyUrl: string;
|
||||
linkPreviewUrl: string;
|
||||
|
||||
SENTRY_DSN: string;
|
||||
}
|
||||
|
||||
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;
|
||||
7
tools/@types/build-config/package.json
Normal file
7
tools/@types/build-config/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@types/build-config",
|
||||
"private": true,
|
||||
"types": "./__all.d.ts",
|
||||
"type": "module",
|
||||
"version": "0.27.5"
|
||||
}
|
||||
52
tools/@types/env/__all.d.ts
vendored
Normal file
52
tools/@types/env/__all.d.ts
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
import '@affine/env/constant';
|
||||
import '@blocksuite/affine/global/types'
|
||||
|
||||
declare module '@blocksuite/affine/store' {
|
||||
interface DocMeta {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
favorite?: boolean;
|
||||
trash?: boolean;
|
||||
trashDate?: number;
|
||||
updatedDate?: number;
|
||||
mode?: 'page' | 'edgeless';
|
||||
// todo: support `number` in the future
|
||||
isPublic?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
declare global {
|
||||
|
||||
declare type Environment = {
|
||||
// Variant
|
||||
isSelfHosted: boolean;
|
||||
|
||||
// Device
|
||||
isLinux: boolean;
|
||||
isMacOs: boolean;
|
||||
isIOS: boolean;
|
||||
isSafari: boolean;
|
||||
isWindows: boolean;
|
||||
isFireFox: boolean;
|
||||
isMobile: boolean;
|
||||
isChrome: boolean;
|
||||
isPwa: boolean;
|
||||
chromeVersion?: number;
|
||||
|
||||
// runtime configs
|
||||
publicPath: string;
|
||||
subPath: string;
|
||||
};
|
||||
|
||||
var process: {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
var environment: Environment;
|
||||
var $AFFINE_SETUP: boolean | undefined;
|
||||
/**
|
||||
* Inject by https://www.npmjs.com/package/@sentry/webpack-plugin
|
||||
*/
|
||||
var SENTRY_RELEASE: { id: string } | undefined;
|
||||
}
|
||||
11
tools/@types/env/package.json
vendored
Normal file
11
tools/@types/env/package.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@types/affine__env",
|
||||
"private": true,
|
||||
"types": "./__all.d.ts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@affine/env": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
},
|
||||
"version": "0.27.5"
|
||||
}
|
||||
183
tools/changelog/index.js
Normal file
183
tools/changelog/index.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Repository, Sort } from '@napi-rs/simple-git';
|
||||
import { WebClient } from '@slack/web-api';
|
||||
import {
|
||||
generateMarkdown,
|
||||
parseCommits,
|
||||
resolveAuthors,
|
||||
resolveConfig,
|
||||
} from 'changelogithub';
|
||||
|
||||
import { render } from './markdown.js';
|
||||
|
||||
const {
|
||||
DEPLOYED_URL,
|
||||
NAMESPACE,
|
||||
CHANNEL_ID,
|
||||
SLACK_BOT_TOKEN,
|
||||
PREV_VERSION,
|
||||
DEPLOYMENT,
|
||||
FLAVOR,
|
||||
BLOCKSUITE_REPO_PATH,
|
||||
} = process.env;
|
||||
|
||||
const slack = new WebClient(SLACK_BOT_TOKEN);
|
||||
const rootDir = join(fileURLToPath(import.meta.url), '..', '..', '..');
|
||||
const repo = new Repository(rootDir);
|
||||
|
||||
/**
|
||||
* @param {import('@napi-rs/simple-git').Repository} repo
|
||||
* @param {string} name
|
||||
*/
|
||||
function findTagByName(repo, name) {
|
||||
let tag = null;
|
||||
repo.tagForeach((id, tagName) => {
|
||||
if (`refs/tags/v${name}` === tagName.toString('utf-8')) {
|
||||
tag = repo.findCommit(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@napi-rs/simple-git').Repository} repo
|
||||
* @param {string} previousCommit
|
||||
* @param {string | undefined} currentCommit
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function getChangeLog(repo, previousCommit, currentCommit) {
|
||||
const prevCommit =
|
||||
repo.findCommit(previousCommit) ?? findTagByName(repo, previousCommit);
|
||||
if (!prevCommit) {
|
||||
console.log(
|
||||
`Previous commit ${previousCommit} in ${repo.path()} not found`
|
||||
);
|
||||
return '';
|
||||
}
|
||||
/** @type {typeof import('changelogithub')['parseCommits'] extends (commit: infer C, ...args: any[]) => any ? C : any} */
|
||||
const commits = [];
|
||||
|
||||
const revWalk = repo.revWalk();
|
||||
|
||||
let headId = repo.head().target();
|
||||
|
||||
if (currentCommit) {
|
||||
const commit =
|
||||
repo.findCommit(currentCommit) ?? findTagByName(repo, currentCommit);
|
||||
if (!commit) {
|
||||
console.log(
|
||||
`Current commit ${currentCommit} not found in ${repo.path()}`
|
||||
);
|
||||
return '';
|
||||
}
|
||||
headId = commit.id();
|
||||
revWalk.push(commit.id());
|
||||
} else {
|
||||
revWalk.pushHead();
|
||||
}
|
||||
|
||||
for (const commitId of revWalk.setSorting(Sort.Time & Sort.Topological)) {
|
||||
const commit = repo.findCommit(commitId);
|
||||
commits.push({
|
||||
message: commit.message(),
|
||||
body: commit.body() ?? '',
|
||||
shortHash: commit.id().substring(0, 8),
|
||||
author: {
|
||||
name: commit.author().name(),
|
||||
email: commit.author().email(),
|
||||
},
|
||||
});
|
||||
if (commitId === prevCommit.id()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const parseConfig = await resolveConfig({
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
|
||||
parseConfig.from = prevCommit.id();
|
||||
parseConfig.to = headId;
|
||||
|
||||
const parsedCommits = parseCommits(commits, parseConfig);
|
||||
await resolveAuthors(parsedCommits, parseConfig);
|
||||
return generateMarkdown(parsedCommits, parseConfig)
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('<samp>', '')
|
||||
.replaceAll('</samp>', '');
|
||||
}
|
||||
|
||||
let blockSuiteChangelog = '';
|
||||
const pkgJsonPath = 'packages/frontend/core/package.json';
|
||||
|
||||
const content = await readFile(join(rootDir, pkgJsonPath), 'utf8');
|
||||
const { dependencies } = JSON.parse(content);
|
||||
const blocksuiteVersion = dependencies['@blocksuite/affine'];
|
||||
|
||||
const prevCommit = repo.findCommit(PREV_VERSION);
|
||||
|
||||
if (!prevCommit) {
|
||||
console.info(
|
||||
`Can't find prev commit ${PREV_VERSION} on the git tree, skip the changelog generation`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const previousPkgJsonBlob = prevCommit
|
||||
.tree()
|
||||
.getPath(pkgJsonPath)
|
||||
.toObject(repo)
|
||||
.peelToBlob();
|
||||
const previousPkgJson = JSON.parse(
|
||||
Buffer.from(previousPkgJsonBlob.content()).toString('utf8')
|
||||
);
|
||||
const previousBlocksuiteVersion =
|
||||
previousPkgJson.dependencies['@blocksuite/affine'];
|
||||
|
||||
if (blocksuiteVersion !== previousBlocksuiteVersion) {
|
||||
const blockSuiteRepo = new Repository(
|
||||
BLOCKSUITE_REPO_PATH ?? join(rootDir, '..', 'blocksuite')
|
||||
);
|
||||
console.log(
|
||||
`Blocksuite ${previousBlocksuiteVersion} -> ${blocksuiteVersion}`
|
||||
);
|
||||
blockSuiteChangelog = await getChangeLog(
|
||||
blockSuiteRepo,
|
||||
previousBlocksuiteVersion,
|
||||
blocksuiteVersion
|
||||
);
|
||||
}
|
||||
|
||||
const messageHead =
|
||||
DEPLOYMENT === 'server'
|
||||
? `# Server deployed in ${NAMESPACE}
|
||||
|
||||
- [${DEPLOYED_URL}](${DEPLOYED_URL})
|
||||
`
|
||||
: `# AFFiNE Client ${FLAVOR} released`;
|
||||
|
||||
let changelogMessage = `${messageHead}
|
||||
|
||||
${await getChangeLog(repo, PREV_VERSION)}
|
||||
`;
|
||||
|
||||
if (blockSuiteChangelog) {
|
||||
changelogMessage += `
|
||||
|
||||
# Blocksuite Changelog
|
||||
|
||||
${blockSuiteChangelog}`;
|
||||
}
|
||||
|
||||
const { ok } = await slack.chat.postMessage({
|
||||
channel: CHANNEL_ID,
|
||||
text: `${DEPLOYMENT === 'server' ? 'Server' : 'Client'} deployed`,
|
||||
blocks: render(changelogMessage),
|
||||
});
|
||||
|
||||
console.assert(ok, 'Failed to send a message to Slack');
|
||||
25
tools/changelog/markdown.js
Normal file
25
tools/changelog/markdown.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { jsxslack } from 'jsx-slack';
|
||||
import { marked, Renderer } from 'marked';
|
||||
|
||||
export const render = markdown => {
|
||||
const rendered = marked(markdown, {
|
||||
renderer: new (class CustomRenderer extends Renderer {
|
||||
heading({ tokens }) {
|
||||
return `
|
||||
<Fragment>
|
||||
<Section><b>${tokens[0].text}</b></Section>
|
||||
<Divider />
|
||||
</Fragment>`;
|
||||
}
|
||||
|
||||
list(token) {
|
||||
return `<Section>${super.list(token)}</Section>`;
|
||||
}
|
||||
|
||||
hr() {
|
||||
return `<Divider />`;
|
||||
}
|
||||
})(),
|
||||
});
|
||||
return jsxslack([`<Blocks>${rendered}</Blocks>`]);
|
||||
};
|
||||
18
tools/changelog/package.json
Normal file
18
tools/changelog/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@affine/changelog",
|
||||
"version": "0.27.5",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"description": "Generate changelog from version changes",
|
||||
"dependencies": {
|
||||
"@napi-rs/simple-git": "^1.0.0",
|
||||
"@slack/web-api": "^8.0.0",
|
||||
"changelogithub": "^13.0.0",
|
||||
"jsx-slack": "^6.1.2",
|
||||
"marked": "^15.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
131
tools/cli/README.md
Normal file
131
tools/cli/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 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 manually wiring a TypeScript loader
|
||||
|
||||
`affine run` will automatically inject `tsx` for your scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@affine/demo",
|
||||
"scripts": {
|
||||
"dev": "node ./dev.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
affine @affine/demo dev
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@affine/demo",
|
||||
"scripts": {
|
||||
"dev": "r ./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-tools/cli": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
6
tools/cli/bin/cli.js
Executable file
6
tools/cli/bin/cli.js
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
spawnSync('yarn', ['r', 'affine.ts', ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
78
tools/cli/bin/runner.js
Executable file
78
tools/cli/bin/runner.js
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/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 serverRoot = join(projectRoot, 'packages', 'backend', 'server');
|
||||
const tsRuntimeRegister = join(scriptsFolder, 'register.js');
|
||||
const tsxRuntimeRegister = join(scriptsFolder, 'tsx-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) {
|
||||
if (scriptLocation) {
|
||||
break;
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
if (scriptLocation.startsWith(serverRoot)) {
|
||||
nodeOptions.unshift(`--import=${pathToFileURL(tsRuntimeRegister)}`);
|
||||
} else {
|
||||
nodeOptions.unshift(`--import=${pathToFileURL(tsxRuntimeRegister)}`);
|
||||
}
|
||||
} else {
|
||||
nodeOptions.unshift('--experimental-specifier-resolution=node');
|
||||
}
|
||||
|
||||
spawn(node, [...nodeOptions, scriptLocation, ...options], {
|
||||
stdio: 'inherit',
|
||||
}).on('exit', code => {
|
||||
process.exit(code);
|
||||
});
|
||||
165
tools/cli/hooks.js
Normal file
165
tools/cli/hooks.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { transform } from '@swc/core';
|
||||
|
||||
const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
|
||||
const JS_EXTENSIONS = ['.js', '.mjs', '.cjs'];
|
||||
const ALL_EXTENSIONS = [...TS_EXTENSIONS, ...JS_EXTENSIONS];
|
||||
|
||||
const JS_EXTENSION_TO_TS = {
|
||||
'.js': ['.ts', '.tsx', '.js'],
|
||||
'.mjs': ['.mts', '.mjs'],
|
||||
'.cjs': ['.cts', '.cjs'],
|
||||
};
|
||||
|
||||
const transformCache = new Map();
|
||||
|
||||
function createCandidates(basePath) {
|
||||
const parsedExt = path.extname(basePath);
|
||||
const hasKnownExtension =
|
||||
parsedExt in JS_EXTENSION_TO_TS || ALL_EXTENSIONS.includes(parsedExt);
|
||||
const ext = hasKnownExtension ? parsedExt : '';
|
||||
const stem = ext ? basePath.slice(0, -ext.length) : basePath;
|
||||
const candidates = new Set();
|
||||
|
||||
const extensions = ext ? (JS_EXTENSION_TO_TS[ext] ?? [ext]) : ALL_EXTENSIONS;
|
||||
|
||||
for (const candidateExt of extensions) {
|
||||
candidates.add(`${stem}${candidateExt}`);
|
||||
}
|
||||
|
||||
if (!ext) {
|
||||
for (const candidateExt of ALL_EXTENSIONS) {
|
||||
candidates.add(path.join(basePath, `index${candidateExt}`));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function isPathLike(specifier) {
|
||||
return (
|
||||
specifier.startsWith('./') ||
|
||||
specifier.startsWith('../') ||
|
||||
specifier.startsWith('/') ||
|
||||
specifier.startsWith('file:')
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePathLikeSpecifier(specifier, parentURL) {
|
||||
if (!isPathLike(specifier)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [specifierWithoutQuery, queryString = ''] = specifier.split('?');
|
||||
const querySuffix = queryString ? `?${queryString}` : '';
|
||||
|
||||
const parentPath = parentURL?.startsWith('file:')
|
||||
? fileURLToPath(parentURL)
|
||||
: path.join(process.cwd(), 'index.js');
|
||||
|
||||
const basePath = specifierWithoutQuery.startsWith('file:')
|
||||
? fileURLToPath(specifierWithoutQuery)
|
||||
: path.isAbsolute(specifierWithoutQuery)
|
||||
? specifierWithoutQuery
|
||||
: path.resolve(path.dirname(parentPath), specifierWithoutQuery);
|
||||
|
||||
for (const candidate of createCandidates(basePath)) {
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) {
|
||||
return `${pathToFileURL(candidate).href}${querySuffix}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore missing candidates
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
try {
|
||||
return await nextResolve(specifier, context);
|
||||
} catch (error) {
|
||||
const resolvedUrl = resolvePathLikeSpecifier(specifier, context.parentURL);
|
||||
if (resolvedUrl) {
|
||||
return {
|
||||
url: resolvedUrl,
|
||||
shortCircuit: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function load(url, context, nextLoad) {
|
||||
const [urlWithoutQuery] = url.split('?');
|
||||
|
||||
if (!urlWithoutQuery.startsWith('file:')) {
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
|
||||
const filePath = fileURLToPath(urlWithoutQuery);
|
||||
if (!TS_EXTENSIONS.has(path.extname(filePath))) {
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const cached = transformCache.get(filePath);
|
||||
if (cached?.mtimeMs === stat.mtimeMs) {
|
||||
return {
|
||||
format: cached.format,
|
||||
source: cached.source,
|
||||
shortCircuit: true,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceText = await fs.promises.readFile(filePath, 'utf8');
|
||||
const isCommonJs = filePath.endsWith('.cts');
|
||||
const moduleType = isCommonJs ? 'commonjs' : 'es6';
|
||||
const tsx = filePath.endsWith('.tsx');
|
||||
|
||||
let output;
|
||||
try {
|
||||
output = await transform(sourceText, {
|
||||
filename: filePath,
|
||||
sourceMaps: 'inline',
|
||||
module: { type: moduleType },
|
||||
jsc: {
|
||||
target: 'es2022',
|
||||
keepClassNames: true,
|
||||
experimental: { keepImportAttributes: true },
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
tsx,
|
||||
decorators: true,
|
||||
dynamicImport: true,
|
||||
},
|
||||
transform: {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
useDefineForClassFields: false,
|
||||
react: tsx
|
||||
? { runtime: 'automatic', importSource: 'react' }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`[swc-loader] Failed to compile ${filePath}\n${detail}`);
|
||||
}
|
||||
|
||||
const source = output.code ?? '';
|
||||
const format = isCommonJs ? 'commonjs' : 'module';
|
||||
transformCache.set(filePath, { mtimeMs: stat.mtimeMs, source, format });
|
||||
|
||||
return {
|
||||
format,
|
||||
source,
|
||||
shortCircuit: true,
|
||||
};
|
||||
}
|
||||
52
tools/cli/package.json
Normal file
52
tools/cli/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@affine-tools/cli",
|
||||
"version": "0.27.5",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"affine": "./bin/cli.js",
|
||||
"r": "./bin/runner.js"
|
||||
},
|
||||
"exports": {
|
||||
"./loader": "./loader.js",
|
||||
"./bundle": "./src/bundle.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"affine": "r ./src/affine.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"@affine/s3-compat": "workspace:*",
|
||||
"@napi-rs/simple-git": "^1.0.0",
|
||||
"@rspack/core": "^2.0.8",
|
||||
"@rspack/dev-server": "^2.1.0",
|
||||
"@sentry/webpack-plugin": "^5.3.0",
|
||||
"@swc/core": "^1.10.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@vanilla-extract/webpack-plugin": "^2.3.15",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"clipanion": "^3.2.1",
|
||||
"css-loader": "^7.1.4",
|
||||
"cssnano": "^7.0.6",
|
||||
"inquirer": "^12.3.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"mime-types": "^3.0.0",
|
||||
"oxfmt": "0.63.0",
|
||||
"postcss": "^8.5.18",
|
||||
"postcss-loader": "^8.2.1",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typanion": "^3.14.0",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mime-types": "^3.0.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
3
tools/cli/register.js
Normal file
3
tools/cli/register.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { register } from 'node:module';
|
||||
|
||||
register('./hooks.js', import.meta.url);
|
||||
34
tools/cli/src/affine.ts
Normal file
34
tools/cli/src/affine.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Workspace } from '@affine-tools/utils/workspace';
|
||||
import { Cli } from 'clipanion';
|
||||
|
||||
import { BuildCommand } from './build';
|
||||
import { BundleCommand } from './bundle';
|
||||
import { CertCommand } from './cert';
|
||||
import { CleanCommand } from './clean';
|
||||
import type { CliContext } from './context';
|
||||
import { DevCommand } from './dev';
|
||||
import { InitCommand } from './init';
|
||||
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(InitCommand);
|
||||
cli.register(CleanCommand);
|
||||
cli.register(BuildCommand);
|
||||
cli.register(DevCommand);
|
||||
cli.register(BundleCommand);
|
||||
cli.register(CertCommand);
|
||||
|
||||
await cli.runExit(process.argv.slice(2), {
|
||||
workspace: new Workspace(),
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
17
tools/cli/src/build.ts
Normal file
17
tools/cli/src/build.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PackageCommand } from './command';
|
||||
|
||||
export class BuildCommand extends PackageCommand {
|
||||
static override paths = [['build'], ['b']];
|
||||
|
||||
async execute() {
|
||||
const args: string[] = [];
|
||||
|
||||
if (this.deps) {
|
||||
args.push('--deps', '--wait-deps');
|
||||
}
|
||||
|
||||
args.push(this.package, 'build');
|
||||
|
||||
await this.cli.run(args);
|
||||
}
|
||||
}
|
||||
72
tools/cli/src/bundle-shared.ts
Normal file
72
tools/cli/src/bundle-shared.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Configuration as RspackDevServerConfiguration } from '@rspack/dev-server';
|
||||
|
||||
export const RSPACK_SUPPORTED_PACKAGES = [
|
||||
'@affine/admin',
|
||||
'@affine/web',
|
||||
'@affine/mobile',
|
||||
'@affine/ios',
|
||||
'@affine/android',
|
||||
'@affine/electron-renderer',
|
||||
'@affine/server',
|
||||
'@affine/reader',
|
||||
] as const;
|
||||
|
||||
const rspackSupportedPackageSet = new Set<string>(RSPACK_SUPPORTED_PACKAGES);
|
||||
|
||||
export function isRspackSupportedPackageName(name: string) {
|
||||
return rspackSupportedPackageSet.has(name);
|
||||
}
|
||||
|
||||
export function assertRspackSupportedPackageName(name: string) {
|
||||
if (isRspackSupportedPackageName(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Rspack bundling currently supports: ${Array.from(RSPACK_SUPPORTED_PACKAGES).join(', ')}. Unsupported package: ${name}.`
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_DEV_SERVER_CONFIG: RspackDevServerConfiguration = {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: 'all',
|
||||
hot: false,
|
||||
liveReload: true,
|
||||
compress: !process.env.CI,
|
||||
setupExitSignals: true,
|
||||
client: {
|
||||
overlay: process.env.DISABLE_DEV_OVERLAY === 'true' ? false : undefined,
|
||||
logging: process.env.CI ? 'none' : 'error',
|
||||
// see: https://webpack.js.org/configuration/dev-server/#websocketurl
|
||||
// must be an explicit ws/wss URL because custom protocols (e.g. assets://)
|
||||
// cannot be used to construct WebSocket endpoints in Electron
|
||||
webSocketURL: 'ws://0.0.0.0:8080/ws',
|
||||
},
|
||||
historyApiFallback: {
|
||||
rewrites: [
|
||||
{
|
||||
from: /.*/,
|
||||
to: () => {
|
||||
return process.env.SELF_HOSTED === 'true'
|
||||
? '/selfhost.html'
|
||||
: '/index.html';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
proxy: [
|
||||
{
|
||||
context: '/api',
|
||||
target: 'http://localhost:3010',
|
||||
},
|
||||
{
|
||||
context: '/socket.io',
|
||||
target: 'http://localhost:3010',
|
||||
ws: true,
|
||||
},
|
||||
{
|
||||
context: '/graphql',
|
||||
target: 'http://localhost:3010',
|
||||
},
|
||||
],
|
||||
};
|
||||
306
tools/cli/src/bundle.ts
Normal file
306
tools/cli/src/bundle.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { rmSync } from 'node:fs';
|
||||
import { cpus } from 'node:os';
|
||||
|
||||
import { Logger } from '@affine-tools/utils/logger';
|
||||
import { Package } from '@affine-tools/utils/workspace';
|
||||
import rspack, { type MultiRspackOptions } from '@rspack/core';
|
||||
import {
|
||||
type Configuration as RspackDevServerConfiguration,
|
||||
RspackDevServer,
|
||||
} from '@rspack/dev-server';
|
||||
import { merge } from 'lodash-es';
|
||||
|
||||
import {
|
||||
assertRspackSupportedPackageName,
|
||||
DEFAULT_DEV_SERVER_CONFIG,
|
||||
} from './bundle-shared';
|
||||
import { Option, PackageCommand } from './command';
|
||||
import {
|
||||
createHTMLTargetConfig as createRspackHTMLTargetConfig,
|
||||
createNodeTargetConfig as createRspackNodeTargetConfig,
|
||||
createWorkerTargetConfig as createRspackWorkerTargetConfig,
|
||||
} from './rspack';
|
||||
import {
|
||||
shouldUploadReleaseAssets,
|
||||
uploadDistAssetsToS3,
|
||||
} from './rspack-shared/s3-plugin.js';
|
||||
|
||||
type WorkerConfig = { name: string };
|
||||
type CreateWorkerTargetConfig = (pkg: Package, entry: string) => WorkerConfig;
|
||||
type BaseWorkerOptions = {
|
||||
includeMermaidAndTypst?: boolean;
|
||||
};
|
||||
|
||||
function assertRspackSupportedPackage(pkg: Package) {
|
||||
assertRspackSupportedPackageName(pkg.name);
|
||||
}
|
||||
|
||||
function shouldUploadAssetsForPackage(pkg: Package): boolean {
|
||||
return (
|
||||
!!process.env.R2_SECRET_ACCESS_KEY && shouldUploadReleaseAssets(pkg.name)
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadAssetsForPackage(pkg: Package, logger: Logger) {
|
||||
if (!shouldUploadAssetsForPackage(pkg)) {
|
||||
return;
|
||||
}
|
||||
logger.info('Uploading dist assets to R2...');
|
||||
await uploadDistAssetsToS3(pkg.distPath.value);
|
||||
logger.info('Uploaded dist assets to R2.');
|
||||
}
|
||||
|
||||
function getBaseWorkerConfigs(
|
||||
pkg: Package,
|
||||
createWorkerTargetConfig: CreateWorkerTargetConfig,
|
||||
options: BaseWorkerOptions = {}
|
||||
) {
|
||||
const core = new Package('@affine/core');
|
||||
const includeMermaidAndTypst = options.includeMermaidAndTypst ?? true;
|
||||
|
||||
const workerConfigs = [
|
||||
createWorkerTargetConfig(
|
||||
pkg,
|
||||
core.srcPath.join(
|
||||
'modules/workspace-engine/impls/workspace-profile.worker.ts'
|
||||
).value
|
||||
),
|
||||
createWorkerTargetConfig(
|
||||
pkg,
|
||||
core.srcPath.join('modules/pdf/renderer/pdf.worker.ts').value
|
||||
),
|
||||
createWorkerTargetConfig(
|
||||
pkg,
|
||||
core.srcPath.join(
|
||||
'blocksuite/view-extensions/turbo-renderer/turbo-painter.worker.ts'
|
||||
).value
|
||||
),
|
||||
];
|
||||
|
||||
if (includeMermaidAndTypst) {
|
||||
workerConfigs.push(
|
||||
createWorkerTargetConfig(
|
||||
pkg,
|
||||
core.srcPath.join('modules/mermaid/renderer/mermaid.worker.ts').value
|
||||
),
|
||||
createWorkerTargetConfig(
|
||||
pkg,
|
||||
core.srcPath.join('modules/typst/renderer/typst.worker.ts').value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return workerConfigs;
|
||||
}
|
||||
|
||||
function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
|
||||
assertRspackSupportedPackage(pkg);
|
||||
|
||||
switch (pkg.name) {
|
||||
case '@affine/admin': {
|
||||
return [
|
||||
createRspackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value, {
|
||||
selfhostPublicPath: '/admin/',
|
||||
}),
|
||||
] as MultiRspackOptions;
|
||||
}
|
||||
case '@affine/web':
|
||||
case '@affine/mobile': {
|
||||
const workerConfigs = getBaseWorkerConfigs(
|
||||
pkg,
|
||||
createRspackWorkerTargetConfig
|
||||
);
|
||||
workerConfigs.push(
|
||||
createRspackWorkerTargetConfig(
|
||||
pkg,
|
||||
pkg.srcPath.join('nbstore.worker.ts').value
|
||||
)
|
||||
);
|
||||
|
||||
return [
|
||||
createRspackHTMLTargetConfig(
|
||||
pkg,
|
||||
pkg.srcPath.join('index.tsx').value,
|
||||
{},
|
||||
workerConfigs.map(config => config.name)
|
||||
),
|
||||
...workerConfigs,
|
||||
] as MultiRspackOptions;
|
||||
}
|
||||
case '@affine/ios':
|
||||
case '@affine/android': {
|
||||
const workerConfigs = getBaseWorkerConfigs(
|
||||
pkg,
|
||||
createRspackWorkerTargetConfig,
|
||||
{ includeMermaidAndTypst: false }
|
||||
);
|
||||
workerConfigs.push(
|
||||
createRspackWorkerTargetConfig(
|
||||
pkg,
|
||||
pkg.srcPath.join('nbstore.worker.ts').value
|
||||
)
|
||||
);
|
||||
|
||||
return [
|
||||
createRspackHTMLTargetConfig(
|
||||
pkg,
|
||||
pkg.srcPath.join('index.tsx').value,
|
||||
{},
|
||||
workerConfigs.map(config => config.name)
|
||||
),
|
||||
...workerConfigs,
|
||||
] as MultiRspackOptions;
|
||||
}
|
||||
case '@affine/electron-renderer': {
|
||||
const workerConfigs = getBaseWorkerConfigs(
|
||||
pkg,
|
||||
createRspackWorkerTargetConfig,
|
||||
{ includeMermaidAndTypst: false }
|
||||
);
|
||||
|
||||
return [
|
||||
createRspackHTMLTargetConfig(
|
||||
pkg,
|
||||
{
|
||||
index: pkg.srcPath.join('app/index.tsx').value,
|
||||
shell: pkg.srcPath.join('shell/index.tsx').value,
|
||||
popup: pkg.srcPath.join('popup/index.tsx').value,
|
||||
backgroundWorker: pkg.srcPath.join('background-worker/index.ts')
|
||||
.value,
|
||||
},
|
||||
{
|
||||
additionalEntryForSelfhost: false,
|
||||
injectGlobalErrorHandler: false,
|
||||
emitAssetsManifest: false,
|
||||
},
|
||||
workerConfigs.map(config => config.name)
|
||||
),
|
||||
...workerConfigs,
|
||||
] as MultiRspackOptions;
|
||||
}
|
||||
case '@affine/server': {
|
||||
return [
|
||||
createRspackNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value),
|
||||
] as MultiRspackOptions;
|
||||
}
|
||||
case '@affine/reader': {
|
||||
return [
|
||||
createRspackNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value, {
|
||||
outputFilename: 'index.js',
|
||||
decoratorVersion: '2022-03',
|
||||
libraryType: 'module',
|
||||
bundleAllDependencies: true,
|
||||
forceExternal: ['yjs'],
|
||||
}),
|
||||
] as MultiRspackOptions;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported package: ${pkg.name}`);
|
||||
}
|
||||
|
||||
export class BundleCommand extends PackageCommand {
|
||||
static override paths = [['bundle'], ['pack'], ['bun']];
|
||||
|
||||
// bundle is not able to run with deps
|
||||
override _deps = false;
|
||||
override waitDeps = false;
|
||||
|
||||
dev = Option.Boolean('--dev,-d', false, {
|
||||
description: 'Run in Development mode',
|
||||
});
|
||||
|
||||
async execute() {
|
||||
const pkg = this.workspace.getPackage(this.package);
|
||||
|
||||
if (this.dev) {
|
||||
await BundleCommand.dev(pkg);
|
||||
} else {
|
||||
await BundleCommand.build(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
static async build(pkg: Package) {
|
||||
return BundleCommand.buildWithRspack(pkg);
|
||||
}
|
||||
|
||||
static async dev(
|
||||
pkg: Package,
|
||||
devServerConfig?: RspackDevServerConfiguration
|
||||
) {
|
||||
return BundleCommand.devWithRspack(pkg, devServerConfig);
|
||||
}
|
||||
|
||||
static async buildWithRspack(pkg: Package) {
|
||||
process.env.NODE_ENV = 'production';
|
||||
assertRspackSupportedPackage(pkg);
|
||||
|
||||
const logger = new Logger('bundle');
|
||||
logger.info(`Packing package ${pkg.name} with rspack...`);
|
||||
logger.info('Cleaning old output...');
|
||||
rmSync(pkg.distPath.value, { recursive: true, force: true });
|
||||
|
||||
const config = getRspackBundleConfigs(pkg);
|
||||
config.parallelism = cpus().length;
|
||||
|
||||
const compiler = rspack(config);
|
||||
if (!compiler) {
|
||||
throw new Error('Failed to create rspack compiler');
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await new Promise<any>((resolve, reject) => {
|
||||
compiler.run((error, stats) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (!stats) {
|
||||
reject(new Error('Failed to get rspack stats'));
|
||||
return;
|
||||
}
|
||||
resolve(stats);
|
||||
});
|
||||
});
|
||||
if (stats.hasErrors()) {
|
||||
console.error(stats.toString('errors-only'));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
console.log(stats.toString('minimal'));
|
||||
await uploadAssetsForPackage(pkg, logger);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static async devWithRspack(
|
||||
pkg: Package,
|
||||
devServerConfig?: RspackDevServerConfiguration
|
||||
) {
|
||||
process.env.NODE_ENV = 'development';
|
||||
assertRspackSupportedPackage(pkg);
|
||||
|
||||
const logger = new Logger('bundle');
|
||||
logger.info(`Starting rspack dev server for ${pkg.name}...`);
|
||||
|
||||
const config = getRspackBundleConfigs(pkg);
|
||||
config.parallelism = cpus().length;
|
||||
|
||||
const compiler = rspack(config);
|
||||
if (!compiler) {
|
||||
throw new Error('Failed to create rspack compiler');
|
||||
}
|
||||
|
||||
const serverConfig = merge({}, DEFAULT_DEV_SERVER_CONFIG, devServerConfig);
|
||||
if (devServerConfig?.proxy) {
|
||||
serverConfig.proxy = devServerConfig.proxy;
|
||||
}
|
||||
|
||||
const devServer = new RspackDevServer(serverConfig, compiler);
|
||||
|
||||
await devServer.start();
|
||||
}
|
||||
}
|
||||
124
tools/cli/src/cert.ts
Normal file
124
tools/cli/src/cert.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { type Path, ProjectRoot } from '@affine-tools/utils/path';
|
||||
|
||||
import { Command, Option } from './command';
|
||||
|
||||
const CERT_DIR = ProjectRoot.join('.docker/dev/certs');
|
||||
const CA_DIR = CERT_DIR.join('ca');
|
||||
const TEMPLATES_DIR = ProjectRoot.join('.docker/dev/templates');
|
||||
const NGINX_CONF_DIR = ProjectRoot.join('.docker/dev/nginx/conf.d');
|
||||
const CA_PEM_PATH = CA_DIR.join('affine-self-signed.pem');
|
||||
const CA_KEY_PATH = CA_DIR.join('affine-self-signed.key');
|
||||
|
||||
const CA_ORG = 'AFFiNE Dev CA Self Signed Org';
|
||||
const CA_NAME = 'AFFiNE Dev CA Self Signed CN';
|
||||
|
||||
export class CertCommand extends Command {
|
||||
static override paths = [['cert']];
|
||||
|
||||
install = Option.Boolean('--install', {
|
||||
description: 'Install the CA',
|
||||
});
|
||||
|
||||
domain = Option.String('--domain', {
|
||||
description:
|
||||
'Generate certificates for given domain. e.g. "affine.localhost"',
|
||||
});
|
||||
|
||||
uninstall = Option.Boolean('--uninstall', {
|
||||
description: 'Uninstall the CA',
|
||||
});
|
||||
|
||||
async execute() {
|
||||
if (this.install) {
|
||||
this.installCa();
|
||||
} else if (this.uninstall) {
|
||||
this.uninstallCa(CA_PEM_PATH);
|
||||
} else if (this.domain) {
|
||||
this.createCert(this.domain);
|
||||
}
|
||||
}
|
||||
|
||||
private createCert(domain: string) {
|
||||
if (!this.checkInstalled(CA_PEM_PATH)) {
|
||||
this.logger.error(
|
||||
'CA not installed. Please run `yarn affine cert --install` first.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const domainDir = CERT_DIR.join(domain);
|
||||
domainDir.rm({ recursive: true });
|
||||
domainDir.mkdir();
|
||||
NGINX_CONF_DIR.mkdir();
|
||||
|
||||
const keyPath = domainDir.join('key');
|
||||
const crtPath = domainDir.join('crt');
|
||||
const csrPath = domainDir.join('csr');
|
||||
const confPath = domainDir.join('conf');
|
||||
const nginxConfPath = NGINX_CONF_DIR.join(`${domain}.conf`);
|
||||
|
||||
const configTemp = TEMPLATES_DIR.join('openssl.conf')
|
||||
.readAsFile()
|
||||
.toString('utf-8');
|
||||
const config = configTemp.replaceAll('DEV_DOMAIN', domain);
|
||||
|
||||
confPath.writeFile(config);
|
||||
this.exec(`openssl genrsa -out ${keyPath} 2048`);
|
||||
this.exec(
|
||||
`openssl req -new -key ${keyPath} -out ${csrPath} -config ${confPath} -subj "/C=/ST=/O=/localityName=/commonName=${domain}/organizationalUnitName=/emailAddress=${domain}@affine.pro/"`
|
||||
);
|
||||
this.exec(
|
||||
`openssl x509 -req -days 1024 -in ${csrPath} -CA ${CA_PEM_PATH} -CAkey ${CA_KEY_PATH} -CAcreateserial -out ${crtPath} -extensions v3_req -extfile ${confPath}`
|
||||
);
|
||||
|
||||
const nginxConfTemp = TEMPLATES_DIR.join('nginx.conf')
|
||||
.readAsFile()
|
||||
.toString('utf-8');
|
||||
const nginxConf = nginxConfTemp.replaceAll('DEV_DOMAIN', domain);
|
||||
nginxConfPath.writeFile(nginxConf);
|
||||
}
|
||||
|
||||
private installCa() {
|
||||
if (this.checkInstalled(CA_PEM_PATH)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove old ca
|
||||
CA_PEM_PATH.rm();
|
||||
CA_KEY_PATH.rm();
|
||||
CA_DIR.mkdir();
|
||||
|
||||
this.exec(
|
||||
`openssl req -new -newkey rsa:2048 -days 1024 -nodes -x509 -subj "/C=/ST=/O=${CA_ORG}/localityName=/commonName=${CA_NAME}/organizationalUnitName=Developers/emailAddress=dev@affine.pro/" -keyout ${CA_KEY_PATH} -out ${CA_PEM_PATH}`
|
||||
);
|
||||
this.trustCa(CA_PEM_PATH);
|
||||
}
|
||||
|
||||
private trustCa(pem: Path) {
|
||||
this.logger.info(`Trusting AFFiNE Dev Self Signed CA`);
|
||||
this.exec(
|
||||
`sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${pem}`
|
||||
);
|
||||
}
|
||||
|
||||
private uninstallCa(pem: Path) {
|
||||
if (!this.checkInstalled(pem)) {
|
||||
this.logger.error('CA not installed');
|
||||
return;
|
||||
}
|
||||
|
||||
this.exec(
|
||||
`sudo security delete-certificate -c ${CA_NAME} /Library/Keychains/System.keychain`
|
||||
);
|
||||
}
|
||||
|
||||
private checkInstalled(pem: Path) {
|
||||
if (!pem.exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ret = this.exec(`security verify-cert -c ${pem}`);
|
||||
|
||||
return ret.includes('...certificate verification successful.');
|
||||
}
|
||||
}
|
||||
71
tools/cli/src/clean.ts
Normal file
71
tools/cli/src/clean.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
142
tools/cli/src/command.ts
Normal file
142
tools/cli/src/command.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { AliasToPackage } from '@affine-tools/utils/distribution';
|
||||
import { Logger } from '@affine-tools/utils/logger';
|
||||
import { exec, execAsync, spawn } from '@affine-tools/utils/process';
|
||||
import { type PackageName, Workspace } from '@affine-tools/utils/workspace';
|
||||
import { Command as BaseCommand, Option } from 'clipanion';
|
||||
import inquirer from 'inquirer';
|
||||
import * as t from 'typanion';
|
||||
|
||||
import type { CliContext } from './context';
|
||||
|
||||
export abstract class Command extends BaseCommand<CliContext> {
|
||||
// @ts-expect-error hack: Get the command name
|
||||
cmd = this.constructor.paths[0][0];
|
||||
|
||||
get logger() {
|
||||
return new Logger(this.cmd);
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this.context.workspace;
|
||||
}
|
||||
|
||||
set workspace(workspace: Workspace) {
|
||||
this.context.workspace = workspace;
|
||||
}
|
||||
|
||||
exec = exec.bind(null, this.cmd);
|
||||
execAsync = execAsync.bind(null, this.cmd);
|
||||
spawn = spawn.bind(null, this.cmd);
|
||||
}
|
||||
|
||||
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 {
|
||||
const name =
|
||||
AliasToPackage.get(this.packageNameOrAlias as any) ??
|
||||
(this.packageNameOrAlias as PackageName);
|
||||
|
||||
// check
|
||||
this.workspace.getPackage(name);
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
protected _deps = Option.Boolean('--deps', false, {
|
||||
description:
|
||||
'Execute the same command in workspace dependencies, if defined.',
|
||||
});
|
||||
|
||||
get deps() {
|
||||
return this._deps;
|
||||
}
|
||||
|
||||
waitDeps = Option.Boolean('--wait-deps', false, {
|
||||
description: 'Wait for dependencies to be ready before running the command',
|
||||
});
|
||||
}
|
||||
|
||||
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 abstract class PackageSelectorCommand extends Command {
|
||||
protected availablePackages = Workspace.PackageNames;
|
||||
|
||||
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', {
|
||||
validator: this.packageNameValidator,
|
||||
description: 'The package name or alias to be run with',
|
||||
});
|
||||
|
||||
async getPackage(): Promise<PackageName> {
|
||||
let name = this.packageNameOrAlias
|
||||
? (AliasToPackage.get(this.packageNameOrAlias as any) ??
|
||||
this.packageNameOrAlias)
|
||||
: undefined;
|
||||
|
||||
if (!name) {
|
||||
const answer = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'package',
|
||||
message: 'Which package do you want to dev?',
|
||||
choices: this.availablePackages.map(name => ({
|
||||
name,
|
||||
value: name,
|
||||
})),
|
||||
pageSize: 10,
|
||||
default: '@affine/web',
|
||||
},
|
||||
]);
|
||||
|
||||
name = answer.package as PackageName;
|
||||
}
|
||||
|
||||
// check
|
||||
this.workspace.getPackage(name as PackageName);
|
||||
|
||||
return name as PackageName;
|
||||
}
|
||||
}
|
||||
|
||||
export { Option };
|
||||
6
tools/cli/src/context.ts
Normal file
6
tools/cli/src/context.ts
Normal 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;
|
||||
}
|
||||
35
tools/cli/src/dev.ts
Normal file
35
tools/cli/src/dev.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { PackageName } from '@affine-tools/utils/workspace';
|
||||
|
||||
import { Option, PackageSelectorCommand } from './command';
|
||||
|
||||
export class DevCommand extends PackageSelectorCommand {
|
||||
static override paths = [['dev'], ['d']];
|
||||
|
||||
protected override availablePackages: PackageName[] = [
|
||||
'@affine/web',
|
||||
'@affine/server',
|
||||
'@affine/electron',
|
||||
'@affine/electron-renderer',
|
||||
'@affine/mobile',
|
||||
'@affine/ios',
|
||||
'@affine/android',
|
||||
'@affine/admin',
|
||||
];
|
||||
|
||||
protected deps = Option.Boolean('--deps', {
|
||||
description: 'Run dev with dependencies',
|
||||
});
|
||||
|
||||
async execute() {
|
||||
const name = await this.getPackage();
|
||||
const args = [];
|
||||
|
||||
if (this.deps) {
|
||||
args.push('--deps');
|
||||
}
|
||||
|
||||
args.push(name, 'dev');
|
||||
|
||||
await this.cli.run(args);
|
||||
}
|
||||
}
|
||||
134
tools/cli/src/init.ts
Executable file
134
tools/cli/src/init.ts
Executable file
@@ -0,0 +1,134 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import type { Path } from '@affine-tools/utils/path';
|
||||
import {
|
||||
type Package,
|
||||
Workspace,
|
||||
yarnList,
|
||||
} from '@affine-tools/utils/workspace';
|
||||
import { applyEdits, modify } from 'jsonc-parser';
|
||||
import { format, type FormatConfig } from 'oxfmt';
|
||||
|
||||
import { Command } from './command';
|
||||
|
||||
export class InitCommand 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() {
|
||||
this.workspace = new Workspace(yarnList());
|
||||
const filesToGenerate: [Path, (prev: string) => string][] = [
|
||||
[this.workspace.join('tsconfig.json'), this.genProjectTsConfig],
|
||||
[
|
||||
this.workspace
|
||||
.getPackage('@affine-tools/utils')
|
||||
.join('src/workspace.gen.ts'),
|
||||
this.genWorkspaceInfo,
|
||||
],
|
||||
[this.workspace.join('.oxlintrc.json'), this.genOxlintConfig],
|
||||
...this.workspace.packages
|
||||
.filter(p => p.isTsProject)
|
||||
.map(
|
||||
p =>
|
||||
[
|
||||
p.join('tsconfig.json'),
|
||||
this.genPackageTsConfig.bind(this, p),
|
||||
] satisfies [Path, (prev: string) => string]
|
||||
),
|
||||
];
|
||||
|
||||
for (const [path, content] of filesToGenerate) {
|
||||
this.logger.info(`Generating: ${path}`);
|
||||
const previous = readFileSync(path.value, 'utf-8');
|
||||
const file = await this.format(content(previous), path.value);
|
||||
writeFileSync(path.value, file);
|
||||
}
|
||||
}
|
||||
|
||||
async format(content: string, fileName: string) {
|
||||
const config = JSON.parse(
|
||||
readFileSync(this.workspace.join('.oxfmtrc.json').value, 'utf-8')
|
||||
) as FormatConfig;
|
||||
const result = await format(fileName, content, config);
|
||||
if (result.errors.length) {
|
||||
throw new Error(
|
||||
result.errors.map(error => error.codeframe ?? error.message).join('\n')
|
||||
);
|
||||
}
|
||||
return result.code;
|
||||
}
|
||||
|
||||
genOxlintConfig = () => {
|
||||
const json = JSON.parse(
|
||||
readFileSync(this.workspace.join('.oxlintrc.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);
|
||||
};
|
||||
|
||||
genWorkspaceInfo = () => {
|
||||
const list = 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 = (prev: string) => {
|
||||
return applyEdits(
|
||||
prev,
|
||||
modify(
|
||||
prev,
|
||||
['references'],
|
||||
this.workspace.packages
|
||||
.filter(p => p.isTsProject)
|
||||
.map(p => ({ path: p.path.relativePath })),
|
||||
{}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
genPackageTsConfig = (pkg: Package, prev: string) => {
|
||||
// TODO(@forehalo):
|
||||
// currently electron-api => electron => nbstore => electron-api
|
||||
// this is a circular dependency, we need to fix it
|
||||
// basically, the electron app don't need to use nbstore for exposing js bridge apis
|
||||
if (pkg.name === '@affine/electron-api') {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return applyEdits(
|
||||
prev,
|
||||
modify(
|
||||
prev,
|
||||
['references'],
|
||||
pkg.deps
|
||||
.filter(p => p.isTsProject)
|
||||
.map(d => ({ path: pkg.path.relative(d.path.value) })),
|
||||
{}
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
111
tools/cli/src/postcss/queuedash-scope.ts
Normal file
111
tools/cli/src/postcss/queuedash-scope.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { AtRule, Container, Node, PluginCreator, Rule } from 'postcss';
|
||||
import selectorParser from 'postcss-selector-parser';
|
||||
|
||||
export interface QueuedashScopeOptions {
|
||||
scopeClass?: string;
|
||||
}
|
||||
|
||||
function normalizeFilePath(filePath: string) {
|
||||
return filePath.replaceAll('\\', '/').split('?')[0];
|
||||
}
|
||||
|
||||
function isInKeyframes(rule: Rule) {
|
||||
let parent: Node | undefined = rule.parent;
|
||||
while (parent) {
|
||||
if (parent.type === 'atrule') {
|
||||
const name = (parent as AtRule).name?.toLowerCase();
|
||||
if (name && name.endsWith('keyframes')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE_CLASS = 'affine-queuedash';
|
||||
|
||||
export const queuedashScopePostcssPlugin: PluginCreator<
|
||||
QueuedashScopeOptions
|
||||
> = (options = {}) => {
|
||||
const scopeClass = options.scopeClass ?? DEFAULT_SCOPE_CLASS;
|
||||
const scopeSelector = `:where(.${scopeClass})`;
|
||||
|
||||
const scopeAst = selectorParser().astSync(scopeSelector);
|
||||
const scopeNodes = scopeAst.nodes[0]?.nodes;
|
||||
|
||||
if (!scopeNodes) {
|
||||
throw new Error(
|
||||
`[queuedashScopePostcssPlugin] Failed to parse scope selector: ${scopeSelector}`
|
||||
);
|
||||
}
|
||||
|
||||
const scopeProcessor = selectorParser(selectors => {
|
||||
selectors.each(selector => {
|
||||
const raw = selector.toString().trim();
|
||||
|
||||
if (
|
||||
raw.startsWith(scopeSelector) ||
|
||||
raw.startsWith(`.${scopeClass}`) ||
|
||||
raw.startsWith(`:where(.${scopeClass})`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
raw === 'html' ||
|
||||
raw === 'body' ||
|
||||
raw === ':host' ||
|
||||
raw === ':root'
|
||||
) {
|
||||
selector.nodes = scopeNodes.map(node => node.clone());
|
||||
return;
|
||||
}
|
||||
|
||||
const prefixNodes = scopeNodes.map(node => node.clone());
|
||||
const space = selectorParser.combinator({ value: ' ' });
|
||||
selector.nodes = [...prefixNodes, space, ...selector.nodes];
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
postcssPlugin: 'affine-queuedash-scope',
|
||||
Once(root, { result }) {
|
||||
const from =
|
||||
root.source?.input.file ||
|
||||
root.source?.input.from ||
|
||||
result.opts.from ||
|
||||
'';
|
||||
|
||||
const normalized = from ? normalizeFilePath(from) : '';
|
||||
const isQueuedashVendorCss = normalized.endsWith(
|
||||
'/@queuedash/ui/dist/styles.css'
|
||||
);
|
||||
|
||||
const queuedashLayers: AtRule[] = [];
|
||||
root.walkAtRules('layer', atRule => {
|
||||
if (atRule.params?.trim() === 'queuedash' && atRule.nodes?.length) {
|
||||
queuedashLayers.push(atRule);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isQueuedashVendorCss && queuedashLayers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targets: Container[] =
|
||||
queuedashLayers.length > 0 ? queuedashLayers : [root];
|
||||
|
||||
targets.forEach(container => {
|
||||
container.walkRules(rule => {
|
||||
if (!rule.selector || isInKeyframes(rule)) {
|
||||
return;
|
||||
}
|
||||
rule.selector = scopeProcessor.processSync(rule.selector);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
queuedashScopePostcssPlugin.postcss = true;
|
||||
100
tools/cli/src/rspack-shared/cache-group.ts
Normal file
100
tools/cli/src/rspack-shared/cache-group.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
function testPackageName(regexp: RegExp): (module: any) => boolean {
|
||||
return (module: any) =>
|
||||
module.nameForCondition && regexp.test(module.nameForCondition());
|
||||
}
|
||||
|
||||
// https://hackernoon.com/the-100-correct-way-to-split-your-chunks-with-webpack-f8a9df5b7758
|
||||
export const productionCacheGroups = {
|
||||
i18n: {
|
||||
test: /frontend[\\/]i18n[\\/]/,
|
||||
name: (module: any) => {
|
||||
const name = module.resource.match(/[\\/]([^\\/]+)\.json$/)?.[1];
|
||||
if (name && name !== 'en') {
|
||||
return `i18n-langs.${name}`;
|
||||
}
|
||||
|
||||
return 'i18n';
|
||||
},
|
||||
priority: 200,
|
||||
enforce: true,
|
||||
},
|
||||
asyncVendor: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name(module: any) {
|
||||
const modulePath =
|
||||
module?.nameForCondition?.() || module?.context || module?.resource;
|
||||
|
||||
if (!modulePath || typeof modulePath !== 'string') {
|
||||
return 'app-async';
|
||||
}
|
||||
|
||||
// monorepo linked in node_modules, so it's not a npm package
|
||||
if (!modulePath.includes('node_modules')) {
|
||||
return `app-async`;
|
||||
}
|
||||
const name = modulePath.match(
|
||||
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
|
||||
)?.[1];
|
||||
return `npm-async-${name}`;
|
||||
},
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
chunks: 'async' as const,
|
||||
},
|
||||
blocksuite: {
|
||||
name: `npm-blocksuite`,
|
||||
test: testPackageName(/[\\/]node_modules[\\/](@blocksuite)[\\/]/),
|
||||
priority: 200,
|
||||
enforce: true,
|
||||
},
|
||||
react: {
|
||||
name: `npm-react`,
|
||||
test: testPackageName(
|
||||
/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/
|
||||
),
|
||||
priority: 200,
|
||||
enforce: true,
|
||||
},
|
||||
jotai: {
|
||||
name: `npm-jotai`,
|
||||
test: testPackageName(/[\\/]node_modules[\\/](jotai)[\\/]/),
|
||||
priority: 200,
|
||||
enforce: true,
|
||||
},
|
||||
rxjs: {
|
||||
name: `npm-rxjs`,
|
||||
test: testPackageName(/[\\/]node_modules[\\/]rxjs[\\/]/),
|
||||
priority: 200,
|
||||
enforce: true,
|
||||
},
|
||||
lodash: {
|
||||
name: `npm-lodash`,
|
||||
test: testPackageName(/[\\/]node_modules[\\/]lodash[\\/]/),
|
||||
priority: 200,
|
||||
enforce: true,
|
||||
},
|
||||
emotion: {
|
||||
name: `npm-emotion`,
|
||||
test: testPackageName(/[\\/]node_modules[\\/](@emotion)[\\/]/),
|
||||
priority: 200,
|
||||
enforce: true,
|
||||
},
|
||||
vendor: {
|
||||
name: 'vendor',
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
priority: 190,
|
||||
enforce: true,
|
||||
},
|
||||
styles: {
|
||||
name: 'styles',
|
||||
test: (module: any) =>
|
||||
module.nameForCondition &&
|
||||
module.nameForCondition()?.endsWith('.css') &&
|
||||
!module.type.startsWith('javascript'),
|
||||
chunks: 'all' as const,
|
||||
minSize: 1,
|
||||
minChunks: 1,
|
||||
reuseExistingChunk: true,
|
||||
priority: 1000,
|
||||
enforce: true,
|
||||
},
|
||||
};
|
||||
116
tools/cli/src/rspack-shared/error-handler.js
Normal file
116
tools/cli/src/rspack-shared/error-handler.js
Normal file
@@ -0,0 +1,116 @@
|
||||
(function () {
|
||||
let errorEl = null;
|
||||
function showGlobalErrorPage() {
|
||||
if (errorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
errorEl = document.createElement('div');
|
||||
errorEl.innerHTML = [
|
||||
'<style>',
|
||||
'.gue {display:flex;flex-direction:column;align-items:center;justify-content:center;width:380px;}',
|
||||
'.gue img{width:380px;}',
|
||||
'.gue div{padding:16px 40px 0 40px;text-align:center;}',
|
||||
'.gue .p1{color:#141414;line-height:24px;font-weight:500;}',
|
||||
'.gue .p2{color:#7A7A7A;line-height:22px;}',
|
||||
'</style>',
|
||||
'<div class="gue">',
|
||||
'<img src="https://cdn.affine.pro/error.png" />',
|
||||
'<div>',
|
||||
'<p class="p1">Unsupported Environment</p>',
|
||||
'<p class="p2">',
|
||||
'It looks like AFFiNE cannot run in this environment.',
|
||||
"Please ensure you are using a supported browser or update your device's operating system to the latest version.",
|
||||
'If the issue persists, visit our <a href="https://github.com/toeverything/AFFiNE/issues">support page</a> for further assistance.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
].join('');
|
||||
errorEl.setAttribute(
|
||||
'style',
|
||||
'position:absolute;top:0;left:0;height:100vh;width:100vw;display:flex;flex-direction:column;align-items:center;justify-content:center;background:white;z-index:999;'
|
||||
);
|
||||
document.body.append(errorEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param event {PromiseRejectionEvent|ErrorEvent}
|
||||
*/
|
||||
function handler(event) {
|
||||
let error;
|
||||
|
||||
if ('error' in event) {
|
||||
error =
|
||||
event.error ||
|
||||
(event.message === 'Script error.'
|
||||
? new SyntaxError(event.message)
|
||||
: new Error(event.message));
|
||||
} else {
|
||||
error = event.reason;
|
||||
}
|
||||
|
||||
console.error('unhandled unrecoverable error', error);
|
||||
|
||||
const shouldCache =
|
||||
// syntax error
|
||||
error && error instanceof SyntaxError;
|
||||
|
||||
if (!shouldCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopImmediatePropagation();
|
||||
showGlobalErrorPage();
|
||||
}
|
||||
|
||||
function registerGlobalErrorHandler() {
|
||||
if (typeof document !== 'undefined') {
|
||||
globalThis.addEventListener('unhandledrejection', handler);
|
||||
globalThis.addEventListener('error', handler);
|
||||
|
||||
return function () {
|
||||
globalThis.removeEventListener('unhandledrejection', handler);
|
||||
globalThis.removeEventListener('error', handler);
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function unregisterRegisterGlobalErrorHandler(fn) {
|
||||
if (typeof fn === 'function') {
|
||||
const app = document.getElementById('app');
|
||||
if (app) {
|
||||
let ob = new MutationObserver(function () {
|
||||
fn();
|
||||
ob.disconnect();
|
||||
ob = null;
|
||||
});
|
||||
|
||||
ob.observe(app, { childList: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBasicEnvironment() {
|
||||
const globals = [
|
||||
'Promise',
|
||||
'Map',
|
||||
'fetch',
|
||||
'customElements',
|
||||
'MutationObserver',
|
||||
];
|
||||
|
||||
// oxlint-disable-next-line typescript/prefer-for-of
|
||||
for (let i = 0; i < globals.length; i++) {
|
||||
if (!(globals[i] in globalThis)) {
|
||||
showGlobalErrorPage();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureBasicEnvironment();
|
||||
const goodtogo = registerGlobalErrorHandler();
|
||||
unregisterRegisterGlobalErrorHandler(goodtogo);
|
||||
})();
|
||||
297
tools/cli/src/rspack-shared/html-plugin.ts
Normal file
297
tools/cli/src/rspack-shared/html-plugin.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { Path, ProjectRoot } from '@affine-tools/utils/path';
|
||||
import { Repository } from '@napi-rs/simple-git';
|
||||
import { HtmlRspackPlugin, type HtmlRspackPluginOptions } from '@rspack/core';
|
||||
import { once } from 'lodash-es';
|
||||
|
||||
type HtmlRspackPluginInstance = InstanceType<typeof HtmlRspackPlugin>;
|
||||
|
||||
type PluginLike = {
|
||||
apply: (compiler: CompilerLike) => void;
|
||||
};
|
||||
|
||||
type CompilerLike = {
|
||||
webpack?: {
|
||||
sources?: {
|
||||
RawSource?: new (source: string) => unknown;
|
||||
};
|
||||
};
|
||||
hooks: {
|
||||
compilation: {
|
||||
tap: (name: string, callback: (compilation: any) => void) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function createRawSource(compiler: CompilerLike, source: string) {
|
||||
const RawSource = compiler.webpack?.sources?.RawSource;
|
||||
if (!RawSource) {
|
||||
throw new Error(
|
||||
'compiler.webpack.sources.RawSource is required for html plugin assets emission'
|
||||
);
|
||||
}
|
||||
|
||||
return new RawSource(source);
|
||||
}
|
||||
|
||||
export const getPublicPath = (BUILD_CONFIG: BUILD_CONFIG_TYPE) => {
|
||||
const { BUILD_TYPE } = process.env;
|
||||
if (typeof process.env.PUBLIC_PATH === 'string') {
|
||||
return process.env.PUBLIC_PATH;
|
||||
}
|
||||
|
||||
if (
|
||||
BUILD_CONFIG.debug ||
|
||||
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.value);
|
||||
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);
|
||||
|
||||
export interface CreateHTMLPluginConfig {
|
||||
filename?: string;
|
||||
template?: string;
|
||||
copySharedPublicAssets?: boolean;
|
||||
additionalEntryForSelfhost?: boolean;
|
||||
selfhostPublicPath?: string;
|
||||
injectGlobalErrorHandler?: boolean;
|
||||
emitAssetsManifest?: boolean;
|
||||
}
|
||||
|
||||
function getHTMLPluginOptions(BUILD_CONFIG: BUILD_CONFIG_TYPE) {
|
||||
const publicPath = getPublicPath(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',
|
||||
minify: false,
|
||||
templateParameters: templateParams,
|
||||
chunks: ['app'],
|
||||
scriptLoading: 'blocking',
|
||||
} satisfies HtmlRspackPluginOptions;
|
||||
}
|
||||
|
||||
const AssetsManifestPlugin = {
|
||||
apply(compiler: CompilerLike) {
|
||||
compiler.hooks.compilation.tap('assets-manifest-plugin', compilation => {
|
||||
HtmlRspackPlugin.getCompilationHooks(
|
||||
compilation
|
||||
).beforeAssetTagGeneration.tap('assets-manifest-plugin', arg => {
|
||||
if (!compilation.getAsset('assets-manifest.json')) {
|
||||
compilation.emitAsset(
|
||||
`assets-manifest.json`,
|
||||
createRawSource(
|
||||
compiler,
|
||||
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: gitShortHash(),
|
||||
description: DESCRIPTION,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
),
|
||||
{
|
||||
immutable: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return arg;
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const GlobalErrorHandlerPlugin = {
|
||||
apply(compiler: CompilerLike) {
|
||||
const globalErrorHandler = [
|
||||
'js/global-error-handler.js',
|
||||
readFileSync(currentDir.join('./error-handler.js').toString(), 'utf-8'),
|
||||
];
|
||||
|
||||
compiler.hooks.compilation.tap(
|
||||
'global-error-handler-plugin',
|
||||
compilation => {
|
||||
HtmlRspackPlugin.getCompilationHooks(
|
||||
compilation
|
||||
).beforeAssetTagGeneration.tap('global-error-handler-plugin', arg => {
|
||||
if (!compilation.getAsset(globalErrorHandler[0])) {
|
||||
compilation.emitAsset(
|
||||
globalErrorHandler[0],
|
||||
createRawSource(compiler, globalErrorHandler[1])
|
||||
);
|
||||
arg.assets.js.unshift(
|
||||
arg.assets.publicPath + globalErrorHandler[0]
|
||||
);
|
||||
}
|
||||
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const CorsPlugin = {
|
||||
apply(compiler: CompilerLike) {
|
||||
compiler.hooks.compilation.tap('html-js-cors-plugin', compilation => {
|
||||
HtmlRspackPlugin.getCompilationHooks(compilation).alterAssetTags.tap(
|
||||
'html-js-cors-plugin',
|
||||
options => {
|
||||
if (options.publicPath !== '/') {
|
||||
options.assetTags.scripts.forEach(script => {
|
||||
script.attributes.crossorigin = true;
|
||||
});
|
||||
options.assetTags.styles.forEach(style => {
|
||||
style.attributes.crossorigin = true;
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function createHTMLPlugins(
|
||||
BUILD_CONFIG: BUILD_CONFIG_TYPE,
|
||||
config: CreateHTMLPluginConfig
|
||||
): (HtmlRspackPluginInstance | PluginLike)[] {
|
||||
const publicPath = getPublicPath(BUILD_CONFIG);
|
||||
const htmlPluginOptions = {
|
||||
...getHTMLPluginOptions(BUILD_CONFIG),
|
||||
...(config.template ? { template: config.template } : {}),
|
||||
};
|
||||
const selfhostPublicPath = config.selfhostPublicPath ?? '/';
|
||||
|
||||
const plugins: (HtmlRspackPluginInstance | PluginLike)[] = [];
|
||||
plugins.push(
|
||||
new HtmlRspackPlugin({
|
||||
...htmlPluginOptions,
|
||||
chunks: ['index'],
|
||||
filename: config.filename,
|
||||
publicPath,
|
||||
meta: {
|
||||
'env:publicPath': publicPath,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
plugins.push(
|
||||
new HtmlRspackPlugin({
|
||||
...htmlPluginOptions,
|
||||
chunks: ['shell'],
|
||||
filename: 'shell.html',
|
||||
publicPath,
|
||||
meta: {
|
||||
'env:publicPath': publicPath,
|
||||
},
|
||||
}),
|
||||
new HtmlRspackPlugin({
|
||||
...htmlPluginOptions,
|
||||
filename: 'popup.html',
|
||||
chunks: ['popup'],
|
||||
publicPath,
|
||||
meta: {
|
||||
'env:publicPath': publicPath,
|
||||
},
|
||||
}),
|
||||
new HtmlRspackPlugin({
|
||||
...htmlPluginOptions,
|
||||
filename: 'background-worker.html',
|
||||
chunks: ['backgroundWorker'],
|
||||
publicPath,
|
||||
meta: {
|
||||
'env:publicPath': publicPath,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!BUILD_CONFIG.isElectron) {
|
||||
plugins.push(CorsPlugin);
|
||||
}
|
||||
|
||||
if (config.emitAssetsManifest) {
|
||||
plugins.push(AssetsManifestPlugin);
|
||||
}
|
||||
|
||||
if (config.injectGlobalErrorHandler) {
|
||||
plugins.push(GlobalErrorHandlerPlugin);
|
||||
}
|
||||
|
||||
if (config.additionalEntryForSelfhost) {
|
||||
plugins.push(
|
||||
new HtmlRspackPlugin({
|
||||
...htmlPluginOptions,
|
||||
chunks: ['index'],
|
||||
publicPath: selfhostPublicPath,
|
||||
meta: {
|
||||
'env:isSelfHosted': 'true',
|
||||
'env:publicPath': selfhostPublicPath,
|
||||
},
|
||||
filename: 'selfhost.html',
|
||||
templateParameters: {
|
||||
...htmlPluginOptions.templateParameters,
|
||||
PRECONNECT: '',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
18
tools/cli/src/rspack-shared/node-loader.js
Normal file
18
tools/cli/src/rspack-shared/node-loader.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { parse } from 'node:path';
|
||||
|
||||
export const raw = true;
|
||||
/**
|
||||
* @type {import('webpack').LoaderDefinitionFunction}
|
||||
*/
|
||||
export default function loader(content) {
|
||||
const name = parse(this.resourcePath).base;
|
||||
this.emitFile(name, content);
|
||||
|
||||
return `
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const binding = require('./${name}')
|
||||
export default binding
|
||||
`;
|
||||
}
|
||||
141
tools/cli/src/rspack-shared/s3-plugin.ts
Normal file
141
tools/cli/src/rspack-shared/s3-plugin.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
import { createS3CompatClient } from '@affine/s3-compat';
|
||||
import { lookup } from 'mime-types';
|
||||
|
||||
export const R2_BUCKET =
|
||||
process.env.R2_BUCKET ??
|
||||
(process.env.BUILD_TYPE === 'canary' ? 'assets-dev' : 'assets-prod');
|
||||
|
||||
const S3_UPLOAD_PACKAGE_NAMES = new Set([
|
||||
'@affine/web',
|
||||
'@affine/mobile',
|
||||
'@affine/admin',
|
||||
]);
|
||||
const MAX_UPLOAD_RETRIES = 3;
|
||||
const UPLOAD_RETRY_BASE_DELAY_MS = 500;
|
||||
|
||||
function createR2Client() {
|
||||
const { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY } = process.env;
|
||||
if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) {
|
||||
throw new Error('Missing R2 credentials for uploading release assets');
|
||||
}
|
||||
|
||||
return createS3CompatClient(
|
||||
{
|
||||
region: 'auto',
|
||||
bucket: R2_BUCKET,
|
||||
forcePathStyle: true,
|
||||
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
},
|
||||
{
|
||||
accessKeyId: R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: R2_SECRET_ACCESS_KEY,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function collectFiles(dir: string): Promise<string[]> {
|
||||
const dirs = [dir];
|
||||
const files: string[] = [];
|
||||
|
||||
while (dirs.length > 0) {
|
||||
const current = dirs.pop()!;
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
dirs.push(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function toAssetKey(outputPath: string, filePath: string): string {
|
||||
return relative(outputPath, filePath).split(sep).join('/');
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function putObjectWithRetry(
|
||||
s3: ReturnType<typeof createR2Client>,
|
||||
asset: string,
|
||||
assetSource: Buffer,
|
||||
contentType: string | false | undefined
|
||||
) {
|
||||
let retries = 0;
|
||||
while (true) {
|
||||
try {
|
||||
await s3.putObject(asset, assetSource, {
|
||||
contentType: contentType || undefined,
|
||||
contentLength: assetSource.byteLength,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (retries >= MAX_UPLOAD_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
retries += 1;
|
||||
const delay = UPLOAD_RETRY_BASE_DELAY_MS * 2 ** (retries - 1);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`[s3-upload] Retry ${retries}/${MAX_UPLOAD_RETRIES} for ${asset}: ${errorMessage}`
|
||||
);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runInParallel<T>(
|
||||
values: T[],
|
||||
worker: (value: T) => Promise<void>,
|
||||
concurrency = 16
|
||||
) {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, values.length) },
|
||||
async () => {
|
||||
while (true) {
|
||||
const index = nextIndex++;
|
||||
if (index >= values.length) {
|
||||
return;
|
||||
}
|
||||
await worker(values[index]!);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export function shouldUploadReleaseAssets(pkgName: string): boolean {
|
||||
return S3_UPLOAD_PACKAGE_NAMES.has(pkgName);
|
||||
}
|
||||
|
||||
export async function uploadDistAssetsToS3(outputPath: string) {
|
||||
const allFiles = await collectFiles(outputPath);
|
||||
const uploadFiles = allFiles.filter(file => !file.endsWith('.html'));
|
||||
|
||||
if (uploadFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const s3 = createR2Client();
|
||||
await runInParallel(uploadFiles, async filePath => {
|
||||
const asset = toAssetKey(outputPath, filePath);
|
||||
const assetSource = await readFile(filePath);
|
||||
const contentType = lookup(asset);
|
||||
await putObjectWithRetry(s3, asset, assetSource, contentType);
|
||||
});
|
||||
}
|
||||
48
tools/cli/src/rspack-shared/template.html
Normal file
48
tools/cli/src/rspack-shared/template.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=<%= VIEWPORT_FIT %>"
|
||||
/>
|
||||
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
|
||||
<title>AFFiNE</title>
|
||||
<meta name="theme-color" content="#fafafa" />
|
||||
<%= PRECONNECT %>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" sizes="192x192" href="/favicon-192.png" />
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=2" />
|
||||
<meta name="emotion-insertion-point" content="" />
|
||||
<meta property="description" content="<%= DESCRIPTION %>" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://app.affine.pro/" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="AFFiNE: There can be more than Notion and Miro."
|
||||
/>
|
||||
<meta name="twitter:description" content="<%= DESCRIPTION %>" />
|
||||
<meta name="twitter:site" content="@AffineOfficial" />
|
||||
<meta name="twitter:image" content="https://affine.pro/og.jpeg" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="AFFiNE: There can be more than Notion and Miro."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="<%= DESCRIPTION %>" />
|
||||
<meta property="og:url" content="https://app.affine.pro/" />
|
||||
<meta property="og:image" content="https://affine.pro/og.jpeg" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" data-version="<%= GIT_SHORT_SHA %>"></div>
|
||||
</body>
|
||||
</html>
|
||||
696
tools/cli/src/rspack/index.ts
Normal file
696
tools/cli/src/rspack/index.ts
Normal file
@@ -0,0 +1,696 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getBuildConfig } from '@affine-tools/utils/build-config';
|
||||
import { Path, ProjectRoot } from '@affine-tools/utils/path';
|
||||
import { Package } from '@affine-tools/utils/workspace';
|
||||
import rspack, {
|
||||
type Configuration as RspackConfiguration,
|
||||
} from '@rspack/core';
|
||||
import type { sentryWebpackPlugin as SentryWebpackPluginFactory } from '@sentry/webpack-plugin';
|
||||
import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin';
|
||||
import cssnano from 'cssnano';
|
||||
import { compact, merge } from 'lodash-es';
|
||||
|
||||
import { queuedashScopePostcssPlugin } from '../postcss/queuedash-scope.js';
|
||||
import { productionCacheGroups } from '../rspack-shared/cache-group.js';
|
||||
import {
|
||||
type CreateHTMLPluginConfig,
|
||||
createHTMLPlugins,
|
||||
} from '../rspack-shared/html-plugin.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const IN_CI = !!process.env.CI;
|
||||
const hasSentryBuildEnvs = () =>
|
||||
!!(
|
||||
process.env.SENTRY_AUTH_TOKEN &&
|
||||
process.env.SENTRY_ORG &&
|
||||
process.env.SENTRY_PROJECT
|
||||
);
|
||||
|
||||
function createSentryPlugin() {
|
||||
if (!hasSentryBuildEnvs()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin') as {
|
||||
sentryWebpackPlugin: typeof SentryWebpackPluginFactory;
|
||||
};
|
||||
|
||||
return sentryWebpackPlugin({
|
||||
org: process.env.SENTRY_ORG!,
|
||||
project: process.env.SENTRY_PROJECT!,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN!,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error ? error.message : 'unknown load error';
|
||||
throw new Error(
|
||||
`Failed to load @sentry/webpack-plugin while SENTRY_* envs are set: ${reason}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const availableChannels = ['canary', 'beta', 'stable', 'internal'];
|
||||
function getBuildConfigFromEnv(pkg: Package) {
|
||||
const channel = process.env.BUILD_TYPE ?? 'canary';
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
if (!availableChannels.includes(channel)) {
|
||||
throw new Error(
|
||||
`BUILD_TYPE must be one of ${availableChannels.join(', ')}, received [${channel}]`
|
||||
);
|
||||
}
|
||||
|
||||
return getBuildConfig(pkg, {
|
||||
// @ts-expect-error checked
|
||||
channel,
|
||||
mode: dev ? 'development' : 'production',
|
||||
});
|
||||
}
|
||||
|
||||
export function createHTMLTargetConfig(
|
||||
pkg: Package,
|
||||
entry: string | Record<string, string>,
|
||||
htmlConfig: Partial<CreateHTMLPluginConfig> = {},
|
||||
deps?: string[]
|
||||
): RspackConfiguration {
|
||||
entry = typeof entry === 'string' ? { index: entry } : entry;
|
||||
const tailwindConfigPath = pkg.join('tailwind.config.js');
|
||||
const hasTailwind = tailwindConfigPath.exists();
|
||||
const tailwindPlugin = tailwindConfigPath.exists()
|
||||
? ['@tailwindcss/postcss', require(tailwindConfigPath.value)]
|
||||
: ['@tailwindcss/postcss'];
|
||||
|
||||
htmlConfig = merge(
|
||||
{},
|
||||
{
|
||||
filename: 'index.html',
|
||||
additionalEntryForSelfhost: true,
|
||||
copySharedPublicAssets: true,
|
||||
injectGlobalErrorHandler: true,
|
||||
emitAssetsManifest: true,
|
||||
},
|
||||
htmlConfig
|
||||
);
|
||||
|
||||
const buildConfig = getBuildConfigFromEnv(pkg);
|
||||
const codeBlockPreviewBackendFile =
|
||||
buildConfig.distribution === 'desktop'
|
||||
? 'platform-backend.desktop.ts'
|
||||
: buildConfig.distribution === 'ios' ||
|
||||
buildConfig.distribution === 'android'
|
||||
? 'platform-backend.mobile.ts'
|
||||
: 'platform-backend.ts';
|
||||
const codeBlockPreviewBackendAlias = ProjectRoot.join(
|
||||
'packages',
|
||||
'frontend',
|
||||
'core',
|
||||
'src',
|
||||
'modules',
|
||||
'code-block-preview-renderer',
|
||||
codeBlockPreviewBackendFile
|
||||
).value;
|
||||
|
||||
console.log(
|
||||
`Building [${pkg.name}] for [${buildConfig.appBuildType}] channel in [${buildConfig.debug ? 'development' : 'production'}] mode.`
|
||||
);
|
||||
console.log(
|
||||
`Entry points: ${Object.entries(entry)
|
||||
.map(([name, path]) => `${name}: ${path}`)
|
||||
.join(', ')}`
|
||||
);
|
||||
console.log(`Output path: ${pkg.distPath.value}`);
|
||||
console.log(`Config: ${JSON.stringify(buildConfig, null, 2)}`);
|
||||
|
||||
const config: RspackConfiguration = {
|
||||
//#region basic bundler config
|
||||
name: entry['index'],
|
||||
dependencies: deps,
|
||||
context: ProjectRoot.value,
|
||||
experiments: {
|
||||
asyncWebAssembly: true,
|
||||
},
|
||||
entry,
|
||||
output: {
|
||||
environment: { module: true, dynamicImport: true },
|
||||
filename: buildConfig.debug
|
||||
? 'js/[name].js'
|
||||
: 'js/[name].[contenthash:8].js',
|
||||
assetModuleFilename: buildConfig.debug
|
||||
? '[name].[contenthash:8][ext]'
|
||||
: 'assets/[name].[contenthash:8][ext][query]',
|
||||
path: pkg.distPath.value,
|
||||
clean: false,
|
||||
globalObject: 'globalThis',
|
||||
// NOTE: always keep it '/'
|
||||
publicPath: '/',
|
||||
},
|
||||
target: ['web', 'es2022'],
|
||||
mode: buildConfig.debug ? 'development' : 'production',
|
||||
devtool: buildConfig.debug ? 'cheap-module-source-map' : 'source-map',
|
||||
resolve: {
|
||||
symlinks: true,
|
||||
extensionAlias: {
|
||||
'.js': ['.js', '.tsx', '.ts'],
|
||||
'.mjs': ['.mjs', '.mts'],
|
||||
},
|
||||
extensions: ['.js', '.ts', '.tsx'],
|
||||
alias: {
|
||||
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,
|
||||
'@affine/core/modules/code-block-preview-renderer/platform-backend':
|
||||
codeBlockPreviewBackendAlias,
|
||||
},
|
||||
},
|
||||
//#endregion
|
||||
|
||||
//#region module config
|
||||
module: {
|
||||
parser: {
|
||||
javascript: {
|
||||
// Do not mock Node.js globals
|
||||
node: false,
|
||||
requireJs: false,
|
||||
import: true,
|
||||
// Treat as missing export as error
|
||||
strictExportPresence: true,
|
||||
},
|
||||
},
|
||||
//#region rules
|
||||
rules: [
|
||||
{ test: /\.m?js?$/, resolve: { fullySpecified: false } },
|
||||
{
|
||||
test: /\.js$/,
|
||||
enforce: 'pre',
|
||||
include: /@blocksuite/,
|
||||
use: ['source-map-loader'],
|
||||
},
|
||||
{
|
||||
oneOf: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'swc-loader',
|
||||
options: {
|
||||
// https://swc.rs/docs/configuring-swc/
|
||||
jsc: {
|
||||
preserveAllComments: true,
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
dynamicImport: true,
|
||||
topLevelAwait: false,
|
||||
tsx: false,
|
||||
decorators: true,
|
||||
},
|
||||
target: 'es2022',
|
||||
externalHelpers: false,
|
||||
transform: {
|
||||
useDefineForClassFields: false,
|
||||
decoratorVersion: '2022-03',
|
||||
},
|
||||
},
|
||||
sourceMaps: true,
|
||||
inlineSourcesContent: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.tsx$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'swc-loader',
|
||||
options: {
|
||||
// https://swc.rs/docs/configuring-swc/
|
||||
jsc: {
|
||||
preserveAllComments: true,
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
dynamicImport: true,
|
||||
topLevelAwait: false,
|
||||
tsx: true,
|
||||
decorators: true,
|
||||
},
|
||||
target: 'es2022',
|
||||
externalHelpers: false,
|
||||
transform: {
|
||||
react: { runtime: 'automatic' },
|
||||
useDefineForClassFields: false,
|
||||
decoratorVersion: '2022-03',
|
||||
},
|
||||
},
|
||||
sourceMaps: true,
|
||||
inlineSourcesContent: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|gif|svg|webp|mp4|zip)$/,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
{ test: /\.(ttf|eot|woff|woff2)$/, type: 'asset/resource' },
|
||||
{ test: /\.txt$/, type: 'asset/source' },
|
||||
{ test: /\.inline\.svg$/, type: 'asset/inline' },
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
buildConfig.debug
|
||||
? 'style-loader'
|
||||
: rspack.CssExtractRspackPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
url: true,
|
||||
sourceMap: false,
|
||||
modules: false,
|
||||
import: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'postcss-loader',
|
||||
options: {
|
||||
postcssOptions: {
|
||||
plugins: hasTailwind
|
||||
? [
|
||||
tailwindPlugin,
|
||||
['autoprefixer'],
|
||||
...(buildConfig.isAdmin
|
||||
? [queuedashScopePostcssPlugin()]
|
||||
: []),
|
||||
]
|
||||
: [
|
||||
cssnano({
|
||||
preset: ['default', { convertValues: false }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
//#endregion
|
||||
},
|
||||
//#endregion
|
||||
|
||||
//#region plugins
|
||||
plugins: compact([
|
||||
!IN_CI && new rspack.ProgressPlugin(),
|
||||
...createHTMLPlugins(buildConfig, htmlConfig),
|
||||
new rspack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
|
||||
...Object.entries(buildConfig).reduce(
|
||||
(def, [k, v]) => {
|
||||
def[`BUILD_CONFIG.${k}`] = JSON.stringify(v);
|
||||
return def;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
}),
|
||||
!buildConfig.debug &&
|
||||
// todo: support multiple entry points
|
||||
new rspack.CssExtractRspackPlugin({
|
||||
filename: `[name].[contenthash:8].css`,
|
||||
ignoreOrder: true,
|
||||
}),
|
||||
new VanillaExtractPlugin(),
|
||||
!buildConfig.isAdmin &&
|
||||
htmlConfig.copySharedPublicAssets &&
|
||||
new rspack.CopyRspackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
// copy the shared public assets into dist
|
||||
from: new Package('@affine/core').join('public').value,
|
||||
},
|
||||
],
|
||||
}),
|
||||
createSentryPlugin(),
|
||||
// sourcemap url like # sourceMappingURL=76-6370cd185962bc89.js.map wont load in electron
|
||||
// this is because the default file:// protocol will be ignored by Chromium
|
||||
// so we need to replace the sourceMappingURL to assets:// protocol
|
||||
// for example:
|
||||
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
|
||||
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
|
||||
buildConfig.isElectron &&
|
||||
new rspack.SourceMapDevToolPlugin({
|
||||
append: (pathData: { filename?: string }) => {
|
||||
return `\n//# sourceMappingURL=assets://./${pathData.filename ?? ''}.map`;
|
||||
},
|
||||
filename: '[file].map',
|
||||
}),
|
||||
]),
|
||||
//#endregion
|
||||
|
||||
stats: { errorDetails: true },
|
||||
|
||||
//#region optimization
|
||||
optimization: {
|
||||
minimize: !buildConfig.debug,
|
||||
minimizer: [
|
||||
new rspack.SwcJsMinimizerRspackPlugin({
|
||||
extractComments: true,
|
||||
minimizerOptions: {
|
||||
ecma: 2020,
|
||||
compress: { unused: true },
|
||||
mangle: { keep_classnames: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
removeEmptyChunks: true,
|
||||
providedExports: true,
|
||||
usedExports: true,
|
||||
sideEffects: true,
|
||||
runtimeChunk: { name: 'runtime' },
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
minSize: 1,
|
||||
minChunks: 1,
|
||||
maxInitialRequests: Number.MAX_SAFE_INTEGER,
|
||||
maxAsyncRequests: Number.MAX_SAFE_INTEGER,
|
||||
cacheGroups: {
|
||||
...productionCacheGroups,
|
||||
// Rspack tends to pull async node_modules into the initial vendor chunk
|
||||
// when `vendor` is configured as `chunks: 'all'`.
|
||||
vendor: {
|
||||
...productionCacheGroups.vendor,
|
||||
chunks: 'initial',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
//#endregion
|
||||
};
|
||||
|
||||
if (buildConfig.debug && !IN_CI) {
|
||||
config.optimization = {
|
||||
...config.optimization,
|
||||
minimize: false,
|
||||
runtimeChunk: false,
|
||||
splitChunks: {
|
||||
maxInitialRequests: Infinity,
|
||||
chunks: 'all',
|
||||
cacheGroups: {
|
||||
defaultVendors: {
|
||||
test: `[\\/]node_modules[\\/](?!.*vanilla-extract)`,
|
||||
priority: -10,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
default: { minChunks: 2, priority: -20, reuseExistingChunk: true },
|
||||
styles: {
|
||||
name: 'styles',
|
||||
type: 'css/mini-extract',
|
||||
chunks: 'all',
|
||||
enforce: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function createWorkerTargetConfig(
|
||||
pkg: Package,
|
||||
entry: string
|
||||
): Omit<RspackConfiguration, 'name'> & { name: string } {
|
||||
const workerName = path.basename(entry).replace(/\.worker\.ts$/, '');
|
||||
const buildConfig = getBuildConfigFromEnv(pkg);
|
||||
|
||||
return {
|
||||
name: entry,
|
||||
context: ProjectRoot.value,
|
||||
experiments: {
|
||||
asyncWebAssembly: true,
|
||||
},
|
||||
entry: { [workerName]: entry },
|
||||
output: {
|
||||
filename: `js/${workerName}-${buildConfig.appVersion}.worker.js`,
|
||||
path: pkg.distPath.value,
|
||||
clean: false,
|
||||
globalObject: 'globalThis',
|
||||
// NOTE: always keep it '/'
|
||||
publicPath: '/',
|
||||
},
|
||||
target: ['webworker', 'es2022'],
|
||||
mode: buildConfig.debug ? 'development' : 'production',
|
||||
devtool: buildConfig.debug ? 'cheap-module-source-map' : 'source-map',
|
||||
resolve: {
|
||||
symlinks: true,
|
||||
extensionAlias: { '.js': ['.js', '.ts'], '.mjs': ['.mjs', '.mts'] },
|
||||
extensions: ['.js', '.ts'],
|
||||
alias: { yjs: ProjectRoot.join('node_modules', 'yjs').value },
|
||||
},
|
||||
|
||||
module: {
|
||||
parser: {
|
||||
javascript: {
|
||||
// Do not mock Node.js globals
|
||||
node: false,
|
||||
requireJs: false,
|
||||
import: true,
|
||||
// Treat as missing export as error
|
||||
strictExportPresence: true,
|
||||
},
|
||||
},
|
||||
rules: [
|
||||
{ test: /\.m?js?$/, resolve: { fullySpecified: false } },
|
||||
{
|
||||
test: /\.js$/,
|
||||
enforce: 'pre',
|
||||
include: /@blocksuite/,
|
||||
use: ['source-map-loader'],
|
||||
},
|
||||
{
|
||||
oneOf: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'swc-loader',
|
||||
options: {
|
||||
// https://swc.rs/docs/configuring-swc/
|
||||
jsc: {
|
||||
preserveAllComments: true,
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
dynamicImport: true,
|
||||
topLevelAwait: false,
|
||||
tsx: false,
|
||||
decorators: true,
|
||||
},
|
||||
target: 'es2022',
|
||||
externalHelpers: false,
|
||||
transform: {
|
||||
useDefineForClassFields: false,
|
||||
decoratorVersion: '2022-03',
|
||||
},
|
||||
},
|
||||
sourceMaps: true,
|
||||
inlineSourcesContent: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: compact([
|
||||
new rspack.DefinePlugin(
|
||||
Object.entries(buildConfig).reduce(
|
||||
(def, [k, v]) => {
|
||||
def[`BUILD_CONFIG.${k}`] = JSON.stringify(v);
|
||||
return def;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
),
|
||||
new rspack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
|
||||
createSentryPlugin(),
|
||||
]),
|
||||
stats: { errorDetails: true },
|
||||
optimization: {
|
||||
minimize: !buildConfig.debug,
|
||||
minimizer: [
|
||||
new rspack.SwcJsMinimizerRspackPlugin({
|
||||
extractComments: true,
|
||||
minimizerOptions: {
|
||||
ecma: 2020,
|
||||
compress: { unused: true },
|
||||
mangle: { keep_classnames: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
removeEmptyChunks: true,
|
||||
providedExports: true,
|
||||
usedExports: true,
|
||||
sideEffects: true,
|
||||
runtimeChunk: false,
|
||||
splitChunks: false,
|
||||
},
|
||||
performance: { hints: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function createNodeTargetConfig(
|
||||
pkg: Package,
|
||||
entry: string,
|
||||
options: {
|
||||
outputFilename?: string;
|
||||
decoratorVersion?: 'legacy' | '2022-03';
|
||||
libraryType?: 'module' | 'commonjs2';
|
||||
bundleAllDependencies?: boolean;
|
||||
forceExternal?: string[];
|
||||
} = {}
|
||||
): Omit<RspackConfiguration, 'name'> & { name: string } {
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
const useLegacyDecorator = options.decoratorVersion !== '2022-03';
|
||||
const forceExternal = options.forceExternal ?? [];
|
||||
return {
|
||||
name: entry,
|
||||
context: ProjectRoot.value,
|
||||
experiments: {
|
||||
asyncWebAssembly: true,
|
||||
},
|
||||
entry: { index: entry },
|
||||
output: {
|
||||
filename: options.outputFilename ?? 'main.js',
|
||||
path: pkg.distPath.value,
|
||||
clean: true,
|
||||
globalObject: 'globalThis',
|
||||
module: pkg.packageJson.type === 'module',
|
||||
...(options.libraryType
|
||||
? { library: { type: options.libraryType } }
|
||||
: {}),
|
||||
},
|
||||
target: ['node', 'es2022'],
|
||||
externals: ((data: any, callback: (err: null, value: boolean) => void) => {
|
||||
if (
|
||||
data.request &&
|
||||
forceExternal.some(
|
||||
dep => data.request === dep || data.request.startsWith(`${dep}/`)
|
||||
)
|
||||
) {
|
||||
callback(null, true);
|
||||
} else if (
|
||||
data.request &&
|
||||
// import ... from 'module'
|
||||
/^[a-zA-Z@]/.test(data.request) &&
|
||||
!options.bundleAllDependencies &&
|
||||
// not workspace deps
|
||||
!pkg.deps.some(dep => data.request!.startsWith(dep.name))
|
||||
) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(null, false);
|
||||
}
|
||||
}) as any,
|
||||
externalsPresets: { node: true },
|
||||
node: { __dirname: false, __filename: false },
|
||||
mode: dev ? 'development' : 'production',
|
||||
devtool: 'source-map',
|
||||
resolve: {
|
||||
symlinks: true,
|
||||
extensionAlias: { '.js': ['.js', '.ts'], '.mjs': ['.mjs', '.mts'] },
|
||||
extensions: ['.js', '.ts', '.tsx', '.node'],
|
||||
alias: { yjs: ProjectRoot.join('node_modules', 'yjs').value },
|
||||
},
|
||||
module: {
|
||||
parser: {
|
||||
javascript: { url: false, importMeta: false, createRequire: false },
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
enforce: 'pre',
|
||||
include: /@blocksuite/,
|
||||
use: ['source-map-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.node$/,
|
||||
loader: Path.dir(import.meta.url).join(
|
||||
'../rspack-shared/node-loader.js'
|
||||
).value,
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'swc-loader',
|
||||
options: {
|
||||
// https://swc.rs/docs/configuring-swc/
|
||||
jsc: {
|
||||
preserveAllComments: true,
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
dynamicImport: true,
|
||||
topLevelAwait: true,
|
||||
tsx: true,
|
||||
decorators: true,
|
||||
},
|
||||
target: 'es2022',
|
||||
externalHelpers: false,
|
||||
transform: {
|
||||
...(useLegacyDecorator
|
||||
? {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
}
|
||||
: {
|
||||
useDefineForClassFields: false,
|
||||
decoratorVersion: '2022-03',
|
||||
}),
|
||||
react: { runtime: 'automatic' },
|
||||
},
|
||||
},
|
||||
sourceMaps: true,
|
||||
inlineSourcesContent: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: compact([
|
||||
new rspack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
|
||||
new rspack.IgnorePlugin({
|
||||
checkResource(resource) {
|
||||
const lazyImports = [
|
||||
'@nestjs/microservices',
|
||||
'@nestjs/websockets/socket-module',
|
||||
'@apollo/subgraph',
|
||||
'@apollo/gateway',
|
||||
'@as-integrations/fastify',
|
||||
'ts-morph',
|
||||
'class-validator',
|
||||
'class-transformer',
|
||||
];
|
||||
return lazyImports.some(lazyImport =>
|
||||
resource.startsWith(lazyImport)
|
||||
);
|
||||
},
|
||||
}),
|
||||
new rspack.DefinePlugin({
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
}),
|
||||
]),
|
||||
stats: { errorDetails: true },
|
||||
optimization: {
|
||||
nodeEnv: false,
|
||||
minimize: !dev,
|
||||
minimizer: [
|
||||
new rspack.SwcJsMinimizerRspackPlugin({
|
||||
extractComments: true,
|
||||
minimizerOptions: {
|
||||
ecma: 2020,
|
||||
compress: { unused: true },
|
||||
mangle: { keep_classnames: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
performance: { hints: false },
|
||||
ignoreWarnings: [/^(?!CriticalDependenciesWarning$)/],
|
||||
};
|
||||
}
|
||||
238
tools/cli/src/run.ts
Normal file
238
tools/cli/src/run.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Path } from '@affine-tools/utils/path';
|
||||
import { execAsync } from '@affine-tools/utils/process';
|
||||
import type { Package, PackageName } from '@affine-tools/utils/workspace';
|
||||
|
||||
import { Option, PackageCommand } from './command';
|
||||
|
||||
interface RunScriptOptions {
|
||||
includeDependencies?: boolean;
|
||||
waitDependencies?: boolean;
|
||||
ignoreIfNotFound?: boolean;
|
||||
}
|
||||
|
||||
const currentDir = Path.dir(import.meta.url);
|
||||
const serverRuntimeLoader = currentDir
|
||||
.join('../register.js')
|
||||
.toFileUrl()
|
||||
.toString();
|
||||
const tsxRuntimeLoader = currentDir
|
||||
.join('../tsx-register.js')
|
||||
.toFileUrl()
|
||||
.toString();
|
||||
|
||||
const ignoreLoaderScripts = [
|
||||
'vitest',
|
||||
'vite',
|
||||
'tsx',
|
||||
'prisma',
|
||||
'cap',
|
||||
'tsc',
|
||||
'typedoc',
|
||||
/^r$/,
|
||||
/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 init\` Generate the required files if there are any package added or removed
|
||||
|
||||
\`affine clean\` Clean the output files of ts, cargo, bundler outputs, 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 'init' for workspace`, '$0 init'],
|
||||
[`Clean dist of each package`, '$0 clean --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: this.waitDeps,
|
||||
});
|
||||
}
|
||||
|
||||
async run(name: PackageName, args: string[], opts: RunScriptOptions = {}) {
|
||||
opts = {
|
||||
includeDependencies: false,
|
||||
waitDependencies: true,
|
||||
ignoreIfNotFound: false,
|
||||
...opts,
|
||||
};
|
||||
|
||||
const pkg = this.workspace.getPackage(name);
|
||||
const scriptName = args[0];
|
||||
const pkgScript = pkg.scripts[scriptName];
|
||||
|
||||
if (pkgScript) {
|
||||
await this.runScript(pkg, scriptName, args.slice(1), opts);
|
||||
} else {
|
||||
await this.runCommand(pkg, args);
|
||||
}
|
||||
}
|
||||
|
||||
async runScript(
|
||||
pkg: Package,
|
||||
scriptName: string,
|
||||
args: string[],
|
||||
opts: RunScriptOptions = {}
|
||||
) {
|
||||
const rawScript = pkg.scripts[scriptName];
|
||||
|
||||
if (!rawScript) {
|
||||
if (opts.ignoreIfNotFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Script ${scriptName} not found in ${pkg.name}`);
|
||||
}
|
||||
|
||||
const rawArgs = [...rawScript.split(' '), ...args];
|
||||
|
||||
const { args: extractedArgs, envs } = this.extractEnvs(rawArgs);
|
||||
|
||||
args = extractedArgs;
|
||||
|
||||
if (opts.includeDependencies) {
|
||||
const depsRun = Promise.all(
|
||||
pkg.deps.map(dep => {
|
||||
return this.runScript(
|
||||
pkg.workspace.getPackage(dep.name),
|
||||
scriptName,
|
||||
[],
|
||||
{
|
||||
...opts,
|
||||
ignoreIfNotFound: true,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
if (opts.waitDependencies) {
|
||||
await depsRun;
|
||||
} else {
|
||||
depsRun.catch(e => {
|
||||
this.logger.error(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isAFFiNECommand = args[0] === 'affine';
|
||||
if (isAFFiNECommand) {
|
||||
// remove 'affine' from 'affine xxx' command
|
||||
args.shift();
|
||||
args.push('-p', pkg.name);
|
||||
|
||||
process.env = {
|
||||
...process.env,
|
||||
...envs,
|
||||
};
|
||||
await this.cli.run(args);
|
||||
} else {
|
||||
await this.runCommand(pkg, rawArgs);
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(pkg: Package, args: string[]) {
|
||||
const { args: extractedArgs, envs } = this.extractEnvs(args);
|
||||
args = extractedArgs;
|
||||
|
||||
const bin = args[0] === 'yarn' ? args[1] : args[0];
|
||||
const loader =
|
||||
pkg.name === '@affine/server' ? serverRuntimeLoader : tsxRuntimeLoader;
|
||||
const hasKnownLoader =
|
||||
process.env.NODE_OPTIONS?.includes('tsx') ||
|
||||
process.env.NODE_OPTIONS?.includes(tsxRuntimeLoader) ||
|
||||
process.env.NODE_OPTIONS?.includes(serverRuntimeLoader);
|
||||
|
||||
// very simple test for auto ts/mjs scripts
|
||||
const isLoaderRequired =
|
||||
!ignoreLoaderScripts.some(ignore => new RegExp(ignore).test(bin)) ||
|
||||
hasKnownLoader ||
|
||||
process.env.NODE_OPTIONS?.includes(loader);
|
||||
|
||||
let NODE_OPTIONS = process.env.NODE_OPTIONS
|
||||
? [process.env.NODE_OPTIONS]
|
||||
: [];
|
||||
|
||||
if (isLoaderRequired) {
|
||||
NODE_OPTIONS.push(`--import=${loader}`);
|
||||
}
|
||||
|
||||
if (args[0] !== 'yarn') {
|
||||
// add 'yarn' to the command so we can bypass bin execution to it
|
||||
args.unshift('yarn');
|
||||
}
|
||||
|
||||
await execAsync(pkg.name, args, {
|
||||
cwd: pkg.path.value,
|
||||
env: {
|
||||
...envs,
|
||||
NODE_OPTIONS: NODE_OPTIONS.join(' '),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private extractEnvs(args: string[]): {
|
||||
args: string[];
|
||||
envs: Record<string, string>;
|
||||
} {
|
||||
const envs: Record<string, string> = {};
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
if (arg === 'cross-env') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = arg.match(/^([A-Z_]+)=(.+)$/);
|
||||
|
||||
if (match) {
|
||||
envs[match[1]] = match[2];
|
||||
i++;
|
||||
} else {
|
||||
// not envs any more
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
args: args.slice(i),
|
||||
envs,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
tools/cli/tsconfig.json
Normal file
13
tools/cli/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../utils" },
|
||||
{ "path": "../../packages/common/s3-compat" }
|
||||
]
|
||||
}
|
||||
1
tools/cli/tsx-register.js
Normal file
1
tools/cli/tsx-register.js
Normal file
@@ -0,0 +1 @@
|
||||
import 'tsx';
|
||||
35
tools/commitlint/.commitlintrc.json
Normal file
35
tools/commitlint/.commitlintrc.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"],
|
||||
"rules": {
|
||||
"scope-enum": [
|
||||
2,
|
||||
"always",
|
||||
[
|
||||
"admin",
|
||||
"electron",
|
||||
"server",
|
||||
"core",
|
||||
"web",
|
||||
"mobile",
|
||||
"ios",
|
||||
"android",
|
||||
"mobile-native",
|
||||
"docs",
|
||||
"component",
|
||||
"env",
|
||||
"graphql",
|
||||
"hooks",
|
||||
"i18n",
|
||||
"native",
|
||||
"templates",
|
||||
"debug",
|
||||
"nbstore",
|
||||
"infra",
|
||||
"editor",
|
||||
"tools",
|
||||
"y-octo",
|
||||
"client"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
3
tools/commitlint/README.md
Normal file
3
tools/commitlint/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# commitlint
|
||||
|
||||
This package defines commitlint and it's dependencies, so that it can be used in a CI pipeline to minimize the installation time.
|
||||
10
tools/commitlint/package.json
Normal file
10
tools/commitlint/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@affine/commitlint-config",
|
||||
"version": "0.27.5",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"commitlint": "^19.6.0"
|
||||
}
|
||||
}
|
||||
19
tools/copilot-result/index.js
Normal file
19
tools/copilot-result/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { WebClient } from '@slack/web-api';
|
||||
|
||||
import { render } from './markdown.js';
|
||||
|
||||
const { CHANNEL_ID, SLACK_BOT_TOKEN, COPILOT_RESULT, BRANCH_SHA, BRANCH_NAME } =
|
||||
process.env;
|
||||
|
||||
const { ok } = await new WebClient(SLACK_BOT_TOKEN).chat.postMessage({
|
||||
channel: CHANNEL_ID,
|
||||
text: `AFFiNE Copilot Test ${COPILOT_RESULT}`,
|
||||
blocks: render(
|
||||
`# AFFiNE Copilot Test ${COPILOT_RESULT}
|
||||
|
||||
- [${BRANCH_NAME?.replace('refs/heads/', '') || BRANCH_SHA}](https://github.com/toeverything/AFFiNE/commit/${BRANCH_SHA})
|
||||
`
|
||||
),
|
||||
});
|
||||
|
||||
console.assert(ok, 'Failed to send a message to Slack');
|
||||
25
tools/copilot-result/markdown.js
Normal file
25
tools/copilot-result/markdown.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { jsxslack } from 'jsx-slack';
|
||||
import { marked, Renderer } from 'marked';
|
||||
|
||||
export const render = markdown => {
|
||||
const rendered = marked(markdown, {
|
||||
renderer: new (class CustomRenderer extends Renderer {
|
||||
heading({ tokens }) {
|
||||
return `
|
||||
<Fragment>
|
||||
<Section><b>${tokens[0].text}</b></Section>
|
||||
<Divider />
|
||||
</Fragment>`;
|
||||
}
|
||||
|
||||
list(token) {
|
||||
return `<Section>${super.list(token)}</Section>`;
|
||||
}
|
||||
|
||||
hr() {
|
||||
return `<Divider />`;
|
||||
}
|
||||
})(),
|
||||
});
|
||||
return jsxslack([`<Blocks>${rendered}</Blocks>`]);
|
||||
};
|
||||
16
tools/copilot-result/package.json
Normal file
16
tools/copilot-result/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@affine/copilot-result",
|
||||
"version": "0.27.5",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"description": "Send copilot result to slack",
|
||||
"dependencies": {
|
||||
"@slack/web-api": "^8.0.0",
|
||||
"jsx-slack": "^6.1.2",
|
||||
"marked": "^15.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
22
tools/doc-diff/package.json
Normal file
22
tools/doc-diff/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@affine/doc-diff",
|
||||
"version": "0.27.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Diff AFFiNE Yjs snapshot docs (rootdoc/folder/favorite)",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"diff": "r ./src/index.ts",
|
||||
"diff:root": "r ./src/index.ts root",
|
||||
"diff:folder": "r ./src/index.ts folder",
|
||||
"diff:favorite": "r ./src/index.ts favorite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2"
|
||||
}
|
||||
}
|
||||
83
tools/doc-diff/src/diff.ts
Normal file
83
tools/doc-diff/src/diff.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { isEqual, MISSING, type PlainValue } from './plain';
|
||||
|
||||
export type KeyedRecord = { [key: string]: PlainValue };
|
||||
|
||||
export type FieldChange = {
|
||||
key: string;
|
||||
from: PlainValue | typeof MISSING;
|
||||
to: PlainValue | typeof MISSING;
|
||||
};
|
||||
|
||||
export type RecordChange = {
|
||||
id: string;
|
||||
fromRecord: KeyedRecord;
|
||||
toRecord: KeyedRecord;
|
||||
fields: FieldChange[];
|
||||
};
|
||||
|
||||
export type KeyedDiff = {
|
||||
added: { id: string; record: KeyedRecord }[];
|
||||
removed: { id: string; record: KeyedRecord }[];
|
||||
changed: RecordChange[];
|
||||
};
|
||||
|
||||
export function diffKeyedRecords(
|
||||
fromRecords: Map<string, KeyedRecord>,
|
||||
toRecords: Map<string, KeyedRecord>
|
||||
): KeyedDiff {
|
||||
const added: { id: string; record: KeyedRecord }[] = [];
|
||||
const removed: { id: string; record: KeyedRecord }[] = [];
|
||||
const changed: RecordChange[] = [];
|
||||
|
||||
const fromIds = new Set(fromRecords.keys());
|
||||
const toIds = new Set(toRecords.keys());
|
||||
|
||||
for (const id of Array.from(toIds).sort()) {
|
||||
if (!fromIds.has(id)) {
|
||||
const record = toRecords.get(id);
|
||||
if (record) {
|
||||
added.push({ id, record });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of Array.from(fromIds).sort()) {
|
||||
if (!toIds.has(id)) {
|
||||
const record = fromRecords.get(id);
|
||||
if (record) {
|
||||
removed.push({ id, record });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of Array.from(fromIds).sort()) {
|
||||
if (!toIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
const fromRecord = fromRecords.get(id);
|
||||
const toRecord = toRecords.get(id);
|
||||
if (!fromRecord || !toRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keys = Array.from(
|
||||
new Set([...Object.keys(fromRecord), ...Object.keys(toRecord)])
|
||||
).sort();
|
||||
const fields: FieldChange[] = [];
|
||||
for (const key of keys) {
|
||||
const fromValue = Object.hasOwn(fromRecord, key)
|
||||
? fromRecord[key]
|
||||
: MISSING;
|
||||
const toValue = Object.hasOwn(toRecord, key) ? toRecord[key] : MISSING;
|
||||
if (!isEqual(fromValue, toValue)) {
|
||||
fields.push({ key, from: fromValue, to: toValue });
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length) {
|
||||
changed.push({ id, fromRecord, toRecord, fields });
|
||||
}
|
||||
}
|
||||
|
||||
return { added, removed, changed };
|
||||
}
|
||||
143
tools/doc-diff/src/index.ts
Normal file
143
tools/doc-diff/src/index.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { getDisplayLabel, readYjsDocFromFile } from './io';
|
||||
import { extractRootDocPagesMeta, printRootDocPairDiff } from './rootdoc';
|
||||
import {
|
||||
extractYjsTable,
|
||||
printFavoritePairDiff,
|
||||
printFolderPairDiff,
|
||||
} from './table';
|
||||
|
||||
type Mode = 'rootdoc' | 'folder' | 'favorite';
|
||||
|
||||
const HELP_TEXT = `
|
||||
Diff AFFiNE Yjs snapshot docs between multiple binaries.
|
||||
|
||||
Usage:
|
||||
# Root doc: diff meta.pages
|
||||
r ./tools/doc-diff/index.ts <rootDoc1.bin> <rootDoc2.bin> [...more]
|
||||
r ./tools/doc-diff/index.ts root <rootDoc1.bin> <rootDoc2.bin> [...more]
|
||||
|
||||
# Organize: diff db$...$folder (table doc)
|
||||
r ./tools/doc-diff/index.ts folder <folderDoc1.bin> <folderDoc2.bin> [...more]
|
||||
|
||||
# Favorites: diff userdata$...$favorite (table doc)
|
||||
r ./tools/doc-diff/index.ts favorite <favoriteDoc1.bin> <favoriteDoc2.bin> [...more]
|
||||
|
||||
Notes:
|
||||
- Every argument after the optional subcommand is treated as a file path (relative or absolute).
|
||||
- Files must be the same doc type (no mixing).
|
||||
`.trim();
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
console.error('');
|
||||
console.error(HELP_TEXT);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseMode(value: string): Mode | null {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'root':
|
||||
case 'rootdoc':
|
||||
case 'meta':
|
||||
return 'rootdoc';
|
||||
case 'folder':
|
||||
case 'folders':
|
||||
return 'folder';
|
||||
case 'favorite':
|
||||
case 'favourite':
|
||||
case 'favorites':
|
||||
return 'favorite';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
if (argv.some(arg => arg === '-h' || arg === '--help')) {
|
||||
console.log(HELP_TEXT);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let mode: Mode = 'rootdoc';
|
||||
let cursor = 0;
|
||||
const maybeMode = argv[cursor];
|
||||
if (maybeMode) {
|
||||
if (maybeMode.startsWith('-')) {
|
||||
fail(`Unknown argument: ${maybeMode}`);
|
||||
}
|
||||
const parsed = parseMode(maybeMode);
|
||||
if (parsed) {
|
||||
mode = parsed;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const files = argv.slice(cursor);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('-')) {
|
||||
fail(`Unknown argument: ${file}`);
|
||||
}
|
||||
}
|
||||
if (files.length < 2) {
|
||||
fail('Please provide at least two snapshot file paths.');
|
||||
}
|
||||
|
||||
return { mode, files };
|
||||
}
|
||||
|
||||
const { mode, files } = parseArgs(process.argv.slice(2));
|
||||
const resolvedFiles = files.map(f => path.resolve(process.cwd(), f));
|
||||
|
||||
const docs = resolvedFiles.map(filePath => {
|
||||
try {
|
||||
return readYjsDocFromFile(filePath);
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
return fail(`Failed to read/parse snapshot file "${filePath}": ${details}`);
|
||||
}
|
||||
});
|
||||
|
||||
switch (mode) {
|
||||
case 'rootdoc': {
|
||||
const metas = docs.map(extractRootDocPagesMeta);
|
||||
for (let i = 0; i < files.length - 1; i += 1) {
|
||||
printRootDocPairDiff({
|
||||
fromLabel: getDisplayLabel(files[i]!),
|
||||
toLabel: getDisplayLabel(files[i + 1]!),
|
||||
fromMeta: metas[i]!,
|
||||
toMeta: metas[i + 1]!,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'folder': {
|
||||
const tables = docs.map(doc => extractYjsTable(doc, 'id'));
|
||||
for (let i = 0; i < files.length - 1; i += 1) {
|
||||
printFolderPairDiff({
|
||||
fromLabel: getDisplayLabel(files[i]!),
|
||||
toLabel: getDisplayLabel(files[i + 1]!),
|
||||
fromTable: tables[i]!,
|
||||
toTable: tables[i + 1]!,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'favorite': {
|
||||
const tables = docs.map(doc => extractYjsTable(doc, 'key'));
|
||||
for (let i = 0; i < files.length - 1; i += 1) {
|
||||
printFavoritePairDiff({
|
||||
fromLabel: getDisplayLabel(files[i]!),
|
||||
toLabel: getDisplayLabel(files[i + 1]!),
|
||||
fromTable: tables[i]!,
|
||||
toTable: tables[i + 1]!,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const unreachableMode: never = mode;
|
||||
fail(`Unknown mode: ${unreachableMode}`);
|
||||
}
|
||||
}
|
||||
19
tools/doc-diff/src/io.ts
Normal file
19
tools/doc-diff/src/io.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { applyUpdate, Doc } from 'yjs';
|
||||
|
||||
export function getDisplayLabel(inputPath: string) {
|
||||
const resolved = path.resolve(process.cwd(), inputPath);
|
||||
if (resolved === inputPath) {
|
||||
return inputPath;
|
||||
}
|
||||
return `${inputPath} (${resolved})`;
|
||||
}
|
||||
|
||||
export function readYjsDocFromFile(filePath: string): Doc {
|
||||
const bin = readFileSync(filePath);
|
||||
const doc = new Doc();
|
||||
applyUpdate(doc, new Uint8Array(bin));
|
||||
return doc;
|
||||
}
|
||||
136
tools/doc-diff/src/plain.ts
Normal file
136
tools/doc-diff/src/plain.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Array as YArray, Doc, Map as YMap, Text } from 'yjs';
|
||||
|
||||
export type PlainValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| PlainValue[]
|
||||
| { [key: string]: PlainValue };
|
||||
|
||||
export const MISSING: unique symbol = Symbol('missing');
|
||||
|
||||
export function toPlain(value: unknown): PlainValue {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'number':
|
||||
case 'string':
|
||||
return value;
|
||||
case 'undefined':
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Text) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Doc) {
|
||||
return { __type: 'YDoc', guid: value.guid };
|
||||
}
|
||||
|
||||
if (value instanceof YArray) {
|
||||
return value.toArray().map(toPlain);
|
||||
}
|
||||
|
||||
if (value instanceof YMap) {
|
||||
const keys = Array.from(value.keys()).sort();
|
||||
const obj: Record<string, PlainValue> = {};
|
||||
for (const key of keys) {
|
||||
obj[key] = toPlain(value.get(key));
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (value instanceof Uint8Array) {
|
||||
return Buffer.from(value).toString('base64');
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(toPlain);
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
const obj: Record<string, PlainValue> = {};
|
||||
for (const key of keys) {
|
||||
obj[key] = toPlain(record[key]);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function stableComparable(value: unknown): unknown {
|
||||
if (value === MISSING) {
|
||||
return { __missing: true };
|
||||
}
|
||||
if (value === null || typeof value !== 'object') {
|
||||
if (value === undefined) {
|
||||
return { __undefined: true };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
return { __uint8array_base64: Buffer.from(value).toString('base64') };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableComparable);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
obj[key] = stableComparable(record[key]);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function isEqual(a: unknown, b: unknown): boolean {
|
||||
return (
|
||||
JSON.stringify(stableComparable(a)) === JSON.stringify(stableComparable(b))
|
||||
);
|
||||
}
|
||||
|
||||
export function truncate(s: string, maxLen = 200) {
|
||||
if (s.length <= maxLen) {
|
||||
return s;
|
||||
}
|
||||
return `${s.slice(0, Math.max(0, maxLen - 3))}...`;
|
||||
}
|
||||
|
||||
function isLikelyTimestampKey(key: string) {
|
||||
const lower = key.toLowerCase();
|
||||
return (
|
||||
lower.endsWith('date') ||
|
||||
lower.endsWith('at') ||
|
||||
lower === 'created' ||
|
||||
lower === 'updated' ||
|
||||
lower.includes('timestamp')
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimestampMillis(ms: number) {
|
||||
if (!Number.isFinite(ms)) {
|
||||
return String(ms);
|
||||
}
|
||||
const date = new Date(ms);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(ms);
|
||||
}
|
||||
return `${ms} (${date.toISOString()})`;
|
||||
}
|
||||
|
||||
export function formatValue(key: string, value: PlainValue | typeof MISSING) {
|
||||
if (value === MISSING) {
|
||||
return '<missing>';
|
||||
}
|
||||
if (typeof value === 'number' && isLikelyTimestampKey(key)) {
|
||||
return formatTimestampMillis(value);
|
||||
}
|
||||
return truncate(JSON.stringify(value));
|
||||
}
|
||||
145
tools/doc-diff/src/rootdoc.ts
Normal file
145
tools/doc-diff/src/rootdoc.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Doc } from 'yjs';
|
||||
import { Array as YArray, Map as YMap } from 'yjs';
|
||||
|
||||
import { diffKeyedRecords, type KeyedRecord } from './diff';
|
||||
import { formatValue, toPlain } from './plain';
|
||||
|
||||
type RootDocMetaExtract = {
|
||||
recordsById: Map<string, KeyedRecord>;
|
||||
duplicateIds: string[];
|
||||
};
|
||||
|
||||
export function extractRootDocPagesMeta(rootDoc: Doc): RootDocMetaExtract {
|
||||
const recordsById = new Map<string, KeyedRecord>();
|
||||
const duplicateIds: string[] = [];
|
||||
|
||||
const meta = rootDoc.getMap('meta');
|
||||
const pages = meta.get('pages');
|
||||
const entries =
|
||||
pages instanceof YArray
|
||||
? pages.toArray()
|
||||
: Array.isArray(pages)
|
||||
? pages
|
||||
: null;
|
||||
if (!entries) {
|
||||
return { recordsById, duplicateIds };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
let id: string | null = null;
|
||||
if (entry instanceof YMap) {
|
||||
const idRaw = entry.get('id');
|
||||
id =
|
||||
typeof idRaw === 'string'
|
||||
? idRaw
|
||||
: typeof idRaw?.toString === 'function'
|
||||
? idRaw.toString()
|
||||
: null;
|
||||
} else if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
const record = entry as Record<string, unknown>;
|
||||
id =
|
||||
typeof record.id === 'string'
|
||||
? record.id
|
||||
: (record.id?.toString?.() ?? null);
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recordsById.has(id)) {
|
||||
duplicateIds.push(id);
|
||||
}
|
||||
|
||||
const metaPlain = toPlain(entry) as unknown;
|
||||
if (
|
||||
metaPlain &&
|
||||
typeof metaPlain === 'object' &&
|
||||
!Array.isArray(metaPlain)
|
||||
) {
|
||||
recordsById.set(id, metaPlain as KeyedRecord);
|
||||
}
|
||||
}
|
||||
|
||||
return { recordsById, duplicateIds };
|
||||
}
|
||||
|
||||
function docLabel(id: string, meta: KeyedRecord) {
|
||||
const title = meta.title;
|
||||
if (typeof title === 'string' && title.trim()) {
|
||||
return `${id} "${title}"`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function printRootDocPairDiff(opts: {
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
fromMeta: RootDocMetaExtract;
|
||||
toMeta: RootDocMetaExtract;
|
||||
}) {
|
||||
const diff = diffKeyedRecords(
|
||||
opts.fromMeta.recordsById,
|
||||
opts.toMeta.recordsById
|
||||
);
|
||||
|
||||
console.log(`\n=== ${opts.fromLabel} -> ${opts.toLabel} ===`);
|
||||
console.log(
|
||||
`Docs: ${opts.fromMeta.recordsById.size} -> ${opts.toMeta.recordsById.size} (+${diff.added.length} / -${diff.removed.length} / ~${diff.changed.length})`
|
||||
);
|
||||
|
||||
if (opts.fromMeta.duplicateIds.length) {
|
||||
console.log(
|
||||
`! Warning: duplicate page ids in FROM: ${Array.from(new Set(opts.fromMeta.duplicateIds)).sort().join(', ')}`
|
||||
);
|
||||
}
|
||||
if (opts.toMeta.duplicateIds.length) {
|
||||
console.log(
|
||||
`! Warning: duplicate page ids in TO: ${Array.from(new Set(opts.toMeta.duplicateIds)).sort().join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (diff.added.length) {
|
||||
console.log(`\n+ Added (${diff.added.length})`);
|
||||
for (const { id, record } of diff.added) {
|
||||
console.log(` + ${docLabel(id, record)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.removed.length) {
|
||||
console.log(`\n- Removed (${diff.removed.length})`);
|
||||
for (const { id, record } of diff.removed) {
|
||||
console.log(` - ${docLabel(id, record)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.changed.length) {
|
||||
console.log(`\n~ Changed (${diff.changed.length})`);
|
||||
for (const change of diff.changed) {
|
||||
const fromTitle = change.fromRecord.title;
|
||||
const toTitle = change.toRecord.title;
|
||||
|
||||
let header = ` ~ ${change.id}`;
|
||||
if (
|
||||
typeof fromTitle === 'string' &&
|
||||
typeof toTitle === 'string' &&
|
||||
fromTitle !== toTitle
|
||||
) {
|
||||
header += ` "${fromTitle}" -> "${toTitle}"`;
|
||||
} else if (typeof toTitle === 'string' && toTitle.trim()) {
|
||||
header += ` "${toTitle}"`;
|
||||
}
|
||||
console.log(header);
|
||||
|
||||
for (const field of change.fields) {
|
||||
console.log(
|
||||
` - ${field.key}: ${formatValue(field.key, field.from)} -> ${formatValue(field.key, field.to)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!diff.added.length && !diff.removed.length && !diff.changed.length) {
|
||||
console.log('\n(no changes)');
|
||||
}
|
||||
}
|
||||
227
tools/doc-diff/src/table.ts
Normal file
227
tools/doc-diff/src/table.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { Doc } from 'yjs';
|
||||
import { Map as YMap } from 'yjs';
|
||||
|
||||
import { diffKeyedRecords, type KeyedRecord } from './diff';
|
||||
import { formatValue, toPlain } from './plain';
|
||||
|
||||
const DELETE_FLAG_KEY = '$$DELETED';
|
||||
|
||||
type TableExtract = {
|
||||
recordsByKey: Map<string, KeyedRecord>;
|
||||
duplicateKeys: string[];
|
||||
};
|
||||
|
||||
function getRecordKey(record: YMap<any>, keyField: string): string | null {
|
||||
const keyRaw = record.get(keyField);
|
||||
if (typeof keyRaw === 'string') {
|
||||
return keyRaw;
|
||||
}
|
||||
if (typeof keyRaw?.toString === 'function') {
|
||||
return keyRaw.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDeletedRecord(record: YMap<any>): boolean {
|
||||
return record.get(DELETE_FLAG_KEY) === true || record.size === 0;
|
||||
}
|
||||
|
||||
export function extractYjsTable(doc: Doc, keyField: string): TableExtract {
|
||||
const recordsByKey = new Map<string, KeyedRecord>();
|
||||
const duplicateKeys: string[] = [];
|
||||
|
||||
for (const sharedKey of doc.share.keys()) {
|
||||
let record: unknown;
|
||||
try {
|
||||
record = doc.getMap(sharedKey);
|
||||
} catch {
|
||||
// Not a YMap shared type; ignore.
|
||||
continue;
|
||||
}
|
||||
if (!(record instanceof YMap)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = getRecordKey(record, keyField);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDeletedRecord(record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recordsByKey.has(key)) {
|
||||
duplicateKeys.push(key);
|
||||
}
|
||||
|
||||
const recordPlain = toPlain(record) as unknown;
|
||||
if (
|
||||
recordPlain &&
|
||||
typeof recordPlain === 'object' &&
|
||||
!Array.isArray(recordPlain)
|
||||
) {
|
||||
recordsByKey.set(key, recordPlain as KeyedRecord);
|
||||
}
|
||||
}
|
||||
|
||||
return { recordsByKey, duplicateKeys };
|
||||
}
|
||||
|
||||
function folderLabel(id: string, record: KeyedRecord) {
|
||||
const type = record.type;
|
||||
const data = record.data;
|
||||
if (type === 'folder') {
|
||||
const name = typeof data === 'string' && data.trim() ? `"${data}"` : '';
|
||||
return `folder:${id}${name ? ` ${name}` : ''}`;
|
||||
}
|
||||
if (typeof type === 'string' && typeof data === 'string') {
|
||||
return `${type}:${data} (id=${id})`;
|
||||
}
|
||||
return `id=${id}`;
|
||||
}
|
||||
|
||||
function favoriteLabel(key: string) {
|
||||
return key;
|
||||
}
|
||||
|
||||
export function printFolderPairDiff(opts: {
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
fromTable: TableExtract;
|
||||
toTable: TableExtract;
|
||||
}) {
|
||||
const diff = diffKeyedRecords(
|
||||
opts.fromTable.recordsByKey,
|
||||
opts.toTable.recordsByKey
|
||||
);
|
||||
|
||||
console.log(`\n=== ${opts.fromLabel} -> ${opts.toLabel} ===`);
|
||||
console.log(
|
||||
`Rows: ${opts.fromTable.recordsByKey.size} -> ${opts.toTable.recordsByKey.size} (+${diff.added.length} / -${diff.removed.length} / ~${diff.changed.length})`
|
||||
);
|
||||
|
||||
if (opts.fromTable.duplicateKeys.length) {
|
||||
console.log(
|
||||
`! Warning: duplicate keys in FROM: ${Array.from(new Set(opts.fromTable.duplicateKeys)).sort().join(', ')}`
|
||||
);
|
||||
}
|
||||
if (opts.toTable.duplicateKeys.length) {
|
||||
console.log(
|
||||
`! Warning: duplicate keys in TO: ${Array.from(new Set(opts.toTable.duplicateKeys)).sort().join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (diff.added.length) {
|
||||
console.log(`\n+ Added (${diff.added.length})`);
|
||||
for (const { id, record } of diff.added) {
|
||||
const parentId = record.parentId;
|
||||
const index = record.index;
|
||||
console.log(
|
||||
` + ${folderLabel(id, record)} (parentId=${formatValue('parentId', parentId ?? null)}, index=${formatValue('index', index ?? null)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.removed.length) {
|
||||
console.log(`\n- Removed (${diff.removed.length})`);
|
||||
for (const { id, record } of diff.removed) {
|
||||
const parentId = record.parentId;
|
||||
const index = record.index;
|
||||
console.log(
|
||||
` - ${folderLabel(id, record)} (parentId=${formatValue('parentId', parentId ?? null)}, index=${formatValue('index', index ?? null)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.changed.length) {
|
||||
console.log(`\n~ Changed (${diff.changed.length})`);
|
||||
for (const change of diff.changed) {
|
||||
const fromName =
|
||||
change.fromRecord.type === 'folder' ? change.fromRecord.data : null;
|
||||
const toName =
|
||||
change.toRecord.type === 'folder' ? change.toRecord.data : null;
|
||||
let header = ` ~ ${folderLabel(change.id, change.toRecord)}`;
|
||||
if (
|
||||
typeof fromName === 'string' &&
|
||||
typeof toName === 'string' &&
|
||||
fromName !== toName
|
||||
) {
|
||||
header += ` ("${fromName}" -> "${toName}")`;
|
||||
}
|
||||
console.log(header);
|
||||
|
||||
for (const field of change.fields) {
|
||||
console.log(
|
||||
` - ${field.key}: ${formatValue(field.key, field.from)} -> ${formatValue(field.key, field.to)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!diff.added.length && !diff.removed.length && !diff.changed.length) {
|
||||
console.log('\n(no changes)');
|
||||
}
|
||||
}
|
||||
|
||||
export function printFavoritePairDiff(opts: {
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
fromTable: TableExtract;
|
||||
toTable: TableExtract;
|
||||
}) {
|
||||
const diff = diffKeyedRecords(
|
||||
opts.fromTable.recordsByKey,
|
||||
opts.toTable.recordsByKey
|
||||
);
|
||||
|
||||
console.log(`\n=== ${opts.fromLabel} -> ${opts.toLabel} ===`);
|
||||
console.log(
|
||||
`Rows: ${opts.fromTable.recordsByKey.size} -> ${opts.toTable.recordsByKey.size} (+${diff.added.length} / -${diff.removed.length} / ~${diff.changed.length})`
|
||||
);
|
||||
|
||||
if (opts.fromTable.duplicateKeys.length) {
|
||||
console.log(
|
||||
`! Warning: duplicate keys in FROM: ${Array.from(new Set(opts.fromTable.duplicateKeys)).sort().join(', ')}`
|
||||
);
|
||||
}
|
||||
if (opts.toTable.duplicateKeys.length) {
|
||||
console.log(
|
||||
`! Warning: duplicate keys in TO: ${Array.from(new Set(opts.toTable.duplicateKeys)).sort().join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (diff.added.length) {
|
||||
console.log(`\n+ Added (${diff.added.length})`);
|
||||
for (const { id, record } of diff.added) {
|
||||
console.log(
|
||||
` + ${favoriteLabel(id)} (index=${formatValue('index', record.index ?? null)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.removed.length) {
|
||||
console.log(`\n- Removed (${diff.removed.length})`);
|
||||
for (const { id, record } of diff.removed) {
|
||||
console.log(
|
||||
` - ${favoriteLabel(id)} (index=${formatValue('index', record.index ?? null)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.changed.length) {
|
||||
console.log(`\n~ Changed (${diff.changed.length})`);
|
||||
for (const change of diff.changed) {
|
||||
console.log(` ~ ${favoriteLabel(change.id)}`);
|
||||
for (const field of change.fields) {
|
||||
console.log(
|
||||
` - ${field.key}: ${formatValue(field.key, field.from)} -> ${formatValue(field.key, field.to)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!diff.added.length && !diff.removed.length && !diff.changed.length) {
|
||||
console.log('\n(no changes)');
|
||||
}
|
||||
}
|
||||
8
tools/doc-diff/tsconfig.json
Normal file
8
tools/doc-diff/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [{ "path": "../cli" }]
|
||||
}
|
||||
76
tools/playstore-auto-bump/index.ts
Normal file
76
tools/playstore-auto-bump/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { Package } from '@affine-tools/utils/workspace';
|
||||
import {
|
||||
androidpublisher_v3,
|
||||
auth as google_auth,
|
||||
} from '@googleapis/androidpublisher';
|
||||
|
||||
export async function fetchVersionCode(applicationId: string): Promise<number> {
|
||||
const auth = new google_auth.GoogleAuth({
|
||||
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
|
||||
});
|
||||
const androidPublisher = new androidpublisher_v3.Androidpublisher({
|
||||
auth,
|
||||
});
|
||||
const appEdit = await androidPublisher.edits.insert({
|
||||
packageName: applicationId,
|
||||
requestBody: {
|
||||
// 20min
|
||||
expiryTimeSeconds: (Math.floor(Date.now() / 1000) + 12000).toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!appEdit.data.id) {
|
||||
throw new Error('Failed to create edit');
|
||||
}
|
||||
|
||||
const lists = await androidPublisher.edits.bundles.list({
|
||||
packageName: applicationId,
|
||||
editId: appEdit.data.id,
|
||||
});
|
||||
|
||||
let versionCode: number = 1;
|
||||
try {
|
||||
versionCode =
|
||||
lists.data.bundles?.[lists.data.bundles.length - 1].versionCode || 1;
|
||||
} catch {}
|
||||
|
||||
console.info(`Remote version code: ${versionCode}`);
|
||||
|
||||
console.info(`Writing edit ID to ${process.env.GITHUB_OUTPUT}`);
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
execSync(
|
||||
`echo "EDIT_ID=${appEdit.data.id}" >> ${process.env.GITHUB_OUTPUT}`,
|
||||
{
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return versionCode;
|
||||
}
|
||||
|
||||
const versionCodeRegexPattern = /(versionCode(?:\s|=)*)(.*)/;
|
||||
|
||||
const gradlePath = new Package('@affine/android').join(
|
||||
'App/app/build.gradle'
|
||||
).value;
|
||||
|
||||
let gradleVersionCode = 0;
|
||||
|
||||
const gradleFile = fs.readFileSync(gradlePath, 'utf8');
|
||||
const matched = gradleFile.match(versionCodeRegexPattern);
|
||||
|
||||
const remoteVersion = await fetchVersionCode('app.affine.pro');
|
||||
|
||||
gradleVersionCode = parseInt(matched?.[2] || '0');
|
||||
gradleVersionCode = isNaN(gradleVersionCode) ? 0 : gradleVersionCode;
|
||||
const versionCode = Math.max(gradleVersionCode, remoteVersion) + 1;
|
||||
|
||||
fs.writeFileSync(
|
||||
gradlePath,
|
||||
gradleFile.replace(versionCodeRegexPattern, `$1 ${versionCode}`)
|
||||
);
|
||||
20
tools/playstore-auto-bump/package.json
Normal file
20
tools/playstore-auto-bump/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@affine/playstore-auto-bump",
|
||||
"version": "0.27.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Automatically bump the versionCode and versionName of an Android app from the Google Play Store versions",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"bump": "r ./index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"@googleapis/androidpublisher": "^36.0.0",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
8
tools/playstore-auto-bump/tsconfig.json
Normal file
8
tools/playstore-auto-bump/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"files": ["index.ts"],
|
||||
"references": [{ "path": "../cli" }, { "path": "../utils" }]
|
||||
}
|
||||
180
tools/revert-update/index.ts
Normal file
180
tools/revert-update/index.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
applyUpdate,
|
||||
Doc,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateVector,
|
||||
UndoManager,
|
||||
} from 'yjs';
|
||||
|
||||
type InputFormat = 'file' | 'base64' | 'hex';
|
||||
type OutputFormat = 'bin' | 'base64' | 'hex';
|
||||
|
||||
const HELP_TEXT = `
|
||||
Generate a revert update from two Yjs snapshot binaries.
|
||||
|
||||
Usage:
|
||||
r ./tools/revert-update/index.ts --from <value> --to <value> [options]
|
||||
|
||||
Options:
|
||||
--from <value> Newer snapshot input (path/base64/hex)
|
||||
--to <value> Older snapshot input (path/base64/hex)
|
||||
--from-format <fmt> file|base64|hex (default: file)
|
||||
--to-format <fmt> file|base64|hex (default: file)
|
||||
--out <path> Output path (default: stdout)
|
||||
--out-format <fmt> bin|base64|hex (default: base64 if stdout, bin if file)
|
||||
-h, --help Show help
|
||||
|
||||
Examples:
|
||||
r ./tools/revert-update/index.ts --from ./from.bin --to ./to.bin --out ./revert.bin
|
||||
r ./tools/revert-update/index.ts --from "$FROM" --from-format base64 --to "$TO" --to-format base64
|
||||
`;
|
||||
|
||||
function generateRevertUpdate(
|
||||
fromNewerBin: Uint8Array,
|
||||
toOlderBin: Uint8Array
|
||||
): Uint8Array {
|
||||
const newerDoc = new Doc();
|
||||
applyUpdate(newerDoc, fromNewerBin);
|
||||
const olderDoc = new Doc();
|
||||
applyUpdate(olderDoc, toOlderBin);
|
||||
|
||||
const newerState = encodeStateVector(newerDoc);
|
||||
const olderState = encodeStateVector(olderDoc);
|
||||
|
||||
const diff = encodeStateAsUpdate(newerDoc, olderState);
|
||||
const undoManager = new UndoManager(Array.from(olderDoc.share.values()));
|
||||
|
||||
applyUpdate(olderDoc, diff);
|
||||
undoManager.undo();
|
||||
|
||||
return encodeStateAsUpdate(olderDoc, newerState);
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
console.error(HELP_TEXT.trim());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseInputFormat(value: string, flag: string): InputFormat {
|
||||
switch (value) {
|
||||
case 'file':
|
||||
case 'base64':
|
||||
case 'hex':
|
||||
return value;
|
||||
default:
|
||||
fail(`Unknown ${flag} value: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseOutputFormat(value: string, flag: string): OutputFormat {
|
||||
switch (value) {
|
||||
case 'bin':
|
||||
case 'base64':
|
||||
case 'hex':
|
||||
return value;
|
||||
default:
|
||||
fail(`Unknown ${flag} value: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args = new Map<string, string>();
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
console.log(HELP_TEXT.trim());
|
||||
process.exit(0);
|
||||
}
|
||||
if (!arg.startsWith('--')) {
|
||||
fail(`Unknown argument: ${arg}`);
|
||||
}
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
fail(`Missing value for ${arg}`);
|
||||
}
|
||||
args.set(arg, value);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const from = args.get('--from');
|
||||
const to = args.get('--to');
|
||||
if (!from || !to) {
|
||||
fail('Both --from and --to are required.');
|
||||
}
|
||||
|
||||
const fromFormat = parseInputFormat(
|
||||
(args.get('--from-format') ?? 'file').toLowerCase(),
|
||||
'--from-format'
|
||||
);
|
||||
const toFormat = parseInputFormat(
|
||||
(args.get('--to-format') ?? 'file').toLowerCase(),
|
||||
'--to-format'
|
||||
);
|
||||
|
||||
const outPath = args.get('--out');
|
||||
const defaultOutFormat = outPath ? 'bin' : 'base64';
|
||||
const outFormat = parseOutputFormat(
|
||||
(args.get('--out-format') ?? defaultOutFormat).toLowerCase(),
|
||||
'--out-format'
|
||||
);
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
fromFormat,
|
||||
toFormat,
|
||||
outPath,
|
||||
outFormat,
|
||||
};
|
||||
}
|
||||
|
||||
function readInput(value: string, format: InputFormat): Uint8Array {
|
||||
try {
|
||||
if (format === 'file') {
|
||||
return new Uint8Array(readFileSync(value));
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
const decoded = Buffer.from(trimmed, format === 'hex' ? 'hex' : 'base64');
|
||||
return new Uint8Array(decoded);
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
fail(`Failed to read ${format} input: ${details}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeOutput(
|
||||
update: Uint8Array,
|
||||
outPath: string | undefined,
|
||||
format: OutputFormat
|
||||
) {
|
||||
if (outPath) {
|
||||
if (format === 'bin') {
|
||||
writeFileSync(outPath, update);
|
||||
return;
|
||||
}
|
||||
const encoded = Buffer.from(update).toString(format);
|
||||
writeFileSync(outPath, encoded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === 'bin') {
|
||||
process.stdout.write(Buffer.from(update));
|
||||
return;
|
||||
}
|
||||
const encoded = Buffer.from(update).toString(format);
|
||||
process.stdout.write(`${encoded}\n`);
|
||||
}
|
||||
|
||||
const { from, to, fromFormat, toFormat, outPath, outFormat } = parseArgs(
|
||||
process.argv.slice(2)
|
||||
);
|
||||
|
||||
const fromBin = readInput(from, fromFormat);
|
||||
const toBin = readInput(to, toFormat);
|
||||
|
||||
const revertUpdate = generateRevertUpdate(fromBin, toBin);
|
||||
|
||||
writeOutput(revertUpdate, outPath, outFormat);
|
||||
19
tools/revert-update/package.json
Normal file
19
tools/revert-update/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@affine/revert-update",
|
||||
"version": "0.27.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Generate a revert update from two Yjs snapshot binaries",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"generate": "r ./index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2"
|
||||
}
|
||||
}
|
||||
8
tools/revert-update/tsconfig.json
Normal file
8
tools/revert-update/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"files": ["index.ts"],
|
||||
"references": [{ "path": "../cli" }]
|
||||
}
|
||||
23
tools/utils/package.json
Normal file
23
tools/utils/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@affine-tools/utils",
|
||||
"version": "0.27.5",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./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": "^22.0.0",
|
||||
"chalk": "^5.3.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"oxfmt": "0.63.0",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2"
|
||||
}
|
||||
}
|
||||
100
tools/utils/src/build-config.ts
Normal file
100
tools/utils/src/build-config.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Package } from '@affine-tools/utils/workspace';
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
const buildPreset: Record<BuildFlags['channel'], BUILD_CONFIG_TYPE> = {
|
||||
get stable() {
|
||||
return {
|
||||
debug: buildFlags.mode === 'development',
|
||||
distribution,
|
||||
isDesktopEdition: (
|
||||
['web', 'desktop', 'admin'] as BUILD_CONFIG_TYPE['distribution'][]
|
||||
).includes(distribution),
|
||||
isMobileEdition: (
|
||||
['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',
|
||||
isNative:
|
||||
distribution === 'desktop' ||
|
||||
distribution === 'ios' ||
|
||||
distribution === 'android',
|
||||
isAdmin: distribution === 'admin',
|
||||
|
||||
appBuildType: 'stable' as const,
|
||||
appVersion: pkg.version,
|
||||
// editorVersion: pkg.dependencies['@blocksuite/affine'],
|
||||
editorVersion: pkg.version,
|
||||
githubUrl: 'https://github.com/toeverything/AFFiNE',
|
||||
changelogUrl: 'https://affine.pro/what-is-new',
|
||||
downloadUrl: 'https://affine.pro/download',
|
||||
pricingUrl: 'https://affine.pro/pricing',
|
||||
discordUrl: 'https://affine.pro/redirect/discord',
|
||||
requestLicenseUrl: 'https://affine.pro/redirect/license',
|
||||
imageProxyUrl: '/api/worker/image-proxy',
|
||||
linkPreviewUrl: '/api/worker/link-preview',
|
||||
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
||||
};
|
||||
},
|
||||
get beta() {
|
||||
return {
|
||||
...this.stable,
|
||||
appBuildType: 'beta' as const,
|
||||
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
||||
};
|
||||
},
|
||||
get internal() {
|
||||
return {
|
||||
...this.stable,
|
||||
appBuildType: 'internal' as const,
|
||||
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
||||
};
|
||||
},
|
||||
// canary will be aggressive and enable all features
|
||||
get canary() {
|
||||
return {
|
||||
...this.stable,
|
||||
appBuildType: 'canary' as const,
|
||||
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const currentBuild = buildFlags.channel;
|
||||
|
||||
if (!(currentBuild in buildPreset)) {
|
||||
throw new Error(`BUILD_TYPE ${currentBuild} is not supported`);
|
||||
}
|
||||
|
||||
const currentBuildPreset = buildPreset[currentBuild];
|
||||
|
||||
const environmentPreset = {
|
||||
changelogUrl: process.env.CHANGELOG_URL ?? currentBuildPreset.changelogUrl,
|
||||
};
|
||||
|
||||
return {
|
||||
...currentBuildPreset,
|
||||
// environment preset will overwrite current build preset
|
||||
// this environment variable is for debug proposes only
|
||||
// do not put them into CI
|
||||
...(process.env.CI ? {} : environmentPreset),
|
||||
};
|
||||
}
|
||||
30
tools/utils/src/distribution.ts
Normal file
30
tools/utils/src/distribution.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PackageList, type PackageName } from './yarn';
|
||||
|
||||
export const PackageToDistribution = new Map<
|
||||
PackageName,
|
||||
BUILD_CONFIG_TYPE['distribution']
|
||||
>([
|
||||
['@affine/admin', 'admin'],
|
||||
['@affine/web', 'web'],
|
||||
['@affine/electron-renderer', 'desktop'],
|
||||
['@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'],
|
||||
['electron', '@affine/electron'],
|
||||
['desktop', '@affine/electron-renderer'],
|
||||
['renderer', '@affine/electron-renderer'],
|
||||
['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]
|
||||
),
|
||||
]);
|
||||
22
tools/utils/src/format.ts
Normal file
22
tools/utils/src/format.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { once } from 'lodash-es';
|
||||
import { format, type FormatConfig } from 'oxfmt';
|
||||
|
||||
import { ProjectRoot } from './path';
|
||||
|
||||
const readConfig = once(() => {
|
||||
const path = ProjectRoot.join('.oxfmtrc.json').value;
|
||||
const config = JSON.parse(readFileSync(path, 'utf-8')) as FormatConfig;
|
||||
return config;
|
||||
});
|
||||
|
||||
export async function formatCode(content: string, fileName: string) {
|
||||
const result = await format(fileName, content, readConfig());
|
||||
if (result.errors.length) {
|
||||
throw new Error(
|
||||
result.errors.map(error => error.codeframe ?? error.message).join('\n')
|
||||
);
|
||||
}
|
||||
return result.code;
|
||||
}
|
||||
2
tools/utils/src/index.ts
Normal file
2
tools/utils/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './format';
|
||||
export * from './path';
|
||||
46
tools/utils/src/logger.ts
Normal file
46
tools/utils/src/logger.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import chalk from 'chalk';
|
||||
import { identity } from 'lodash-es';
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
81
tools/utils/src/package.ts
Normal file
81
tools/utils/src/package.ts
Normal 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 './yarn';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
93
tools/utils/src/path.ts
Normal file
93
tools/utils/src/path.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
export class Path {
|
||||
static dir(url: string) {
|
||||
return new Path(fileURLToPath(url)).join('..');
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
get relativePath() {
|
||||
return './' + this.path.slice(ProjectRoot.path.length).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
constructor(private readonly path: string) {}
|
||||
|
||||
join(...paths: string[]) {
|
||||
return new Path(join(this.path, ...paths));
|
||||
}
|
||||
|
||||
parent() {
|
||||
return this.join('..');
|
||||
}
|
||||
|
||||
toPosixString() {
|
||||
if (sep === '\\') {
|
||||
return this.path.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
return this.path;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
exists() {
|
||||
return existsSync(this.path);
|
||||
}
|
||||
|
||||
rm(opts: { recursive?: boolean } = {}) {
|
||||
rmSync(this.path, { ...opts, force: true });
|
||||
}
|
||||
|
||||
mkdir() {
|
||||
mkdirSync(this.path, { recursive: true });
|
||||
}
|
||||
|
||||
readAsFile() {
|
||||
return readFileSync(this.path);
|
||||
}
|
||||
|
||||
writeFile(content: Buffer | string) {
|
||||
writeFileSync(this.path, content);
|
||||
}
|
||||
|
||||
stats() {
|
||||
return statSync(this.path);
|
||||
}
|
||||
|
||||
isFile() {
|
||||
return this.exists() && this.stats().isFile();
|
||||
}
|
||||
|
||||
isDirectory() {
|
||||
return this.exists() && this.stats().isDirectory();
|
||||
}
|
||||
|
||||
toFileUrl() {
|
||||
return pathToFileURL(this.path);
|
||||
}
|
||||
|
||||
relative(to: string) {
|
||||
const re = relative(this.value, to);
|
||||
if (sep === '\\') {
|
||||
return re.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
return re;
|
||||
}
|
||||
}
|
||||
|
||||
export const ProjectRoot = Path.dir(import.meta.url).join('../../../');
|
||||
99
tools/utils/src/process.ts
Normal file
99
tools/utils/src/process.ts
Normal 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;
|
||||
}
|
||||
19
tools/utils/src/types.ts
Normal file
19
tools/utils/src/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface YarnWorkspaceItem {
|
||||
name: string;
|
||||
location: string;
|
||||
workspaceDependencies: string[];
|
||||
// we don't need it
|
||||
mismatchedWorkspaceDependencies?: string[];
|
||||
}
|
||||
|
||||
export interface CommonPackageJsonContent {
|
||||
name: string;
|
||||
type?: 'module' | 'commonjs';
|
||||
version: string;
|
||||
private?: boolean;
|
||||
dependencies?: { [key: string]: string };
|
||||
devDependencies?: { [key: string]: string };
|
||||
scripts?: { [key: string]: string };
|
||||
main?: string;
|
||||
exports?: { [key: string]: string | { [key: string]: string } };
|
||||
}
|
||||
1581
tools/utils/src/workspace.gen.ts
Normal file
1581
tools/utils/src/workspace.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
152
tools/utils/src/workspace.ts
Normal file
152
tools/utils/src/workspace.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Logger } from './logger';
|
||||
import { Package, readPackageJson } from './package';
|
||||
import { ProjectRoot } from './path';
|
||||
import type { CommonPackageJsonContent } from './types';
|
||||
import { PackageList, type PackageName, yarnList } from './yarn';
|
||||
|
||||
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 ?? {};
|
||||
}
|
||||
|
||||
get isTsProject() {
|
||||
return this.join('tsconfig.json').exists();
|
||||
}
|
||||
|
||||
constructor(list: typeof PackageList = PackageList) {
|
||||
this.packageJson = readPackageJson(ProjectRoot);
|
||||
const packages = new Map<string, Package>();
|
||||
|
||||
for (const meta of list) {
|
||||
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);
|
||||
console.log(inProcessPackages, e.currentName);
|
||||
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(dep.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);
|
||||
}
|
||||
|
||||
forEach(callback: (pkg: Package) => void) {
|
||||
this.packages.forEach(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export { Package, type PackageName, yarnList };
|
||||
36
tools/utils/src/yarn.ts
Normal file
36
tools/utils/src/yarn.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { once } from 'lodash-es';
|
||||
|
||||
import { Logger } from './logger';
|
||||
import { exec } from './process';
|
||||
import type { YarnWorkspaceItem } from './types';
|
||||
import type { PackageName } from './workspace.gen';
|
||||
|
||||
async function loadPackageList() {
|
||||
try {
|
||||
const packageList = await import('./workspace.gen');
|
||||
return packageList.PackageList;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
new Logger('yarn').error('Failed to load package list');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const PackageList = await loadPackageList();
|
||||
export type { PackageName };
|
||||
|
||||
export const 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 !== '.');
|
||||
});
|
||||
10
tools/utils/tsconfig.json
Normal file
10
tools/utils/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": []
|
||||
}
|
||||
Reference in New Issue
Block a user