Compare commits

...

6 Commits

Author SHA1 Message Date
DarkSky
819402d9f1 feat: asset upload with retry 2026-02-14 17:24:22 +08:00
DarkSky
33bc3e2fe9 feat: improve ci (#14438)
#### PR Dependency Tree


* **PR #14438** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Refined PR trigger automation to run only on open/reopen/synchronize
events
* Split native CI into platform-specific builds (Linux, Windows, macOS)
for more reliable pipelines
* Added conditional Copilot test gating to run API/E2E tests only when
relevant
* Added conditional PR-title lint skip when edits don't change the title
  * Improved test result uploads and artifact handling for gated flows
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 16:59:49 +08:00
DarkSky
2b71b3f345 feat: improve test & bundler (#14434)
#### PR Dependency Tree


* **PR #14434** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced rspack bundler as an alternative to webpack for optimized
builds.

* **Tests & Quality**
* Added comprehensive editor semantic tests covering markdown, hotkeys,
and slash-menu operations.
* Expanded CI cross-browser testing to Chromium, Firefox, and WebKit;
improved shape-rendering tests to account for zoom.

* **Bug Fixes**
  * Corrected CSS overlay styling for development servers.
  * Fixed TypeScript typings for build tooling.

* **Other**
  * Document duplication now produces consistent "(n)" suffixes.
  * French i18n completeness increased to 100%.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 16:09:09 +08:00
dcornuel-del
3bc28ba78c feat(i18n): update French translations for various keys (#14437)
ajout de definition

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Enhanced French language support with improved grammar, gender
neutrality, and consistency across UI text.
  * Added French translations for new AI-powered features.
* Refined French phrasing in prompts, tooltips, and messages for better
clarity and natural language flow.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 14:43:22 +08:00
DarkSky
72df9cb457 feat: improve editor performance (#14429)
#### PR Dependency Tree


* **PR #14429** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* HTML import now splits lines on <br> into separate paragraphs while
preserving inline formatting.

* **Bug Fixes**
* Paste falls back to inserting after the first paragraph when no
explicit target is found.

* **Style**
  * Improved page-mode viewport styling for consistent content layout.

* **Tests**
* Added snapshot tests for <br>-based paragraph splitting; re-enabled an
e2e drag-page test.

* **Chores**
* Deferred/deduplicated font loading, inline text caching,
drag-handle/pointer optimizations, and safer inline render
synchronization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 00:43:36 +08:00
DarkSky
98e5747fdc feat: merge service 2026-02-13 21:52:11 +08:00
56 changed files with 3203 additions and 929 deletions

View File

@@ -33,24 +33,22 @@ const replicaConfig = {
stable: {
front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2,
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
},
beta: {
front: Number(process.env.BETA_FRONT_REPLICA) || 1,
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
},
canary: { front: 1, graphql: 1, doc: 1 },
canary: { front: 1, graphql: 1 },
};
const cpuConfig = {
beta: { front: '1', graphql: '1', doc: '1' },
canary: { front: '500m', graphql: '1', doc: '500m' },
beta: { front: '1', graphql: '1' },
canary: { front: '500m', graphql: '1' },
};
const memoryConfig = {
beta: { front: '1Gi', graphql: '1Gi', doc: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi', doc: '512Mi' },
beta: { front: '2Gi', graphql: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi' },
};
const createHelmCommand = ({ isDryRun }) => {
@@ -80,7 +78,6 @@ const createHelmCommand = ({ isDryRun }) => {
const serviceAnnotations = [
`--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json doc.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
].concat(
isProduction || isBeta || isInternal
? [
@@ -98,7 +95,6 @@ const createHelmCommand = ({ isDryRun }) => {
? [
`--set-json front.nodeSelector="${spotNodeSelector}"`,
`--set-json graphql.nodeSelector="${spotNodeSelector}"`,
`--set-json doc.nodeSelector="${spotNodeSelector}"`,
]
: [];
@@ -109,14 +105,12 @@ const createHelmCommand = ({ isDryRun }) => {
resources = resources.concat([
`--set front.resources.requests.cpu="${cpu.front}"`,
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
`--set doc.resources.requests.cpu="${cpu.doc}"`,
]);
}
if (memory) {
resources = resources.concat([
`--set front.resources.requests.memory="${memory.front}"`,
`--set graphql.resources.requests.memory="${memory.graphql}"`,
`--set doc.resources.requests.memory="${memory.doc}"`,
]);
}
@@ -155,9 +149,6 @@ const createHelmCommand = ({ isDryRun }) => {
`--set graphql.replicaCount=${replica.graphql}`,
`--set-string graphql.image.tag="${imageTag}"`,
`--set-string graphql.app.host="${primaryHost}"`,
`--set-string doc.image.tag="${imageTag}"`,
`--set-string doc.app.host="${primaryHost}"`,
`--set doc.replicaCount=${replica.doc}`,
...serviceAnnotations,
...spotScheduling,
...resources,

View File

@@ -1,16 +0,0 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "doc.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "doc.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "doc.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "doc.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -1,63 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "doc.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "doc.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "doc.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "doc.labels" -}}
helm.sh/chart: {{ include "doc.chart" . }}
{{ include "doc.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*
Selector labels
*/}}
{{- define "doc.selectorLabels" -}}
app.kubernetes.io/name: {{ include "doc.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "doc.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "doc.fullname" .) .Values.global.docService.name }}
{{- else }}
{{- default "default" .Values.global.docService.name }}
{{- end }}
{{- end }}

View File

@@ -1,118 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "doc.fullname" . }}
labels:
{{- include "doc.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "doc.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "doc.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "doc.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NODE_OPTIONS
value: "--max-old-space-size=4096"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR
value: "doc"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_INDEXER_SEARCH_PROVIDER
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
- name: AFFINE_SERVER_PORT
value: "{{ .Values.global.docService.port }}"
- name: AFFINE_SERVER_SUB_PATH
value: "{{ .Values.app.path }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
ports:
- name: http
containerPort: {{ .Values.global.docService.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
readinessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,12 +0,0 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "doc.serviceAccountName" . }}
labels:
{{- include "doc.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -1,15 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "doc.fullname" . }}-test-connection"
labels:
{{- include "doc.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "doc.fullname" . }}:{{ .Values.global.docService.port }}']
restartPolicy: Never

View File

@@ -88,8 +88,6 @@ spec:
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
- name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
ports:
- name: http
containerPort: {{ .Values.app.port }}

View File

@@ -1,19 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "doc.fullname" . }}
name: {{ .Values.global.docService.name }}
labels:
{{- include "doc.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
{{- include "front.labels" . | nindent 4 }}
{{- with .Values.services.doc.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
type: {{ .Values.services.doc.type }}
ports:
- port: {{ .Values.global.docService.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "doc.selectorLabels" . | nindent 4 }}
{{- include "front.selectorLabels" . | nindent 4 }}

View File

@@ -57,6 +57,9 @@ services:
type: ClusterIP
port: 8080
annotations: {}
doc:
type: ClusterIP
annotations: {}
nodeSelector: {}
tolerations: []

View File

@@ -47,12 +47,6 @@ graphql:
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
doc:
service:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
front:
services:
sync:
@@ -71,3 +65,7 @@ front:
name: affine-web
type: ClusterIP
port: 8080
doc:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'

View File

@@ -1,6 +1,10 @@
name: 'Pull Request Labeler'
on:
- pull_request_target
pull_request_target:
types:
- opened
- reopened
- synchronize
jobs:
triage:

View File

@@ -210,18 +210,13 @@ jobs:
e2e-blocksuite-cross-browser-test:
name: E2E BlockSuite Cross Browser Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1]
browser: ['chromium', 'firefox', 'webkit']
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: true
playwright-platform: ${{ matrix.browser }}
playwright-platform: 'chromium,firefox,webkit'
electron-install: false
full-cache: true
@@ -229,18 +224,64 @@ jobs:
run: yarn workspace @blocksuite/playground build
- name: Run playwright tests
env:
BROWSER: ${{ matrix.browser }}
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
run: |
yarn workspace @blocksuite/integration-test test:unit
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-bs-cross-browser-${{ matrix.browser }}-${{ matrix.shard }}
name: test-results-e2e-bs-cross-browser
path: ./test-results
if-no-files-found: ignore
bundler-matrix:
name: Bundler Matrix (${{ matrix.bundler }})
runs-on: ubuntu-24.04-arm
strategy:
fail-fast: false
matrix:
bundler: [webpack, rspack]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: false
electron-install: false
full-cache: true
- name: Run frontend build matrix
env:
AFFINE_BUNDLER: ${{ matrix.bundler }}
run: |
set -euo pipefail
packages=(
"@affine/web"
"@affine/mobile"
"@affine/ios"
"@affine/android"
"@affine/admin"
"@affine/electron-renderer"
)
summary="test-results-bundler-${AFFINE_BUNDLER}.txt"
: > "$summary"
for pkg in "${packages[@]}"; do
start=$(date +%s)
yarn affine "$pkg" build
end=$(date +%s)
echo "${pkg},$((end-start))" >> "$summary"
done
- name: Upload bundler timing
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-bundler-${{ matrix.bundler }}
path: ./test-results-bundler-${{ matrix.bundler }}.txt
if-no-files-found: ignore
e2e-test:
name: E2E Test
runs-on: ubuntu-24.04-arm
@@ -307,7 +348,7 @@ jobs:
name: Unit Test
runs-on: ubuntu-latest
needs:
- build-native
- build-native-linux
env:
DISTRIBUTION: web
strategy:
@@ -321,6 +362,7 @@ jobs:
with:
electron-install: true
playwright-install: true
playwright-platform: 'chromium,firefox,webkit'
full-cache: true
- name: Download affine.linux-x64-gnu.node
@@ -341,7 +383,39 @@ jobs:
name: affine
fail_ci_if_error: false
build-native:
build-native-linux:
name: Build AFFiNE native (x86_64-unknown-linux-gnu)
runs-on: ubuntu-latest
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/native
electron-install: false
- name: Setup filename
id: filename
working-directory: ${{ github.workspace }}
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: '@affine/native'
- name: Upload ${{ steps.filename.outputs.filename }}
uses: actions/upload-artifact@v4
if: always()
with:
name: ${{ steps.filename.outputs.filename }}
path: ${{ github.workspace }}/packages/frontend/native/${{ steps.filename.outputs.filename }}
if-no-files-found: error
build-native-macos:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -350,7 +424,6 @@ jobs:
fail-fast: false
matrix:
spec:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { os: macos-latest, target: x86_64-apple-darwin }
- { os: macos-latest, target: aarch64-apple-darwin }
@@ -383,7 +456,7 @@ jobs:
# Split Windows build because it's too slow
# and other ci jobs required linux native
build-windows-native:
build-native-windows:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -483,7 +556,7 @@ jobs:
name: Native Unit Test
runs-on: ubuntu-latest
needs:
- build-native
- build-native-linux
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -577,8 +650,6 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
strategy:
fail-fast: false
env:
NODE_ENV: test
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -819,11 +890,51 @@ jobs:
- name: Run tests
run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast
copilot-test-filter:
name: Copilot test filter
runs-on: ubuntu-latest
outputs:
run-api: ${{ steps.decision.outputs.run_api }}
run-e2e: ${{ steps.decision.outputs.run_e2e }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: copilot-filter
with:
filters: |
api:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
e2e:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Decide test scope
id: decision
run: |
if [[ "${{ steps.copilot-filter.outputs.api }}" == "true" ]]; then
echo "run_api=true" >> "$GITHUB_OUTPUT"
else
echo "run_api=false" >> "$GITHUB_OUTPUT"
fi
if [[ "${{ steps.copilot-filter.outputs.e2e }}" == "true" ]]; then
echo "run_e2e=true" >> "$GITHUB_OUTPUT"
else
echo "run_e2e=false" >> "$GITHUB_OUTPUT"
fi
copilot-api-test:
name: Server Copilot Api Test
if: ${{ needs.copilot-test-filter.outputs.run-api == 'true' }}
runs-on: ubuntu-latest
needs:
- build-server-native
- copilot-test-filter
env:
NODE_ENV: test
DISTRIBUTION: web
@@ -857,53 +968,29 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: apifilter
with:
filters: |
changed:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- name: Setup Node.js
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: ./.github/actions/setup-node
with:
electron-install: false
full-cache: true
- name: Download server-native.node
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: actions/download-artifact@v4
with:
name: server-native.node
path: ./packages/backend/native
- name: Prepare Server Test Environment
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
env:
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
uses: ./.github/actions/server-test-env
- name: Run server tests
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
run: yarn affine @affine/server test:copilot:coverage --forbid-only
env:
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
- name: Upload server test coverage results
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -914,6 +1001,7 @@ jobs:
copilot-e2e-test:
name: Frontend Copilot E2E Test
if: ${{ needs.copilot-test-filter.outputs.run-e2e == 'true' }}
runs-on: ubuntu-latest
env:
DISTRIBUTION: web
@@ -928,6 +1016,7 @@ jobs:
shardTotal: [5]
needs:
- build-server-native
- copilot-test-filter
services:
postgres:
image: pgvector/pgvector:pg16
@@ -951,30 +1040,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: e2efilter
with:
filters: |
changed:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Setup Node.js
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: ./.github/actions/setup-node
with:
playwright-install: true
@@ -983,20 +1049,17 @@ jobs:
hard-link-nm: false
- name: Download server-native.node
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: actions/download-artifact@v4
with:
name: server-native.node
path: ./packages/backend/native
- name: Prepare Server Test Environment
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
env:
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
uses: ./.github/actions/server-test-env
- name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: ./.github/actions/copilot-test
with:
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
@@ -1006,7 +1069,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
- build-native
- build-native-linux
env:
DISTRIBUTION: web
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -1099,7 +1162,9 @@ jobs:
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native
- build-native-linux
- build-native-macos
- build-native-windows
strategy:
fail-fast: false
matrix:
@@ -1182,84 +1247,6 @@ jobs:
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
run: yarn affine @affine-test/affine-desktop e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
desktop-bundle-check:
name: Desktop bundle check (${{ matrix.spec.os }}, ${{ matrix.spec.platform }}, ${{ matrix.spec.arch }}, ${{ matrix.spec.target }}, ${{ matrix.spec.test }})
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native
strategy:
fail-fast: false
matrix:
spec:
- {
os: macos-latest,
platform: macos,
arch: x64,
target: x86_64-apple-darwin,
test: false,
}
- {
os: macos-latest,
platform: macos,
arch: arm64,
target: aarch64-apple-darwin,
test: true,
}
- {
os: ubuntu-latest,
platform: linux,
arch: x64,
target: x86_64-unknown-linux-gnu,
test: true,
}
- {
os: windows-latest,
platform: windows,
arch: x64,
target: x86_64-pc-windows-msvc,
test: true,
}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
timeout-minutes: 10
with:
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
playwright-install: true
hard-link-nm: false
enableScripts: false
- name: Setup filename
id: filename
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Download ${{ steps.filename.outputs.filename }}
uses: actions/download-artifact@v4
with:
name: ${{ steps.filename.outputs.filename }}
path: ./packages/frontend/native
- name: Download web artifact
uses: ./.github/actions/download-web
with:
path: packages/frontend/apps/electron/resources/web-static
- name: Build Desktop Layers
run: yarn affine @affine/electron build
- name: Make bundle (macOS)
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
env:
@@ -1299,6 +1286,14 @@ jobs:
run: |
yarn affine @affine/electron node ./scripts/macos-arm64-output-check.ts
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
test-done:
needs:
- analyze
@@ -1312,8 +1307,9 @@ jobs:
- e2e-blocksuite-cross-browser-test
- e2e-mobile-test
- unit-test
- build-native
- build-windows-native
- build-native-linux
- build-native-macos
- build-native-windows
- build-server-native
- build-electron-renderer
- native-unit-test
@@ -1323,10 +1319,10 @@ jobs:
- server-test
- server-e2e-test
- rust-test
- copilot-test-filter
- copilot-api-test
- copilot-e2e-test
- desktop-test
- desktop-bundle-check
- cloud-e2e-test
if: always()
runs-on: ubuntu-latest

View File

@@ -16,6 +16,7 @@ jobs:
check-pull-request-title:
name: Check pull request title
runs-on: ubuntu-latest
if: ${{ github.event.action != 'edited' || github.event.changes.title != null }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js

View File

@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph with br should split into multiple blocks', async () => {
const html = template(`<p>aaa<br>bbb<br>ccc</p>`);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'aaa' }],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[2]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'bbb' }],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[3]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'ccc' }],
},
},
children: [],
},
],
};
const htmlAdapter = new HtmlAdapter(createJob(), provider);
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
file: html,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph with br should keep inline styles in each split line', async () => {
const html = template(
`<p><strong>aaa</strong><br><a href="https://www.google.com/">bbb</a><br><em>ccc</em></p>`
);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'aaa',
attributes: {
bold: true,
},
},
],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[2]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'bbb',
attributes: {
link: 'https://www.google.com/',
},
},
],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[3]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'ccc',
attributes: {
italic: true,
},
},
],
},
},
children: [],
},
],
};
const htmlAdapter = new HtmlAdapter(createJob(), provider);
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
file: html,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('nested list', async () => {
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);

View File

@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
return false;
};
const splitDeltaByNewline = (delta: DeltaInsert[]) => {
const lines: DeltaInsert[][] = [[]];
const pending = [...delta];
while (pending.length > 0) {
const op = pending.shift();
if (!op) continue;
const insert = op.insert;
if (typeof insert !== 'string') {
lines[lines.length - 1].push(op);
continue;
}
if (!insert.includes('\n')) {
if (insert.length === 0) {
continue;
}
lines[lines.length - 1].push(op);
continue;
}
const splitIndex = insert.indexOf('\n');
const linePart = insert.slice(0, splitIndex);
const remainPart = insert.slice(splitIndex + 1);
if (linePart.length > 0) {
lines[lines.length - 1].push({ ...op, insert: linePart });
}
lines.push([]);
if (remainPart) {
pending.unshift({ ...op, insert: remainPart });
}
}
return lines;
};
const hasBlockElementDescendant = (node: HtmlAST): boolean => {
if (!HastUtils.isElement(node)) {
return false;
}
return node.children.some(child => {
if (!HastUtils.isElement(child)) {
return false;
}
return (
(HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
hasBlockElementDescendant(child)
);
});
};
const getParagraphDeltas = (
node: HtmlAST,
delta: DeltaInsert[]
): DeltaInsert[][] => {
if (!HastUtils.isElement(node)) return [delta];
if (hasBlockElementDescendant(node)) return [delta];
const hasBr = !!HastUtils.querySelector(node, 'br');
if (!hasBr) return [delta];
const hasNewline = delta.some(
op => typeof op.insert === 'string' && op.insert.includes('\n')
);
if (!hasNewline) return [delta];
return splitDeltaByNewline(delta);
};
const openParagraphBlocks = (
deltas: DeltaInsert[][],
type: string,
// AST walker context from html adapter transform pipeline.
walkerContext: any
) => {
for (const delta of deltas) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: { type, text: { '$blocksuite:internal:text$': true, delta } },
children: [],
},
'children'
)
.closeNode();
}
};
const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
'affine:paragraph:multi-emitted-nodes';
const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
const emittedNodes =
(walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined) ?? new WeakSet<object>();
emittedNodes.add(node as object);
walkerContext.setGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY,
emittedNodes
);
};
const consumeMultiParagraphEmittedMark = (
walkerContext: any,
node: HtmlAST
) => {
const emittedNodes = walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined;
if (!emittedNodes) {
return false;
}
return emittedNodes.delete(node as object);
};
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: ParagraphBlockSchema.model.flavour,
toMatch: o =>
@@ -88,41 +208,37 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
!tagsInAncestor(o, ['p', 'li']) &&
HastUtils.isParagraphLike(o.node)
) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
},
'children'
)
.closeNode();
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
openParagraphBlocks(deltas, 'text', walkerContext);
walkerContext.skipAllChildren();
}
break;
}
case 'p': {
const type = walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text';
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
if (deltas.length > 1) {
openParagraphBlocks(deltas, type, walkerContext);
markMultiParagraphEmitted(walkerContext, o.node);
walkerContext.skipAllChildren();
break;
}
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text',
type,
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
delta,
},
},
children: [],
@@ -192,6 +308,9 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
break;
}
case 'p': {
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
break;
}
if (
o.next?.type === 'element' &&
o.next.tagName === 'div' &&

View File

@@ -86,6 +86,7 @@ export class PageClipboard extends ReadOnlyClipboard {
if (this.std.store.readonly) return;
this.std.store.captureSync();
let hasPasteTarget = false;
this.std.command
.chain()
.try<{}>(cmd => [
@@ -144,18 +145,39 @@ export class PageClipboard extends ReadOnlyClipboard {
if (!ctx.parentBlock) {
return;
}
hasPasteTarget = true;
this.std.clipboard
.paste(
e,
this.std.store,
ctx.parentBlock.model.id,
ctx.blockIndex ? ctx.blockIndex + 1 : 1
ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1
)
.catch(console.error);
return next();
})
.run();
if (hasPasteTarget) return;
// If no valid selection target exists (for example, stale block selection
// right after cut), create/focus the default paragraph and paste after it.
const firstParagraphId = document
.querySelector('affine-page-root')
?.focusFirstParagraph?.()?.id;
const parentModel = firstParagraphId
? this.std.store.getParent(firstParagraphId)
: null;
const paragraphIndex =
firstParagraphId && parentModel
? parentModel.children.findIndex(child => child.id === firstParagraphId)
: -1;
const insertIndex = paragraphIndex >= 0 ? paragraphIndex + 1 : undefined;
this.std.clipboard
.paste(e, this.std.store, parentModel?.id, insertIndex)
.catch(console.error);
};
override mounted() {

View File

@@ -1,3 +1,4 @@
import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model';
import { createIdentifier } from '@blocksuite/global/di';
import { IS_FIREFOX } from '@blocksuite/global/env';
import { LifeCycleWatcher } from '@blocksuite/std';
@@ -20,33 +21,171 @@ const initFontFace = IS_FIREFOX
export class FontLoaderService extends LifeCycleWatcher {
static override readonly key = 'font-loader';
private static readonly DEFERRED_LOAD_DELAY_MS = 5000;
private static readonly DEFERRED_LOAD_BATCH_SIZE = 4;
private static readonly DEFERRED_LOAD_BATCH_INTERVAL_MS = 1000;
private _idleLoadTaskId: number | null = null;
private _lazyLoadTimeoutId: number | null = null;
private _deferredFontsQueue: FontConfig[] = [];
private _deferredFontsCursor = 0;
private readonly _loadedFontKeys = new Set<string>();
readonly fontFaces: FontFace[] = [];
get ready() {
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
}
private readonly _fontKey = ({ font, weight, style, url }: FontConfig) => {
return `${font}:${weight}:${style}:${url}`;
};
private readonly _isCriticalCanvasFont = ({
font,
weight,
style,
}: FontConfig) => {
if (style !== FontStyle.Normal) return false;
if (font === FontFamily.Poppins) {
return (
weight === FontWeight.Regular ||
weight === FontWeight.Medium ||
weight === FontWeight.SemiBold
);
}
if (font === FontFamily.Inter) {
return weight === FontWeight.Regular || weight === FontWeight.SemiBold;
}
if (font === FontFamily.Kalam) {
// Mindmap style four uses bold Kalam text.
// We map to SemiBold because this is the strongest shipped Kalam weight.
return weight === FontWeight.SemiBold;
}
return false;
};
private readonly _scheduleDeferredLoad = (fonts: FontConfig[]) => {
if (fonts.length === 0 || typeof window === 'undefined') {
return;
}
this._deferredFontsQueue = fonts;
this._deferredFontsCursor = 0;
const win = window as Window & {
requestIdleCallback?: (
callback: () => void,
options?: { timeout?: number }
) => number;
cancelIdleCallback?: (handle: number) => void;
};
const scheduleBatch = (delayMs: number) => {
this._lazyLoadTimeoutId = window.setTimeout(() => {
this._lazyLoadTimeoutId = null;
const runBatch = () => {
this._idleLoadTaskId = null;
const start = this._deferredFontsCursor;
const end = Math.min(
start + FontLoaderService.DEFERRED_LOAD_BATCH_SIZE,
this._deferredFontsQueue.length
);
const batch = this._deferredFontsQueue.slice(start, end);
this._deferredFontsCursor = end;
this.load(batch);
if (this._deferredFontsCursor < this._deferredFontsQueue.length) {
scheduleBatch(FontLoaderService.DEFERRED_LOAD_BATCH_INTERVAL_MS);
}
};
if (typeof win.requestIdleCallback === 'function') {
this._idleLoadTaskId = win.requestIdleCallback(runBatch, {
timeout: 2000,
});
return;
}
runBatch();
}, delayMs);
};
scheduleBatch(FontLoaderService.DEFERRED_LOAD_DELAY_MS);
};
private readonly _cancelDeferredLoad = () => {
if (typeof window === 'undefined') {
return;
}
const win = window as Window & {
cancelIdleCallback?: (handle: number) => void;
};
if (
this._idleLoadTaskId !== null &&
typeof win.cancelIdleCallback === 'function'
) {
win.cancelIdleCallback(this._idleLoadTaskId);
this._idleLoadTaskId = null;
}
if (this._lazyLoadTimeoutId !== null) {
window.clearTimeout(this._lazyLoadTimeoutId);
this._lazyLoadTimeoutId = null;
}
this._deferredFontsQueue = [];
this._deferredFontsCursor = 0;
};
load(fonts: FontConfig[]) {
this.fontFaces.push(
...fonts.map(font => {
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
return fontFace;
})
);
for (const font of fonts) {
const key = this._fontKey(font);
if (this._loadedFontKeys.has(key)) {
continue;
}
this._loadedFontKeys.add(key);
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
this.fontFaces.push(fontFace);
}
}
override mounted() {
const config = this.std.getOptional(FontConfigIdentifier);
if (config) {
this.load(config);
if (!config || config.length === 0) {
return;
}
const criticalFonts = config.filter(this._isCriticalCanvasFont);
const eagerFonts =
criticalFonts.length > 0 ? criticalFonts : config.slice(0, 3);
const eagerFontKeySet = new Set(eagerFonts.map(this._fontKey));
const deferredFonts = config.filter(
font => !eagerFontKeySet.has(this._fontKey(font))
);
this.load(eagerFonts);
this._scheduleDeferredLoad(deferredFonts);
}
override unmounted() {
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
this._cancelDeferredLoad();
for (const fontFace of this.fontFaces) {
document.fonts.delete(fontFace);
}
this.fontFaces.splice(0, this.fontFaces.length);
this._loadedFontKeys.clear();
}
}

View File

@@ -14,6 +14,17 @@ import {
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
type HoveredElemArea = {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
padding: number;
containerWidth: number;
};
/**
* Used to control the drag handle visibility in edgeless mode
*
@@ -21,6 +32,52 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
* 2. Multiple selection is not supported
*/
export class EdgelessWatcher {
private _pendingHoveredElemArea: HoveredElemArea | null = null;
private _lastAppliedHoveredElemArea: HoveredElemArea | null = null;
private _showDragHandleRafId: number | null = null;
private _surfaceElementUpdatedRafId: number | null = null;
private readonly _cloneArea = (area: HoveredElemArea): HoveredElemArea => ({
left: area.left,
top: area.top,
right: area.right,
bottom: area.bottom,
width: area.width,
height: area.height,
padding: area.padding,
containerWidth: area.containerWidth,
});
private readonly _isAreaEqual = (
left: HoveredElemArea | null,
right: HoveredElemArea | null
) => {
if (!left || !right) return false;
return (
left.left === right.left &&
left.top === right.top &&
left.right === right.right &&
left.bottom === right.bottom &&
left.width === right.width &&
left.height === right.height &&
left.padding === right.padding &&
left.containerWidth === right.containerWidth
);
};
private readonly _scheduleShowDragHandleFromSurfaceUpdate = () => {
if (this._surfaceElementUpdatedRafId !== null) return;
this._surfaceElementUpdatedRafId = requestAnimationFrame(() => {
this._surfaceElementUpdatedRafId = null;
if (!this.widget.isGfxDragHandleVisible) return;
this._showDragHandle();
});
};
private readonly _handleEdgelessToolUpdated = (
newTool: ToolOptionWithType
) => {
@@ -43,46 +100,123 @@ export class EdgelessWatcher {
}
if (
this.widget.center[0] !== center[0] &&
this.widget.center[0] !== center[0] ||
this.widget.center[1] !== center[1]
) {
this.widget.center = [...center];
}
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle();
this._updateDragHoverRectTopLevelBlock();
const area = this.hoveredElemArea;
this._showDragHandle(area);
this._updateDragHoverRectTopLevelBlock(area);
} else if (this.widget.activeDragHandle) {
this.widget.hide();
}
};
private readonly _showDragHandle = () => {
if (!this.widget.anchorBlockId) return;
private readonly _flushShowDragHandle = () => {
this._showDragHandleRafId = null;
if (!this.widget.anchorBlockId.peek()) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this.hoveredElemArea;
const area = this._pendingHoveredElemArea ?? this.hoveredElemArea;
this._pendingHoveredElemArea = null;
if (!area) return;
container.style.transition = 'none';
container.style.paddingTop = `0px`;
container.style.paddingBottom = `0px`;
container.style.left = `${area.left}px`;
container.style.top = `${area.top}px`;
container.style.display = 'flex';
if (
this.widget.isGfxDragHandleVisible &&
this._isAreaEqual(this._lastAppliedHoveredElemArea, area)
) {
return;
}
if (container.style.transition !== 'none') {
container.style.transition = 'none';
}
const nextPaddingTop = '0px';
if (container.style.paddingTop !== nextPaddingTop) {
container.style.paddingTop = nextPaddingTop;
}
const nextPaddingBottom = '0px';
if (container.style.paddingBottom !== nextPaddingBottom) {
container.style.paddingBottom = nextPaddingBottom;
}
const nextLeft = `${area.left}px`;
if (container.style.left !== nextLeft) {
container.style.left = nextLeft;
}
const nextTop = `${area.top}px`;
if (container.style.top !== nextTop) {
container.style.top = nextTop;
}
if (container.style.display !== 'flex') {
container.style.display = 'flex';
}
this.widget.handleAnchorModelDisposables();
this.widget.activeDragHandle = 'gfx';
this._lastAppliedHoveredElemArea = this._cloneArea(area);
};
private readonly _updateDragHoverRectTopLevelBlock = () => {
private readonly _showDragHandle = (area?: HoveredElemArea | null) => {
const nextArea = area ?? this.hoveredElemArea;
this._pendingHoveredElemArea = nextArea;
if (!this._pendingHoveredElemArea) {
return;
}
if (
this.widget.isGfxDragHandleVisible &&
this._showDragHandleRafId === null &&
this._isAreaEqual(
this._lastAppliedHoveredElemArea,
this._pendingHoveredElemArea
)
) {
return;
}
if (this._showDragHandleRafId !== null) {
return;
}
this._showDragHandleRafId = requestAnimationFrame(
this._flushShowDragHandle
);
};
private readonly _updateDragHoverRectTopLevelBlock = (
area?: HoveredElemArea | null
) => {
if (!this.widget.dragHoverRect) return;
this.widget.dragHoverRect = this.hoveredElemAreaRect;
const nextArea = area ?? this.hoveredElemArea;
if (!nextArea) {
this.widget.dragHoverRect = null;
return;
}
const nextRect = new Rect(
nextArea.left,
nextArea.top,
nextArea.right,
nextArea.bottom
);
const prevRect = this.widget.dragHoverRect;
if (
prevRect &&
prevRect.left === nextRect.left &&
prevRect.top === nextRect.top &&
prevRect.width === nextRect.width &&
prevRect.height === nextRect.height
) {
return;
}
this.widget.dragHoverRect = nextRect;
};
get gfx() {
@@ -123,7 +257,7 @@ export class EdgelessWatcher {
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoveredElemArea() {
get hoveredElemArea(): HoveredElemArea | null {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
@@ -174,6 +308,19 @@ export class EdgelessWatcher {
viewport.viewportUpdated.subscribe(this._handleEdgelessViewPortUpdated)
);
disposables.add(() => {
if (this._showDragHandleRafId !== null) {
cancelAnimationFrame(this._showDragHandleRafId);
this._showDragHandleRafId = null;
}
if (this._surfaceElementUpdatedRafId !== null) {
cancelAnimationFrame(this._surfaceElementUpdatedRafId);
this._surfaceElementUpdatedRafId = null;
}
this._pendingHoveredElemArea = null;
this._lastAppliedHoveredElemArea = null;
});
disposables.add(
selection.slots.updated.subscribe(() => {
this.updateAnchorElement();
@@ -216,7 +363,7 @@ export class EdgelessWatcher {
this.widget.hide();
}
if (payload.type === 'update') {
this._showDragHandle();
this._scheduleShowDragHandleFromSurfaceUpdate();
}
}
})
@@ -224,9 +371,10 @@ export class EdgelessWatcher {
if (surface) {
disposables.add(
surface.elementUpdated.subscribe(() => {
surface.elementUpdated.subscribe(({ id }) => {
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle();
if (id !== this.widget.anchorBlockId.peek()) return;
this._scheduleShowDragHandleFromSurfaceUpdate();
}
})
);

View File

@@ -153,6 +153,10 @@ export class PointerEventWatcher {
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
private _lastPointerHitBlockId: string | null = null;
private _lastPointerHitBlockElement: Element | null = null;
/**
* When pointer move on block, should show drag handle
* And update hover block id and path
@@ -169,6 +173,7 @@ export class PointerEventWatcher {
point
);
if (!closestBlock) {
this._lastPointerHitBlockId = null;
this.widget.anchorBlockId.value = null;
return;
}
@@ -237,19 +242,38 @@ export class PointerEventWatcher {
const state = ctx.get('pointerState');
// When pointer is moving, should do nothing
if (state.delta.x !== 0 && state.delta.y !== 0) return;
const { target } = state.raw;
const element = captureEventTarget(target);
// When pointer not on block or on dragging, should do nothing
if (!element) return;
if (!element) {
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = null;
return;
}
// When pointer on drag handle, should do nothing
if (element.closest('.affine-drag-handle-container')) return;
if (!this.widget.rootComponent) return;
const hitBlock = element.closest(`[${BLOCK_ID_ATTR}]`);
const hitBlockId = hitBlock?.getAttribute(BLOCK_ID_ATTR) ?? null;
// Pointer move events are high-frequency. If hovered block identity is
// unchanged and the underlying block element is the same, skip the
// closest-note lookup.
if (
hitBlockId &&
this.widget.isBlockDragHandleVisible &&
hitBlockId === this._lastPointerHitBlockId &&
hitBlock === this._lastPointerHitBlockElement &&
isBlockIdEqual(this.widget.anchorBlockId.peek(), hitBlockId)
) {
return;
}
this._lastPointerHitBlockId = hitBlockId;
this._lastPointerHitBlockElement = hitBlock;
// When pointer out of note block hover area or inside database, should hide drag handle
const point = new Point(state.raw.x, state.raw.y);
@@ -354,6 +378,8 @@ export class PointerEventWatcher {
reset() {
this._lastHoveredBlockId = null;
this._lastShowedBlock = null;
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = null;
}
watch() {

View File

@@ -10,25 +10,15 @@ import type { InlineRange } from '../types.js';
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
export class RenderService<TextAttributes extends BaseTextAttributes> {
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
private _pendingRemoteInlineRangeSync = false;
const yText = this.editor.yText;
private _carriageReturnValidationCounter = 0;
if (yText.toString().includes('\r')) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'yText must not contain "\\r" because it will break the range synchronization'
);
}
this.render();
private _renderVersion = 0;
private readonly _syncRemoteInlineRange = () => {
const inlineRange = this.editor.inlineRange$.peek();
if (!inlineRange || transaction.local) return;
if (!inlineRange) return;
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
@@ -50,7 +40,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
const startIndex = absoluteStart?.index;
const endIndex = absoluteEnd?.index;
if (!startIndex || !endIndex) return;
if (startIndex == null || endIndex == null) return;
const newInlineRange: InlineRange = {
index: startIndex,
@@ -59,7 +49,31 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
if (!this.editor.isValidInlineRange(newInlineRange)) return;
this.editor.setInlineRange(newInlineRange);
this.editor.syncInlineRange();
};
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
const yText = this.editor.yText;
if (
(this._carriageReturnValidationCounter++ & 0x3f) === 0 &&
yText.toString().includes('\r')
) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'yText must not contain "\\r" because it will break the range synchronization'
);
}
if (!transaction.local) {
this._pendingRemoteInlineRangeSync = true;
}
this.render();
};
mount = () => {
@@ -70,6 +84,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
editor.disposables.add({
dispose: () => {
yText.unobserve(this._onYTextChange);
this._pendingRemoteInlineRangeSync = false;
},
});
};
@@ -82,6 +97,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
render = () => {
if (!this.editor.rootElement) return;
const renderVersion = ++this._renderVersion;
this._rendering = true;
const rootElement = this.editor.rootElement;
@@ -152,11 +168,21 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
this.editor
.waitForUpdate()
.then(() => {
if (renderVersion !== this._renderVersion) return;
if (this._pendingRemoteInlineRangeSync) {
this._pendingRemoteInlineRangeSync = false;
this._syncRemoteInlineRange();
}
this._rendering = false;
this.editor.slots.renderComplete.next();
this.editor.syncInlineRange();
})
.catch(console.error);
.catch(error => {
if (renderVersion === this._renderVersion) {
this._rendering = false;
}
console.error(error);
});
};
rerenderWholeEditor = () => {

View File

@@ -9,7 +9,12 @@ import {
isVElement,
isVLine,
} from './guard.js';
import { calculateTextLength, getTextNodesFromElement } from './text.js';
import {
calculateTextLength,
getInlineRootTextCache,
getTextNodesFromElement,
invalidateInlineRootTextCache,
} from './text.js';
export function nativePointToTextPoint(
node: unknown,
@@ -67,19 +72,6 @@ export function textPointToDomPoint(
if (!rootElement.contains(text)) return null;
const texts = getTextNodesFromElement(rootElement);
if (texts.length === 0) return null;
const goalIndex = texts.indexOf(text);
let index = 0;
for (const text of texts.slice(0, goalIndex)) {
index += calculateTextLength(text);
}
if (text.wholeText !== ZERO_WIDTH_FOR_EMPTY_LINE) {
index += offset;
}
const textParentElement = text.parentElement;
if (!textParentElement) {
throw new BlockSuiteError(
@@ -97,9 +89,44 @@ export function textPointToDomPoint(
);
}
const textOffset = text.wholeText === ZERO_WIDTH_FOR_EMPTY_LINE ? 0 : offset;
for (let attempt = 0; attempt < 2; attempt++) {
const { textNodes, textNodeIndexMap, prefixLengths, lineIndexMap } =
getInlineRootTextCache(rootElement);
if (textNodes.length === 0) return null;
const goalIndex = textNodeIndexMap.get(text);
const lineIndex = lineIndexMap.get(lineElement);
if (goalIndex !== undefined && lineIndex !== undefined) {
const index = (prefixLengths[goalIndex] ?? 0) + textOffset;
return { text, index: index + lineIndex };
}
if (attempt === 0) {
// MutationObserver marks cache dirty asynchronously; force one sync retry
// when a newly-added node is queried within the same task.
invalidateInlineRootTextCache(rootElement);
}
}
// Fallback to linear scan when cache still misses. This keeps behavior
// stable even if MutationObserver-based invalidation lags behind.
const texts = getTextNodesFromElement(rootElement);
if (texts.length === 0) return null;
const goalIndex = texts.indexOf(text);
if (goalIndex < 0) return null;
let index = textOffset;
for (const beforeText of texts.slice(0, goalIndex)) {
index += calculateTextLength(beforeText);
}
const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf(
lineElement
);
if (lineIndex < 0) return null;
return { text, index: index + lineIndex };
}

View File

@@ -8,6 +8,92 @@ export function calculateTextLength(text: Text): number {
}
}
type InlineRootTextCache = {
dirty: boolean;
observer: MutationObserver | null;
textNodes: Text[];
textNodeIndexMap: WeakMap<Text, number>;
prefixLengths: number[];
lineIndexMap: WeakMap<Element, number>;
};
const inlineRootTextCaches = new WeakMap<HTMLElement, InlineRootTextCache>();
const buildInlineRootTextCache = (
rootElement: HTMLElement,
cache: InlineRootTextCache
) => {
const textSpanElements = Array.from(
rootElement.querySelectorAll('[data-v-text="true"]')
);
const textNodes: Text[] = [];
const textNodeIndexMap = new WeakMap<Text, number>();
const prefixLengths: number[] = [];
let prefixLength = 0;
for (const textSpanElement of textSpanElements) {
const textNode = Array.from(textSpanElement.childNodes).find(
(node): node is Text => node instanceof Text
);
if (!textNode) continue;
prefixLengths.push(prefixLength);
textNodeIndexMap.set(textNode, textNodes.length);
textNodes.push(textNode);
prefixLength += calculateTextLength(textNode);
}
const lineIndexMap = new WeakMap<Element, number>();
const lineElements = Array.from(rootElement.querySelectorAll('v-line'));
for (const [index, line] of lineElements.entries()) {
lineIndexMap.set(line, index);
}
cache.textNodes = textNodes;
cache.textNodeIndexMap = textNodeIndexMap;
cache.prefixLengths = prefixLengths;
cache.lineIndexMap = lineIndexMap;
cache.dirty = false;
};
export function invalidateInlineRootTextCache(rootElement: HTMLElement) {
const cache = inlineRootTextCaches.get(rootElement);
if (cache) {
cache.dirty = true;
}
}
export function getInlineRootTextCache(rootElement: HTMLElement) {
let cache = inlineRootTextCaches.get(rootElement);
if (!cache) {
cache = {
dirty: true,
observer: null,
textNodes: [],
textNodeIndexMap: new WeakMap(),
prefixLengths: [],
lineIndexMap: new WeakMap(),
};
inlineRootTextCaches.set(rootElement, cache);
}
if (!cache.observer && typeof MutationObserver !== 'undefined') {
cache.observer = new MutationObserver(() => {
cache!.dirty = true;
});
cache.observer.observe(rootElement, {
subtree: true,
childList: true,
characterData: true,
});
}
if (cache.dirty) {
buildInlineRootTextCache(rootElement, cache);
}
return cache;
}
export function getTextNodesFromElement(element: Element): Text[] {
const textSpanElements = Array.from(
element.querySelectorAll('[data-v-text="true"]')

View File

@@ -47,7 +47,10 @@ describe('frame', () => {
expect(rect!.width).toBeGreaterThan(0);
expect(rect!.height).toBeGreaterThan(0);
const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y);
const [titleX, titleY] = service.viewport.toModelCoordFromClientCoord([
rect!.x,
rect!.y,
]);
expect(titleX).toBeCloseTo(0);
expect(titleY).toBeLessThan(0);
@@ -66,10 +69,11 @@ describe('frame', () => {
if (!nestedTitle) return;
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord(
nestedTitleRect.x,
nestedTitleRect.y
);
const [nestedTitleX, nestedTitleY] =
service.viewport.toModelCoordFromClientCoord([
nestedTitleRect.x,
nestedTitleRect.y,
]);
expect(nestedTitleX).toBeGreaterThan(20);
expect(nestedTitleY).toBeGreaterThan(20);

View File

@@ -5,6 +5,14 @@ import { wait } from '../utils/common.js';
import { getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
function expectPxCloseTo(
value: string,
expected: number,
precision: number = 2
) {
expect(Number.parseFloat(value)).toBeCloseTo(expected, precision);
}
describe('Shape rendering with DOM renderer', () => {
beforeEach(async () => {
const cleanup = await setupEditor('edgeless', [], {
@@ -59,7 +67,8 @@ describe('Shape rendering with DOM renderer', () => {
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.borderRadius).toBe('6px');
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.borderRadius, 6 * zoom);
});
test('should remove shape DOM node when element is deleted', async () => {
@@ -110,8 +119,9 @@ describe('Shape rendering with DOM renderer', () => {
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.width).toBe('80px');
expect(shapeElement?.style.height).toBe('60px');
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.width, 80 * zoom);
expectPxCloseTo(shapeElement!.style.height, 60 * zoom);
});
test('should correctly render triangle shape', async () => {
@@ -132,7 +142,8 @@ describe('Shape rendering with DOM renderer', () => {
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.width).toBe('80px');
expect(shapeElement?.style.height).toBe('60px');
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.width, 80 * zoom);
expectPxCloseTo(shapeElement!.style.height, 60 * zoom);
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,363 @@
import { LinkExtension } from '@blocksuite/affine-inline-link';
import { textKeymap } from '@blocksuite/affine-inline-preset';
import type {
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { insertContent } from '@blocksuite/affine-rich-text';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import { createDefaultDoc } from '@blocksuite/affine-shared/utils';
import { TextSelection } from '@blocksuite/std';
import type { InlineMarkdownMatch } from '@blocksuite/std/inline';
import { Text } from '@blocksuite/store';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defaultSlashMenuConfig } from '../../../../affine/widgets/slash-menu/src/config.js';
import type {
SlashMenuActionItem,
SlashMenuItem,
} from '../../../../affine/widgets/slash-menu/src/types.js';
import { wait } from '../utils/common.js';
import { addNote } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
type RichTextElement = HTMLElement & {
inlineEditor: {
getFormat: (range: {
index: number;
length: number;
}) => Record<string, unknown>;
getInlineRange: () => { index: number; length: number } | null;
setInlineRange: (range: { index: number; length: number }) => void;
yTextString: string;
};
markdownMatches: InlineMarkdownMatch[];
undoManager: {
stopCapturing: () => void;
};
};
function findSlashActionItem(
items: SlashMenuItem[],
name: string
): SlashMenuActionItem {
const item = items.find(entry => entry.name === name);
if (!item || !('action' in item)) {
throw new Error(`Cannot find slash-menu action: ${name}`);
}
return item;
}
function getRichTextByBlockId(blockId: string): RichTextElement {
const block = editor.host?.view.getBlock(blockId) as HTMLElement | null;
if (!block) {
throw new Error(`Cannot find block view: ${blockId}`);
}
const richText = block.querySelector('rich-text') as RichTextElement | null;
if (!richText) {
throw new Error(`Cannot find rich-text for block: ${blockId}`);
}
return richText;
}
async function createParagraph(text = '') {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const paragraph = note.children[0] as ParagraphBlockModel | undefined;
if (!paragraph) {
throw new Error('Cannot find paragraph model');
}
if (text) {
doc.updateBlock(paragraph, {
text: new Text(text),
});
}
await wait();
return {
noteId,
paragraphId: paragraph.id,
};
}
function setTextSelection(blockId: string, index: number, length: number) {
const to = length
? {
blockId,
index: index + length,
length: 0,
}
: null;
const selection = editor.host?.selection.create(TextSelection, {
from: {
blockId,
index,
length: 0,
},
to,
});
if (!selection) {
throw new Error('Cannot create text selection');
}
editor.host?.selection.setGroup('note', [selection]);
const richText = getRichTextByBlockId(blockId);
richText.inlineEditor.setInlineRange({ index, length });
}
async function triggerMarkdown(
blockId: string,
input: string,
matcherName: string
) {
const model = doc.getBlock(blockId)?.model as ParagraphBlockModel | undefined;
if (!model) {
throw new Error(`Cannot find paragraph model: ${blockId}`);
}
doc.updateBlock(model, {
text: new Text(input),
});
await wait();
const richText = getRichTextByBlockId(blockId);
const matcher = richText.markdownMatches.find(
item => item.name === matcherName
);
if (!matcher) {
throw new Error(`Cannot find markdown matcher: ${matcherName}`);
}
const inlineRange = { index: input.length, length: 0 };
setTextSelection(blockId, inlineRange.index, 0);
matcher.action({
inlineEditor: richText.inlineEditor as any,
prefixText: input,
inlineRange,
pattern: matcher.pattern,
undoManager: richText.undoManager as any,
});
await wait();
}
function mockKeyboardContext() {
const preventDefault = vi.fn();
const ctx = {
get(key: string) {
if (key === 'keyboardState') {
return { raw: { preventDefault } };
}
throw new Error(`Unexpected state key: ${key}`);
},
};
return { ctx: ctx as any, preventDefault };
}
beforeEach(async () => {
const cleanup = await setupEditor('page', [LinkExtension]);
return cleanup;
});
describe('markdown/list/paragraph/quote/code/link', () => {
test('markdown list shortcut converts to todo list and keeps checked state', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '[x] ', 'list');
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const model = note.children[0] as ListBlockModel;
expect(model.flavour).toBe('affine:list');
expect(model.props.type).toBe('todo');
expect(model.props.checked).toBe(true);
});
test('markdown heading and quote shortcuts convert paragraph type', async () => {
const { noteId: headingNoteId, paragraphId: headingParagraphId } =
await createParagraph();
await triggerMarkdown(headingParagraphId, '# ', 'heading');
const headingNote = doc.getBlock(headingNoteId)?.model;
if (!headingNote) {
throw new Error('Cannot find heading note model');
}
const headingModel = headingNote.children[0] as ParagraphBlockModel;
expect(headingModel.flavour).toBe('affine:paragraph');
expect(headingModel.props.type).toBe('h1');
const { noteId: quoteNoteId, paragraphId: quoteParagraphId } =
await createParagraph();
await triggerMarkdown(quoteParagraphId, '> ', 'heading');
const quoteNote = doc.getBlock(quoteNoteId)?.model;
if (!quoteNote) {
throw new Error('Cannot find quote note model');
}
const quoteModel = quoteNote.children[0] as ParagraphBlockModel;
expect(quoteModel.flavour).toBe('affine:paragraph');
expect(quoteModel.props.type).toBe('quote');
});
test('markdown code shortcut converts paragraph to code block with language', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '```ts ', 'code-block');
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const model = note.children[0];
expect(model.flavour).toBe('affine:code');
expect((model as any).props.language).toBe('typescript');
});
test('inline markdown converts style and link attributes', async () => {
const { paragraphId: boldParagraphId } = await createParagraph();
await triggerMarkdown(boldParagraphId, '**bold** ', 'bold');
const boldRichText = getRichTextByBlockId(boldParagraphId);
expect(boldRichText.inlineEditor.yTextString).toBe('bold');
expect(
boldRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
bold: true,
});
const { paragraphId: codeParagraphId } = await createParagraph();
await triggerMarkdown(codeParagraphId, '`code` ', 'code');
const codeRichText = getRichTextByBlockId(codeParagraphId);
expect(codeRichText.inlineEditor.yTextString).toBe('code');
expect(
codeRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
code: true,
});
const { paragraphId: linkParagraphId } = await createParagraph();
await triggerMarkdown(
linkParagraphId,
'[AFFiNE](https://affine.pro) ',
'link'
);
const linkRichText = getRichTextByBlockId(linkParagraphId);
expect(linkRichText.inlineEditor.yTextString).toBe('AFFiNE');
expect(
linkRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
link: 'https://affine.pro',
});
});
});
describe('hotkey/bracket/linked-page', () => {
test('bracket keymap skips redundant right bracket in code block', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '```ts ', 'code-block');
const note = doc.getBlock(noteId)?.model;
const codeId = note?.children[0]?.id;
if (!codeId) {
throw new Error('Cannot find code block id');
}
const codeModel = doc.getBlock(codeId)?.model;
if (!codeModel) {
throw new Error('Cannot find code block model');
}
const keymap = textKeymap(editor.std);
const leftHandler = keymap['('];
const rightHandler = keymap[')'];
expect(leftHandler).toBeDefined();
if (!rightHandler) {
throw new Error('Cannot find bracket key handlers');
}
doc.updateBlock(codeModel, {
text: new Text('()'),
});
await wait();
const codeRichText = getRichTextByBlockId(codeId);
setTextSelection(codeId, 1, 0);
const rightContext = mockKeyboardContext();
rightHandler(rightContext.ctx);
expect(rightContext.preventDefault).not.toHaveBeenCalled();
expect(codeRichText.inlineEditor.yTextString).toBe('()');
});
test('consecutive linked-page reference nodes render as separate references', async () => {
const { paragraphId } = await createParagraph();
const paragraphModel = doc.getBlock(paragraphId)?.model as
| ParagraphBlockModel
| undefined;
if (!paragraphModel) {
throw new Error('Cannot find paragraph model');
}
const linkedDoc = createDefaultDoc(collection, {
title: 'Linked page',
});
setTextSelection(paragraphId, 0, 0);
insertContent(editor.std, paragraphModel, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: linkedDoc.id,
},
});
insertContent(editor.std, paragraphModel, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: linkedDoc.id,
},
});
await wait();
expect(collection.docs.has(linkedDoc.id)).toBe(true);
const richText = getRichTextByBlockId(paragraphId);
expect(richText.querySelectorAll('affine-reference').length).toBe(2);
expect(richText.inlineEditor.yTextString.length).toBe(2);
});
});
describe('slash-menu action semantics', () => {
test('date and move actions mutate block content/order as expected', async () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const first = note.children[0] as ParagraphBlockModel;
const secondId = doc.addBlock(
'affine:paragraph',
{ text: new Text('second') },
noteId
);
const second = doc.getBlock(secondId)?.model as
| ParagraphBlockModel
| undefined;
if (!second) {
throw new Error('Cannot find second paragraph model');
}
doc.updateBlock(first, { text: new Text('first') });
await wait();
const slashItems = defaultSlashMenuConfig.items;
const items =
typeof slashItems === 'function'
? slashItems({ std: editor.std, model: first })
: slashItems;
const today = findSlashActionItem(items, 'Today');
const moveDown = findSlashActionItem(items, 'Move Down');
const moveUp = findSlashActionItem(items, 'Move Up');
moveDown.action({ std: editor.std, model: first });
await wait();
expect(note.children.map(child => child.id)).toEqual([second.id, first.id]);
moveUp.action({ std: editor.std, model: first });
await wait();
expect(note.children.map(child => child.id)).toEqual([first.id, second.id]);
setTextSelection(first.id, 0, 0);
today.action({ std: editor.std, model: first });
await wait();
const richText = getRichTextByBlockId(first.id);
expect(richText.inlineEditor.yTextString).toMatch(/\d{4}-\d{2}-\d{2}/);
});
});

View File

@@ -19,7 +19,11 @@ export default defineConfig(_configEnv =>
browser: {
enabled: true,
headless: process.env.CI === 'true',
instances: [{ browser: 'chromium' }],
instances: [
{ browser: 'chromium' },
{ browser: 'firefox' },
{ browser: 'webkit' },
],
provider: 'playwright',
isolate: false,
viewport: {

View File

@@ -1,12 +1,28 @@
import { getCurrentUserQuery } from '@affine/graphql';
import { JobExecutor } from '../../../base/job/queue/executor';
import { DatabaseDocReader, DocReader } from '../../../core/doc';
import { createApp } from '../create-app';
import { e2e } from '../test';
e2e('should init doc service', async t => {
type TestFlavor = 'doc' | 'graphql' | 'sync' | 'renderer' | 'front';
const createFlavorApp = async (flavor: TestFlavor) => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'doc';
await using app = await createApp();
globalThis.env.FLAVOR = flavor;
return await createApp({
tapModule(module) {
module.overrideProvider(JobExecutor).useValue({
onConfigInit: async () => {},
onConfigChanged: async () => {},
onModuleDestroy: async () => {},
});
},
});
};
e2e('should init doc service', async t => {
await using app = await createFlavorApp('doc');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'doc');
@@ -15,9 +31,7 @@ e2e('should init doc service', async t => {
});
e2e('should init graphql service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'graphql';
await using app = await createApp();
await using app = await createFlavorApp('graphql');
const res = await app.GET('/info').expect(200);
@@ -28,28 +42,25 @@ e2e('should init graphql service', async t => {
});
e2e('should init sync service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'sync';
await using app = await createApp();
await using app = await createFlavorApp('sync');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'sync');
});
e2e('should init renderer service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'renderer';
await using app = await createApp();
await using app = await createFlavorApp('renderer');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'renderer');
});
e2e('should init front service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'front';
await using app = await createApp();
await using app = await createFlavorApp('front');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'front');
const docReader = app.get(DocReader);
t.true(docReader instanceof DatabaseDocReader);
});

View File

@@ -159,8 +159,11 @@ export function buildAppModule(env: Env) {
// basic
.use(...FunctionalityModules)
// enable indexer module on graphql server and doc service
.useIf(() => env.flavors.graphql || env.flavors.doc, IndexerModule)
// enable indexer module on graphql, doc and front service
.useIf(
() => env.flavors.graphql || env.flavors.doc || env.flavors.front,
IndexerModule
)
// auth
.use(UserModule, AuthModule, PermissionModule)
@@ -202,8 +205,8 @@ export function buildAppModule(env: Env) {
AccessTokenModule,
QueueDashboardModule
)
// doc service only
.useIf(() => env.flavors.doc, DocServiceModule)
// doc service and front service
.useIf(() => env.flavors.doc || env.flavors.front, DocServiceModule)
// worker for and self-hosted API only for self-host and local development only
.useIf(() => env.dev || env.selfhosted, WorkerModule, SelfhostModule)
// static frontend routes for front flavor

View File

@@ -31,8 +31,8 @@ export class JobExecutor implements OnModuleDestroy {
? difference(QUEUES, [Queue.DOC, Queue.INDEXER])
: [];
// NOTE(@forehalo): only enable doc queue in doc service
if (env.flavors.doc) {
// Enable doc/indexer queues in both doc and front service.
if (env.flavors.doc || env.flavors.front) {
queues.push(Queue.DOC);
// NOTE(@fengmk2): Once the index task cannot be processed in time, it needs to be separated from the doc service and deployed independently.
queues.push(Queue.INDEXER);

View File

@@ -447,7 +447,7 @@ export class RpcDocReader extends DatabaseDocReader {
export const DocReaderProvider: FactoryProvider = {
provide: DocReader,
useFactory: (ref: ModuleRef) => {
if (env.flavors.doc) {
if (env.flavors.doc || env.flavors.front) {
return ref.create(DatabaseDocReader);
}
return ref.create(RpcDocReader);

View File

@@ -6,7 +6,8 @@ textarea
-webkit-app-region: no-drag;
}
#webpack-dev-server-client-overlay {
#webpack-dev-server-client-overlay,
#rspack-dev-server-client-overlay {
-webkit-app-region: no-drag;
}

View File

@@ -1,6 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
export const mainContainer = style({
containerType: 'inline-size',
@@ -47,6 +47,12 @@ export const affineDocViewport = style({
},
});
export const pageModeViewportContentBox = style({});
globalStyle(`${pageModeViewportContentBox} >:first-child`, {
display: 'table !important',
minWidth: '100%',
});
export const scrollbar = style({
marginRight: '4px',
});

View File

@@ -347,7 +347,8 @@ const DetailPageImpl = memo(function DetailPageImpl() {
className={clsx(
'affine-page-viewport',
styles.affineDocViewport,
styles.editorContainer
styles.editorContainer,
{ [styles.pageModeViewportContentBox]: mode === 'page' }
)}
>
<PageDetailEditor onLoad={onLoad} readonly={readonly} />

View File

@@ -0,0 +1,8 @@
export const WORKSPACE_ROUTE_PATH = '/workspace/:workspaceId/*';
export const SHARE_ROUTE_PATH = '/share/:workspaceId/:pageId';
export const NOT_FOUND_ROUTE_PATH = '/404';
export const CATCH_ALL_ROUTE_PATH = '*';
export function getWorkspaceDocPath(workspaceId: string, docId: string) {
return `/workspace/${workspaceId}/${docId}`;
}

View File

@@ -10,6 +10,13 @@ import {
import { AffineErrorComponent } from '../components/affine/affine-error-boundary/affine-error-fallback';
import { NavigateContext } from '../components/hooks/use-navigate-helper';
import { RootWrapper } from './pages/root';
import {
CATCH_ALL_ROUTE_PATH,
getWorkspaceDocPath,
NOT_FOUND_ROUTE_PATH,
SHARE_ROUTE_PATH,
WORKSPACE_ROUTE_PATH,
} from './route-paths';
export function RootRouter() {
const navigate = useNavigate();
@@ -38,17 +45,19 @@ export const topLevelRoutes = [
lazy: () => import('./pages/index'),
},
{
path: '/workspace/:workspaceId/*',
path: WORKSPACE_ROUTE_PATH,
lazy: () => import('./pages/workspace/index'),
},
{
path: '/share/:workspaceId/:pageId',
path: SHARE_ROUTE_PATH,
loader: ({ params }) => {
return redirect(`/workspace/${params.workspaceId}/${params.pageId}`);
return redirect(
getWorkspaceDocPath(params.workspaceId ?? '', params.pageId ?? '')
);
},
},
{
path: '/404',
path: NOT_FOUND_ROUTE_PATH,
lazy: () => import('./pages/404'),
},
{
@@ -175,7 +184,7 @@ export const topLevelRoutes = [
lazy: () => import('./pages/open-app'),
},
{
path: '*',
path: CATCH_ALL_ROUTE_PATH,
lazy: () => import('./pages/404'),
},
],

View File

@@ -18,6 +18,7 @@ import type { DocPropertiesStore } from '../stores/doc-properties';
import type { DocsStore } from '../stores/docs';
import type { DocCreateOptions } from '../types';
import { DocService } from './doc';
import { getDuplicatedDocTitle } from './duplicate-title';
const logger = new DebugLogger('DocsService');
@@ -286,13 +287,7 @@ export class DocsService extends Service {
});
// duplicate doc title
const originalTitle = sourceDoc.title$.value;
const lastDigitRegex = /\((\d+)\)$/;
const match = originalTitle.match(lastDigitRegex);
const newNumber = match ? parseInt(match[1], 10) + 1 : 1;
const newPageTitle =
originalTitle.replace(lastDigitRegex, '') + `(${newNumber})`;
targetDoc.changeDocTitle(newPageTitle);
targetDoc.changeDocTitle(getDuplicatedDocTitle(sourceDoc.title$.value));
// duplicate doc properties
const properties = sourceDoc.getProperties();

View File

@@ -0,0 +1,9 @@
const DUPLICATED_DOC_TITLE_SUFFIX = /\((\d+)\)$/;
export function getDuplicatedDocTitle(originalTitle: string) {
const match = originalTitle.match(DUPLICATED_DOC_TITLE_SUFFIX);
const nextSequence = match ? parseInt(match[1], 10) + 1 : 1;
return (
originalTitle.replace(DUPLICATED_DOC_TITLE_SUFFIX, '') + `(${nextSequence})`
);
}

View File

@@ -46,6 +46,10 @@ import type {
} from '../../workspace';
import { WorkspaceImpl } from '../../workspace/impls/workspace';
import { getWorkspaceProfileWorker } from './out-worker';
import {
dedupeWorkspaceIds,
normalizeWorkspaceIds,
} from './workspace-id-utils';
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
export const LOCAL_WORKSPACE_GLOBAL_STATE_KEY =
@@ -61,13 +65,6 @@ type GlobalStateStorageLike = {
set<T>(key: string, value: T): void;
};
function normalizeWorkspaceIds(ids: unknown): string[] {
if (!Array.isArray(ids)) {
return [];
}
return ids.filter((id): id is string => typeof id === 'string');
}
function getElectronGlobalStateStorage(): GlobalStateStorageLike | null {
if (!BUILD_CONFIG.isElectron) {
return null;
@@ -113,7 +110,7 @@ export function setLocalWorkspaceIds(
? idsOrUpdater(getLocalWorkspaceIds())
: idsOrUpdater
);
const deduplicated = [...new Set(next)];
const deduplicated = dedupeWorkspaceIds(next);
const globalState = getElectronGlobalStateStorage();
if (globalState) {
@@ -168,14 +165,12 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
}
setLocalWorkspaceIds(currentIds => {
return [
...new Set([
...currentIds,
...persistedIds,
...legacyIds,
...scannedIds,
]),
];
return dedupeWorkspaceIds([
...currentIds,
...persistedIds,
...legacyIds,
...scannedIds,
]);
});
})()
.catch(e => {

View File

@@ -0,0 +1,8 @@
export function normalizeWorkspaceIds(ids: unknown): string[] {
if (!Array.isArray(ids)) return [];
return ids.filter((id): id is string => typeof id === 'string');
}
export function dedupeWorkspaceIds(ids: string[]): string[] {
return [...new Set(ids)];
}

View File

@@ -1,4 +1,5 @@
/// <reference types="@webpack/env"" />
/// <reference types="@webpack/env" />
/// <reference types="@rspack/core/module" />
declare module '*.md' {
const text: string;

View File

@@ -9,7 +9,7 @@
"es-CL": 98,
"es": 96,
"fa": 96,
"fr": 98,
"fr": 100,
"hi": 1,
"it-IT": 98,
"it": 1,

View File

@@ -26,7 +26,7 @@
"DB_FILE_MIGRATION_FAILED": "La migration du fichier de base de données a échoué",
"DB_FILE_PATH_INVALID": "Le chemin d'accès du fichier de base de données est invalide",
"Date": "Date",
"Delete": "Supprimer objet",
"Delete": "Supprimer",
"Deleted": "Supprimer",
"Disable": "Désactiver",
"Disable Public Sharing": "Désactiver le Partage Public",
@@ -44,7 +44,7 @@
"Full Backup Hint": "Synchroniser toutes les données cloud et exporter une sauvegarde complète de l'espace de travail",
"Quick Export": "Exportation rapide",
"Quick Export Description": "Ignorer la synchronisation cloud et exporter rapidement les données actuelles (certaines pièces jointes ou documents peuvent manquer)",
"Export failed": "L'exportation à échouer",
"Export failed": "L'exportation a échoué",
"Export success": "Exporté avec succès",
"Export to HTML": "Exporter en HTML",
"Export to Markdown": "Exporter en Markdown",
@@ -68,7 +68,7 @@
"Invite Members": "Inviter des membres",
"Invite Members Message": "Les membres invités collaboreront avec vous dans l'espace de travail actuel",
"insufficient-team-seat": "Place d'équipe insuffisante",
"Joined Workspace": "L'espace de travail a été rejoint",
"Joined Workspace": "Espace de travail rejoint",
"Leave": "Quitter",
"Link": "Lien hypertexte (avec le texte sélectionné)",
"Loading": "Chargement...",
@@ -94,7 +94,7 @@
"Remove from workspace": "Retirer de l'espace de travail",
"Remove photo": "Supprimer la photo",
"Remove special filter": "Retirer le filtre spécial",
"Removed successfully": "Supprimer avec succès",
"Removed successfully": "Supprimé avec succès",
"Rename": "Renommer",
"Retry": "Réessayer",
"Save": "Enregistrer",
@@ -120,15 +120,15 @@
"Undo": "Annuler",
"Unpin": "Désépingler",
"Untitled": "Sans titre",
"Update workspace name success": "L'espace de travail à été renommé avec succès",
"Update workspace name success": "L'espace de travail a été renommé avec succès",
"Updated": "Mis à jour",
"Upload": "Uploader",
"Users": "Utilisateur",
"Upload": "Téléverser",
"Users": "Utilisateurs",
"Version": "Version",
"Visit Workspace": "Visiter l'espace de travail",
"Workspace Name": "Nom de l'espace de travail",
"Workspace Owner": "Propriétaire de lespace de travail",
"Workspace Profile": "Profil de l'Espace de travail",
"Workspace Profile": "Profil de l'espace de travail",
"Workspace Settings": "Paramètres de l'espace de travail",
"Workspace Settings with name": "Paramètres de {{name}}",
"Workspace saved locally": "{{name}} est sauvegardé localement",
@@ -140,7 +140,7 @@
"current": "actuel",
"created at": "créé à {{time}}",
"updated at": "dernière mise à jour à {{time}}",
"com.affine.aboutAFFiNE.autoCheckUpdate.description": "Vérifiez automatiquement pour de nouvelles mises à jour régulièrement.",
"com.affine.aboutAFFiNE.autoCheckUpdate.description": "Vérifie automatiquement la disponibilité de nouvelles mises à jour.",
"com.affine.aboutAFFiNE.autoCheckUpdate.title": "Vérifier automatiquement les mises à jours",
"com.affine.aboutAFFiNE.autoDownloadUpdate.description": "Télécharger les mises à jour automatiquement (pour cet appareil)",
"com.affine.aboutAFFiNE.autoDownloadUpdate.title": "Télécharger les mises à jour automatiquement",
@@ -188,7 +188,7 @@
"com.affine.ai-onboarding.general.get-started": "Commencer",
"com.affine.ai-onboarding.general.next": "Suivant",
"com.affine.ai-onboarding.general.prev": "Retour",
"com.affine.ai-onboarding.general.privacy": "En continuant, vous acceptez nos <a>Conditions AI</a>.",
"com.affine.ai-onboarding.general.privacy": "En continuant, vous acceptez nos <a>Conditions IA</a>.",
"com.affine.ai-onboarding.general.purchase": "Obtenir une utilisation illimitée",
"com.affine.ai-onboarding.general.skip": "Rappelez-moi plus tard",
"com.affine.ai-onboarding.general.try-for-free": "Essayer gratuitement",
@@ -205,7 +205,7 @@
"com.affine.ai.login-required.dialog-confirm": "Se connecter",
"com.affine.ai.login-required.dialog-content": "Pour utiliser AFFiNE IA, veuillez vous connecter à votre compte AFFiNE Cloud.",
"com.affine.ai.login-required.dialog-title": "Connectez-vous pour continuer",
"com.affine.ai.template-insert.failed": "Échec lors de l'insertion du modele, veuillez réessayer",
"com.affine.ai.template-insert.failed": "Échec lors de l'insertion du modèle, veuillez réessayer.",
"com.affine.all-pages.header": "Tous les documents",
"com.affine.app-sidebar.learn-more": "En savoir plus",
"com.affine.app-sidebar.star-us": "Étoilez-nous",
@@ -271,7 +271,7 @@
"com.affine.auth.open.affine.doc.edit-settings": "Modifier les paramètres",
"com.affine.auth.open.affine.doc.footer-text": "Nécessite la version 0.18 ou plus de l'application de bureau AFFiNE.",
"com.affine.auth.page.sent.email.subtitle": "Merci de rentrer un mot de passe de {{min}}-{{max}} caractères avec des lettres et des numéros pour continuer à vous créer un compte",
"com.affine.auth.page.sent.email.title": "Bienvenu au AFFiNE Cloud, vous êtes presque !",
"com.affine.auth.page.sent.email.title": "Bienvenue sur AFFiNE Cloud, vous y êtes presque !",
"com.affine.auth.password": "Mot de passe",
"com.affine.auth.password.error": "Mot de passe invalide",
"com.affine.auth.password.set-failed": "Échec de la définition du mot de passe",
@@ -293,15 +293,15 @@
"com.affine.auth.sent.change.email.hint": "Le lien de vérification a été envoyé",
"com.affine.auth.sent.change.password.hint": "Le lien de réinitialisation de mot de passe a été envoyé",
"com.affine.auth.sent.reset.password.success.message": "Votre mot de passe a été mis à jour ! Vous pouvez vous connecter à AFFiNE Cloud avec le nouveau mot de passe !",
"com.affine.auth.sent.set.password.hint": "Le lien pour définir votre mot de passe à été envoyé",
"com.affine.auth.sent.set.password.success.message": "Votre mot de passe est enregistré! Vous pouvez vous connecter sur AFFINE Cloud avec votre email et votre mot de passe!",
"com.affine.auth.sent.set.password.hint": "Le lien pour définir votre mot de passe a été envoyé.",
"com.affine.auth.sent.set.password.success.message": "Votre mot de passe a été enregistré ! Vous pouvez vous connecter à AFFiNE Cloud avec votre e-mail et votre mot de passe.",
"com.affine.auth.sent.verify.email.hint": "Le lien de vérification a été envoyé",
"com.affine.auth.set.email.save": "Enregistrer le mail",
"com.affine.auth.set.password": "Définir le mot de passe",
"com.affine.auth.set.password.message": "Merci de rentrer un mot de passe de {{min}}-{{max}} caractères avec des lettres et des numéros pour continuer à vous créer un compte",
"com.affine.auth.set.password.message.maxlength": "Maximum {{max}} caractères",
"com.affine.auth.set.password.message.minlength": "Minimum {{max}} caractères",
"com.affine.auth.set.password.page.success": "Mot de passe définit avec succès",
"com.affine.auth.set.password.message.minlength": "Minimum {{min}} caractères",
"com.affine.auth.set.password.page.success": "Mot de passe défini avec succès",
"com.affine.auth.set.password.page.title": "Définir votre mot de passe pour AFFiNE Cloud",
"com.affine.auth.set.password.placeholder": "Définissez un mot de passe d'au moins {{min}} caractères",
"com.affine.auth.set.password.placeholder.confirm": "Confirmer votre mot de passe",
@@ -423,7 +423,7 @@
"com.affine.collection.allCollections": "Toutes les collections",
"com.affine.collection.emptyCollection": "Collection vide",
"com.affine.collection.emptyCollectionDescription": "La collection est un dossier intelligent auquel vous pouvez ajouter des documents manuellement ou automatiquement à l'aide de règles.",
"com.affine.collection.helpInfo": "AIDE INFO",
"com.affine.collection.helpInfo": "Informations",
"com.affine.collection.menu.edit": "Modifier la collection",
"com.affine.collection.menu.rename": "Renommer",
"com.affine.collection.removePage.success": "Supprimer avec succès",
@@ -581,7 +581,7 @@
"com.affine.history.confirm-restore-modal.hint": "Vous êtes sur le point de restaurer la version actuelle du document vers la dernière version disponible. Cette action écrasera toutes les modifications apportées à la dernière version.",
"com.affine.history.confirm-restore-modal.load-more": "Charger plus",
"com.affine.history.confirm-restore-modal.plan-prompt.limited-title": "HISTORIQUE DES DOCUMENTS LIMITÉS",
"com.affine.history.confirm-restore-modal.plan-prompt.title": "AIDE INFO",
"com.affine.history.confirm-restore-modal.plan-prompt.title": "Informations",
"com.affine.history.confirm-restore-modal.pro-plan-prompt.description": "Avec le compte payant du créateur de l'espace de travail, tous les membres ont le privilège d'accéder à jusqu'à <1>30 jours<1> d'historique des versions.",
"com.affine.history.confirm-restore-modal.pro-plan-prompt.upgrade": "Passer à la version Pro",
"com.affine.history.confirm-restore-modal.restore": "Restaurer",
@@ -833,7 +833,7 @@
"com.affine.pageMode.all": "tout",
"com.affine.pageMode.edgeless": "Mode sans bords",
"com.affine.pageMode.page": "Page",
"com.affine.payment.ai-upgrade-success-page.text": "Félicitations pour votre achat d'AFFiNE IA ! Vous avez désormais la possibilité de perfectionner votre contenu, de générer des images et de créer des cartes mentales complètes directement avec AFFiNE AI, améliorant considérablement votre productivité.",
"com.affine.payment.ai-upgrade-success-page.text": "Félicitations pour votre achat d'AFFiNE IA ! Vous avez désormais la possibilité de perfectionner votre contenu, de générer des images et de créer des cartes mentales complètes directement avec AFFiNE IA, améliorant considérablement votre productivité.",
"com.affine.payment.ai-upgrade-success-page.title": "Achat réussi !",
"com.affine.payment.ai.action.cancel.button-label": "Annuler l'abonnement",
"com.affine.payment.ai.action.cancel.confirm.cancel-text": "Garder AFFiNE IA",
@@ -871,7 +871,7 @@
"com.affine.payment.ai.pricing-plan.title-caption-2": "Un véritable copilote intelligent multimodale.",
"com.affine.payment.ai.subscribe.billed-annually": "Facturé annuellement",
"com.affine.payment.ai.usage-description-purchased": "Vous avez acheté AFFiNE IA.",
"com.affine.payment.ai.usage-title": "Utilisation d'AFFiNE AI",
"com.affine.payment.ai.usage-title": "Utilisation d'AFFiNE IA",
"com.affine.payment.ai.usage.change-button-label": "Passé à la version Pro",
"com.affine.payment.ai.usage.purchase-button-label": "Passer à la version Pro",
"com.affine.payment.ai.usage.used-caption": "Nombre d'utilisation",
@@ -1323,12 +1323,12 @@
"com.affine.settings.editorSettings.edgeless.text.font-style": "Style de police",
"com.affine.settings.editorSettings.edgeless.text.font-weight": "Poids de la police",
"com.affine.settings.editorSettings.general": "Général",
"com.affine.settings.editorSettings.general.ai.description": "Activer l'assistant AI puissant, AFFiNE AI.",
"com.affine.settings.editorSettings.general.ai.disable.confirm": "Désactiver l'AI et recharger",
"com.affine.settings.editorSettings.general.ai.disable.description": "Êtes-vous sûr de vouloir désactiver l'AI ? Nous apprécions votre productivité et notre AI peut l'améliorer. Réfléchissez-y à deux fois !",
"com.affine.settings.editorSettings.general.ai.description": "Activer l'assistant IA puissant, AFFiNE IA.",
"com.affine.settings.editorSettings.general.ai.disable.confirm": "Désactiver l'IA et recharger",
"com.affine.settings.editorSettings.general.ai.disable.description": "Êtes-vous sûr de vouloir désactiver l'IA ? Nous apprécions votre productivité et notre IA peut l'améliorer. Réfléchissez-y à deux fois !",
"com.affine.settings.editorSettings.general.ai.disable.title": "Désactiver l'IA ?",
"com.affine.settings.editorSettings.general.ai.enable.confirm": "Activer l'AI et recharger",
"com.affine.settings.editorSettings.general.ai.enable.description": "Souhaitez-vous activer l'AI ? Notre assistant AI est prêt à améliorer votre productivité et à offrir une assistance intelligente. Commençons ! Nous devons recharger la page pour effectuer ce changement.",
"com.affine.settings.editorSettings.general.ai.enable.confirm": "Activer l'IA et recharger",
"com.affine.settings.editorSettings.general.ai.enable.description": "Souhaitez-vous activer l'IA ? Notre assistant IA est prêt à améliorer votre productivité et à offrir une assistance intelligente. Commençons ! Nous devons recharger la page pour effectuer ce changement.",
"com.affine.settings.editorSettings.general.ai.enable.title": "Activer l'IA ?",
"com.affine.settings.editorSettings.general.ai.title": "AFFiNE IA",
"com.affine.settings.editorSettings.general.default-code-block.language.description": "Définir un langage de programmation par défaut.",
@@ -1396,22 +1396,22 @@
"com.affine.settings.translucent-style": "UI translucide sur la barre latérale",
"com.affine.settings.translucent-style-description": "Utiliser l'effet translucide sur la barre latérale",
"com.affine.settings.meetings": "Réunions",
"com.affine.settings.meetings.setting.welcome": "Au-delà de l'enregistrement\nVotre assistant de réunion AI est ici",
"com.affine.settings.meetings.setting.welcome": "Au-delà de l'enregistrement\nVotre assistant de réunion IA est disponible",
"com.affine.settings.meetings.setting.prompt": "Capture audio native, pas de robots requis - Directement de votre Mac à l'intelligence des réunions.",
"com.affine.settings.meetings.setting.prompt.2": "Fonctionnalités de réunion disponibles <strong>gratuitement</strong> en phase bêta",
"com.affine.settings.meetings.setting.welcome.hints": "<strong> Où l'AI rencontre vos réunions - affine votre collaboration.</strong>\n<ul><li>Extraire instantanément les éléments d'action et les idées clés</li><li>La capture automatique intelligente commence avec votre réunion</li><li>Intégration transparente sur toutes les plateformes de réunion</li><li>Un espace unifié pour tout le contexte de votre réunion</li><li>Votre assistant AI avec chaque contexte de réunion préservé</li></ul>",
"com.affine.settings.meetings.setting.welcome.hints": "<strong>L'IA au service de vos réunions pour fluidifier la collaboration.</strong>\n<ul><li>Extraction instantanée des actions à mener et des points clés</li><li>Capture intelligente automatique au démarrage de la réunion</li><li>Intégration transparente avec les principales plateformes de réunion</li><li>Espace unifié pour centraliser le contexte de vos réunions</li><li>Assistant IA avec conservation de l'historique de chaque réunion</li></ul>",
"com.affine.settings.meetings.setting.welcome.learn-more": "En savoir plus",
"com.affine.settings.meetings.enable.title": "Activer les notes de réunion",
"com.affine.settings.meetings.enable.description": "Utilisez les notes de réunion et les fonctionnalités de résumé AI fournies par AFFiNE. <1>Discutez-en plus dans la communauté</1>.",
"com.affine.settings.meetings.enable.description": "Utilisez les notes de réunion et les fonctionnalités de synthèse IA fournies par AFFiNE. <1>En savoir plus dans la communauté</1>.",
"com.affine.settings.meetings.record.header": "Enregistrement de la réunion",
"com.affine.settings.meetings.record.recording-mode": "Quand la réunion commence",
"com.affine.settings.meetings.record.recording-mode.description": "Choisissez le comportement au démarrage de la réunion.",
"com.affine.settings.meetings.record.open-saved-file": "Ouvrir les enregistrements sauvegardés",
"com.affine.settings.meetings.record.open-saved-file.description": "Ouvrez les fichiers d'enregistrement stockés localement.",
"com.affine.settings.meetings.transcription.header": "Transcription avec IA",
"com.affine.settings.meetings.transcription.auto-summary": "Résumé automatique de l'AI",
"com.affine.settings.meetings.transcription.auto-summary": "Résumé automatique de l'IA",
"com.affine.settings.meetings.transcription.auto-summary.description": "Générez automatiquement un résumé des notes de réunion.",
"com.affine.settings.meetings.transcription.auto-todo": "Liste de tâches automatique AI",
"com.affine.settings.meetings.transcription.auto-todo": "Liste de tâches automatique IA",
"com.affine.settings.meetings.transcription.auto-todo.description": "Générez automatiquement une liste de tâches des notes de réunion.",
"com.affine.settings.meetings.privacy.header": "Confidentialité et sécurité",
"com.affine.settings.meetings.privacy.screen-system-audio-recording": "Enregistrement de l'écran et de l'audio système",
@@ -1444,10 +1444,10 @@
"com.affine.settings.workspace.experimental-features.enable-ai.description": "Activer ou désactiver toutes les fonctionnalités d'IA.",
"com.affine.settings.workspace.experimental-features.enable-ai-network-search.name": "Activer la recherche de réseau IA",
"com.affine.settings.workspace.experimental-features.enable-ai-network-search.description": "Activer ou désactiver la fonction de recherche de réseau IA.",
"com.affine.settings.workspace.experimental-features.enable-ai-model-switch.name": "Activer l'interrupteur du modèle AI",
"com.affine.settings.workspace.experimental-features.enable-ai-model-switch.description": "Activer ou désactiver la fonction de commutation de modèle AI.",
"com.affine.settings.workspace.experimental-features.enable-ai-playground.name": "Activer AI Playground",
"com.affine.settings.workspace.experimental-features.enable-ai-playground.description": "Activer ou désactiver la fonction AI Playground.",
"com.affine.settings.workspace.experimental-features.enable-ai-model-switch.name": "Activer le sélecteur de modèle IA",
"com.affine.settings.workspace.experimental-features.enable-ai-model-switch.description": "Activer ou désactiver la fonctionnalité de sélection de modèle IA.",
"com.affine.settings.workspace.experimental-features.enable-ai-playground.name": "Activer le bac à sable IA",
"com.affine.settings.workspace.experimental-features.enable-ai-playground.description": "Activer ou désactiver la fonctionnalité de bac à sable IA.",
"com.affine.settings.workspace.experimental-features.enable-database-full-width.name": "Largeur complète de la base de données",
"com.affine.settings.workspace.experimental-features.enable-database-full-width.description": "La base de données sera affichée en mode pleine largeur.",
"com.affine.settings.workspace.experimental-features.enable-database-attachment-note.name": "Note de l'attachement de la base de données",
@@ -1591,8 +1591,8 @@
"com.affine.settings.workspace.sharing.url-preview.description": "Autoriser le dépliage d'URL par Slack et d'autres applications sociales, même si un document est uniquement accessible par les membres de l'espace de travail.",
"com.affine.settings.workspace.sharing.url-preview.title": "Toujours activer l&#39;aperçu de l&#39;URL",
"com.affine.settings.workspace.affine-ai.title": "AFFiNE IA",
"com.affine.settings.workspace.affine-ai.label": "Autoriser l'assistant AFFiNE AI",
"com.affine.settings.workspace.affine-ai.description": "Permettre aux membres de l'espace de travail d'utiliser les fonctionnalités AFFiNE AI. Ce paramètre n'affecte pas la facturation. Les membres de l'espace de travail utilisent AFFiNE AI à travers leurs comptes personnels.",
"com.affine.settings.workspace.affine-ai.label": "Autoriser l'assistant AFFiNE IA",
"com.affine.settings.workspace.affine-ai.description": "Permettre aux membres de l'espace de travail d'utiliser les fonctionnalités AFFiNE IA. Ce paramètre n'affecte pas la facturation. Les membres de l'espace de travail utilisent AFFiNE IA via leurs comptes personnels.",
"com.affine.settings.workspace.backup": "Espaces de travail archivés",
"com.affine.settings.workspace.backup.subtitle": "Gérer les fichiers d'espaces de travail locaux archivés",
"com.affine.settings.workspace.backup.empty": "Aucun fichier d'espace de travail archivé trouvé",
@@ -1604,7 +1604,7 @@
"com.affine.settings.workspace.backup.import.success.action": "Ouvrir",
"com.affine.settings.workspace.backup.delete-at": "Supprimé le {{date}} à {{time}}",
"com.affine.settings.workspace.indexer-embedding.title": "Indexeur & Intégration",
"com.affine.settings.workspace.indexer-embedding.description": "Gérer l'indexation AFFiNE et l'intégration AI pour le traitement de contenu local",
"com.affine.settings.workspace.indexer-embedding.description": "Gérer l'indexation AFFiNE et l'intégration IA pour le traitement du contenu local",
"com.affine.settings.workspace.indexer-embedding.embedding.title": "Intégration",
"com.affine.settings.workspace.indexer-embedding.embedding.description": "L'intégration permet à l'IA de récupérer votre contenu. Si l'indexeur utilise des paramètres locaux, cela peut affecter certains résultats de l'intégration.",
"com.affine.settings.workspace.indexer-embedding.embedding.disabled-tooltip": "Seul le propriétaire de l'espace de travail peut activer l'intégration de l'espace de travail.",
@@ -2071,8 +2071,8 @@
"com.affine.integration.mcp-server.desc": "Permettre à d'autres clients MCP de rechercher et de consulter la documentation d'AFFiNE.",
"com.affine.audio.notes": "Notes",
"com.affine.audio.transcribing": "Transcription en cours",
"com.affine.audio.transcribe.non-owner.confirm.title": "Impossible de récupérer les résultats AI pour les autres",
"com.affine.audio.transcribe.non-owner.confirm.message": "Veuillez contacter <1>{{user}}</1> pour mettre à niveau les droits AI ou renvoyer la pièce jointe.",
"com.affine.audio.transcribe.non-owner.confirm.title": "Impossible de récupérer les résultats IA pour les autres",
"com.affine.audio.transcribe.non-owner.confirm.message": "Veuillez contacter <1>{{user}}</1> pour mettre à niveau les droits IA ou renvoyer la pièce jointe.",
"com.affine.recording.new": "Activité audio",
"com.affine.recording.success.prompt": "Terminé",
"com.affine.recording.success.button": "Ouvrir l'application",
@@ -2099,7 +2099,7 @@
"com.affine.comment.filter.only-current-mode": "Seul le mode actuel",
"com.affine.payment.subscription.title": "Débloquer plus de fonctionnalités",
"com.affine.payment.subscription.description": "L'éditeur universel qui vous permet de travailler, de vous divertir, de présenter ou de créer presque tout.",
"com.affine.payment.subscription.button": "Mettre a niveau",
"com.affine.payment.subscription.button": "Mettre à niveau",
"com.affine.comment.reply": "Réponse",
"com.affine.comment.copy-link": "Copier le lien",
"com.affine.context-menu.copy": "Copier",
@@ -2159,9 +2159,9 @@
"error.BLOB_NOT_FOUND": "Blob {{blobId}} introuvable dans l'Espace {{spaceId}}.",
"error.EXPECT_TO_PUBLISH_DOC": "Attendu publier un document, pas un espace.",
"error.EXPECT_TO_REVOKE_PUBLIC_DOC": "Attendu révoquer un document public, pas un espace.",
"error.EXPECT_TO_GRANT_DOC_USER_ROLES": "Attendu accorder des rôles sur le doc {{docId}} sous l'espace {{spaceId}}, pas un espace.",
"error.EXPECT_TO_REVOKE_DOC_USER_ROLES": "Attendu révoquer des rôles sur le doc {{docId}} sous l'espace {{spaceId}}, pas un espace.",
"error.EXPECT_TO_UPDATE_DOC_USER_ROLE": "Attendu mettre à jour des rôles sur le doc {{docId}} sous l'espace {{spaceId}}, pas un espace.",
"error.EXPECT_TO_GRANT_DOC_USER_ROLES": "Accord de rôles attendu sur le document {{docId}} dans l'espace {{spaceId}}, pas sur un espace.",
"error.EXPECT_TO_REVOKE_DOC_USER_ROLES": "Révocation de rôles attendue sur le document {{docId}} dans l'espace {{spaceId}}, pas sur un espace.",
"error.EXPECT_TO_UPDATE_DOC_USER_ROLE": "Mise à jour de rôles attendue sur le document {{docId}} dans l'espace {{spaceId}}, pas sur un espace.",
"error.DOC_IS_NOT_PUBLIC": "Le document n'est pas public.",
"error.FAILED_TO_SAVE_UPDATES": "Échec de l'enregistrement des mises à jour du document.",
"error.FAILED_TO_UPSERT_SNAPSHOT": "Échec de l'enregistrement du snapshot du document.",
@@ -2233,7 +2233,7 @@
"error.LICENSE_EXPIRED": "La licence a expiré.",
"error.UNSUPPORTED_CLIENT_VERSION": "Client non pris en charge avec la version [{{clientVersion}}], la version requise est [{{requiredVersion}}].",
"error.NOTIFICATION_NOT_FOUND": "Notification non trouvée.",
"error.MENTION_USER_DOC_ACCESS_DENIED": "L'utilisateur mentionné ne peut pas accéder au doc {{docId}}.",
"error.MENTION_USER_DOC_ACCESS_DENIED": "L'utilisateur mentionné ne peut pas accéder au document {{docId}}.",
"error.MENTION_USER_ONESELF_DENIED": "Vous ne pouvez pas vous mentionner vous-même.",
"error.INVALID_APP_CONFIG": "Configuration d'application invalide pour le module `{{module}}` avec la clé `{{key}}`. {{hint}}.",
"error.INVALID_APP_CONFIG_INPUT": "Entrée de configuration d'application invalide : {{message}}",
@@ -2243,5 +2243,71 @@
"error.COMMENT_NOT_FOUND": "Commentaire non trouvé.",
"error.REPLY_NOT_FOUND": "Réponse non trouvée.",
"error.COMMENT_ATTACHMENT_NOT_FOUND": "Pièce jointe du commentaire introuvable.",
"error.COMMENT_ATTACHMENT_QUOTA_EXCEEDED": "Vous avez dépassé le quota de taille de pièce jointe de commentaire."
"error.COMMENT_ATTACHMENT_QUOTA_EXCEEDED": "Vous avez dépassé le quota de taille de pièce jointe de commentaire.",
"com.affine.ai.chat-panel.embedding-progress": "Intégration {{done}}/{{total}}",
"com.affine.ai.chat-panel.loading-history": "AFFiNE IA charge l'historique...",
"com.affine.ai.chat-panel.session.delete.confirm.message": "Voulez-vous supprimer cet historique de conversation IA ? Une fois supprimé, il ne pourra pas être récupéré.",
"com.affine.ai.chat-panel.session.delete.confirm.title": "Supprimer cet historique ?",
"com.affine.ai.chat-panel.session.delete.toast.failed": "Échec de la suppression de l'historique",
"com.affine.ai.chat-panel.session.delete.toast.success": "Historique supprimé",
"com.affine.ai.chat-panel.title": "AFFiNE IA",
"com.affine.appearanceSettings.images.antialiasing.description": "Lorsqu'elle est désactivée, les images sont rendues avec un redimensionnement au plus proche voisin pour des pixels nets.",
"com.affine.appearanceSettings.images.antialiasing.title": "Rendu lissé des images",
"com.affine.appearanceSettings.images.title": "Images",
"com.affine.doc.analytics.chart.total-views": "Vues totales",
"com.affine.doc.analytics.chart.unique-views": "Vues uniques",
"com.affine.doc.analytics.empty.no-page-views": "Aucune vue de page sur cette période.",
"com.affine.doc.analytics.empty.no-viewers": "Aucun lecteur sur cette période.",
"com.affine.doc.analytics.error.load-analytics": "Impossible de charger les statistiques.",
"com.affine.doc.analytics.error.load-viewers": "Impossible de charger les lecteurs.",
"com.affine.doc.analytics.metric.guest": "Invité",
"com.affine.doc.analytics.metric.total": "Total",
"com.affine.doc.analytics.metric.unique": "Unique",
"com.affine.doc.analytics.paywall.open-pricing": "Voir les offres tarifaires",
"com.affine.doc.analytics.paywall.toast": "Les statistiques de document au-delà de 7 jours nécessitent un abonnement AFFiNE Team.",
"com.affine.doc.analytics.summary.total": "({{count}} au total)",
"com.affine.doc.analytics.title": "Statistiques de vues",
"com.affine.doc.analytics.viewers.show-all": "Afficher tous les lecteurs",
"com.affine.doc.analytics.viewers.title": "Lecteurs",
"com.affine.doc.analytics.window.last-days": "{{days}} derniers jours",
"com.affine.docIconPicker.placeholder": "Ajouter une icône",
"com.affine.import.docx": "Docx",
"com.affine.integration.calendar.account.count": "{{count}} calendrier",
"com.affine.integration.calendar.account.link": "Lier",
"com.affine.integration.calendar.account.linked-empty": "Aucun compte calendrier lié pour le moment.",
"com.affine.integration.calendar.account.load-error": "Échec du chargement des comptes calendrier",
"com.affine.integration.calendar.account.status.failed": "Autorisation échouée : {{error}}",
"com.affine.integration.calendar.account.status.failed-reconnect": "Autorisation échouée. Veuillez reconnecter votre compte.",
"com.affine.integration.calendar.account.unlink": "Dissocier",
"com.affine.integration.calendar.account.unlink-error": "Échec de la dissociation du compte calendrier",
"com.affine.integration.calendar.auth.start-error": "Échec du démarrage de l'autorisation calendrier",
"com.affine.integration.calendar.caldav.field.displayName": "Nom d'affichage (optionnel)",
"com.affine.integration.calendar.caldav.field.displayName.placeholder": "Mon CalDAV",
"com.affine.integration.calendar.caldav.field.password": "Mot de passe",
"com.affine.integration.calendar.caldav.field.password.error": "Le mot de passe est requis.",
"com.affine.integration.calendar.caldav.field.password.placeholder": "Mot de passe ou mot de passe spécifique à l'application",
"com.affine.integration.calendar.caldav.field.provider": "Fournisseur",
"com.affine.integration.calendar.caldav.field.provider.error": "Veuillez sélectionner un fournisseur.",
"com.affine.integration.calendar.caldav.field.provider.placeholder": "Sélectionner un fournisseur",
"com.affine.integration.calendar.caldav.field.username": "Nom d'utilisateur",
"com.affine.integration.calendar.caldav.field.username.error": "Le nom d'utilisateur est requis.",
"com.affine.integration.calendar.caldav.field.username.placeholder": "email@example.com",
"com.affine.integration.calendar.caldav.hint.app-password": "Un mot de passe spécifique à l'application est requis.",
"com.affine.integration.calendar.caldav.hint.guide": "Guide de configuration du fournisseur",
"com.affine.integration.calendar.caldav.hint.learn-more": "En savoir plus",
"com.affine.integration.calendar.caldav.link.failed": "Échec de la liaison du compte CalDAV",
"com.affine.integration.calendar.caldav.link.title": "Lier un compte CalDAV",
"com.affine.integration.calendar.no-calendar": "Aucun calendrier abonné pour le moment.",
"com.affine.integration.calendar.no-journal": "Aucune page journal trouvée pour le {{date}}. Veuillez d'abord créer une page journal.",
"com.affine.integration.calendar.provider.load-error": "Échec du chargement des fournisseurs de calendrier",
"com.affine.integration.calendar.save-error": "Une erreur est survenue lors de l'enregistrement des paramètres de calendrier",
"com.affine.settings.workspace.sharing.workspace-sharing.description": "Contrôlez si les pages de cet espace de travail peuvent être partagées publiquement. Désactivez pour bloquer les nouveaux partages et l'accès externe aux partages existants.",
"com.affine.settings.workspace.sharing.workspace-sharing.title": "Autoriser le partage des pages de l'espace de travail",
"com.affine.share-menu.workspace-sharing.disabled.tooltip": "Le partage est désactivé pour cet espace de travail. Veuillez contacter un administrateur pour l'activer.",
"com.affine.workspaceSubPath.chat": "Intelligence",
"error.BLOB_INVALID": "Le blob est invalide.",
"error.CALENDAR_PROVIDER_REQUEST_ERROR": "Erreur de requête du fournisseur de calendrier, statut : {{status}}, message : {{message}}",
"error.MANAGED_BY_APP_STORE_OR_PLAY": "Cet abonnement est géré par l'App Store ou Google Play. Veuillez le gérer dans la boutique correspondante.",
"error.RESPONSE_TOO_LARGE_ERROR": "Réponse trop volumineuse ({{receivedBytes}} octets), la limite est de {{limitBytes}} octets",
"error.SSRF_BLOCKED_ERROR": "URL invalide"
}

View File

@@ -231,7 +231,7 @@ test('items in favourites can be reordered by dragging', async ({ page }) => {
});
// some how this test always timeout, so we skip it
test.skip('drag a page link in editor to favourites', async ({ page }) => {
test('drag a page link in editor to favourites', async ({ page }) => {
await clickNewPageButton(page);
await page.waitForTimeout(500);
await page.keyboard.press('Enter');

View File

@@ -1,5 +1,4 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export let coreUrl = 'http://localhost:8080';
@@ -21,6 +20,7 @@ export async function confirmCreateJournal(page: Page) {
}
export async function openJournalsPage(page: Page) {
const { expect } = await import('@playwright/test');
await page.getByTestId('slider-bar-journals-button').click();
await confirmCreateJournal(page);
await expect(

View File

@@ -19,6 +19,8 @@
"@affine/s3-compat": "workspace:*",
"@napi-rs/simple-git": "^0.1.22",
"@perfsee/webpack": "^1.13.0",
"@rspack/core": "^1.7.6",
"@rspack/dev-server": "^1.1.3",
"@sentry/webpack-plugin": "^4.0.0",
"@swc/core": "^1.10.1",
"@tailwindcss/postcss": "^4.0.0",

View File

@@ -0,0 +1,77 @@
import type { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
export const RSPACK_SUPPORTED_PACKAGES = [
'@affine/admin',
'@affine/web',
'@affine/mobile',
'@affine/ios',
'@affine/android',
'@affine/electron-renderer',
'@affine/server',
] 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(
`AFFINE_BUNDLER=rspack currently supports: ${Array.from(RSPACK_SUPPORTED_PACKAGES).join(', ')}. Use AFFINE_BUNDLER=webpack for ${name}.`
);
}
const IN_CI = !!process.env.CI;
const httpProxyMiddlewareLogLevel = IN_CI ? 'silent' : 'error';
export const DEFAULT_DEV_SERVER_CONFIG: WebpackDevServerConfiguration = {
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',
logLevel: httpProxyMiddlewareLogLevel,
},
{
context: '/socket.io',
target: 'http://localhost:3010',
ws: true,
logLevel: httpProxyMiddlewareLogLevel,
},
{
context: '/graphql',
target: 'http://localhost:3010',
logLevel: httpProxyMiddlewareLogLevel,
},
],
};

View File

@@ -3,20 +3,65 @@ 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 webpack from 'webpack';
import WebpackDevServer, {
type Configuration as DevServerConfiguration,
type Configuration as WebpackDevServerConfiguration,
} from 'webpack-dev-server';
import {
assertRspackSupportedPackageName,
DEFAULT_DEV_SERVER_CONFIG,
isRspackSupportedPackageName,
} from './bundle-shared';
import { type Bundler, getBundler } from './bundler';
import { Option, PackageCommand } from './command';
import {
createHTMLTargetConfig,
createNodeTargetConfig,
createWorkerTargetConfig,
createHTMLTargetConfig as createRspackHTMLTargetConfig,
createNodeTargetConfig as createRspackNodeTargetConfig,
createWorkerTargetConfig as createRspackWorkerTargetConfig,
} from './rspack';
import {
createHTMLTargetConfig as createWebpackHTMLTargetConfig,
createNodeTargetConfig as createWebpackNodeTargetConfig,
createWorkerTargetConfig as createWebpackWorkerTargetConfig,
} from './webpack';
import {
shouldUploadReleaseAssets,
uploadDistAssetsToS3,
} from './webpack/s3-plugin.js';
function getBaseWorkerConfigs(pkg: Package) {
type WorkerConfig = { name: string };
type CreateWorkerTargetConfig = (pkg: Package, entry: string) => WorkerConfig;
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
) {
const core = new Package('@affine/core');
return [
@@ -39,27 +84,30 @@ function getBaseWorkerConfigs(pkg: Package) {
];
}
function getBundleConfigs(pkg: Package): webpack.MultiConfiguration {
function getWebpackBundleConfigs(pkg: Package): webpack.MultiConfiguration {
switch (pkg.name) {
case '@affine/admin': {
return [
createHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value),
createWebpackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value),
] as webpack.MultiConfiguration;
}
case '@affine/web':
case '@affine/mobile':
case '@affine/ios':
case '@affine/android': {
const workerConfigs = getBaseWorkerConfigs(pkg);
const workerConfigs = getBaseWorkerConfigs(
pkg,
createWebpackWorkerTargetConfig
);
workerConfigs.push(
createWorkerTargetConfig(
createWebpackWorkerTargetConfig(
pkg,
pkg.srcPath.join('nbstore.worker.ts').value
)
);
return [
createHTMLTargetConfig(
createWebpackHTMLTargetConfig(
pkg,
pkg.srcPath.join('index.tsx').value,
{},
@@ -69,10 +117,13 @@ function getBundleConfigs(pkg: Package): webpack.MultiConfiguration {
] as webpack.MultiConfiguration;
}
case '@affine/electron-renderer': {
const workerConfigs = getBaseWorkerConfigs(pkg);
const workerConfigs = getBaseWorkerConfigs(
pkg,
createWebpackWorkerTargetConfig
);
return [
createHTMLTargetConfig(
createWebpackHTMLTargetConfig(
pkg,
{
index: pkg.srcPath.join('app/index.tsx').value,
@@ -93,7 +144,7 @@ function getBundleConfigs(pkg: Package): webpack.MultiConfiguration {
}
case '@affine/server': {
return [
createNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value),
createWebpackNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value),
] as webpack.MultiConfiguration;
}
}
@@ -101,55 +152,75 @@ function getBundleConfigs(pkg: Package): webpack.MultiConfiguration {
throw new Error(`Unsupported package: ${pkg.name}`);
}
const IN_CI = !!process.env.CI;
const httpProxyMiddlewareLogLevel = IN_CI ? 'silent' : 'error';
function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
assertRspackSupportedPackage(pkg);
const defaultDevServerConfig: DevServerConfiguration = {
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',
logLevel: httpProxyMiddlewareLogLevel,
},
{
context: '/socket.io',
target: 'http://localhost:3010',
ws: true,
logLevel: httpProxyMiddlewareLogLevel,
},
{
context: '/graphql',
target: 'http://localhost:3010',
logLevel: httpProxyMiddlewareLogLevel,
},
],
};
switch (pkg.name) {
case '@affine/admin': {
return [
createRspackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value),
] as MultiRspackOptions;
}
case '@affine/web':
case '@affine/mobile':
case '@affine/ios':
case '@affine/android': {
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/electron-renderer': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig
);
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;
}
}
throw new Error(`Unsupported package: ${pkg.name}`);
}
export class BundleCommand extends PackageCommand {
static override paths = [['bundle'], ['webpack'], ['pack'], ['bun']];
@@ -164,22 +235,36 @@ export class BundleCommand extends PackageCommand {
async execute() {
const pkg = this.workspace.getPackage(this.package);
const bundler = getBundler();
if (this.dev) {
await BundleCommand.dev(pkg);
await BundleCommand.dev(pkg, bundler);
} else {
await BundleCommand.build(pkg);
await BundleCommand.build(pkg, bundler);
}
}
static async build(pkg: Package) {
static async build(pkg: Package, bundler: Bundler = getBundler()) {
if (bundler === 'rspack' && !isRspackSupportedPackageName(pkg.name)) {
return BundleCommand.buildWithWebpack(pkg);
}
switch (bundler) {
case 'webpack':
return BundleCommand.buildWithWebpack(pkg);
case 'rspack':
return BundleCommand.buildWithRspack(pkg);
}
}
static async buildWithWebpack(pkg: Package) {
process.env.NODE_ENV = 'production';
const logger = new Logger('bundle');
logger.info(`Packing package ${pkg.name}...`);
logger.info(`Packing package ${pkg.name} with webpack...`);
logger.info('Cleaning old output...');
rmSync(pkg.distPath.value, { recursive: true, force: true });
const config = getBundleConfigs(pkg);
const config = getWebpackBundleConfigs(pkg);
config.parallelism = cpus().length;
const compiler = webpack(config);
@@ -187,28 +272,73 @@ export class BundleCommand extends PackageCommand {
throw new Error('Failed to create webpack compiler');
}
compiler.run((error, stats) => {
if (error) {
console.error(error);
process.exit(1);
}
if (stats) {
if (stats.hasErrors()) {
console.error(stats.toString('errors-only'));
process.exit(1);
} else {
console.log(stats.toString('minimal'));
try {
const stats = await new Promise<webpack.Stats | webpack.MultiStats>(
(resolve, reject) => {
compiler.run((error, stats) => {
if (error) {
reject(error);
return;
}
if (!stats) {
reject(new Error('Failed to get webpack 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 dev(pkg: Package, devServerConfig?: DevServerConfiguration) {
static async dev(
pkg: Package,
bundler: Bundler = getBundler(),
devServerConfig?:
| WebpackDevServerConfiguration
| RspackDevServerConfiguration
) {
if (bundler === 'rspack' && !isRspackSupportedPackageName(pkg.name)) {
return BundleCommand.devWithWebpack(
pkg,
devServerConfig as WebpackDevServerConfiguration | undefined
);
}
switch (bundler) {
case 'webpack':
return BundleCommand.devWithWebpack(
pkg,
devServerConfig as WebpackDevServerConfiguration | undefined
);
case 'rspack':
return BundleCommand.devWithRspack(
pkg,
devServerConfig as RspackDevServerConfiguration | undefined
);
}
}
static async devWithWebpack(
pkg: Package,
devServerConfig?: WebpackDevServerConfiguration
) {
process.env.NODE_ENV = 'development';
const logger = new Logger('bundle');
logger.info(`Starting dev server for ${pkg.name}...`);
logger.info(`Starting webpack dev server for ${pkg.name}...`);
const config = getBundleConfigs(pkg);
const config = getWebpackBundleConfigs(pkg);
config.parallelism = cpus().length;
const compiler = webpack(config);
@@ -217,7 +347,78 @@ export class BundleCommand extends PackageCommand {
}
const devServer = new WebpackDevServer(
merge({}, defaultDevServerConfig, devServerConfig),
merge({}, DEFAULT_DEV_SERVER_CONFIG, devServerConfig),
compiler
);
await devServer.start();
}
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 devServer = new RspackDevServer(
merge({}, DEFAULT_DEV_SERVER_CONFIG, devServerConfig),
compiler
);

27
tools/cli/src/bundler.ts Normal file
View File

@@ -0,0 +1,27 @@
export const SUPPORTED_BUNDLERS = ['webpack', 'rspack'] as const;
export type Bundler = (typeof SUPPORTED_BUNDLERS)[number];
export const DEFAULT_BUNDLER: Bundler = 'rspack';
function isBundler(value: string): value is Bundler {
return SUPPORTED_BUNDLERS.includes(value as Bundler);
}
export function normalizeBundler(input: string | undefined | null): Bundler {
const value = input?.trim().toLowerCase();
if (!value) {
return DEFAULT_BUNDLER;
}
if (isBundler(value)) {
return value;
}
throw new Error(
`Unsupported AFFINE_BUNDLER: "${input}". Expected one of: ${SUPPORTED_BUNDLERS.join(', ')}.`
);
}
export function getBundler(env: NodeJS.ProcessEnv = process.env): Bundler {
return normalizeBundler(env.AFFINE_BUNDLER);
}

View File

@@ -0,0 +1,633 @@
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 { sentryWebpackPlugin } from '@sentry/webpack-plugin';
import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin';
import cssnano from 'cssnano';
import { compact, merge } from 'lodash-es';
import { productionCacheGroups } from '../webpack/cache-group.js';
import {
type CreateHTMLPluginConfig,
createHTMLPlugins as createWebpackCompatibleHTMLPlugins,
} from '../webpack/html-plugin.js';
const require = createRequire(import.meta.url);
const IN_CI = !!process.env.CI;
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;
htmlConfig = merge(
{},
{
filename: 'index.html',
additionalEntryForSelfhost: true,
injectGlobalErrorHandler: true,
emitAssetsManifest: true,
},
htmlConfig
);
const buildConfig = getBuildConfigFromEnv(pkg);
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 webpack config
name: entry['index'],
dependencies: deps,
context: ProjectRoot.value,
experiments: {
topLevelAwait: true,
outputModule: false,
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,
},
},
//#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: pkg.join('tailwind.config.js').exists()
? [
[
'@tailwindcss/postcss',
require(pkg.join('tailwind.config.js').value),
],
['autoprefixer'],
]
: [
cssnano({
preset: ['default', { convertValues: false }],
}),
],
},
},
},
],
},
],
},
],
//#endregion
},
//#endregion
//#region plugins
plugins: compact([
!IN_CI && new rspack.ProgressPlugin(),
...createWebpackCompatibleHTMLPlugins(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 &&
new rspack.CopyRspackPlugin({
patterns: [
{
// copy the shared public assets into dist
from: new Package('@affine/core').join('public').value,
},
],
}),
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT &&
sentryWebpackPlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
}),
// 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,
removeAvailableModules: 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: {
topLevelAwait: true,
outputModule: false,
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 }),
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT &&
sentryWebpackPlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
}),
]),
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,
removeAvailableModules: true,
runtimeChunk: false,
splitChunks: false,
},
performance: { hints: false },
};
}
export function createNodeTargetConfig(
pkg: Package,
entry: string
): Omit<RspackConfiguration, 'name'> & { name: string } {
const dev = process.env.NODE_ENV === 'development';
return {
name: entry,
context: ProjectRoot.value,
experiments: {
topLevelAwait: true,
outputModule: pkg.packageJson.type === 'module',
asyncWebAssembly: true,
},
entry: { index: entry },
output: {
filename: `main.js`,
path: pkg.distPath.value,
clean: true,
globalObject: 'globalThis',
},
target: ['node', 'es2022'],
externals: ((data: any, callback: (err: null, value: boolean) => void) => {
if (
data.request &&
// import ... from 'module'
/^[a-zA-Z@]/.test(data.request) &&
// 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('../webpack/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: {
legacyDecorator: true,
decoratorMetadata: true,
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$)/],
};
}

View File

@@ -21,11 +21,18 @@ export const productionCacheGroups = {
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 (!module.context.includes('node_modules')) {
if (!modulePath.includes('node_modules')) {
return `app-async`;
}
const name = module.context.match(
const name = modulePath.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)?.[1];
return `npm-async-${name}`;

View File

@@ -5,8 +5,31 @@ import { Path, ProjectRoot } from '@affine-tools/utils/path';
import { Repository } from '@napi-rs/simple-git';
import HTMLPlugin from 'html-webpack-plugin';
import { once } from 'lodash-es';
import type { Compiler, WebpackPluginInstance } from 'webpack';
import webpack from 'webpack';
import type { WebpackPluginInstance } from 'webpack';
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;
@@ -86,7 +109,7 @@ function getHTMLPluginOptions(BUILD_CONFIG: BUILD_CONFIG_TYPE) {
}
const AssetsManifestPlugin = {
apply(compiler: Compiler) {
apply(compiler: CompilerLike) {
compiler.hooks.compilation.tap('assets-manifest-plugin', compilation => {
HTMLPlugin.getHooks(compilation).beforeAssetTagGeneration.tap(
'assets-manifest-plugin',
@@ -94,7 +117,8 @@ const AssetsManifestPlugin = {
if (!compilation.getAsset('assets-manifest.json')) {
compilation.emitAsset(
`assets-manifest.json`,
new webpack.sources.RawSource(
createRawSource(
compiler,
JSON.stringify(
{
...arg.assets,
@@ -125,7 +149,7 @@ const AssetsManifestPlugin = {
};
const GlobalErrorHandlerPlugin = {
apply(compiler: Compiler) {
apply(compiler: CompilerLike) {
const globalErrorHandler = [
'js/global-error-handler.js',
readFileSync(currentDir.join('./error-handler.js').toString(), 'utf-8'),
@@ -140,7 +164,7 @@ const GlobalErrorHandlerPlugin = {
if (!compilation.getAsset(globalErrorHandler[0])) {
compilation.emitAsset(
globalErrorHandler[0],
new webpack.sources.RawSource(globalErrorHandler[1])
createRawSource(compiler, globalErrorHandler[1])
);
arg.assets.js.unshift(
arg.assets.publicPath + globalErrorHandler[0]
@@ -156,7 +180,7 @@ const GlobalErrorHandlerPlugin = {
};
const CorsPlugin = {
apply(compiler: Compiler) {
apply(compiler: CompilerLike) {
compiler.hooks.compilation.tap('html-js-cors-plugin', compilation => {
HTMLPlugin.getHooks(compilation).alterAssetTags.tap(
'html-js-cors-plugin',

View File

@@ -18,7 +18,6 @@ import {
type CreateHTMLPluginConfig,
createHTMLPlugins,
} from './html-plugin.js';
import { WebpackS3Plugin } from './s3-plugin.js';
const require = createRequire(import.meta.url);
const cssnano = require('cssnano');
@@ -279,10 +278,6 @@ export function createHTMLTargetConfig(
},
],
}),
!buildConfig.debug &&
(buildConfig.isWeb || buildConfig.isMobileWeb || buildConfig.isAdmin) &&
process.env.R2_SECRET_ACCESS_KEY &&
new WebpackS3Plugin(),
!buildConfig.debug &&
process.env.PERFSEE_TOKEN &&
new PerfseePlugin({ project: 'affine-toeverything' }),

View File

@@ -1,43 +1,141 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
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';
import type { Compiler, WebpackPluginInstance } from 'webpack';
export const R2_BUCKET =
process.env.R2_BUCKET ??
(process.env.BUILD_TYPE === 'canary' ? 'assets-dev' : 'assets-prod');
export class WebpackS3Plugin implements WebpackPluginInstance {
private readonly s3 = createS3CompatClient(
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://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
},
{
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
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]!);
}
}
);
apply(compiler: Compiler) {
compiler.hooks.assetEmitted.tapPromise(
'WebpackS3Plugin',
async (asset, { outputPath }) => {
if (asset.endsWith('.html')) {
return;
}
const assetPath = join(outputPath, asset);
const assetSource = await readFile(assetPath);
const contentType = lookup(asset) || undefined;
await this.s3.putObject(asset, assetSource, {
contentType,
contentLength: assetSource.byteLength,
});
}
);
}
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);
});
}

614
yarn.lock
View File

@@ -119,6 +119,8 @@ __metadata:
"@affine/s3-compat": "workspace:*"
"@napi-rs/simple-git": "npm:^0.1.22"
"@perfsee/webpack": "npm:^1.13.0"
"@rspack/core": "npm:^1.7.6"
"@rspack/dev-server": "npm:^1.1.3"
"@sentry/webpack-plugin": "npm:^4.0.0"
"@swc/core": "npm:^1.10.1"
"@tailwindcss/postcss": "npm:^4.0.0"
@@ -4899,13 +4901,13 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/core@npm:^1.4.0, @emnapi/core@npm:^1.7.1":
version: 1.7.1
resolution: "@emnapi/core@npm:1.7.1"
"@emnapi/core@npm:^1.4.0, @emnapi/core@npm:^1.5.0, @emnapi/core@npm:^1.7.1":
version: 1.8.1
resolution: "@emnapi/core@npm:1.8.1"
dependencies:
"@emnapi/wasi-threads": "npm:1.1.0"
tslib: "npm:^2.4.0"
checksum: 10/260841f6dd2a7823a964d9de6da3a5e6f565dac8d21a5bd8f6215b87c45c22a4dc371b9ad877961579ee3cca8a76e55e3dd033ae29cba1998999cda6d794bdab
checksum: 10/904ea60c91fc7d8aeb4a8f2c433b8cfb47c50618f2b6f37429fc5093c857c6381c60628a5cfbc3a7b0d75b0a288f21d4ed2d4533e82f92c043801ef255fd6a5c
languageName: node
linkType: hard
@@ -8247,6 +8249,61 @@ __metadata:
languageName: node
linkType: hard
"@module-federation/error-codes@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/error-codes@npm:0.22.0"
checksum: 10/4edb269e9f3039899f879788c84d2bfecff94ca8e87ffcd80dbf8589d8543ec32558b3fa05c8549a8abd3ac33e856ff2aacf458dea5c0d7bea608bf12bb13359
languageName: node
linkType: hard
"@module-federation/runtime-core@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/runtime-core@npm:0.22.0"
dependencies:
"@module-federation/error-codes": "npm:0.22.0"
"@module-federation/sdk": "npm:0.22.0"
checksum: 10/d21969198322b6f79e0513b702d0af5097613d47819724c849b6c677c163cd10fb8c89e3ff62b798bec498ee4d8e95dec71861071bc4ed74bd86a7e43193bc05
languageName: node
linkType: hard
"@module-federation/runtime-tools@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/runtime-tools@npm:0.22.0"
dependencies:
"@module-federation/runtime": "npm:0.22.0"
"@module-federation/webpack-bundler-runtime": "npm:0.22.0"
checksum: 10/0e7693c1ec02fc5bef770b478c8757cad9cfefb2310d1943151d0ad079b72472d9b2c8a087299e9124dfcd6b649c83290c7fdfa333865baab4ba193f39e7b6bd
languageName: node
linkType: hard
"@module-federation/runtime@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/runtime@npm:0.22.0"
dependencies:
"@module-federation/error-codes": "npm:0.22.0"
"@module-federation/runtime-core": "npm:0.22.0"
"@module-federation/sdk": "npm:0.22.0"
checksum: 10/eca608be999d7d2e83abc1169643c2f795a5ed950f9e2bdf7000400a30b3e1e0ca4bdaa5daa09f55e44868383d444707e40236cec1aaa7b40432b0cce800b7f3
languageName: node
linkType: hard
"@module-federation/sdk@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/sdk@npm:0.22.0"
checksum: 10/d7085d883730a33145052520787a7e59cf9c54b51b2946bebc7c63a6bb668bcc6cbdc27fa0b7354a62f5a7ee4e8829a66b84e644607498f2e37cfd5eb4ded0da
languageName: node
linkType: hard
"@module-federation/webpack-bundler-runtime@npm:0.22.0":
version: 0.22.0
resolution: "@module-federation/webpack-bundler-runtime@npm:0.22.0"
dependencies:
"@module-federation/runtime": "npm:0.22.0"
"@module-federation/sdk": "npm:0.22.0"
checksum: 10/afd24406817dfc6474ebcf5be714ccf26690eb3f6f5172bda711c8f23dba149fe47293f7aa2d0733dfed0334c98d4d3d9e7c2da2be78750cae5a72d72f32ce93
languageName: node
linkType: hard
"@monaco-editor/loader@npm:^1.5.0":
version: 1.7.0
resolution: "@monaco-editor/loader@npm:1.7.0"
@@ -9131,6 +9188,17 @@ __metadata:
languageName: node
linkType: hard
"@napi-rs/wasm-runtime@npm:1.0.7":
version: 1.0.7
resolution: "@napi-rs/wasm-runtime@npm:1.0.7"
dependencies:
"@emnapi/core": "npm:^1.5.0"
"@emnapi/runtime": "npm:^1.5.0"
"@tybys/wasm-util": "npm:^0.10.1"
checksum: 10/6bc32d32d486d07b83220a9b7b2b715e39acacbacef0011ebca05c00b41d80a0535123da10fea7a7d6d7e206712bb50dc50ac3cf88b770754d44378570fb5c05
languageName: node
linkType: hard
"@napi-rs/wasm-runtime@npm:^0.2.5, @napi-rs/wasm-runtime@npm:^0.2.9":
version: 0.2.9
resolution: "@napi-rs/wasm-runtime@npm:0.2.9"
@@ -15058,6 +15126,178 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-darwin-arm64@npm:1.7.6"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-darwin-x64@npm:1.7.6"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-linux-arm64-gnu@npm:1.7.6"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-linux-arm64-musl@npm:1.7.6"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-linux-x64-gnu@npm:1.7.6"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-linux-x64-musl@npm:1.7.6"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-wasm32-wasi@npm:1.7.6"
dependencies:
"@napi-rs/wasm-runtime": "npm:1.0.7"
conditions: cpu=wasm32
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-win32-arm64-msvc@npm:1.7.6"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-win32-ia32-msvc@npm:1.7.6"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-win32-x64-msvc@npm:1.7.6"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding@npm:1.7.6"
dependencies:
"@rspack/binding-darwin-arm64": "npm:1.7.6"
"@rspack/binding-darwin-x64": "npm:1.7.6"
"@rspack/binding-linux-arm64-gnu": "npm:1.7.6"
"@rspack/binding-linux-arm64-musl": "npm:1.7.6"
"@rspack/binding-linux-x64-gnu": "npm:1.7.6"
"@rspack/binding-linux-x64-musl": "npm:1.7.6"
"@rspack/binding-wasm32-wasi": "npm:1.7.6"
"@rspack/binding-win32-arm64-msvc": "npm:1.7.6"
"@rspack/binding-win32-ia32-msvc": "npm:1.7.6"
"@rspack/binding-win32-x64-msvc": "npm:1.7.6"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
"@rspack/binding-darwin-x64":
optional: true
"@rspack/binding-linux-arm64-gnu":
optional: true
"@rspack/binding-linux-arm64-musl":
optional: true
"@rspack/binding-linux-x64-gnu":
optional: true
"@rspack/binding-linux-x64-musl":
optional: true
"@rspack/binding-wasm32-wasi":
optional: true
"@rspack/binding-win32-arm64-msvc":
optional: true
"@rspack/binding-win32-ia32-msvc":
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/fec6c978e51f20471e278a07018b414125cf3bccf9c6bd7032ca65603cfe5bf0fdd7f58c156c0640b5dfab05e82a1e1170ac6d1aacaf4f46b61564be77dbe41b
languageName: node
linkType: hard
"@rspack/core@npm:^1.7.6":
version: 1.7.6
resolution: "@rspack/core@npm:1.7.6"
dependencies:
"@module-federation/runtime-tools": "npm:0.22.0"
"@rspack/binding": "npm:1.7.6"
"@rspack/lite-tapable": "npm:1.1.0"
peerDependencies:
"@swc/helpers": ">=0.5.1"
peerDependenciesMeta:
"@swc/helpers":
optional: true
checksum: 10/9f23c4849926d9ddff34f703ab2be41878bca9e877c130d16d20d911ba4b13f15dfe96d7e86225d7f5a1e48034ab92cccec89f3765f84ff518538f6bb07f1f06
languageName: node
linkType: hard
"@rspack/dev-server@npm:^1.1.3":
version: 1.2.1
resolution: "@rspack/dev-server@npm:1.2.1"
dependencies:
"@types/bonjour": "npm:^3.5.13"
"@types/connect-history-api-fallback": "npm:^1.5.4"
"@types/express": "npm:^4.17.25"
"@types/express-serve-static-core": "npm:^4.17.21"
"@types/serve-index": "npm:^1.9.4"
"@types/serve-static": "npm:^1.15.5"
"@types/sockjs": "npm:^0.3.36"
"@types/ws": "npm:^8.5.10"
ansi-html-community: "npm:^0.0.8"
bonjour-service: "npm:^1.2.1"
chokidar: "npm:^3.6.0"
colorette: "npm:^2.0.10"
compression: "npm:^1.8.1"
connect-history-api-fallback: "npm:^2.0.0"
express: "npm:^4.22.1"
graceful-fs: "npm:^4.2.6"
http-proxy-middleware: "npm:^2.0.9"
ipaddr.js: "npm:^2.1.0"
launch-editor: "npm:^2.6.1"
open: "npm:^10.0.3"
p-retry: "npm:^6.2.0"
schema-utils: "npm:^4.2.0"
selfsigned: "npm:^2.4.1"
serve-index: "npm:^1.9.1"
sockjs: "npm:^0.3.24"
spdy: "npm:^4.0.2"
webpack-dev-middleware: "npm:^7.4.2"
ws: "npm:^8.18.0"
peerDependencies:
"@rspack/core": "*"
checksum: 10/154808faef8079dc1d6eae1712455864cc7bc1ec686f3020f7117ad3e5f2906940f27ec514eb40230276132371570ecdf6b47f7ab117ad209462bcba7c2b0692
languageName: node
linkType: hard
"@rspack/lite-tapable@npm:1.1.0":
version: 1.1.0
resolution: "@rspack/lite-tapable@npm:1.1.0"
checksum: 10/41ff73fe5e1b8dccaad746c9c1bd36dd67649e1ad35776f311b5ba94333a397704e11158579e25a6a7e677c51abe35e66987b1b000faef48d4e4ad2470fea150
languageName: node
linkType: hard
"@scarf/scarf@npm:=1.4.0":
version: 1.4.0
resolution: "@scarf/scarf@npm:1.4.0"
@@ -17079,15 +17319,15 @@ __metadata:
languageName: node
linkType: hard
"@types/express@npm:^4.17.13, @types/express@npm:^4.17.21":
version: 4.17.22
resolution: "@types/express@npm:4.17.22"
"@types/express@npm:^4.17.13, @types/express@npm:^4.17.21, @types/express@npm:^4.17.25":
version: 4.17.25
resolution: "@types/express@npm:4.17.25"
dependencies:
"@types/body-parser": "npm:*"
"@types/express-serve-static-core": "npm:^4.17.33"
"@types/qs": "npm:*"
"@types/serve-static": "npm:*"
checksum: 10/9497634fc341ff4ac966ec0c529ded03bdacd2c3dae164f10a060ff250c66591b873aedce92d0239869cf3d05615ae9bcad584c7349fe68780242f6fef010c62
"@types/serve-static": "npm:^1"
checksum: 10/c309fdb79fb8569b5d8d8f11268d0160b271f8b38f0a82c20a0733e526baf033eb7a921cd51d54fe4333c616de9e31caf7d4f3ef73baaf212d61f23f460b0369
languageName: node
linkType: hard
@@ -17610,13 +17850,13 @@ __metadata:
languageName: node
linkType: hard
"@types/send@npm:*":
version: 0.17.4
resolution: "@types/send@npm:0.17.4"
"@types/send@npm:*, @types/send@npm:<1":
version: 0.17.6
resolution: "@types/send@npm:0.17.6"
dependencies:
"@types/mime": "npm:^1"
"@types/node": "npm:*"
checksum: 10/28320a2aa1eb704f7d96a65272a07c0bf3ae7ed5509c2c96ea5e33238980f71deeed51d3631927a77d5250e4091b3e66bce53b42d770873282c6a20bb8b0280d
checksum: 10/4948ab32ab84a81a0073f8243dd48ee766bc80608d5391060360afd1249f83c08a7476f142669ac0b0b8831c89d909a88bcb392d1b39ee48b276a91b50f3d8d1
languageName: node
linkType: hard
@@ -17629,14 +17869,14 @@ __metadata:
languageName: node
linkType: hard
"@types/serve-static@npm:*, @types/serve-static@npm:^1.15.5":
version: 1.15.7
resolution: "@types/serve-static@npm:1.15.7"
"@types/serve-static@npm:*, @types/serve-static@npm:^1, @types/serve-static@npm:^1.15.5":
version: 1.15.10
resolution: "@types/serve-static@npm:1.15.10"
dependencies:
"@types/http-errors": "npm:*"
"@types/node": "npm:*"
"@types/send": "npm:*"
checksum: 10/c5a7171d5647f9fbd096ed1a26105759f3153ccf683824d99fee4c7eb9cde2953509621c56a070dd9fb1159e799e86d300cbe4e42245ebc5b0c1767e8ca94a67
"@types/send": "npm:<1"
checksum: 10/d9be72487540b9598e7d77260d533f241eb2e5db5181bb885ef2d6bc4592dad1c9e8c0e27f465d59478b2faf90edd2d535e834f20fbd9dd3c0928d43dc486404
languageName: node
linkType: hard
@@ -19555,26 +19795,6 @@ __metadata:
languageName: node
linkType: hard
"body-parser@npm:1.20.3":
version: 1.20.3
resolution: "body-parser@npm:1.20.3"
dependencies:
bytes: "npm:3.1.2"
content-type: "npm:~1.0.5"
debug: "npm:2.6.9"
depd: "npm:2.0.0"
destroy: "npm:1.2.0"
http-errors: "npm:2.0.0"
iconv-lite: "npm:0.4.24"
on-finished: "npm:2.4.1"
qs: "npm:6.13.0"
raw-body: "npm:2.5.2"
type-is: "npm:~1.6.18"
unpipe: "npm:1.0.0"
checksum: 10/8723e3d7a672eb50854327453bed85ac48d045f4958e81e7d470c56bf111f835b97e5b73ae9f6393d0011cc9e252771f46fd281bbabc57d33d3986edf1e6aeca
languageName: node
linkType: hard
"body-parser@npm:^2.2.0, body-parser@npm:^2.2.1":
version: 2.2.2
resolution: "body-parser@npm:2.2.2"
@@ -19592,6 +19812,26 @@ __metadata:
languageName: node
linkType: hard
"body-parser@npm:~1.20.3":
version: 1.20.4
resolution: "body-parser@npm:1.20.4"
dependencies:
bytes: "npm:~3.1.2"
content-type: "npm:~1.0.5"
debug: "npm:2.6.9"
depd: "npm:2.0.0"
destroy: "npm:~1.2.0"
http-errors: "npm:~2.0.1"
iconv-lite: "npm:~0.4.24"
on-finished: "npm:~2.4.1"
qs: "npm:~6.14.0"
raw-body: "npm:~2.5.3"
type-is: "npm:~1.6.18"
unpipe: "npm:~1.0.0"
checksum: 10/ff67e28d3f426707be8697a75fdf8d564dc50c341b41f054264d8ab6e2924e519c7ce8acc9d0de05328fdc41e1d9f3f200aec9c1cfb1867d6b676a410d97c689
languageName: node
linkType: hard
"bonjour-service@npm:^1.2.1":
version: 1.3.0
resolution: "bonjour-service@npm:1.3.0"
@@ -21034,18 +21274,18 @@ __metadata:
languageName: node
linkType: hard
"compression@npm:^1.7.4":
version: 1.8.0
resolution: "compression@npm:1.8.0"
"compression@npm:^1.7.4, compression@npm:^1.8.1":
version: 1.8.1
resolution: "compression@npm:1.8.1"
dependencies:
bytes: "npm:3.1.2"
compressible: "npm:~2.0.18"
debug: "npm:2.6.9"
negotiator: "npm:~0.6.4"
on-headers: "npm:~1.0.2"
on-headers: "npm:~1.1.0"
safe-buffer: "npm:5.2.1"
vary: "npm:~1.1.2"
checksum: 10/ca213b9bd03e56c7c3596399d846237b5f0b31ca4cdeaa76a9547cd3c1465fbcfcb0fe93a5d7ff64eff28383fc65b53f1ef8bb2720d11bb48ad8c0836c502506
checksum: 10/e7552bfbd780f2003c6fe8decb44561f5cc6bc82f0c61e81122caff5ec656f37824084f52155b1e8ef31d7656cecbec9a2499b7a68e92e20780ffb39b479abb7
languageName: node
linkType: hard
@@ -21140,15 +21380,6 @@ __metadata:
languageName: node
linkType: hard
"content-disposition@npm:0.5.4":
version: 0.5.4
resolution: "content-disposition@npm:0.5.4"
dependencies:
safe-buffer: "npm:5.2.1"
checksum: 10/b7f4ce176e324f19324be69b05bf6f6e411160ac94bc523b782248129eb1ef3be006f6cff431aaea5e337fe5d176ce8830b8c2a1b721626ead8933f0cbe78720
languageName: node
linkType: hard
"content-disposition@npm:^1.0.0":
version: 1.0.0
resolution: "content-disposition@npm:1.0.0"
@@ -21158,6 +21389,15 @@ __metadata:
languageName: node
linkType: hard
"content-disposition@npm:~0.5.4":
version: 0.5.4
resolution: "content-disposition@npm:0.5.4"
dependencies:
safe-buffer: "npm:5.2.1"
checksum: 10/b7f4ce176e324f19324be69b05bf6f6e411160ac94bc523b782248129eb1ef3be006f6cff431aaea5e337fe5d176ce8830b8c2a1b721626ead8933f0cbe78720
languageName: node
linkType: hard
"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5":
version: 1.0.5
resolution: "content-type@npm:1.0.5"
@@ -21249,14 +21489,14 @@ __metadata:
languageName: node
linkType: hard
"cookie@npm:0.7.1":
version: 0.7.1
resolution: "cookie@npm:0.7.1"
checksum: 10/aec6a6aa0781761bf55d60447d6be08861d381136a0fe94aa084fddd4f0300faa2b064df490c6798adfa1ebaef9e0af9b08a189c823e0811b8b313b3d9a03380
"cookie-signature@npm:~1.0.6":
version: 1.0.7
resolution: "cookie-signature@npm:1.0.7"
checksum: 10/1a62808cd30d15fb43b70e19829b64d04b0802d8ef00275b57d152de4ae6a3208ca05c197b6668d104c4d9de389e53ccc2d3bc6bcaaffd9602461417d8c40710
languageName: node
linkType: hard
"cookie@npm:0.7.2, cookie@npm:^0.7.1, cookie@npm:~0.7.2":
"cookie@npm:0.7.2, cookie@npm:^0.7.1, cookie@npm:~0.7.1, cookie@npm:~0.7.2":
version: 0.7.2
resolution: "cookie@npm:0.7.2"
checksum: 10/24b286c556420d4ba4e9bc09120c9d3db7d28ace2bd0f8ccee82422ce42322f73c8312441271e5eefafbead725980e5996cc02766dbb89a90ac7f5636ede608f
@@ -22470,7 +22710,7 @@ __metadata:
languageName: node
linkType: hard
"destroy@npm:1.2.0":
"destroy@npm:1.2.0, destroy@npm:~1.2.0":
version: 1.2.0
resolution: "destroy@npm:1.2.0"
checksum: 10/0acb300b7478a08b92d810ab229d5afe0d2f4399272045ab22affa0d99dbaf12637659411530a6fcd597a9bdac718fc94373a61a95b4651bbc7b83684a565e38
@@ -23133,13 +23373,6 @@ __metadata:
languageName: node
linkType: hard
"encodeurl@npm:~1.0.2":
version: 1.0.2
resolution: "encodeurl@npm:1.0.2"
checksum: 10/e50e3d508cdd9c4565ba72d2012e65038e5d71bdc9198cb125beb6237b5b1ade6c0d343998da9e170fb2eae52c1bed37d4d6d98a46ea423a0cddbed5ac3f780c
languageName: node
linkType: hard
"encoding@npm:^0.1.13":
version: 0.1.13
resolution: "encoding@npm:0.1.13"
@@ -24075,42 +24308,42 @@ __metadata:
languageName: node
linkType: hard
"express@npm:^4.21.1, express@npm:^4.21.2":
version: 4.21.2
resolution: "express@npm:4.21.2"
"express@npm:^4.21.1, express@npm:^4.21.2, express@npm:^4.22.1":
version: 4.22.1
resolution: "express@npm:4.22.1"
dependencies:
accepts: "npm:~1.3.8"
array-flatten: "npm:1.1.1"
body-parser: "npm:1.20.3"
content-disposition: "npm:0.5.4"
body-parser: "npm:~1.20.3"
content-disposition: "npm:~0.5.4"
content-type: "npm:~1.0.4"
cookie: "npm:0.7.1"
cookie-signature: "npm:1.0.6"
cookie: "npm:~0.7.1"
cookie-signature: "npm:~1.0.6"
debug: "npm:2.6.9"
depd: "npm:2.0.0"
encodeurl: "npm:~2.0.0"
escape-html: "npm:~1.0.3"
etag: "npm:~1.8.1"
finalhandler: "npm:1.3.1"
fresh: "npm:0.5.2"
http-errors: "npm:2.0.0"
finalhandler: "npm:~1.3.1"
fresh: "npm:~0.5.2"
http-errors: "npm:~2.0.0"
merge-descriptors: "npm:1.0.3"
methods: "npm:~1.1.2"
on-finished: "npm:2.4.1"
on-finished: "npm:~2.4.1"
parseurl: "npm:~1.3.3"
path-to-regexp: "npm:0.1.12"
path-to-regexp: "npm:~0.1.12"
proxy-addr: "npm:~2.0.7"
qs: "npm:6.13.0"
qs: "npm:~6.14.0"
range-parser: "npm:~1.2.1"
safe-buffer: "npm:5.2.1"
send: "npm:0.19.0"
serve-static: "npm:1.16.2"
send: "npm:~0.19.0"
serve-static: "npm:~1.16.2"
setprototypeof: "npm:1.2.0"
statuses: "npm:2.0.1"
statuses: "npm:~2.0.1"
type-is: "npm:~1.6.18"
utils-merge: "npm:1.0.1"
vary: "npm:~1.1.2"
checksum: 10/34571c442fc8c9f2c4b442d2faa10ea1175cf8559237fc6a278f5ce6254a8ffdbeb9a15d99f77c1a9f2926ab183e3b7ba560e3261f1ad4149799e3412ab66bd1
checksum: 10/f33c1bd0c7d36e2a1f18de9cdc176469d32f68e20258d2941b8d296ab9a4fd9011872c246391bf87714f009fac5114c832ec5ac65cbee39421f1258801eb8470
languageName: node
linkType: hard
@@ -24483,21 +24716,6 @@ __metadata:
languageName: node
linkType: hard
"finalhandler@npm:1.3.1":
version: 1.3.1
resolution: "finalhandler@npm:1.3.1"
dependencies:
debug: "npm:2.6.9"
encodeurl: "npm:~2.0.0"
escape-html: "npm:~1.0.3"
on-finished: "npm:2.4.1"
parseurl: "npm:~1.3.3"
statuses: "npm:2.0.1"
unpipe: "npm:~1.0.0"
checksum: 10/4babe72969b7373b5842bc9f75c3a641a4d0f8eb53af6b89fa714d4460ce03fb92b28de751d12ba415e96e7e02870c436d67412120555e2b382640535697305b
languageName: node
linkType: hard
"finalhandler@npm:^2.1.0":
version: 2.1.0
resolution: "finalhandler@npm:2.1.0"
@@ -24512,6 +24730,21 @@ __metadata:
languageName: node
linkType: hard
"finalhandler@npm:~1.3.1":
version: 1.3.2
resolution: "finalhandler@npm:1.3.2"
dependencies:
debug: "npm:2.6.9"
encodeurl: "npm:~2.0.0"
escape-html: "npm:~1.0.3"
on-finished: "npm:~2.4.1"
parseurl: "npm:~1.3.3"
statuses: "npm:~2.0.2"
unpipe: "npm:~1.0.0"
checksum: 10/6cb4f9f80eaeb5a0fac4fdbd27a65d39271f040a0034df16556d896bfd855fd42f09da886781b3102117ea8fceba97b903c1f8b08df1fb5740576d5e0f481eed
languageName: node
linkType: hard
"find-cache-dir@npm:^3.3.2":
version: 3.3.2
resolution: "find-cache-dir@npm:3.3.2"
@@ -24725,13 +24958,6 @@ __metadata:
languageName: node
linkType: hard
"fresh@npm:0.5.2":
version: 0.5.2
resolution: "fresh@npm:0.5.2"
checksum: 10/64c88e489b5d08e2f29664eb3c79c705ff9a8eb15d3e597198ef76546d4ade295897a44abb0abd2700e7ef784b2e3cbf1161e4fbf16f59129193fd1030d16da1
languageName: node
linkType: hard
"fresh@npm:^2.0.0":
version: 2.0.0
resolution: "fresh@npm:2.0.0"
@@ -24739,6 +24965,13 @@ __metadata:
languageName: node
linkType: hard
"fresh@npm:~0.5.2":
version: 0.5.2
resolution: "fresh@npm:0.5.2"
checksum: 10/64c88e489b5d08e2f29664eb3c79c705ff9a8eb15d3e597198ef76546d4ade295897a44abb0abd2700e7ef784b2e3cbf1161e4fbf16f59129193fd1030d16da1
languageName: node
linkType: hard
"fromentries@npm:^1.3.2":
version: 1.3.2
resolution: "fromentries@npm:1.3.2"
@@ -25985,20 +26218,7 @@ __metadata:
languageName: node
linkType: hard
"http-errors@npm:2.0.0":
version: 2.0.0
resolution: "http-errors@npm:2.0.0"
dependencies:
depd: "npm:2.0.0"
inherits: "npm:2.0.4"
setprototypeof: "npm:1.2.0"
statuses: "npm:2.0.1"
toidentifier: "npm:1.0.1"
checksum: 10/0e7f76ee8ff8a33e58a3281a469815b893c41357378f408be8f6d4aa7d1efafb0da064625518e7078381b6a92325949b119dc38fcb30bdbc4e3a35f78c44c439
languageName: node
linkType: hard
"http-errors@npm:^2.0.0, http-errors@npm:~2.0.1":
"http-errors@npm:^2.0.0, http-errors@npm:~2.0.0, http-errors@npm:~2.0.1":
version: 2.0.1
resolution: "http-errors@npm:2.0.1"
dependencies:
@@ -26051,7 +26271,7 @@ __metadata:
languageName: node
linkType: hard
"http-proxy-middleware@npm:^2.0.7":
"http-proxy-middleware@npm:^2.0.7, http-proxy-middleware@npm:^2.0.9":
version: 2.0.9
resolution: "http-proxy-middleware@npm:2.0.9"
dependencies:
@@ -26196,15 +26416,6 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24":
version: 0.4.24
resolution: "iconv-lite@npm:0.4.24"
dependencies:
safer-buffer: "npm:>= 2.1.2 < 3"
checksum: 10/6d3a2dac6e5d1fb126d25645c25c3a1209f70cceecc68b8ef51ae0da3cdc078c151fade7524a30b12a3094926336831fca09c666ef55b37e2c69638b5d6bd2e3
languageName: node
linkType: hard
"iconv-lite@npm:0.6, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3":
version: 0.6.3
resolution: "iconv-lite@npm:0.6.3"
@@ -26214,6 +26425,15 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:^0.4.24, iconv-lite@npm:~0.4.24":
version: 0.4.24
resolution: "iconv-lite@npm:0.4.24"
dependencies:
safer-buffer: "npm:>= 2.1.2 < 3"
checksum: 10/6d3a2dac6e5d1fb126d25645c25c3a1209f70cceecc68b8ef51ae0da3cdc078c151fade7524a30b12a3094926336831fca09c666ef55b37e2c69638b5d6bd2e3
languageName: node
linkType: hard
"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0":
version: 0.7.2
resolution: "iconv-lite@npm:0.7.2"
@@ -26401,7 +26621,7 @@ __metadata:
languageName: node
linkType: hard
"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4":
"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521
@@ -30735,7 +30955,7 @@ __metadata:
languageName: node
linkType: hard
"on-finished@npm:2.4.1, on-finished@npm:^2.4.1":
"on-finished@npm:^2.4.1, on-finished@npm:~2.4.1":
version: 2.4.1
resolution: "on-finished@npm:2.4.1"
dependencies:
@@ -31406,13 +31626,6 @@ __metadata:
languageName: node
linkType: hard
"path-to-regexp@npm:0.1.12":
version: 0.1.12
resolution: "path-to-regexp@npm:0.1.12"
checksum: 10/2e30f6a0144679c1f95c98e166b96e6acd1e72be9417830fefc8de7ac1992147eb9a4c7acaa59119fb1b3c34eec393b2129ef27e24b2054a3906fc4fb0d1398e
languageName: node
linkType: hard
"path-to-regexp@npm:3.3.0":
version: 3.3.0
resolution: "path-to-regexp@npm:3.3.0"
@@ -31441,6 +31654,13 @@ __metadata:
languageName: node
linkType: hard
"path-to-regexp@npm:~0.1.12":
version: 0.1.12
resolution: "path-to-regexp@npm:0.1.12"
checksum: 10/2e30f6a0144679c1f95c98e166b96e6acd1e72be9417830fefc8de7ac1992147eb9a4c7acaa59119fb1b3c34eec393b2129ef27e24b2054a3906fc4fb0d1398e
languageName: node
linkType: hard
"path-type@npm:^2.0.0":
version: 2.0.0
resolution: "path-type@npm:2.0.0"
@@ -32457,21 +32677,12 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:6.13.0":
version: 6.13.0
resolution: "qs@npm:6.13.0"
dependencies:
side-channel: "npm:^1.0.6"
checksum: 10/f548b376e685553d12e461409f0d6e5c59ec7c7d76f308e2a888fd9db3e0c5e89902bedd0754db3a9038eda5f27da2331a6f019c8517dc5e0a16b3c9a6e9cef8
languageName: node
linkType: hard
"qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:^6.7.0":
version: 6.14.1
resolution: "qs@npm:6.14.1"
"qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:^6.7.0, qs@npm:~6.14.0":
version: 6.14.2
resolution: "qs@npm:6.14.2"
dependencies:
side-channel: "npm:^1.1.0"
checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5
checksum: 10/682933a85bb4b7bd0d66e13c0a40d9e612b5e4bcc2cb9238f711a9368cd22d91654097a74fff93551e58146db282c56ac094957dfdc60ce64ea72c3c9d7779ac
languageName: node
linkType: hard
@@ -32573,18 +32784,6 @@ __metadata:
languageName: node
linkType: hard
"raw-body@npm:2.5.2":
version: 2.5.2
resolution: "raw-body@npm:2.5.2"
dependencies:
bytes: "npm:3.1.2"
http-errors: "npm:2.0.0"
iconv-lite: "npm:0.4.24"
unpipe: "npm:1.0.0"
checksum: 10/863b5171e140546a4d99f349b720abac4410338e23df5e409cfcc3752538c9caf947ce382c89129ba976f71894bd38b5806c774edac35ebf168d02aa1ac11a95
languageName: node
linkType: hard
"raw-body@npm:^3.0.0, raw-body@npm:^3.0.1":
version: 3.0.2
resolution: "raw-body@npm:3.0.2"
@@ -32597,6 +32796,18 @@ __metadata:
languageName: node
linkType: hard
"raw-body@npm:~2.5.3":
version: 2.5.3
resolution: "raw-body@npm:2.5.3"
dependencies:
bytes: "npm:~3.1.2"
http-errors: "npm:~2.0.1"
iconv-lite: "npm:~0.4.24"
unpipe: "npm:~1.0.0"
checksum: 10/f35759fe5a6548e7c529121ead1de4dd163f899749a5896c42e278479df2d9d7f98b5bb17312737c03617765e5a1433e586f717616e5cfbebc13b4738b820601
languageName: node
linkType: hard
"rc9@npm:^2.1.2":
version: 2.1.2
resolution: "rc9@npm:2.1.2"
@@ -34159,27 +34370,6 @@ __metadata:
languageName: node
linkType: hard
"send@npm:0.19.0":
version: 0.19.0
resolution: "send@npm:0.19.0"
dependencies:
debug: "npm:2.6.9"
depd: "npm:2.0.0"
destroy: "npm:1.2.0"
encodeurl: "npm:~1.0.2"
escape-html: "npm:~1.0.3"
etag: "npm:~1.8.1"
fresh: "npm:0.5.2"
http-errors: "npm:2.0.0"
mime: "npm:1.6.0"
ms: "npm:2.1.3"
on-finished: "npm:2.4.1"
range-parser: "npm:~1.2.1"
statuses: "npm:2.0.1"
checksum: 10/1f6064dea0ae4cbe4878437aedc9270c33f2a6650a77b56a16b62d057527f2766d96ee282997dd53ec0339082f2aad935bc7d989b46b48c82fc610800dc3a1d0
languageName: node
linkType: hard
"send@npm:^1.1.0, send@npm:^1.2.0":
version: 1.2.0
resolution: "send@npm:1.2.0"
@@ -34199,6 +34389,27 @@ __metadata:
languageName: node
linkType: hard
"send@npm:~0.19.0, send@npm:~0.19.1":
version: 0.19.2
resolution: "send@npm:0.19.2"
dependencies:
debug: "npm:2.6.9"
depd: "npm:2.0.0"
destroy: "npm:1.2.0"
encodeurl: "npm:~2.0.0"
escape-html: "npm:~1.0.3"
etag: "npm:~1.8.1"
fresh: "npm:~0.5.2"
http-errors: "npm:~2.0.1"
mime: "npm:1.6.0"
ms: "npm:2.1.3"
on-finished: "npm:~2.4.1"
range-parser: "npm:~1.2.1"
statuses: "npm:~2.0.2"
checksum: 10/e932a592f62c58560b608a402d52333a8ae98a5ada076feb5db1d03adaa77c3ca32a7befa1c4fd6dedc186e88f342725b0cb4b3d86835eaf834688b259bef18d
languageName: node
linkType: hard
"sentence-case@npm:^3.0.4":
version: 3.0.4
resolution: "sentence-case@npm:3.0.4"
@@ -34258,18 +34469,6 @@ __metadata:
languageName: node
linkType: hard
"serve-static@npm:1.16.2":
version: 1.16.2
resolution: "serve-static@npm:1.16.2"
dependencies:
encodeurl: "npm:~2.0.0"
escape-html: "npm:~1.0.3"
parseurl: "npm:~1.3.3"
send: "npm:0.19.0"
checksum: 10/7fa9d9c68090f6289976b34fc13c50ac8cd7f16ae6bce08d16459300f7fc61fbc2d7ebfa02884c073ec9d6ab9e7e704c89561882bbe338e99fcacb2912fde737
languageName: node
linkType: hard
"serve-static@npm:^2.2.0":
version: 2.2.0
resolution: "serve-static@npm:2.2.0"
@@ -34282,6 +34481,18 @@ __metadata:
languageName: node
linkType: hard
"serve-static@npm:~1.16.2":
version: 1.16.3
resolution: "serve-static@npm:1.16.3"
dependencies:
encodeurl: "npm:~2.0.0"
escape-html: "npm:~1.0.3"
parseurl: "npm:~1.3.3"
send: "npm:~0.19.1"
checksum: 10/149d6718dd9e53166784d0a65535e21a7c01249d9c51f57224b786a7306354c6807e7811a9f6c143b45c863b1524721fca2f52b5c81a8b5194e3dde034a03b9c
languageName: node
linkType: hard
"serve@npm:^14.2.4":
version: 14.2.4
resolution: "serve@npm:14.2.4"
@@ -35143,13 +35354,6 @@ __metadata:
languageName: node
linkType: hard
"statuses@npm:2.0.1":
version: 2.0.1
resolution: "statuses@npm:2.0.1"
checksum: 10/18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb
languageName: node
linkType: hard
"statuses@npm:>= 1.4.0 < 2":
version: 1.5.0
resolution: "statuses@npm:1.5.0"
@@ -35157,7 +35361,7 @@ __metadata:
languageName: node
linkType: hard
"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2":
"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.1, statuses@npm:~2.0.2":
version: 2.0.2
resolution: "statuses@npm:2.0.2"
checksum: 10/6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879
@@ -36120,7 +36324,7 @@ __metadata:
languageName: node
linkType: hard
"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1":
"toidentifier@npm:~1.0.1":
version: 1.0.1
resolution: "toidentifier@npm:1.0.1"
checksum: 10/952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45
@@ -36787,7 +36991,7 @@ __metadata:
languageName: node
linkType: hard
"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
"unpipe@npm:~1.0.0":
version: 1.0.0
resolution: "unpipe@npm:1.0.0"
checksum: 10/4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2