Compare commits

...
13 Commits
Author SHA1 Message Date
eezd 5514a1e465 feat: synchronize pinned tabs across devices
Development CI / check-and-package (push) Successful in 4m18s
GitHub Git mirror / mirror (push) Successful in 28s
Development CI / image (push) Successful in 21s
Development CI / deploy-development (push) Successful in 15s
2026-11-04 18:20:16 +08:00
eezd 403824eccb fix(ci): deploy version tags and install instance controls
Development CI / check-and-package (push) Successful in 4m21s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / image (push) Successful in 22s
Development CI / deploy-development (push) Successful in 16s
2026-11-02 15:37:54 +08:00
eezd 6cbd604a28 fix: keep the interface visible while fonts load
Development CI / check-and-package (push) Successful in 3m48s
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / image (push) Successful in 20s
Development CI / deploy-development (push) Successful in 15s
Render fallback fonts immediately and preload regular Maple in the background. Cache public fonts after use without eagerly downloading every font during PWA installation.
2026-10-30 18:28:00 +08:00
eezd 716b495440 Document the personal DEV instance and artifact-only releases
Main CI / check-and-package (pull_request) Successful in 3m51s
Development CI / check-and-package (push) Successful in 3m52s
GitHub Git mirror / mirror (push) Successful in 4s
Main CI / image (pull_request) Successful in 15s
Development CI / image (push) Successful in 16s
Development CI / deploy-development (push) Successful in 11s
2026-10-30 13:30:54 +08:00
eezd ef39f9d120 Keep DEV as the sole instance and finish releases after publication
Development CI / image (push) Canceled after 0s
Development CI / deploy-development (push) Canceled after 0s
Main CI / check-and-package (pull_request) Canceled after 0s
Main CI / image (pull_request) Canceled after 0s
Development CI / check-and-package (push) Canceled after 2m25s
GitHub Git mirror / mirror (push) Successful in 6s
2026-10-30 13:29:02 +08:00
eezd be616668fc fix(ci): check exact main admission across pending runs
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / check-and-package (push) Successful in 3m49s
Development CI / image (push) Successful in 19s
Main CI / check-and-package (pull_request) Successful in 3m49s
Development CI / deploy-development (push) Successful in 11s
Main CI / image (pull_request) Successful in 11s
2026-10-30 11:03:59 +08:00
eezd 344ff42823 fix(ci): validate release identities and workflow environment
GitHub Git mirror / mirror (push) Successful in 4s
Development CI / check-and-package (push) Canceled after 0s
Development CI / image (push) Canceled after 0s
Development CI / deploy-development (push) Canceled after 0s
Main CI / check-and-package (pull_request) Successful in 3m52s
Main CI / image (pull_request) Successful in 16s
2026-10-30 10:57:52 +08:00
eezd 525fa51b19 feat(ci): adapt formal releases and MAIN deployment
Development CI / check-and-package (push) Successful in 3m52s
GitHub Git mirror / mirror (push) Successful in 5s
Main CI / check-and-package (pull_request) Successful in 3m51s
Development CI / image (push) Successful in 20s
Main CI / image (pull_request) Successful in 12s
Development CI / deploy-development (push) Successful in 5s
2026-10-30 10:54:07 +08:00
eezd 78e463b4fa ci: use the account-level GitHub token
Development CI / check-and-package (push) Successful in 3m53s
GitHub Git mirror / mirror (push) Successful in 6s
Main CI / check-and-package (pull_request) Successful in 3m50s
Development CI / image (push) Successful in 19s
Main CI / image (pull_request) Successful in 12s
Development CI / deploy-development (push) Successful in 11s
2026-10-30 09:50:43 +08:00
root 4607da1a04 fix: preserve note revision ETags through compression proxies
GitHub Git mirror / mirror (push) Skipped
Development CI / check-and-package (push) Successful in 4m3s
Development CI / image (push) Successful in 20s
Development CI / deploy-development (push) Successful in 11s
Main CI / check-and-package (pull_request) Successful in 3m57s
Main CI / image (pull_request) Successful in 11s
2026-10-29 21:18:56 +08:00
root afb959f02a docs: align managed deployment with dev branch workflow
GitHub Git mirror / mirror (push) Skipped
Development CI / check-and-package (push) Successful in 3m51s
Development CI / image (push) Successful in 25s
Development CI / deploy-development (push) Successful in 12s
2026-10-29 21:12:12 +08:00
root 469af15045 ci: separate dev deployment, main checks and GitHub mirroring
GitHub Git mirror / mirror (push) Skipped
Development CI / check-and-package (push) Successful in 4m3s
Development CI / image (push) Successful in 28s
Development CI / deploy-development (push) Successful in 11s
2026-10-29 21:03:01 +08:00
root da4b1e3b18 docs: clarify cross-session handoff guidelines 2026-10-29 20:44:33 +08:00
55 changed files with 1828 additions and 660 deletions

No files matched your search

-50
View File
@@ -1,50 +0,0 @@
name: Deploy production manually
on:
workflow_dispatch:
inputs:
version:
description: Completed formal release version (X.Y.Z)
required: true
type: string
permissions:
contents: read
concurrency:
group: rawmark-production
cancel-in-progress: false
jobs:
deploy:
if: gitea.ref == 'refs/heads/main'
runs-on: docker
timeout-minutes: 20
container:
image: docker:29-cli
volumes:
- /docker/main/rawmark:/docker/main/rawmark
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_READ_TOKEN: ${{ gitea.token }}
RELEASE_VERSION: ${{ inputs.version }}
DOCKER_CONFIG: /tmp/rawmark-anonymous-docker
defaults:
run:
shell: sh
steps:
- name: Install deployment runtimes
run: apk add --no-cache nodejs python3 docker-cli-compose
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Verify completed release, back up and deploy only RawMark
run: |
set -eu
mkdir -p "$DOCKER_CONFIG"
chmod 700 "$DOCKER_CONFIG"
python3 scripts/deploy.py production --directory /docker/main/rawmark \
--version "$RELEASE_VERSION" --client-container "$(hostname)"
- name: Remove job configuration
if: always()
run: rm -rf "$DOCKER_CONFIG"
@@ -2,8 +2,10 @@ name: Development CI
on: on:
push: push:
branches: [main] branches: [dev]
pull_request:
env:
RAWMARK_BUILD_CHANNEL: development
permissions: permissions:
contents: read contents: read
@@ -28,7 +30,7 @@ jobs:
cache: false cache: false
- name: Check CI behavior and project - name: Check CI behavior and project
run: | run: |
python3 -m unittest discover -s scripts/tests -v env -u RAWMARK_BUILD_CHANNEL python3 -m unittest discover -s scripts/tests -v
sh scripts/ci-check.sh development sh scripts/ci-check.sh development
- name: Package and smoke-test Linux amd64 - name: Package and smoke-test Linux amd64
run: sh scripts/ci-package.sh development run: sh scripts/ci-package.sh development
@@ -50,7 +52,7 @@ jobs:
image: image:
needs: check-and-package needs: check-and-package
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/dev'
runs-on: docker runs-on: docker
timeout-minutes: 20 timeout-minutes: 20
env: env:
@@ -85,7 +87,7 @@ jobs:
python3 scripts/release.py prepare-image python3 scripts/release.py prepare-image
docker build --provenance=false --build-arg TARGETARCH=amd64 --tag "$TEMP_IMAGE" \ docker build --provenance=false --build-arg TARGETARCH=amd64 --tag "$TEMP_IMAGE" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \ --label "org.opencontainers.image.revision=${{ gitea.sha }}" \
--label "org.opencontainers.image.version=$(cat VERSION)" \ --label "org.opencontainers.image.version=$(python3 scripts/release.py version)" \
-f deploy/Dockerfile.release image -f deploy/Dockerfile.release image
python3 scripts/smoke-docker.py "$TEMP_IMAGE" --client-container "$(hostname)" \ python3 scripts/smoke-docker.py "$TEMP_IMAGE" --client-container "$(hostname)" \
--resource-label "$CI_RESOURCE_LABEL" --resource-label "$CI_RESOURCE_LABEL"
@@ -106,13 +108,13 @@ jobs:
deploy-development: deploy-development:
needs: image needs: image
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/dev'
runs-on: docker runs-on: docker
timeout-minutes: 15 timeout-minutes: 15
container: container:
image: docker:29-cli image: docker:29-cli
volumes: volumes:
- /docker/gitea/dev/rawmark:/docker/gitea/dev/rawmark - /docker/gitea/dev/RawMark:/docker/gitea/dev/RawMark
env: env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1 GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }}
@@ -135,7 +137,7 @@ jobs:
mkdir -p "$DOCKER_CONFIG" mkdir -p "$DOCKER_CONFIG"
chmod 700 "$DOCKER_CONFIG" chmod 700 "$DOCKER_CONFIG"
printf '%s' "$GITEA_RELEASE_TOKEN" | docker login git.134333.xyz --username eezd --password-stdin printf '%s' "$GITEA_RELEASE_TOKEN" | docker login git.134333.xyz --username eezd --password-stdin
python3 scripts/deploy.py development --directory /docker/gitea/dev/rawmark \ python3 scripts/deploy.py development --directory /docker/gitea/dev/RawMark \
--image "$DEPLOY_IMAGE" --commit "$DEPLOY_COMMIT" --version "$DEPLOY_VERSION" \ --image "$DEPLOY_IMAGE" --commit "$DEPLOY_COMMIT" --version "$DEPLOY_VERSION" \
--client-container "$(hostname)" --client-container "$(hostname)"
- name: Remove job credentials - name: Remove job credentials
+95
View File
@@ -0,0 +1,95 @@
name: Main CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
RAWMARK_BUILD_CHANNEL: development
permissions:
contents: read
concurrency:
group: rawmark-main
cancel-in-progress: false
jobs:
check-and-package:
if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository
runs-on: ubuntu-latest
timeout-minutes: 55
env:
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: https://github.com/actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false
- name: Check CI behavior and project
run: |
env -u RAWMARK_BUILD_CHANNEL python3 -m unittest discover -s scripts/tests -v
sh scripts/ci-check.sh development
- name: Package and smoke-test Linux amd64
run: sh scripts/ci-package.sh development
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: linux-amd64
path: downloads/*_linux_amd64.tar.gz
compression-level: 0
if-no-files-found: error
retention-days: 7
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: checksums
path: |
downloads/SHA256SUMS
downloads/build.json
if-no-files-found: error
retention-days: 7
image:
needs: check-and-package
runs-on: docker
timeout-minutes: 20
env:
CI_RUN_ID: ${{ gitea.run_id }}
CI_RUN_ATTEMPT: ${{ gitea.run_attempt }}
CI_RESOURCE_LABEL: io.rawmark.ci.run=dev-${{ gitea.run_id }}-${{ gitea.run_attempt }}
TEMP_IMAGE: rawmark-ci:${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
DOCKER_CONFIG: /tmp/rawmark-docker-auth
defaults:
run:
shell: sh
steps:
- name: Install CI runtimes
run: apk add --no-cache nodejs python3
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: https://github.com/actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: linux-amd64
path: downloads
- uses: https://github.com/actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: checksums
path: downloads
- name: Verify, build and smoke-test the exact binary
run: |
set -eu
python3 scripts/release.py prepare-image
docker build --provenance=false --build-arg TARGETARCH=amd64 --tag "$TEMP_IMAGE" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
--label "org.opencontainers.image.version=$(python3 scripts/release.py version)" \
-f deploy/Dockerfile.release image
python3 scripts/smoke-docker.py "$TEMP_IMAGE" --client-container "$(hostname)" \
--resource-label "$CI_RESOURCE_LABEL"
- name: Clean this attempt's resources
if: always()
run: python3 scripts/ci-cleanup.py "$CI_RESOURCE_LABEL" --client-container "$(hostname)" --image "$TEMP_IMAGE"
+35
View File
@@ -0,0 +1,35 @@
name: GitHub Git mirror
on:
push:
branches: ['**']
tags: ['**']
create:
delete:
schedule:
- cron: '17 * * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: rawmark-github-mirror
cancel-in-progress: false
jobs:
mirror:
if: vars.RAWMARK_MIRROR_ENABLED == 'true'
runs-on: docker
timeout-minutes: 15
container:
image: docker:29-cli
volumes:
- /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark
steps:
- name: Install mirror runtimes
run: apk add --no-cache nodejs python3 git git-lfs
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: main
persist-credentials: false
- name: Reconcile current source references
env:
GITHUB_MIRROR_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }}
run: python3 scripts/mirror.py --directory /docker/gitea/mirrors/RawMark
+58 -26
View File
@@ -3,12 +3,12 @@ name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: release_id:
description: Version matching VERSION (X.Y.Z) description: Reserved release ID
required: true required: true
type: string type: string
commit: commit:
description: Full main commit SHA with successful development CI description: Full candidate SHA with successful main-push CI
required: true required: true
type: string type: string
@@ -21,6 +21,9 @@ concurrency:
env: env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1 GITEA_API_URL: ${{ gitea.server_url }}/api/v1
RAWMARK_BUILD_CHANNEL: release
RELEASE_ID: ${{ inputs.release_id }}
RELEASE_COMMIT: ${{ inputs.commit }}
CI_RUN_ID: ${{ gitea.run_id }} CI_RUN_ID: ${{ gitea.run_id }}
jobs: jobs:
@@ -28,38 +31,46 @@ jobs:
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 55 timeout-minutes: 55
env: container:
GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} image: node:24-bookworm
GITHUB_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITHUB_TOKEN }} volumes:
RELEASE_VERSION: ${{ inputs.version }} - /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark
RELEASE_COMMIT: ${{ inputs.commit }}
outputs: outputs:
completed: ${{ steps.preflight.outputs.completed }} completed: ${{ steps.preflight.outputs.completed }}
steps: steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: with:
ref: ${{ inputs.commit }} ref: ${{ gitea.sha }}
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Validate source, permissions and existing release checkpoint - name: Validate source, permissions and existing release checkpoint
id: preflight id: preflight
run: python3 scripts/release.py preflight "$RELEASE_VERSION" "$RELEASE_COMMIT" env:
GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }}
GITHUB_RELEASE_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }}
run: python3 scripts/release.py preflight "$(python3 -c 'import sys; sys.path.insert(0,"scripts"); import release; import os; print(release.version_for(os.environ["RELEASE_COMMIT"]))')" "$RELEASE_COMMIT"
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ inputs.commit }}
path: source
persist-credentials: false
- uses: https://github.com/actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - uses: https://github.com/actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
go-version-file: go.mod go-version-file: source/go.mod
cache: false cache: false
- name: Check and build the five platform packages - name: Check and build the six platform packages
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
working-directory: source
run: | run: |
python3 -m unittest discover -s scripts/tests -v env -u RAWMARK_BUILD_CHANNEL python3 -m unittest discover -s scripts/tests -v
sh scripts/ci-check.sh release sh scripts/ci-check.sh release
sh scripts/ci-package.sh release sh scripts/ci-package.sh release
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: linux-amd64 name: linux-amd64
path: downloads/*_linux_amd64.tar.gz path: source/downloads/*_linux_amd64.tar.gz
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -67,7 +78,7 @@ jobs:
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: linux-arm64 name: linux-arm64
path: downloads/*_linux_arm64.tar.gz path: source/downloads/*_linux_arm64.tar.gz
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -75,7 +86,15 @@ jobs:
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: windows-amd64 name: windows-amd64
path: downloads/*_windows_amd64.zip path: source/downloads/*_windows_amd64.zip
compression-level: 0
if-no-files-found: error
retention-days: 30
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: steps.preflight.outputs.reuse == 'false'
with:
name: windows-arm64
path: source/downloads/*_windows_arm64.zip
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -83,7 +102,7 @@ jobs:
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: darwin-amd64 name: darwin-amd64
path: downloads/*_darwin_amd64.tar.gz path: source/downloads/*_darwin_amd64.tar.gz
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -91,7 +110,7 @@ jobs:
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: darwin-arm64 name: darwin-arm64
path: downloads/*_darwin_arm64.tar.gz path: source/downloads/*_darwin_arm64.tar.gz
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -100,21 +119,25 @@ jobs:
with: with:
name: checksums name: checksums
path: | path: |
downloads/SHA256SUMS source/downloads/SHA256SUMS
downloads/build.json source/downloads/build.json
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: demo name: demo
path: downloads/demo.zip path: source/downloads/demo.zip
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- name: Save immutable build checkpoint after artifact uploads - name: Save immutable build checkpoint after artifact uploads
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
run: python3 scripts/release.py checkpoint working-directory: source
env:
GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }}
GITHUB_RELEASE_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }}
run: python3 ../scripts/release.py checkpoint
publish: publish:
needs: build needs: build
@@ -123,9 +146,9 @@ jobs:
timeout-minutes: 55 timeout-minutes: 55
container: container:
image: docker:29-cli image: docker:29-cli
volumes:
- /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark
env: env:
GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }}
GITHUB_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITHUB_TOKEN }}
CI_RESOURCE_LABEL: io.rawmark.ci.run=release-${{ gitea.run_id }}-${{ gitea.run_attempt }} CI_RESOURCE_LABEL: io.rawmark.ci.run=release-${{ gitea.run_id }}-${{ gitea.run_attempt }}
DOCKER_CONFIG: /tmp/rawmark-docker-auth DOCKER_CONFIG: /tmp/rawmark-docker-auth
defaults: defaults:
@@ -136,15 +159,24 @@ jobs:
run: apk add --no-cache nodejs python3 git skopeo run: apk add --no-cache nodejs python3 git skopeo
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: with:
ref: ${{ inputs.commit }} ref: ${{ gitea.sha }}
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ inputs.commit }}
path: source
persist-credentials: false
- name: Publish the original checked artifacts to both services - name: Publish the original checked artifacts to both services
working-directory: source
env:
GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }}
GITHUB_RELEASE_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }}
run: | run: |
set -eu set -eu
mkdir -p "$DOCKER_CONFIG" mkdir -p "$DOCKER_CONFIG"
chmod 700 "$DOCKER_CONFIG" chmod 700 "$DOCKER_CONFIG"
python3 scripts/release.py publish python3 ../scripts/release.py publish
- name: Clean only this attempt's test resources and credentials - name: Clean only this attempt's test resources and credentials
if: always() if: always()
run: | run: |
+16 -6
View File
@@ -67,10 +67,14 @@
## 文档与交接 ## 文档与交接
- 文档按任务、行为变化或项目约定维护;注释解释意图与约束,项目指引只记录已确认的事实,不额外生成无关文档或备份。 - 文档按任务、行为变化或项目约定按需维护;注释解释意图与约束,项目指引只记录已确认的事实,不额外生成无关文档或备份。
- 仅在已授权执行且交接信息无法从代码、Git 历史或已有文档恢复时,创建或更新 `CONTEXT.md`;简单修改无需记录。 - `CONTEXT.md` 是跨会话交接快照,而非变更日志;仅在已授权执行且关键信息无法从代码、Git 历史或已有文档恢复时创建或更新,简单修改无需记录。
- 交接内容原位更新,保留有效目标、决策及理由、未完成事项和风险,标注未验证信息,删除过时状态;当前状态不超过 100 行,全文不超过 500 行。 - 新会话接续任务时,按需读取 `CONTEXT.md` 及其引用的关键文件,并结合代码和 Git 状态核实,不将历史记录直接视为当前事实。
- 保留 `CONTEXT.md` 原有跟踪策略:本地专用文件继续忽略,不擅自停止跟踪已纳入版本控制的文件。 - 交接内容保留当前目标与进度、关键决策及理由、已否决方案及原因、用户约束、环境注意事项、未解决风险和下一步;已完成里程碑仅保留必要摘要,不重复记录实现细节和变更历史。
- 内容应准确、自包含;必要的路径、命令、报错和配置值保留原文,无法核实的信息标注 `[未核实]`,不得记录密码、密钥或 Token。
- 目标、进度、决策或风险发生重要变化时,按需原位更新;及时清理过时状态、已完成事项和已解决风险,避免内容重复或相互矛盾。
- 当前状态不超过 100 行,全文不超过 500 行;超限时优先压缩历史记录,长期有效的项目规则归入 `AGENTS.md`,详细设计归入现有文档。
- 保留 `CONTEXT.md` 原有 Git 跟踪策略:本地专用文件继续忽略,不擅自停止跟踪已纳入版本控制的文件。
## 项目定位与工具链 ## 项目定位与工具链
@@ -125,6 +129,12 @@ pnpm -C web build
## CI/CD ## CI/CD
Gitea 为主仓库和构建执行方;日常 main push 仅构建 Linux amd64,通过隔离验收后发布开发镜像并更新独立开发实例。开发实例目录为 `/docker/gitea/dev/rawmark`,开发应用容器名为 `rawmark-dev`,生产实例目录为 `/docker/main/rawmark`。正式发布只从 Gitea 手动入口选择已通过开发 CI 的提交,构建五平台包及 Linux 双架构镜像,同步同一版本至 GitHub/GHCR;GitHub 不执行项目构建,仅允许 Pages 平台部署预构建 Demo。生产仅手动选择完整发布版本并固定 GHCR 摘要,不能随开发推送更新。 Gitea 为公开主仓库,GitHub 为单向公开镜像;默认分支均为 main,日常开发使用 dev。
runner 保持容量一,最多两个任务同时运行;当前宿主 Docker 执行器只接收受信代码。临时资源按任务标识清理,不全局 prune,不删除部署容器。CI 行为测试为 `python3 -m unittest discover -s scripts/tests -v`;项目检查与打包入口为 `sh scripts/ci-check.sh development|release`、`sh scripts/ci-package.sh development|release`,仅在 CI 工具链容器执行。正式版本、凭据、上传限制、部署目录与回退步骤见双语开发和部署文档。 - dev push:容器内检查、Linux amd64 打包与隔离冒烟,通过后发布 `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8位SHA>`,按已验收版本标签部署 DEV,摘要仅用于内部身份校验。日期取提交时间 UTC,完整 SHA 保留在回执;同名异提交拒绝,已验证镜像重试复用。
- main push:独立检查实际合并提交、Linux amd64 构建及容器冒烟,不发布镜像、不部署。仓库内受信 PR 只检查;外部 fork PR 不自动进入共享 Docker runner,不注入发布凭据。
- DEV 为 `/docker/gitea/dev/RawMark`,容器 `RawMark-dev`,Compose 项目 `rawmark-dev`,监听 `127.0.0.1:21001`,域名 `raw.134333.xyz`,raw-dev 域名停用。DEV 是唯一常驻实例,保存个人真实数据;CI 与冒烟使用隔离合成数据。数据独立;所有部署共用宿主实例 `.deploy.lock`,锁内核对 dev 最新 SHA,pending 未处理时拒绝覆盖。已有数据即使无旧容器也必须备份。
- Git 镜像独立于 CI 成败:push/create/delete、每小时对账和手动重试,共用 `/docker/gitea/mirrors/RawMark/.mirror.lock`,仅同步业务分支和 TAG 及完整历史,跟随源端删除。同步代码从 main 读取;`RAWMARK_MIRROR_ENABLED=true` 后启用,凭据统一使用 Gitea 账号级 Secret `ALL_GITHUB_TOKEN`(头像 → 设置 → Actions → 密钥),接入前配置并验证实际权限,仓库级同名项会覆盖账号级值。DEV 镜像不上传 GHCR。main 切换前保持该变量关闭。
- CI 凭据 `RAWMARK_GITEA_TOKEN` 需要仓库和 Packages 写权限。runner 保持容量一、`sweep: false`,只运行受信代码;工具链安装在任务容器。资源按任务标签清理,禁止全局 prune。
- 开发构建通过 `RAWMARK_BUILD_CHANNEL=development` 派生版本,前端与二进制一致;正式 `VERSION` 不因开发构建改变。开发包为 `RawMark_<版本>_linux_amd64.tar.gz`;历史正式产物不改名。安装包及单次传输保持既有 90,000,000 字节预算。
- 正式发布从受信 main 手动触发,使用日期哈希注解 TAG、六平台程序包与 Linux 双架构镜像;发布 ID 和候选 SHA 固定,发布记录与 .release.lock 位于持久镜像目录。GitHub 引用同步复用镜像锁,Pages Demo 不切换。双端发布和 GHCR 公开拉取验收后结束发布并标记预约完成,不部署 MAIN、不更新 DEV。DEV 启停入口为实例内 `start.sh` / `stop.sh`,由 `deploy/` 随 CD 安装和更新(755);与 CD 共用实例锁,pending 阻止启动。实例环境文件优先,拒绝覆盖本地改过的脚本。
+2 -2
View File
@@ -45,12 +45,12 @@ Choose an available version from [Releases](https://github.com/eezd/RawMark/rele
docker run -d --name rawmark --restart unless-stopped \ docker run -d --name rawmark --restart unless-stopped \
-p 127.0.0.1:21000:8080 \ -p 127.0.0.1:21000:8080 \
-v rawmark-data:/data \ -v rawmark-data:/data \
ghcr.io/eezd/rawmark:0.1.1 ghcr.io/eezd/rawmark:latest
``` ```
Open **http://localhost:21000** and set your password on first use. A new, empty instance starts with seven English example notes; edit or delete them and begin writing. Data lives in the Docker volume `rawmark-data` and persists when you recreate the container with the same volume. Open **http://localhost:21000** and set your password on first use. A new, empty instance starts with seven English example notes; edit or delete them and begin writing. Data lives in the Docker volume `rawmark-data` and persists when you recreate the container with the same volume.
Docker supports Linux amd64 and arm64. Native programs are available for Linux amd64/arm64, Windows amd64, and macOS Intel/Apple Silicon. Download them from [Releases](https://github.com/eezd/RawMark/releases), or build from source using the deployment guide. Docker supports Linux amd64 and arm64. Native programs are available for Linux amd64/arm64, Windows amd64/arm64, and macOS Intel/Apple Silicon. Download them from [Releases](https://github.com/eezd/RawMark/releases), or build from source using the deployment guide.
[Try the online editor](https://eezd.github.io/RawMark/) without installing anything. Demo edits are temporary and reset on refresh; the demo does not provide a server-backed workspace. [Try the online editor](https://eezd.github.io/RawMark/) without installing anything. Demo edits are temporary and reset on refresh; the demo does not provide a server-backed workspace.
+1 -1
View File
@@ -45,7 +45,7 @@ RawMark 面向**单用户、自托管**场景。目前不提供多人协作或
docker run -d --name rawmark --restart unless-stopped \ docker run -d --name rawmark --restart unless-stopped \
-p 127.0.0.1:21000:8080 \ -p 127.0.0.1:21000:8080 \
-v rawmark-data:/data \ -v rawmark-data:/data \
ghcr.io/eezd/rawmark:0.1.1 ghcr.io/eezd/rawmark:latest
``` ```
打开 **http://localhost:21000**,首次访问设置登录密码。全新空实例会提供 7 篇英文示例笔记,可以自由编辑或删除,然后开始记录自己的内容。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。 打开 **http://localhost:21000**,首次访问设置登录密码。全新空实例会提供 7 篇英文示例笔记,可以自由编辑或删除,然后开始记录自己的内容。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。
+2 -2
View File
@@ -2,8 +2,8 @@
name: ${RAWMARK_PROJECT:?Set RAWMARK_PROJECT} name: ${RAWMARK_PROJECT:?Set RAWMARK_PROJECT}
services: services:
rawmark: rawmark:
container_name: ${RAWMARK_PROJECT:?Set RAWMARK_PROJECT} container_name: ${RAWMARK_CONTAINER_NAME:?Set RAWMARK_CONTAINER_NAME}
image: ${RAWMARK_IMAGE:?Set a verified registry image digest} image: ${RAWMARK_IMAGE:?Set a verified registry image tag}
ports: ports:
- "${RAWMARK_BIND:-127.0.0.1}:${RAWMARK_PORT:?Set RAWMARK_PORT}:8080" - "${RAWMARK_BIND:-127.0.0.1}:${RAWMARK_PORT:?Set RAWMARK_PORT}:8080"
volumes: volumes:
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Manage only this DEV instance under the deployment lock.
set -euo pipefail
if (( $# != 0 )); then
printf '用法:bash %s\n' "$0" >&2
exit 2
fi
instance_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
cd -- "$instance_dir"
for tool in docker flock env; do
command -v "$tool" >/dev/null 2>&1 || { printf '缺少命令:%s\n' "$tool" >&2; exit 1; }
done
exec 9>>.deploy.lock
flock -n 9 || { printf '实例正在部署或启停,请稍后重试\n' >&2; exit 1; }
files=()
for filename in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
[[ ! -f $filename ]] || files+=("$filename")
done
if (( ${#files[@]} != 1 )); then
printf '实例必须有且只有一个 Compose 主文件,当前找到 %d 个\n' "${#files[@]}" >&2
exit 1
fi
# Keep Docker client access; resolve application settings only from instance files.
compose=(env -i "PATH=$PATH")
for variable in HOME DOCKER_CONFIG DOCKER_HOST DOCKER_CONTEXT DOCKER_TLS_VERIFY DOCKER_CERT_PATH XDG_RUNTIME_DIR SSH_AUTH_SOCK; do
if [[ -v $variable ]]; then
compose+=("$variable=${!variable}")
fi
done
compose+=(docker compose --project-directory "$instance_dir" -f "$instance_dir/${files[0]}")
# Explicit env files disable Compose's implicit .env, so include both in order.
for filename in .env images.env; do
if [[ -f $filename ]]; then
compose+=(--env-file "$instance_dir/$filename")
fi
done
if [[ -e deployment.pending.json ]]; then
printf '实例存在未完成部署,请先检查并恢复部署状态\n' >&2
exit 1
fi
"${compose[@]}" config --quiet
# Reuse the verified local image without building or pulling.
"${compose[@]}" up -d --no-build --pull never
printf '已启动实例:%s\n' "${instance_dir##*/}"
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Manage only this DEV instance under the deployment lock.
set -euo pipefail
if (( $# != 0 )); then
printf '用法:bash %s\n' "$0" >&2
exit 2
fi
instance_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
cd -- "$instance_dir"
for tool in docker flock env; do
command -v "$tool" >/dev/null 2>&1 || { printf '缺少命令:%s\n' "$tool" >&2; exit 1; }
done
exec 9>>.deploy.lock
flock -n 9 || { printf '实例正在部署或启停,请稍后重试\n' >&2; exit 1; }
files=()
for filename in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
[[ ! -f $filename ]] || files+=("$filename")
done
if (( ${#files[@]} != 1 )); then
printf '实例必须有且只有一个 Compose 主文件,当前找到 %d 个\n' "${#files[@]}" >&2
exit 1
fi
# Keep Docker client access; resolve application settings only from instance files.
compose=(env -i "PATH=$PATH")
for variable in HOME DOCKER_CONFIG DOCKER_HOST DOCKER_CONTEXT DOCKER_TLS_VERIFY DOCKER_CERT_PATH XDG_RUNTIME_DIR SSH_AUTH_SOCK; do
if [[ -v $variable ]]; then
compose+=("$variable=${!variable}")
fi
done
compose+=(docker compose --project-directory "$instance_dir" -f "$instance_dir/${files[0]}")
# Explicit env files disable Compose's implicit .env, so include both in order.
for filename in .env images.env; do
if [[ -f $filename ]]; then
compose+=(--env-file "$instance_dir/$filename")
fi
done
"${compose[@]}" config --quiet
# Preserve containers, networks, volumes and persistent data.
"${compose[@]}" stop
printf '已停止实例:%s\n' "${instance_dir##*/}"
+6
View File
@@ -53,6 +53,12 @@ Send `Accept-Language: en` or `Accept-Language: zh-CN` to select the language of
Account language is the `locale` field on `/settings`, supporting only `en` and `zh-CN`. PATCH with `null` removes the override and restores default English. The field is exported, imported, and synchronized with other settings. The sign-in page's temporary choice does not write to the account. Request headers affect only the current response, not account language. Account language is the `locale` field on `/settings`, supporting only `en` and `zh-CN`. PATCH with `null` removes the override and restores default English. The field is exported, imported, and synchronized with other settings. The sign-in page's temporary choice does not write to the account. Request headers affect only the current response, not account language.
## Pinned tabs
The `/settings` field `pinnedTabs` is an array of Markdown paths relative to the vault. The browser saves pin/unpin actions to the server and restores existing pinned notes on other devices, including after an event connection reconnects. An explicit empty array means no pinned tabs; if the field is absent, the browser migrates its legacy local pins once. Resetting settings stores an empty array so older device caches cannot restore cleared pins. Concurrent replacements of this list use the last server write.
Unpinned tabs and the active tab remain local to each device. Remote unpinning keeps local tabs and unsaved edits open. Explicitly closing a pinned tab also removes its server pin. Renaming notes or folders through the workspace updates pinned paths; unavailable paths are skipped when opening tabs.
## Browser sign-in ## Browser sign-in
There are no usernames, registration, or roles. Before initialization, `POST /auth/setup` sets the password using `{ password }` and returns a session. An initialized instance cannot be initialized again. There are no usernames, registration, or roles. Before initialization, `POST /auth/setup` sets the password using `{ password }` and returns a session. An initialized instance cannot be initialized again.
+6
View File
@@ -53,6 +53,12 @@ curl --fail-with-body \
账户语言位于 `/settings` 的 `locale` 字段,仅支持 `en`、`zh-CN`;PATCH 传 `null` 删除覆盖,恢复默认英文。该字段与其他设置一同导出、导入和同步;登录页的临时选择不写入账户。请求头只影响当前响应,不修改账户语言。 账户语言位于 `/settings` 的 `locale` 字段,仅支持 `en`、`zh-CN`;PATCH 传 `null` 删除覆盖,恢复默认英文。该字段与其他设置一同导出、导入和同步;登录页的临时选择不写入账户。请求头只影响当前响应,不修改账户语言。
## 固定标签
`/settings` 的 `pinnedTabs` 字段保存相对笔记库的 Markdown 路径数组。浏览器将固定和取消固定写入服务器,其他设备打开时及事件连接重连后会恢复仍存在的固定笔记。显式空数组表示没有固定标签;字段尚不存在时,浏览器会将旧的本地固定标签迁移一次。重置设置会保存空数组,避免其他设备的旧缓存恢复已清空的固定记录。多个设备同时替换该列表时,以服务器最后一次写入为准。
未固定标签和当前选中的标签仍属于各设备本地状态。远程取消固定不会关闭本机标签或丢弃未保存内容;主动关闭固定标签也会移除服务器上的固定记录。通过工作区重命名笔记或目录时会更新固定路径,打开标签时跳过不存在的路径。
## 浏览器登录 ## 浏览器登录
实例没有用户名、注册或角色系统。尚未初始化时,`POST /auth/setup` 使用 `{ password }` 设置密码并返回会话;已经初始化的实例不能再次初始化。 实例没有用户名、注册或角色系统。尚未初始化时,`POST /auth/setup` 使用 `{ password }` 设置密码并返回会话;已经初始化的实例不能再次初始化。
+24 -15
View File
@@ -56,22 +56,22 @@ Do not point the quick-start container and a Compose instance at the same databa
Formal releases publish `ghcr.io/eezd/rawmark:<version>` for Linux amd64 and arm64. The earlier releases were withdrawn; `VERSION` alone does not mean a version is available. Check the Releases page and package visibility before using the example version below. Replace `rawmark` in the Docker commands above with that image name to skip source builds. `latest` follows the latest stable release; use a version tag for controlled upgrades. The repository Compose file continues to support source builds. Formal releases publish `ghcr.io/eezd/rawmark:<version>` for Linux amd64 and arm64. The earlier releases were withdrawn; `VERSION` alone does not mean a version is available. Check the Releases page and package visibility before using the example version below. Replace `rawmark` in the Docker commands above with that image name to skip source builds. `latest` follows the latest stable release; use a version tag for controlled upgrades. The repository Compose file continues to support source builds.
Native archives for Linux amd64/arm64, Windows amd64 and macOS Intel/Apple Silicon, plus `SHA256SUMS`, are available on the [Releases page](https://github.com/eezd/RawMark/releases). Download the archive matching your operating system and architecture and verify its checksum before extracting it: Native archives for Linux amd64/arm64, Windows amd64/arm64 and macOS Intel/Apple Silicon, plus `SHA256SUMS`, are available on the [Releases page](https://github.com/eezd/RawMark/releases). Download the archive matching your operating system and architecture and verify its checksum before extracting it:
```sh ```sh
sha256sum --ignore-missing --check SHA256SUMS sha256sum --ignore-missing --check SHA256SUMS
tar -xzf rawmark_0.1.1_linux_amd64.tar.gz tar -xzf RawMark_YYYYMMDD-xxxxxxxx_linux_amd64.tar.gz
./rawmark --version ./rawmark --version
RAWMARK_DATA="$HOME/rawmark-data" RAWMARK_ADDR=127.0.0.1:21000 ./rawmark RAWMARK_DATA="$HOME/rawmark-data" RAWMARK_ADDR=127.0.0.1:21000 ./rawmark
``` ```
For macOS, use `darwin_arm64` on Apple Silicon or `darwin_amd64` on Intel. Compare `shasum -a 256 rawmark_0.1.1_darwin_arm64.tar.gz` with its entry in `SHA256SUMS`, extract it with `tar -xzf`, then use the same environment variables as above. The program is unsigned; review and approve it through macOS security settings if prompted. For macOS, use `darwin_arm64` on Apple Silicon or `darwin_amd64` on Intel. Compare `shasum -a 256 RawMark_YYYYMMDD-xxxxxxxx_darwin_arm64.tar.gz` with its entry in `SHA256SUMS`, extract it with `tar -xzf`, then use the same environment variables as above. The program is unsigned; review and approve it through macOS security settings if prompted.
On Windows, compare the output of `Get-FileHash` with the archive's entry in `SHA256SUMS`, then run in PowerShell: On Windows, compare the output of `Get-FileHash` with the archive's entry in `SHA256SUMS`, then run in PowerShell:
```powershell ```powershell
Get-FileHash .\rawmark_0.1.1_windows_amd64.zip -Algorithm SHA256 Get-FileHash .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -Algorithm SHA256
Expand-Archive .\rawmark_0.1.1_windows_amd64.zip -DestinationPath .\rawmark Expand-Archive .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -DestinationPath .\rawmark
Set-Location .\rawmark Set-Location .\rawmark
.\rawmark.exe --version .\rawmark.exe --version
$env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark' $env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark'
@@ -146,7 +146,7 @@ In addition to application backups, stop the service before upgrades and back up
1. Check the target version's behavior and data compatibility; retain the current image and deployment configuration. 1. Check the target version's behavior and data compatibility; retain the current image and deployment configuration.
2. Stop the old service and back up application data and any external vault. 2. Stop the old service and back up application data and any external vault.
3. Pull the selected prebuilt image digest and start with the same mounts. Source builds are optional contributor workflows, not managed production updates. Do not run old and new services concurrently. 3. Pull the selected prebuilt image version tag and start with the same mounts. Source builds are optional contributor workflows, not managed production updates. Do not run old and new services concurrently.
4. Verify container health, sign-in, notes, and attachments. Roll back using the matching previous image and data backup. 4. Verify container health, sign-in, notes, and attachments. Roll back using the matching previous image and data backup.
The Bearer authentication upgrade requires the frontend and backend to be released together. Old Cookie-based pages require signing in again. Startup creates the resource-ticket table and indexes through the existing SQLite initialization flow; notes, passwords, settings, and existing API tokens are retained. Third-party clients must follow the [current JSON protocol](api.md). The Bearer authentication upgrade requires the frontend and backend to be released together. Old Cookie-based pages require signing in again. Startup creates the resource-ticket table and indexes through the existing SQLite initialization flow; notes, passwords, settings, and existing API tokens are retained. Third-party clients must follow the [current JSON protocol](api.md).
@@ -160,26 +160,35 @@ The Bearer authentication upgrade requires the frontend and backend to be releas
- Resetting settings retains the account, resources, plugin storage, and local tabs. - Resetting settings retains the account, resources, plugin storage, and local tabs.
- Logs live in `logs/`, with three rotated files retained, each up to 10 MiB. Settings can export logs and a version summary; this does not reconstruct historical logs. - Logs live in `logs/`, with three rotated files retained, each up to 10 MiB. Settings can export logs and a version summary; this does not reconstruct historical logs.
- If the interface breaks after an update, first check that the frontend and backend come from the same build, then inspect browser network requests and application logs. See the [user guide](guide.md#plugins-and-interface-recovery) for plugin and appearance recovery entry points. - If the interface breaks after an update, first check that the frontend and backend come from the same build, then inspect browser network requests and application logs. See the [user guide](guide.md#plugins-and-interface-recovery) for plugin and appearance recovery entry points.
- Note reads send `Cache-Control: no-store, no-transform` so proxies preserve the strong revision ETag used by conditional saves. A proxy rewriting it to a weak ETag can cause otherwise valid edits to return HTTP 412; preserve this response policy through the public ingress.
## Managed development and production ## Managed DEV and formal artifacts
The checked-in `deploy/compose.image.yml` is an image-only template. Development lives in `/docker/gitea/dev/rawmark`; production lives in `/docker/main/rawmark`. Copy the template to the instance directory as `compose.yml`, with a private mode-600 `.env` beside it. The container name follows `RAWMARK_PROJECT`, so the development container is `rawmark-dev`. These instances do not depend on a source checkout. Set all required values explicitly: Personal use keeps only `/docker/gitea/dev/RawMark` running: container `RawMark-dev`, Compose project `rawmark-dev`, loopback port 21001 and public entry `https://raw.134333.xyz`. The raw-dev site is retired. DEV holds real private data; CI and isolated smoke tests use synthetic data.
Copy `deploy/compose.image.yml` to the instance as `compose.yml`, with a private mode-600 `.env` beside it. Preserve existing ownership and never mount repository examples as live data.
```dotenv ```dotenv
RAWMARK_CHANNEL=development RAWMARK_CHANNEL=development
RAWMARK_PROJECT=rawmark-dev RAWMARK_PROJECT=rawmark-dev
RAWMARK_CONTAINER_NAME=RawMark-dev
RAWMARK_BIND=127.0.0.1 RAWMARK_BIND=127.0.0.1
RAWMARK_PORT=21001 RAWMARK_PORT=21001
RAWMARK_DATA_DIR=/docker/gitea/dev/rawmark/data RAWMARK_DATA_DIR=/docker/gitea/dev/RawMark/data
RAWMARK_IMAGE=git.134333.xyz/eezd/rawmark@sha256:<verified-digest> RAWMARK_IMAGE=git.134333.xyz/eezd/dev/rawmark:<verified-version>
``` ```
For production, use channel `production`, project `rawmark`, port 21000, `/docker/main/rawmark/data`, and `ghcr.io/eezd/rawmark@sha256:<verified-digest>`. Preserve the current binding during migration. Create only a new development data directory with owner `65532:65532`; never recursively change existing production ownership or mount the repository example template as live storage. Dev pushes deploy verified Gitea development version tags after checks and isolated smoke tests. Main pushes and PRs only validate. Manual formal releases publish packages and Gitea/GHCR images with GitHub mirrors; they do not create MAIN or update DEV. The instance-local controls share `.deploy.lock` with CD. Startup rejects unresolved pending deployments; stopping remains available after acquiring the lock.
Main-push CI automatically updates development. The **Deploy production manually** Gitea workflow only accepts a version whose Gitea and GitHub releases are formal and contain identical build/release receipts and matching tags. It fixes the GHCR digest, checks current mounts and competing writers, stops only this project's old service, and backs up the whole data directory plus configuration before replacement. Backups are private and include SQLite WAL files; old images and backups are retained. ```bash
bash /docker/gitea/dev/RawMark/start.sh
bash /docker/gitea/dev/RawMark/stop.sh
```
The job records `deployment.pending.json` before replacement and `deployment.json` after health/frontend checks. If the candidate fails after accessing data, it stops the candidate and retains the backup and pending record for diagnosis; it does not automatically restore private data or run an older program against possibly migrated storage. Recovery requires explicit authorization and compatibility review. If backup fails before replacement, the untouched old service can be restarted safely. No global Docker cleanup is performed. CD installs `deploy/start.sh` and `deploy/stop.sh` into the instance with mode 755 before stopping the service, on both first deployment and updates. The `.control-scripts.json` receipt tracks installed hashes; local edits or unrecognized existing scripts require review before deployment and are never silently overwritten. Initial manual setup can copy these two files after checking any existing entries.
Preparing the production files does not restart the current service or execute a data migration. Its first transition from the old source-build Compose is an explicitly authorized production update. Do not run both Compose configurations simultaneously. Test upgrades and recovery with synthetic data before that transition. Controls explicitly load `.env`, followed by `images.env` when present. Application and Compose variables from the caller cannot override those files; only PATH, HOME and the listed Docker client connection/configuration variables are retained. RawMark CD stores its verified image in `.env`; no separate `images.env` is needed. Startup reuses local images with `--no-build --pull never`; stopping preserves containers, networks, volumes and data. A successful start command means Compose accepted startup; use container health to confirm readiness.
A new development domain needs DNS, CDN custom-hostname activation and TLS, plus both the origin and regional ingress proxy configuration. Verify public access; a healthy loopback port or DNS record alone is insufficient. Keep resource-ticket paths redacted on every proxy. Deployment stops the service and backs up complete data, including SQLite WAL, configuration and previous-image details before writing `deployment.pending.json`. Successful health checks write `deployment.json` and clear pending. A failed candidate is stopped while its backup and pending record remain; no automatic data restore or program downgrade occurs. Recovery and private-data migration require explicit authorization and compatibility review. Never run two instances sharing writable data.
Domain changes require public, CDN and TLS validation while preserving resource-ticket log redaction. DNS remains user-managed.
+23 -14
View File
@@ -60,18 +60,18 @@ RAWMARK_DATA_DIR=/your/storage/rawmark/data \
```sh ```sh
sha256sum --ignore-missing --check SHA256SUMS sha256sum --ignore-missing --check SHA256SUMS
tar -xzf rawmark_0.1.1_linux_amd64.tar.gz tar -xzf RawMark_YYYYMMDD-xxxxxxxx_linux_amd64.tar.gz
./rawmark --version ./rawmark --version
RAWMARK_DATA="$HOME/rawmark-data" RAWMARK_ADDR=127.0.0.1:21000 ./rawmark RAWMARK_DATA="$HOME/rawmark-data" RAWMARK_ADDR=127.0.0.1:21000 ./rawmark
``` ```
macOS 的 Apple Silicon 使用 `darwin_arm64`,Intel 使用 `darwin_amd64`。先将 `shasum -a 256 rawmark_0.1.1_darwin_arm64.tar.gz` 的结果与 `SHA256SUMS` 中对应记录比较,再用 `tar -xzf` 解压并沿用上述环境变量启动。程序未签名,若系统提示拦截,请核对来源后通过 macOS 安全设置手动允许。 macOS 的 Apple Silicon 使用 `darwin_arm64`,Intel 使用 `darwin_amd64`。先将 `shasum -a 256 RawMark_YYYYMMDD-xxxxxxxx_darwin_arm64.tar.gz` 的结果与 `SHA256SUMS` 中对应记录比较,再用 `tar -xzf` 解压并沿用上述环境变量启动。程序未签名,若系统提示拦截,请核对来源后通过 macOS 安全设置手动允许。
Windows 先将 `Get-FileHash` 的结果与 `SHA256SUMS` 中对应记录比较,再在 PowerShell 中启动: Windows 先将 `Get-FileHash` 的结果与 `SHA256SUMS` 中对应记录比较,再在 PowerShell 中启动:
```powershell ```powershell
Get-FileHash .\rawmark_0.1.1_windows_amd64.zip -Algorithm SHA256 Get-FileHash .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -Algorithm SHA256
Expand-Archive .\rawmark_0.1.1_windows_amd64.zip -DestinationPath .\rawmark Expand-Archive .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -DestinationPath .\rawmark
Set-Location .\rawmark Set-Location .\rawmark
.\rawmark.exe --version .\rawmark.exe --version
$env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark' $env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark'
@@ -146,7 +146,7 @@ docker run -d --name rawmark --restart unless-stopped \
1. 确认目标版本的行为和数据兼容性,保留当前镜像及部署配置。 1. 确认目标版本的行为和数据兼容性,保留当前镜像及部署配置。
2. 停止旧服务,备份应用数据与外置笔记库。 2. 停止旧服务,备份应用数据与外置笔记库。
3. 拉取选定的预构建镜像摘要,使用同一份挂载启动新实例;源码构建仅供贡献者选择,不用于托管生产更新。不要并行运行新旧服务。 3. 拉取选定的预构建镜像版本标签,使用同一份挂载启动新实例;源码构建仅供贡献者选择,不用于托管生产更新。不要并行运行新旧服务。
4. 核对容器健康、登录、笔记与附件。回退时使用配套的旧镜像与数据备份。 4. 核对容器健康、登录、笔记与附件。回退时使用配套的旧镜像与数据备份。
Bearer 认证升级需要前后端一起发布。旧 Cookie 页面不能继续使用,需重新登录;启动程序会沿现有 SQLite 初始化流程创建资源票据表和索引,笔记、密码、设置与已有 API 令牌保留。第三方客户端的 JSON 解包方式须遵循[当前协议](api.zh-CN.md)。 Bearer 认证升级需要前后端一起发布。旧 Cookie 页面不能继续使用,需重新登录;启动程序会沿现有 SQLite 初始化流程创建资源票据表和索引,笔记、密码、设置与已有 API 令牌保留。第三方客户端的 JSON 解包方式须遵循[当前协议](api.zh-CN.md)。
@@ -160,26 +160,35 @@ Bearer 认证升级需要前后端一起发布。旧 Cookie 页面不能继续
- 重置设置保留账户、资源、插件存储和本机标签页。 - 重置设置保留账户、资源、插件存储和本机标签页。
- 日志位于 `logs/`,轮转保留三份,每份至多 10 MiB;可在设置中导出日志及版本摘要。该功能不补录历史日志。 - 日志位于 `logs/`,轮转保留三份,每份至多 10 MiB;可在设置中导出日志及版本摘要。该功能不补录历史日志。
- 更新后界面异常先核对前后端是否来自同一构建,再查看浏览器网络请求和应用日志;插件与外观恢复入口见[使用指南](guide.zh-CN.md#插件与界面恢复)。 - 更新后界面异常先核对前后端是否来自同一构建,再查看浏览器网络请求和应用日志;插件与外观恢复入口见[使用指南](guide.zh-CN.md#插件与界面恢复)。
- 笔记读取返回 `Cache-Control: no-store, no-transform`,防止代理改写条件保存使用的强版本 ETag。代理将其改为弱 ETag 可能导致正常编辑返回 HTTP 412;公网入口须保留该响应策略。
## 开发与生产托管 ## DEV 托管与正式产物
仓库 `deploy/compose.image.yml` 是仅拉镜像的模板。开发实例放在 `/docker/gitea/dev/rawmark`,生产实例放在 `/docker/main/rawmark`;将模板复制至对应实例目录并命名为 `compose.yml`,旁边放权限 600 的私有 `.env`。容器名跟随 `RAWMARK_PROJECT`,开发容器为 `rawmark-dev`。实例不依赖源码工作目录,必须明确填写: 个人使用只保留 `/docker/gitea/dev/RawMark` 常驻实例,容器 `RawMark-dev`、Compose 项目 `rawmark-dev`、端口 `127.0.0.1:21001`,入口 `https://raw.134333.xyz`。raw-dev 站点停用。实例持有真实用户数据,CI 和隔离冒烟只使用合成数据。
将 `deploy/compose.image.yml` 复制为实例 `compose.yml`,旁边放权限 600 的私有 `.env`。不要覆盖已有数据所有权或挂载仓库示例作为真实数据。
```dotenv ```dotenv
RAWMARK_CHANNEL=development RAWMARK_CHANNEL=development
RAWMARK_PROJECT=rawmark-dev RAWMARK_PROJECT=rawmark-dev
RAWMARK_CONTAINER_NAME=RawMark-dev
RAWMARK_BIND=127.0.0.1 RAWMARK_BIND=127.0.0.1
RAWMARK_PORT=21001 RAWMARK_PORT=21001
RAWMARK_DATA_DIR=/docker/gitea/dev/rawmark/data RAWMARK_DATA_DIR=/docker/gitea/dev/RawMark/data
RAWMARK_IMAGE=git.134333.xyz/eezd/rawmark@sha256:<已验证摘要> RAWMARK_IMAGE=git.134333.xyz/eezd/dev/rawmark:<已验证版本>
``` ```
生产使用 channel `production`、project `rawmark`、端口 21000、`/docker/main/rawmark/data` 和 `ghcr.io/eezd/rawmark@sha256:<已验证摘要>`;迁移时保留当前监听地址。仅为新的开发目录设置 `65532:65532` 所有者,不递归修改现有生产权限,不将仓库示例模板作为运行数据挂载。 DEV push 检查及隔离验收通过后自动部署 Gitea 开发镜像;main push 与 PR 只检查。正式 Release 手动发布程序包、Gitea/GHCR 镜像及 GitHub 镜像,不创建 MAIN 或更新 DEV。实例启停脚本与 CD 共用 `.deploy.lock`。pending 未处理时拒绝启动,停止仍可在取得锁后执行。
main push CI 自动更新开发。Gitea **Deploy production manually** 工作流只接受两端均正式发布、构建/发布回执相同且标签一致的版本。它固定 GHCR 摘要,检查原挂载与并发写入实例,仅停止本项目旧服务,备份完整数据目录和配置后替换。备份为私有文件并包含 SQLite WAL;旧镜像和备份继续保留。 ```bash
bash /docker/gitea/dev/RawMark/start.sh
bash /docker/gitea/dev/RawMark/stop.sh
```
替换前记录 `deployment.pending.json`,健康和前端检查通过后记录 `deployment.json`。候选版本访问数据后失败则停止候选,保留备份和 pending 记录,不自动恢复私人数据,也不直接用旧程序打开可能迁移后的数据;恢复须明确授权并核对兼容性。备份在替换前失败时,旧数据未被新程序访问,可安全重新启动旧容器。不做全局 Docker 清理。 CD 在首次及后续部署停服前,将 `deploy/start.sh`、`deploy/stop.sh` 安装到实例并设置权限 755。`.control-scripts.json` 记录已安装脚本的哈希;遇到本地修改或无法识别的已有脚本会要求先审查,不会静默覆盖。首次手动配置也可在检查已有入口后复制这两个文件。
准备生产文件不等于重启当前服务或执行数据迁移。首次从旧源码构建 Compose 切换须明确授权生产更新,不同时运行两套 Compose。先用合成数据演练更新和恢复。 脚本显式依次加载 `.env` 和存在的 `images.env`,调用者的应用及 Compose 环境变量不能覆盖实例配置;只保留 PATH、HOME 及脚本列明的 Docker 客户端连接与配置变量。RawMark CD 将已验证镜像写入 `.env`,无需另建 `images.env`。启动通过 `--no-build --pull never` 复用本地镜像,停止保留容器、网络、卷和数据。启动命令成功表示 Compose 已接受启动,服务就绪仍需核对容器健康。
新开发域名须完成 DNS、CDN 自定义主机名及证书激活,并配置源站和国内入口。必须验证公网访问,不能仅凭回环端口健康或 DNS 有记录认定成功。所有代理都要对资源票据路径脱敏。 部署停止服务后备份完整数据(含 SQLite WAL)、配置和旧镜像信息,再写入 `deployment.pending.json`;健康检查通过才写入 `deployment.json` 并清除 pending。候选失败时停止候选、保留备份,不自动降级程序或恢复数据库。恢复和私人数据迁移需明确授权及兼容性核查;不得同时运行共享可写数据的新旧实例。
域名切换须验证公网、CDN 与 TLS,并保留资源票据日志脱敏。DNS 由用户管理。
+9 -42
View File
@@ -90,7 +90,7 @@ Default font resources are checked in; ordinary builds require no download or co
python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip
``` ```
The script generates WOFF2 files and `maple-font.css` while retaining the upstream license. Code blocks use highlight.js with fixed Atom One Dark; theme changes must not reintroduce previous highlighters or color schemes. The script generates WOFF2 files and `maple-font.css` while retaining the upstream license; `--css-only` regenerates declarations without rebuilding font binaries. Fonts use swap rendering, so downloads never hide the page. After first paint, regular Maple chunks (about 6.72 MB) load in batches of two; other weights load on demand. PWA installation skips font files and caches public build fonts after use. Code blocks use highlight.js with fixed Atom One Dark; theme changes must not reintroduce previous highlighters or color schemes.
Plugin SDK types are in [common/plugin-api/index.d.ts](../web/src/common/plugin-api/index.d.ts); loading and lifecycle code lives in `common/plugins/`. Plugins run only on the frontend, and installers must trust their code. Namespaces, disabling, and resource versioning do not constitute a permission sandbox. Editor-extension failures preserve document contents and disable the relevant extensions on the current page. Plugin SDK types are in [common/plugin-api/index.d.ts](../web/src/common/plugin-api/index.d.ts); loading and lifecycle code lives in `common/plugins/`. Plugins run only on the frontend, and installers must trust their code. Namespaces, disabling, and resource versioning do not constitute a permission sandbox. Editor-extension failures preserve document contents and disable the relevant extensions on the current page.
@@ -110,45 +110,12 @@ Backend `internal/i18n/` preserves message IDs and parameters. Errors render at
## Gitea CI/CD ## Gitea CI/CD
Gitea is the primary repository and build executor (`origin`). GitHub (`github`) receives explicitly published versions; there is no continuous mirror. All project compilation and checks run in CI toolchain containers, without installing Node or Go on the deployment host. The runner has capacity one. Together with the allowed Pages platform deployment, no more than two jobs run at once. Gitea is the public source repository; GitHub is a strict one-way Git mirror. Both use main as the default branch and dev for daily development.
### Development - A dev push checks, packages and smoke-tests Linux amd64 in CI containers, publishes only `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8-char-SHA>`, then deploys its verified version tag. The date comes from the commit timestamp in UTC. Receipts retain the full SHA; conflicting versions fail and verified images are reused on retry.
- A main push independently checks and smoke-tests the actual merged commit without publishing images or deploying. Trusted same-repository PRs only check; external fork PRs do not automatically enter the shared Docker runner or receive publication credentials.
A main push runs `.gitea/workflows/ci.yml`: project checks, CI behavior tests, Linux amd64 packaging, and native lifecycle smoke tests. A dependent Docker job verifies that run's archive and checks the non-root image with synthetic data, including restart/replacement persistence and server-side logout. Only then does it publish `git.134333.xyz/eezd/rawmark:dev-<full-SHA>` and deploy its digest to the independent development instance. PRs only run checks and packaging; they receive no publication credentials and do not deploy. The host-Docker runner is for trusted code, not an untrusted-code sandbox; external PRs require maintainer review before execution. - DEV lives at `/docker/gitea/dev/RawMark`, container `RawMark-dev`, explicit Compose project `rawmark-dev`, loopback port 21001 and domain `raw.134333.xyz`; raw-dev is retired. DEV is the only persistent instance and holds private user data; CI smoke tests use isolated synthetic data. All deployment entrypoints share the instance `.deploy.lock`; stale dev commits are skipped and unresolved pending deployments block updates. Existing data is backed up even when no old container exists.
- Git mirroring runs independently on push/create/delete, hourly reconciliation and manual retry, sharing `/docker/gitea/mirrors/RawMark/.mirror.lock`. Only business branches, tags and complete history are mirrored, including deletions. Control code comes from main. Enable `RAWMARK_MIRROR_ENABLED=true` after the main cutover and configure the account-level `ALL_GITHUB_TOKEN` in Gitea user Settings → Actions → Secrets with repository/workflow write access. DEV images never go to GHCR.
Development uses `raw-dev.134333.xyz`, loopback port 21001, and `/docker/gitea/dev/rawmark/data`. Production retains its own data under `/docker/main/rawmark` and port 21000. Before updating development, the deployment lock rechecks main so an old run cannot overwrite a newer deployment. - `RAWMARK_GITEA_TOKEN` requires repository and Packages write access. Keep runner capacity one and sweep disabled; only trusted code runs on this Docker-socket runner. Install toolchains inside CI containers and clean only resources labeled for the current attempt.
- `RAWMARK_BUILD_CHANNEL=development` derives a consistent frontend/binary version without editing the formal VERSION file. Development archives use `RawMark_<version>_linux_amd64.tar.gz`; historical release artifacts retain their names. Keep the existing 90,000,000-byte transfer budget.
`sh scripts/ci-check.sh development` and `sh scripts/ci-package.sh development` are the CI entry points. The `release` argument enables the Demo and five-platform packaging. Run `python3 -m unittest discover -s scripts/tests -v` for artifact and deployment behavior tests. Do not rebuild these artifacts on the deployment host. - Formal releases run manually from trusted main, require successful main-push CI, and publish six native packages plus Linux amd64/arm64 images with date/hash versions. Fixed release IDs retain resumable checkpoints under the persistent release lock. After both releases and public GHCR digest pulls pass, the reservation is marked complete without deploying or restarting any instance. Use `bash /docker/gitea/dev/RawMark/start.sh` or `bash /docker/gitea/dev/RawMark/stop.sh` to control only this instance, with the deployment lock and pending-start protection. CD installs and updates these executable scripts from `deploy/`, preserving locally modified entries for review. Keep Pages unchanged.
### Formal releases
Update `VERSION` and `.github/release-notes.md`, review the commit and pass main-push development CI. In Gitea's **Release** workflow, select main and enter the exact full commit SHA and matching `X.Y.Z` version. Publishing is an explicit action, not a tag-push side effect.
The same workflow performs checks and packages Linux amd64/arm64, Windows amd64, and Darwin amd64/arm64. Only Linux amd64 is runtime-tested on the available runner. Cross-builds and vet do not replace native Windows, macOS or ARM64 acceptance; the earlier Windows native test failure has not been reverified. Archives include licenses and `SHA256SUMS`. Linux binaries from these exact archives are packaged into one dual-architecture image and copied unchanged to Gitea Registry and GHCR.
The workflow stages a Gitea draft and immutable `build.json` after artifact uploads. `image.json` records the tested candidate; `release.json` connects the build, image digest and Demo commit. Both final releases contain matching files. On interruption, rerun failed jobs or dispatch the same version/commit: existing receipts and original artifacts are reused. If an original artifact has expired, recovery fails explicitly rather than rebuilding different content. Conflicting tags or attachments fail; published versions are not overwritten. GitHub main advances by normal fast-forward only.
GitHub publication and Pages validation complete before the Gitea release becomes formal. Cross-service publication is not atomic: a failed run can leave a GitHub release or candidate images visible while Gitea remains a draft. Such a version is not production-eligible. Stable image tags advance only after artifact and public-image checks.
Each installation archive and Demo artifact must be smaller than 90,000,000 bytes. Upload platforms separately. Check the source Git pack and image blobs too: the Cloudflare 100 MB limit applies to requests, not only individual tracked files. The combined OCI file is local CI scratch space, never a release upload.
### Credentials and runner
Configure these repository Actions Secrets in Gitea:
| Secret | Required access |
| --- | --- |
| `RAWMARK_GITEA_TOKEN` | Gitea repository releases and Packages write access |
| `RAWMARK_GITHUB_TOKEN` | GitHub classic PAT with repository access, `workflow` for workflow-file changes, and `write:packages` |
Gitea rejects Secret names beginning with `GITEA_` or `GITHUB_`. Tokens do not belong in Git or job output. Job Docker credentials use temporary private directories. The manual production job uses a read-only Gitea job token and anonymous public GitHub/GHCR reads, not the publication PAT.
The first GHCR package is private by default. Make it public in its package settings and rerun the failed publication job; anonymous pulling is required before completion. The workflow does not claim a repository token automatically includes Packages access.
The existing runner uses `container.sweep: false` so deployed services survive job completion. Smoke resources carry a run/attempt label and the always-cleanup step removes only those resources. Deployment mounts are allowlisted to the two instance directories. Do not use global Docker prune or delete historical packages. Check cancellation cleanup as well as successful runs.
### Static Demo
`pnpm -C web build:demo` generates `web/dist-demo/` with base `/RawMark/`. It contains page-local example edits, no private API, authentication, persistent workspace or Service Worker. Gitea copies this prebuilt output to GitHub's `gh-pages` branch with `.nojekyll`; Pages uses that branch as its publishing source. GitHub still runs its platform Pages deployment, the only allowed GitHub job. It does not compile RawMark. The old GitHub project release workflow is removed.
Runtime production updates are a separate manual action; see [deployment](deployment.md#managed-development-and-production).
+8 -41
View File
@@ -110,45 +110,12 @@ python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip
## Gitea CI/CD ## Gitea CI/CD
Gitea 是主仓库和构建执行方(`origin`);GitHub(`github`)只接收明确发布的版本,不持续镜像。项目检查和编译全部在 CI 工具链容器执行,不向部署宿主安装 Node 或 Go。runner 容量保持一;加上允许的 Pages 平台部署,同时执行的任务不超过两个。 Gitea 为公开主仓库,GitHub 为单向公开镜像;默认分支均为 main,日常开发使用 dev。
### 开发流程 - dev push:容器内检查、Linux amd64 打包与隔离冒烟,通过后发布 `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8位SHA>`,按摘要部署 DEV。日期取提交时间 UTC,完整 SHA 保留在回执;同名异提交拒绝,已验证镜像重试复用。
- main push:独立检查实际合并提交、Linux amd64 构建及容器冒烟,不发布镜像、不部署。仓库内受信 PR 只检查;外部 fork PR 不自动进入共享 Docker runner,不注入发布凭据。
main push 触发 `.gitea/workflows/ci.yml`,执行项目检查、CI 行为测试、Linux amd64 打包和原生生命周期冒烟。后续 Docker job 校验同一运行的包,使用合成数据验证非 root 镜像、重启及重建容器持久化、服务端注销;通过后才发布 `git.134333.xyz/eezd/rawmark:dev-<完整SHA>` 并按摘要更新独立开发实例。PR 仅检查和打包,不注入发布凭据、不部署。宿主 Docker runner 只运行受信代码,并非不可信代码沙箱;外部 PR 运行前须由维护者审查。 - DEV 为 `/docker/gitea/dev/RawMark`,容器 `RawMark-dev`,Compose 项目 `rawmark-dev`,监听 `127.0.0.1:21001`,域名 `raw-dev.134333.xyz`。数据独立;所有部署共用宿主实例 `.deploy.lock`,锁内核对 dev 最新 SHA,pending 未处理时拒绝覆盖。已有数据即使无旧容器也必须备份。
- Git 镜像独立于 CI 成败:push/create/delete、每小时对账和手动重试,共用 `/docker/gitea/mirrors/RawMark/.mirror.lock`,仅同步业务分支和 TAG 及完整历史,跟随源端删除。同步代码从 main 读取;`RAWMARK_MIRROR_ENABLED=true` 后启用,凭据统一使用 Gitea 账号级 Secret `ALL_GITHUB_TOKEN`(头像 → 设置 → Actions → 密钥),接入前配置并验证实际权限,仓库级同名项会覆盖账号级值。DEV 镜像不上传 GHCR。main 切换前保持该变量关闭。
开发使用 `raw-dev.134333.xyz`、回环端口 21001 和 `/docker/gitea/dev/rawmark/data`;生产保留 `/docker/main/rawmark` 下的独立数据和 21000 端口。部署锁内再次核对 main,旧运行不能覆盖新部署。 - CI 凭据 `RAWMARK_GITEA_TOKEN` 需要仓库和 Packages 写权限。runner 保持容量一、`sweep: false`,只运行受信代码;工具链安装在任务容器。资源按任务标签清理,禁止全局 prune。
- 开发构建通过 `RAWMARK_BUILD_CHANNEL=development` 派生版本,前端与二进制一致;正式 `VERSION` 不因开发构建改变。开发包为 `RawMark_<版本>_linux_amd64.tar.gz`;历史正式产物不改名。安装包及单次传输保持既有 90,000,000 字节预算。
CI 入口是 `sh scripts/ci-check.sh development` 和 `sh scripts/ci-package.sh development`;参数换为 `release` 才构建 Demo 和五平台程序。`python3 -m unittest discover -s scripts/tests -v` 验证产物与部署行为。部署宿主不重新编译这些产物。 - 正式发布从受信 main 手动触发,要求候选完整 SHA 的 main push CI 成功;日期哈希版本用于六平台包和 Linux 双架构镜像。固定发布 ID 与持久锁保证可续跑;双端发布和公开 GHCR 摘要拉取验收后自动部署 MAIN:`/docker/gitea/main/RawMark`、`RawMark-main`、`rawmark-main`。Pages 保持原配置。
### 正式发布
先更新 `VERSION` 和 `.github/release-notes.md`,review 提交并通过 main push 开发 CI。进入 Gitea 的 **Release** 工作流,选择 main,输入完整提交 SHA 和对应的 `X.Y.Z` 版本。手动操作才发布,推标签不会自动启动发布。
同一工作流完成检查并打包 Linux amd64/arm64、Windows amd64、Darwin amd64/arm64。现有 runner 只对 Linux amd64 执行原生运行验收;交叉构建和 vet 不代替 Windows、macOS、ARM64 原生验收,先前 Windows 原生测试失败尚未复验。程序包附许可证和 `SHA256SUMS`。双架构镜像复用这些包中的 Linux 二进制,原样同步到 Gitea Registry 和 GHCR。
完成产物上传后,工作流准备 Gitea 草稿和不可变的 `build.json`;`image.json` 记录已验证候选镜像,`release.json` 关联构建、镜像摘要和 Demo 提交。两端最终发布文件一致。中断后重跑失败 job 或重新选择相同版本和提交,复用已有回执与原始产物;原产物过期则明确失败,不重新构建另一份内容。标签或附件冲突报错,已发布版本不覆盖。GitHub main 只正常快进。
GitHub 发布和 Pages 验证完成后,Gitea 才成为正式 Release。跨服务发布不具原子性:失败时可能已有 GitHub Release 或候选镜像,但 Gitea 仍为草稿,该版本不能进入生产。稳定镜像标签只在产物和公开镜像验证后更新。
每个安装包及 Demo 产物小于 90,000,000 字节,按平台分别上传。同时核对源码 Git pack 和镜像 blob:Cloudflare 100 MB 限制针对请求,而非只针对单个入库文件。合并 OCI 文件只留在 CI 临时空间,不作为发布附件上传。
### 凭据与 runner
在 Gitea 仓库 Actions Secrets 中配置:
| Secret | 所需权限 |
| --- | --- |
| `RAWMARK_GITEA_TOKEN` | Gitea 仓库 Release 与 Packages 写入 |
| `RAWMARK_GITHUB_TOKEN` | GitHub classic PAT,仓库权限、修改工作流文件所需的 `workflow`、`write:packages` |
Gitea 禁止 Secret 名以 `GITEA_` 或 `GITHUB_` 开头。令牌不进入 Git 或日志;job 的 Docker 凭据使用私有临时目录。手动生产 job 使用只读 Gitea job token 和匿名 GitHub/GHCR 读取,不获取发布 PAT。
首次创建的 GHCR 包默认私有,需要在包设置中设为公开后重跑失败的发布 job;匿名拉取通过后才能完成发布。不假定仓库凭据天然有 Packages 权限。
现有 runner 设置 `container.sweep: false`,避免任务结束后清除已部署服务。冒烟资源带 run/attempt 标签,由 always 清理步骤仅回收本次资源;部署挂载仅允许两个实例目录。不执行全局 Docker prune,不删除历史软件包;成功、失败和取消后均核对清理结果。
### 静态 Demo
`pnpm -C web build:demo` 生成 `web/dist-demo/`,基础路径为 `/RawMark/`。示例编辑仅在当前页面有效,不使用私有 API、身份、持久化工作区或 Service Worker。Gitea 将预构建产物和 `.nojekyll` 同步至 GitHub `gh-pages`,Pages 从此分支发布。GitHub 仍会执行平台 Pages 部署,这是唯一允许的 GitHub job,不编译 RawMark;旧 GitHub 项目发布工作流已移除。
生产更新为独立手动操作,见[部署文档](deployment.zh-CN.md#开发与生产托管)。
+2 -1
View File
@@ -327,7 +327,8 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/markdown; charset=utf-8") w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("ETag", `"`+rev+`"`) w.Header().Set("ETag", `"`+rev+`"`)
w.Header().Set("Cache-Control", "no-store") // Preserve the strong revision ETag through compression proxies for If-Match writes.
w.Header().Set("Cache-Control", "no-store, no-transform")
w.Write(data) w.Write(data)
} }
+42 -1
View File
@@ -37,6 +37,23 @@ type fixture struct {
admin *http.Client admin *http.Client
} }
func TestNoteRevisionSurvivesProxyBoundary(t *testing.T) {
f := newFixture(t)
f.write("proxy.md", "original")
res := f.do(f.admin, "GET", "/api/v1/files/proxy.md", "", nil)
defer res.Body.Close()
etag := res.Header.Get("ETag")
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store, no-transform" || !strings.HasPrefix(etag, `"`) {
t.Fatalf("笔记响应必须保留强版本标识并禁止代理转换: %d %v", res.StatusCode, res.Header)
}
if code := f.status(f.admin, "PUT", "/api/v1/files/proxy.md", "updated", map[string]string{"If-Match": etag}); code != http.StatusOK {
t.Fatalf("读取的版本标识应允许条件保存: %d", code)
}
if code := f.status(f.admin, "PUT", "/api/v1/files/proxy.md", "stale", map[string]string{"If-Match": etag}); code != http.StatusPreconditionFailed {
t.Fatalf("过期版本必须仍拒绝保存: %d", code)
}
}
func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) { func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("from.md", "![](from.assets/x.png)") f.write("from.md", "![](from.assets/x.png)")
@@ -274,7 +291,11 @@ func TestAPIResponsesAreNotCached(t *testing.T) {
// Firefox once cached identity and tree responses offline, incorrectly opening an unloadable workspace. // Firefox once cached identity and tree responses offline, incorrectly opening an unloadable workspace.
for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} { for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} {
res := f.do(f.admin, "GET", path, "", nil) res := f.do(f.admin, "GET", path, "", nil)
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" { want := "no-store"
if strings.HasPrefix(path, "/api/v1/files/") {
want = "no-store, no-transform"
}
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != want {
t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control")) t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control"))
} }
} }
@@ -383,6 +404,26 @@ func TestSettingsMerge(t *testing.T) {
} }
} }
func TestPinnedTabsSharedAcrossSessions(t *testing.T) {
f := newFixture(t)
other := newClient()
f.login(other, "password123")
f.do(f.admin, "PATCH", "/api/v1/settings", `{"pinnedTabs":["a.md","目录/b.md"]}`, nil).Body.Close()
got := decode[struct{ PinnedTabs []string }](t, f.do(other, "GET", "/api/v1/settings", "", nil))
if len(got.PinnedTabs) != 2 || got.PinnedTabs[0] != "a.md" || got.PinnedTabs[1] != "目录/b.md" {
t.Fatalf("另一会话未读取到固定标签: %v", got.PinnedTabs)
}
f.do(other, "PATCH", "/api/v1/settings", `{"pinnedTabs":[]}`, nil).Body.Close()
again := decode[struct{ PinnedTabs []string }](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
if again.PinnedTabs == nil || len(again.PinnedTabs) != 0 {
t.Fatalf("取消固定必须保存为空列表: %v", again.PinnedTabs)
}
stored, ok, err := f.store.Setting(settingsKey)
if err != nil || !ok || !strings.Contains(stored, `"pinnedTabs":[]`) {
t.Fatalf("固定标签未持久化到数据库: %q, %v", stored, err)
}
}
func TestChangePassword(t *testing.T) { func TestChangePassword(t *testing.T) {
f := newFixture(t) f := newFixture(t)
other := newClient() other := newClient()
+6 -4
View File
@@ -156,17 +156,19 @@ func TestSettingsPackageRoundTripMergesResourcesAndPreservesAccount(t *testing.T
func TestResetPreferencesPreservesPluginDataAndCredentials(t *testing.T) { func TestResetPreferencesPreservesPluginDataAndCredentials(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("note.md", "source") f.write("note.md", "source")
f.store.SetSetting(settingsKey, `{"fontSize":24,"trashRetentionDays":0}`) f.store.SetSetting(settingsKey, `{"fontSize":24,"trashRetentionDays":0,"pinnedTabs":["note.md"]}`)
f.store.SetSetting(pluginEnabledKey, `{"example":true}`) f.store.SetSetting(pluginEnabledKey, `{"example":true}`)
f.store.SetSetting("plugin.data.example", `{"important":"content"}`) f.store.SetSetting("plugin.data.example", `{"important":"content"}`)
if status := f.status(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":false}`, nil); status != 400 { if status := f.status(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":false}`, nil); status != 400 {
t.Fatal(status) t.Fatal(status)
} }
assertOK(t, f.do(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":true}`, nil)) assertOK(t, f.do(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":true}`, nil))
for _, key := range []string{settingsKey, pluginEnabledKey} { if _, ok, err := f.store.Setting(pluginEnabledKey); err != nil || ok {
if _, ok, err := f.store.Setting(key); err != nil || ok { t.Fatal("插件启用状态未重置")
t.Fatalf("未重置 %s", key)
} }
settings := decode[map[string]json.RawMessage](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
if len(settings) != 1 || string(settings["pinnedTabs"]) != "[]" {
t.Fatalf("重置必须清空固定标签并阻止旧设备再次迁移: %v", settings)
} }
if value, ok, err := f.store.Setting("plugin.data.example"); err != nil || !ok || value != `{"important":"content"}` { if value, ok, err := f.store.Setting("plugin.data.example"); err != nil || !ok || value != `{"important":"content"}` {
t.Fatal("插件数据丢失") t.Fatal("插件数据丢失")
+12 -1
View File
@@ -481,7 +481,7 @@
"bearer": [] "bearer": []
} }
], ],
"description": "Merge top-level keys: supplied values replace existing values, and null removes a key. On success, broadcast a settings event over WebSocket. trashRetentionDays is a nonnegative safe integer (default 30; 0 disables automatic deletion); null restores the default. Retention applies to existing and new recycle-bin entries. Startup and hourly cleanup permanently delete expired entries based on their original deletion times.", "description": "Merge top-level keys: supplied values replace existing values, and null removes a key. On success, broadcast a settings event over WebSocket. pinnedTabs stores vault-relative Markdown paths shared across devices; an empty array explicitly clears pins, while an absent key allows the browser to migrate legacy local pins. Concurrent replacements use the last server write. trashRetentionDays is a nonnegative safe integer (default 30; 0 disables automatic deletion); null restores the default. Retention applies to existing and new recycle-bin entries. Startup and hourly cleanup permanently delete expired entries based on their original deletion times.",
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -490,6 +490,16 @@
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"properties": { "properties": {
"pinnedTabs": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Ordered vault-relative Markdown paths. Empty means no pins; null removes the setting."
},
"trashRetentionDays": { "trashRetentionDays": {
"type": [ "type": [
"integer", "integer",
@@ -3789,6 +3799,7 @@
"/maintenance/settings/reset": { "/maintenance/settings/reset": {
"post": { "post": {
"summary": "Reset confirmed preferences while retaining plugin storage", "summary": "Reset confirmed preferences while retaining plugin storage",
"description": "Reset application preferences and plugin activation while preserving plugin data and credentials. Store pinnedTabs as an explicit empty array so legacy browser caches cannot migrate cleared pins back to the server.",
"tags": [ "tags": [
"Maintenance" "Maintenance"
], ],
+12 -1
View File
@@ -56,9 +56,20 @@ func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error {
return tx.Commit() return tx.Commit()
} }
func (s *Store) ResetPreferences() error { func (s *Store) ResetPreferences() error {
_, err := s.db.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`) tx, err := s.db.Begin()
if err != nil {
return err return err
} }
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`); err != nil {
return err
}
// An explicit empty list prevents older devices from migrating their local pins after a reset.
if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES ('app.settings',?)`, `{"pinnedTabs":[]}`); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err } func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err }
// CheckBackup verifies readability and required tables without initializing or repairing the supplied database. // CheckBackup verifies readability and required tables without initializing or repairing the supplied database.
+28
View File
@@ -48,3 +48,31 @@ func TestOpenExistingUsesNativeEscapedPathWithoutCreating(t *testing.T) {
t.Fatalf("missing database was created: %v", err) t.Fatalf("missing database was created: %v", err)
} }
} }
func TestResetPreferencesRollsBackIfPinResetFails(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "rawmark.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
original := map[string]string{
"app.settings": `{"fontSize":24,"pinnedTabs":["note.md"]}`,
"plugins.enabled": `{"example":true}`,
}
for key, value := range original {
if err := st.SetSetting(key, value); err != nil {
t.Fatal(err)
}
}
if _, err := st.db.Exec(`CREATE TRIGGER reject_pin_reset BEFORE INSERT ON settings WHEN NEW.key='app.settings' BEGIN SELECT RAISE(ABORT,'intentional reset failure'); END`); err != nil {
t.Fatal(err)
}
if err := st.ResetPreferences(); err == nil {
t.Fatal("固定标签重置失败必须返回错误")
}
for key, value := range original {
if got, ok, err := st.Setting(key); err != nil || !ok || got != value {
t.Fatalf("重置失败未回滚 %s: %q, %v", key, got, err)
}
}
}
+2
View File
@@ -4,6 +4,8 @@ set -eu
mode="${1:?Expected development or release}" mode="${1:?Expected development or release}"
case "$mode" in development|release) ;; *) exit 2 ;; esac case "$mode" in development|release) ;; *) exit 2 ;; esac
export RAWMARK_BUILD_CHANNEL="$mode"
export RAWMARK_BUILD_VERSION="$(python3 scripts/release.py version)"
export CGO_ENABLED=0 export CGO_ENABLED=0
corepack enable corepack enable
corepack prepare [email protected] --activate corepack prepare [email protected] --activate
+4 -2
View File
@@ -4,9 +4,11 @@ set -eu
mode="${1:?Expected development or release}" mode="${1:?Expected development or release}"
case "$mode" in case "$mode" in
development) targets='linux/amd64' ;; development) targets='linux/amd64' ;;
release) targets='linux/amd64 linux/arm64 windows/amd64 darwin/amd64 darwin/arm64' ;; release) targets='linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 darwin/arm64' ;;
*) exit 2 ;; *) exit 2 ;;
esac esac
export RAWMARK_BUILD_CHANNEL="$mode"
export RAWMARK_BUILD_VERSION="$(python3 scripts/release.py version)"
export CGO_ENABLED=0 export CGO_ENABLED=0
mkdir -p package/licenses downloads mkdir -p package/licenses downloads
cp LICENSE package/ cp LICENSE package/
@@ -19,7 +21,7 @@ for target in $targets; do
if [ "$GOOS" = windows ]; then extension='.exe'; fi if [ "$GOOS" = windows ]; then extension='.exe'; fi
rm -f package/rawmark package/rawmark.exe rm -f package/rawmark package/rawmark.exe
go vet ./... go vet ./...
go build -trimpath -ldflags="-s -w -X main.releaseVersion=$(cat VERSION)" \ go build -trimpath -ldflags="-s -w -X main.releaseVersion=$(python3 scripts/release.py version)" \
-o "package/rawmark$extension" ./cmd/rawmark -o "package/rawmark$extension" ./cmd/rawmark
if [ "$target" = linux/amd64 ]; then python3 scripts/smoke-release.py package/rawmark; fi if [ "$target" = linux/amd64 ]; then python3 scripts/smoke-release.py package/rawmark; fi
python3 scripts/package-release.py "$GOOS" "$GOARCH" python3 scripts/package-release.py "$GOOS" "$GOARCH"
+36 -17
View File
@@ -4,6 +4,7 @@
import argparse import argparse
import contextlib import contextlib
import fcntl import fcntl
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
@@ -14,7 +15,7 @@ import tempfile
import time import time
import urllib.request import urllib.request
from release import API, GITEA_IMAGE, GITHUB_IMAGE, release_receipt, require, run from release import API, GITEA_DEV_IMAGE, require, run
def settings(directory, channel): def settings(directory, channel):
@@ -46,11 +47,35 @@ def save(path, data):
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
def install_controls(directory):
# Recognize the original templates; subsequent updates verify the installed receipt.
legacy = {'start.sh': 'b4709f36a66b428671a09f373f640a99259845924779dcb122f5cd9c17e85ab6',
'stop.sh': '83348a7781b7d738a715bde51387951041a5cfcd31796805422ef83139a21571'}
receipt = directory / '.control-scripts.json'
previous = json.loads(receipt.read_text()) if receipt.exists() else {}
sources = Path(__file__).resolve().parents[1] / 'deploy'
scripts = {name: (sources / name).read_bytes() for name in legacy}
hashes = {name: hashlib.sha256(data).hexdigest() for name, data in scripts.items()}
# Check both entries before replacing either; preserve custom or locally edited scripts.
for name in scripts:
target = directory / name
require(not target.is_symlink(), f'Refusing to replace symlink: {target}')
if target.exists():
current = hashlib.sha256(target.read_bytes()).hexdigest()
require(current in (legacy[name], hashes[name], previous.get(name)),
f'Locally modified control script requires review: {target}')
for name, data in scripts.items():
save(directory / name, data)
(directory / name).chmod(0o755)
save(receipt, (json.dumps(hashes, indent=2) + '\n').encode())
def deployment(directory, channel, image, commit, version, client_container=None): def deployment(directory, channel, image, commit, version, client_container=None):
directory = Path(directory).resolve(strict=True) directory = Path(directory).resolve(strict=True)
require(channel == 'development', 'Only the persistent DEV instance supports deployment')
cfg = settings(directory, channel) cfg = settings(directory, channel)
registry = GITEA_IMAGE if channel == 'development' else GITHUB_IMAGE registry = GITEA_DEV_IMAGE
require(re.fullmatch(re.escape(registry) + r'@sha256:[0-9a-f]{64}', image), 'Expected an immutable registry digest') require(image == registry + ':' + version, 'Expected the selected development version tag')
require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Expected a full commit SHA') require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Expected a full commit SHA')
# Serializes actual deployments, including manually started jobs and CLI use. # Serializes actual deployments, including manually started jobs and CLI use.
with (directory / '.deploy.lock').open('a') as lock: with (directory / '.deploy.lock').open('a') as lock:
@@ -67,8 +92,8 @@ def deployment(directory, channel, image, commit, version, client_container=None
'Executable version mismatch') 'Executable version mismatch')
if channel == 'development': if channel == 'development':
api = API(os.environ['GITEA_API_URL'], os.environ['GITEA_RELEASE_TOKEN']) api = API(os.environ['GITEA_API_URL'], os.environ['GITEA_RELEASE_TOKEN'])
if api.repo('/branches/main')['commit']['id'] != commit: if api.repo('/branches/dev')['commit']['id'] != commit:
print('Skipped development deployment: main has advanced') print('Skipped development deployment: dev has advanced')
return return
identifiers = run('docker', 'ps', '-aq', '--filter', 'label=com.docker.compose.project=' + cfg['RAWMARK_PROJECT'], identifiers = run('docker', 'ps', '-aq', '--filter', 'label=com.docker.compose.project=' + cfg['RAWMARK_PROJECT'],
'--filter', 'label=com.docker.compose.service=rawmark').split() '--filter', 'label=com.docker.compose.service=rawmark').split()
@@ -84,9 +109,11 @@ def deployment(directory, channel, image, commit, version, client_container=None
compose = ['docker', 'compose', '--project-name', cfg['RAWMARK_PROJECT'], '--env-file', str(directory / '.env'), compose = ['docker', 'compose', '--project-name', cfg['RAWMARK_PROJECT'], '--env-file', str(directory / '.env'),
'-f', str(directory / 'compose.yml')] '-f', str(directory / 'compose.yml')]
run(*compose, 'config', '--quiet', env=env) run(*compose, 'config', '--quiet', env=env)
install_controls(directory)
backup = None backup = None
if previous: if previous:
run('docker', 'stop', '--time', '15', previous['Id']) run('docker', 'stop', '--time', '15', previous['Id'])
if any((directory / 'data').iterdir()):
try: try:
backups = directory / 'backups' backups = directory / 'backups'
backups.mkdir(mode=0o700, exist_ok=True) backups.mkdir(mode=0o700, exist_ok=True)
@@ -101,7 +128,7 @@ def deployment(directory, channel, image, commit, version, client_container=None
archive.add(directory / name, arcname=name) archive.add(directory / name, arcname=name)
except BaseException: except BaseException:
# No new executable has touched the data; restarting the old container is safe here. # No new executable has touched the data; restarting the old container is safe here.
if previous['State']['Running']: if previous and previous['State']['Running']:
run('docker', 'start', previous['Id']) run('docker', 'start', previous['Id'])
raise raise
pending = {'image': image, 'commit': commit, 'version': version, 'channel': channel, pending = {'image': image, 'commit': commit, 'version': version, 'channel': channel,
@@ -139,23 +166,15 @@ def deployment(directory, channel, image, commit, version, client_container=None
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('channel', choices=('development', 'production')) parser.add_argument('channel', choices=('development',))
parser.add_argument('--directory', required=True) parser.add_argument('--directory', required=True)
parser.add_argument('--image') parser.add_argument('--image')
parser.add_argument('--commit') parser.add_argument('--commit')
parser.add_argument('--version', required=True) parser.add_argument('--version', required=True)
parser.add_argument('--client-container') parser.add_argument('--client-container')
args = parser.parse_args() args = parser.parse_args()
if args.channel == 'production': require(args.image and args.commit, 'Development requires an image version tag and commit')
require(not args.image and not args.commit, 'Production accepts a completed version, not an arbitrary image') deployment(args.directory, args.channel, args.image, args.commit, args.version, args.client_container)
gitea = API(os.environ['GITEA_API_URL'], os.environ['GITEA_READ_TOKEN'])
github = API('https://api.github.com', '', github=True)
build, receipt = release_receipt(gitea, github, args.version)
image, commit = GITHUB_IMAGE + '@' + receipt['image_digest'], build['commit']
else:
image, commit = args.image, args.commit
require(image and commit, 'Development requires an image digest and commit')
deployment(args.directory, args.channel, image, commit, args.version, args.client_container)
if __name__ == '__main__': if __name__ == '__main__':
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Mirror only business Git references under one persistent repository lock."""
import argparse
import fcntl
import json
import os
from pathlib import Path
import subprocess
import time
from release import API, git_env, require
def git(directory, *args, env=None):
return subprocess.check_output(['git', '-C', str(directory), *args], env=env, text=True).strip()
def references(directory, remote=None, env=None):
command = ['ls-remote', remote, 'refs/heads/*', 'refs/tags/*'] if remote else [
'for-each-ref', '--format=%(objectname)\t%(refname)', 'refs/heads/', 'refs/tags/']
output = git(directory, *command, env=env)
return {ref: sha for line in output.splitlines() for sha, ref in [line.split()] if not ref.endswith('^{}')}
def synchronize(directory, source, target, environment):
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
with (directory / '.mirror.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
repo = directory / 'repository.git'
if not repo.exists():
subprocess.run(['git', 'init', '--bare', str(repo)], check=True, stdout=subprocess.DEVNULL)
for _ in range(3):
git(repo, 'fetch', '--prune', '--no-tags', source,
'+refs/heads/*:refs/heads/*', '+refs/tags/*:refs/tags/*', env=environment)
snapshot = references(repo)
require('refs/heads/main' in snapshot, 'Source main is missing; refusing destructive synchronization')
# Fetch every LFS object before updating destination references when LFS is present.
if git(repo, 'log', '--all', '--format=', '--name-only', '--', '.gitattributes', '**/.gitattributes').strip():
subprocess.run(['git', '-C', str(repo), 'lfs', 'fetch', '--all', source], env=environment, check=True)
subprocess.run(['git', '-C', str(repo), 'lfs', 'push', '--all', target], env=environment, check=True)
git(repo, 'push', '--atomic', '--prune', target,
'+refs/heads/*:refs/heads/*', '+refs/tags/*:refs/tags/*', env=environment)
require(references(repo, target, environment) == snapshot, 'Destination references differ')
if references(repo, source, environment) == snapshot:
receipt = {'source': source, 'target': target, 'refs': snapshot, 'verified_at': int(time.time())}
(directory / 'last-sync.json').write_text(json.dumps(receipt, indent=2) + '\n')
return receipt
raise RuntimeError('Source kept changing; retry synchronization')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--directory', required=True)
args = parser.parse_args()
api = API('https://api.github.com', os.environ['GITHUB_MIRROR_TOKEN'], github=True)
synchronize(args.directory, 'https://git.134333.xyz/eezd/RawMark.git',
'https://github.com/eezd/RawMark.git', git_env(api))
print('GitHub mirror verified')
if __name__ == '__main__':
main()
+6 -2
View File
@@ -7,8 +7,12 @@ import tarfile
import zipfile import zipfile
system, arch = sys.argv[1:] system, arch = sys.argv[1:]
version = Path('VERSION').read_text().strip() from release import build_version
name = f'rawmark_{version}_{system}_{arch}'
version = build_version()
from release import archive_name
name = archive_name(version, system + '/' + arch).removesuffix('.zip').removesuffix('.tar.gz')
package = Path('package') package = Path('package')
files = sorted(p for p in package.rglob('*') if p.is_file()) files = sorted(p for p in package.rglob('*') if p.is_file())
if system == 'windows': if system == 'windows':
+12 -5
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Rebuild local fonts from the specified v7.9 ZIP: python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip. """Rebuild local fonts from the specified v7.9 ZIP: python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip.
Use --css-only to regenerate declarations from checked-in chunks.
Only font regeneration requires fonttools[woff]; application builds use the checked-in WOFF2 files. Only font regeneration requires fonttools[woff]; application builds use the checked-in WOFF2 files.
Source: https://github.com/subframe7536/maple-font/releases/tag/v7.9 Source: https://github.com/subframe7536/maple-font/releases/tag/v7.9
""" """
@@ -8,14 +9,13 @@ from pathlib import Path
import sys import sys
import zipfile import zipfile
from fontTools import subset
from fontTools.ttLib import TTFont
root = Path(__file__).resolve().parents[1] / 'web/src/common/appearance' root = Path(__file__).resolve().parents[1] / 'web/src/common/appearance'
output = root / 'fonts/maple' output = root / 'fonts/maple'
output.mkdir(exist_ok=True) output.mkdir(exist_ok=True)
family = 'Maple Mono Normal NL NF CN' family = 'Maple Mono Normal NL NF CN'
css = ['/* Maple Mono v7.9; generated by scripts/prepare-maple-font.py. See fonts/maple/LICENSE.txt for licensing. */'] if sys.argv[1:] != ['--css-only']:
from fontTools import subset
from fontTools.ttLib import TTFont
with zipfile.ZipFile(sys.argv[1]) as archive: with zipfile.ZipFile(sys.argv[1]) as archive:
(output / 'LICENSE.txt').write_bytes(archive.read('LICENSE.txt')) (output / 'LICENSE.txt').write_bytes(archive.read('LICENSE.txt'))
for variant, weight, style in [('Regular', 400, 'normal'), ('Bold', 700, 'normal'), ('Italic', 400, 'italic'), ('BoldItalic', 700, 'italic')]: for variant, weight, style in [('Regular', 400, 'normal'), ('Bold', 700, 'normal'), ('Italic', 400, 'italic'), ('BoldItalic', 700, 'italic')]:
@@ -34,5 +34,12 @@ with zipfile.ZipFile(sys.argv[1]) as archive:
font.flavor = 'woff2' font.flavor = 'woff2'
name = f'{variant.lower()}-{block:02x}.woff2' name = f'{variant.lower()}-{block:02x}.woff2'
font.save(output / name) font.save(output / name)
css.append(f"@font-face {{ font-family: '{family}'; font-style: {style}; font-weight: {weight}; font-display: block; src: url('./fonts/maple/{name}') format('woff2'); unicode-range: U+{block * 4096:X}-{(block + 1) * 4096 - 1:X}; }}") css = ['/* Maple Mono v7.9; generated by scripts/prepare-maple-font.py. See fonts/maple/LICENSE.txt for licensing. */']
for variant, weight, style in [('regular', 400, 'normal'), ('bold', 700, 'normal'), ('italic', 400, 'italic'), ('bolditalic', 700, 'italic')]:
files = sorted(output.glob(f'{variant}-*.woff2'))
if not files:
raise ValueError(f'Missing checked-in font chunks: {variant}')
for file in files:
block = int(file.stem.split('-')[1], 16)
css.append(f"@font-face {{ font-family: '{family}'; font-style: {style}; font-weight: {weight}; font-display: swap; src: url('./fonts/maple/{file.name}') format('woff2'); unicode-range: U+{block * 4096:X}-{(block + 1) * 4096 - 1:X}; }}")
(root / 'maple-font.css').write_text('\n'.join(css) + '\n') (root / 'maple-font.css').write_text('\n'.join(css) + '\n')
+185 -109
View File
@@ -3,6 +3,9 @@
import argparse import argparse
import base64 import base64
import datetime
import contextlib
import fcntl
import hashlib import hashlib
import io import io
import json import json
@@ -22,8 +25,9 @@ import zipfile
REPOSITORY = 'eezd/RawMark' REPOSITORY = 'eezd/RawMark'
GITEA_IMAGE = 'git.134333.xyz/eezd/rawmark' GITEA_IMAGE = 'git.134333.xyz/eezd/rawmark'
GITEA_DEV_IMAGE = 'git.134333.xyz/eezd/dev/rawmark'
GITHUB_IMAGE = 'ghcr.io/eezd/rawmark' GITHUB_IMAGE = 'ghcr.io/eezd/rawmark'
PLATFORMS = ('linux/amd64', 'linux/arm64', 'windows/amd64', 'darwin/amd64', 'darwin/arm64') PLATFORMS = ('linux/amd64', 'linux/arm64', 'windows/amd64', 'windows/arm64', 'darwin/amd64', 'darwin/arm64')
UPLOAD_LIMIT = 90_000_000 UPLOAD_LIMIT = 90_000_000
DOWNLOADS = Path('downloads') DOWNLOADS = Path('downloads')
@@ -52,13 +56,23 @@ def output(name, value):
target.write(f'{name}={value}\n') target.write(f'{name}={value}\n')
def build_version():
if os.environ.get('RAWMARK_BUILD_CHANNEL') not in ('development', 'release'):
return Path('VERSION').read_text().strip()
commit = run('git', 'rev-parse', 'HEAD')
timestamp = int(run('git', 'show', '-s', '--format=%ct', commit))
date = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).strftime('%Y%m%d')
return date + '-' + commit[:8]
def archive_name(version, platform): def archive_name(version, platform):
extension = '.zip' if platform.startswith('windows/') else '.tar.gz' extension = '.zip' if platform.startswith('windows/') else '.tar.gz'
return f'rawmark_{version}_{platform.replace("/", "_")}{extension}' prefix = 'RawMark' if re.fullmatch(r'[0-9]{8}-[0-9a-f]{8}', version) else 'rawmark'
return f'{prefix}_{version}_{platform.replace("/", "_")}{extension}'
def bundle(mode): def bundle(mode):
version = Path('VERSION').read_text().strip() version = build_version()
commit = run('git', 'rev-parse', 'HEAD') commit = run('git', 'rev-parse', 'HEAD')
platforms = PLATFORMS if mode == 'release' else PLATFORMS[:1] platforms = PLATFORMS if mode == 'release' else PLATFORMS[:1]
files = {} files = {}
@@ -91,7 +105,7 @@ def bundle(mode):
def validate_build(build): def validate_build(build):
require(build['schema'] == 1, 'Unsupported build receipt') require(build['schema'] == 1, 'Unsupported build receipt')
require(build['version'] == Path('VERSION').read_text().strip(), 'Build version mismatch') require(build['version'] == build_version(), 'Build version mismatch')
require(build['commit'] == run('git', 'rev-parse', 'HEAD'), 'Build commit mismatch') require(build['commit'] == run('git', 'rev-parse', 'HEAD'), 'Build commit mismatch')
require(build['mode'] in ('development', 'release'), 'Unknown build mode') require(build['mode'] in ('development', 'release'), 'Unknown build mode')
expected = PLATFORMS if build['mode'] == 'release' else PLATFORMS[:1] expected = PLATFORMS if build['mode'] == 'release' else PLATFORMS[:1]
@@ -176,7 +190,7 @@ class API:
return self.request('/repos/' + REPOSITORY + path, **kwargs) return self.request('/repos/' + REPOSITORY + path, **kwargs)
def release(self, version): def release(self, version):
return self.repo('/releases/tags/v' + version, missing=True) return self.repo('/releases/tags/' + version, missing=True)
def assets(self, release): def assets(self, release):
return self.repo(f'/releases/{release["id"]}/assets') return self.repo(f'/releases/{release["id"]}/assets')
@@ -230,16 +244,16 @@ class API:
def clients(): def clients():
require(os.environ.get('GITHUB_RELEASE_TOKEN'), 'Configure the RAWMARK_GITHUB_TOKEN Gitea secret before releasing') require(os.environ.get('GITHUB_RELEASE_TOKEN'), 'Configure the ALL_GITHUB_TOKEN Gitea secret before releasing')
return (API(os.environ['GITEA_API_URL'], os.environ['GITEA_RELEASE_TOKEN']), return (API(os.environ['GITEA_API_URL'], os.environ['GITEA_RELEASE_TOKEN']),
API('https://api.github.com', os.environ['GITHUB_RELEASE_TOKEN'], github=True)) API('https://api.github.com', os.environ['GITHUB_RELEASE_TOKEN'], github=True))
def validate_identity(version, commit): def validate_identity(version, commit):
require(re.fullmatch(r'\d+\.\d+\.\d+', version), 'Version must use X.Y.Z') require(re.fullmatch(r'[0-9]{8}-[0-9a-f]{8}', version), 'Expected a date and eight-character commit hash')
require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Commit must be a full SHA') require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Commit must be a full SHA')
require(version == Path('VERSION').read_text().strip(), 'VERSION does not match the requested version') require(version == version_for(commit), 'Version does not match the selected commit')
require(commit == run('git', 'rev-parse', 'HEAD'), 'Checkout does not match the requested commit') run('git', 'merge-base', '--is-ancestor', commit, 'HEAD')
def git_env(api): def git_env(api):
@@ -258,10 +272,11 @@ def fetch_main(api):
def release_receipt(gitea, github, version): def release_receipt(gitea, github, version):
require(re.fullmatch(r'\d+\.\d+\.\d+', version), 'Version must use X.Y.Z') require(re.fullmatch(r'[0-9]{8}-[0-9a-f]{8}', version), 'Expected a date and eight-character commit hash')
left, right = gitea.release(version), github.release(version) left, right = gitea.release(version), github.release(version)
require(left and right and not any(r['draft'] or r['prerelease'] for r in (left, right)), require(left and right and not any(r['draft'] or r['prerelease'] for r in (left, right)),
'Version is not completely published on both services') 'Version is not completely published on both services')
require(left['name'] == right['name'] and left['body'] == right['body'], 'Published release metadata differs')
a, b = gitea.asset_bytes(left, 'release.json'), github.asset_bytes(right, 'release.json') a, b = gitea.asset_bytes(left, 'release.json'), github.asset_bytes(right, 'release.json')
require(a == b, 'Published release receipts differ') require(a == b, 'Published release receipts differ')
receipt = json.loads(a) receipt = json.loads(a)
@@ -276,7 +291,7 @@ def release_receipt(gitea, github, version):
require(re.fullmatch(r'[0-9a-f]{64}', info['sha256']) and 0 < info['size'] < UPLOAD_LIMIT, require(re.fullmatch(r'[0-9a-f]{64}', info['sha256']) and 0 < info['size'] < UPLOAD_LIMIT,
'Invalid published asset metadata') 'Invalid published asset metadata')
for api in (gitea, github): for api in (gitea, github):
require(api.repo('/commits/v' + version)['sha'] == build['commit'], 'Published tag points to another commit') require(api.repo('/commits/' + version)['sha'] == build['commit'], 'Published tag points to another commit')
assets = {asset['name']: asset for asset in api.assets(left if api is gitea else right)} assets = {asset['name']: asset for asset in api.assets(left if api is gitea else right)}
for name, info in build['files'].items(): for name, info in build['files'].items():
require(name in assets and assets[name]['size'] == info['size'], 'Published asset is missing or has a different size') require(name in assets and assets[name]['size'] == info['size'], 'Published asset is missing or has a different size')
@@ -284,9 +299,32 @@ def release_receipt(gitea, github, version):
return build, receipt return build, receipt
def successful_main_ci(gitea, commit):
page = 1
passed = False
while not passed:
runs = gitea.repo(f'/actions/runs?limit=100&page={page}')['workflow_runs']
if not runs:
break
passed = any(r['head_sha'] == commit and r['event'] == 'push' and r.get('conclusion') == 'success'
and r['path'] == 'main.yml@refs/heads/main' for r in runs)
page += 1
return passed
def preflight(version, commit): def preflight(version, commit):
state = selected_state()
gitea, _ = clients()
active = state.get('active_run_id')
if active and str(active) != os.environ['CI_RUN_ID']:
require(gitea.repo('/actions/runs/' + str(active))['status'] == 'completed', 'Another release run is active')
state['active_run_id'] = int(os.environ['CI_RUN_ID'])
save_state(state)
require(state['version'] == version, 'Reserved version mismatch')
validate_identity(version, commit) validate_identity(version, commit)
gitea, github = clients() gitea, github = clients()
require(gitea.request('/user')['login'] == 'eezd', 'Unexpected Gitea release identity')
require(github.request('/user')['login'] == 'eezd', 'Unexpected GitHub release identity')
repository = github.repo() repository = github.repo()
require(repository['permissions']['push'], 'GitHub credential cannot publish this repository') require(repository['permissions']['push'], 'GitHub credential cannot publish this repository')
scopes = {s.strip() for s in github.headers.get('X-OAuth-Scopes', '').split(',')} scopes = {s.strip() for s in github.headers.get('X-OAuth-Scopes', '').split(',')}
@@ -294,54 +332,122 @@ def preflight(version, commit):
'GitHub classic PAT needs repository access, workflow and write:packages') 'GitHub classic PAT needs repository access, workflow and write:packages')
current = gitea.release(version) current = gitea.release(version)
if current and not current['draft']: if current and not current['draft']:
build, _ = release_receipt(gitea, github, version) complete_publication(gitea, github, version, commit)
require(build['commit'] == commit, 'Published version belongs to another commit')
output('completed', 'true') output('completed', 'true')
output('reuse', 'true') output('reuse', 'true')
return return
main = fetch_main(gitea) main = fetch_main(gitea)
run('git', 'merge-base', '--is-ancestor', commit, main) run('git', 'merge-base', '--is-ancestor', commit, main)
page = 1 require(successful_main_ci(gitea, commit), 'The selected commit has no successful main-push CI')
passed = False
while not passed:
runs = gitea.repo(f'/actions/runs?limit=100&page={page}')['workflow_runs']
if not runs:
break
passed = any(r['head_sha'] == commit and r['event'] == 'push' and r['conclusion'] == 'success'
and r['path'] == 'ci.yml@refs/heads/main' for r in runs)
page += 1
require(passed, 'The selected commit has no successful main-push development CI')
remote = fetch_main(github) remote = fetch_main(github)
run('git', 'merge-base', '--is-ancestor', remote, commit) run('git', 'merge-base', '--is-ancestor', remote, commit)
tag = version
existing = gitea.repo('/commits/' + tag, missing=True)
if existing:
require(existing['sha'] == commit, 'Existing tag belongs to another commit')
else:
run('git', '-c', 'user.name=eezd', '-c', '[email protected]',
'tag', '-a', tag, commit, '-m', 'RawMark ' + version)
require(run('git', 'for-each-ref', '--format=%(taggername)', 'refs/tags/' + tag) == 'eezd', 'Unexpected tagger')
run('git', 'push', gitea.root.removesuffix('/api/v1') + '/' + REPOSITORY + '.git',
'refs/tags/' + tag, env=git_env(gitea))
from mirror import synchronize
synchronize(STATE_DIRECTORY, gitea.root.removesuffix('/api/v1') + '/' + REPOSITORY + '.git',
'https://github.com/' + REPOSITORY + '.git', git_env(github))
require(github.repo('/commits/' + tag)['sha'] == commit, 'GitHub tag has not mirrored')
if current: if current:
restore(gitea, current) restore(gitea, current)
output('completed', 'false') output('completed', 'false')
output('reuse', 'true' if current else 'false') output('reuse', 'true' if current else 'false')
def version_for(commit):
timestamp = int(run('git', 'show', '-s', '--format=%ct', commit))
return datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).strftime('%Y%m%d') + '-' + commit[:8]
STATE_DIRECTORY = Path('/docker/gitea/mirrors/RawMark')
@contextlib.contextmanager
def release_state():
with (STATE_DIRECTORY / '.release.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
path = STATE_DIRECTORY / 'release.pending.json'
state = json.loads(path.read_text()) if path.exists() else None
yield state
def save_state(state):
path = STATE_DIRECTORY / 'release.pending.json'
temporary = path.with_suffix('.tmp')
write_json(temporary, state)
temporary.replace(path)
def complete_publication(gitea, github, version, commit):
"""Finish the matching reservation only after both published receipts validate."""
state = selected_state()
require(state['version'] == version and state['commit'] == commit, 'Publication candidate mismatch')
build, _ = release_receipt(gitea, github, version)
require(build['commit'] == commit, 'Published version belongs to another commit')
state['status'] = 'complete'
save_state(state)
def reserve(dev):
require(re.fullmatch(r'[0-9a-f]{40}', dev), 'Expected the accepted dev SHA')
with release_state() as current:
if current and current.get('status') != 'complete':
require(current['dev'] == dev, 'Another release is pending; resolve it before starting another')
print(current['id'])
return
state = {'id': dev, 'dev': dev, 'status': 'preparing'}
save_state(state)
print(state['id'])
def select_release(commit, notes_file):
with release_state() as state:
require(state and state['status'] == 'preparing', 'Expected a release preparation reservation')
require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Expected a full main SHA')
content = Path(notes_file).read_text()
require(content.strip(), 'Release notes are required')
state.update(commit=commit, version=version_for(commit), notes=content,
notes_sha256=hashlib.sha256(content.encode()).hexdigest(), status='selected')
save_state(state)
print(state['version'])
def selected_state():
state = json.loads((STATE_DIRECTORY / 'release.pending.json').read_text())
require(state['id'] == os.environ['RELEASE_ID'] and state['commit'] == os.environ['RELEASE_COMMIT'],
'Release inputs differ from the reserved candidate')
require(hashlib.sha256(state['notes'].encode()).hexdigest() == state['notes_sha256'], 'Release notes changed')
return state
def notes(build): def notes(build):
return (Path('.github/release-notes.md').read_text().rstrip() + '\n\n' state = selected_state()
require(state['commit'] == build['commit'] and state['version'] == build['version'], 'Release notes candidate mismatch')
return (state['notes'].rstrip() + '\n\n'
f'Source commit: `{build["commit"]}`. Build run: `{build["run_id"]}`.\n\n' f'Source commit: `{build["commit"]}`. Build run: `{build["run_id"]}`.\n\n'
'Validation: Linux amd64 runtime tests; all five targets cross-built and vetted on Gitea. ' 'Validation: Linux amd64 native lifecycle and container tests; six targets cross-built and vetted. '
'Windows, macOS and Linux arm64 have not passed native runtime acceptance for this release.\n') 'Other targets have not passed native runtime acceptance. Downloads are unsigned.\n\n'
'验证:Linux amd64 已完成原生生命周期与容器测试;六平台交叉构建及 vet。其他平台未完成原生运行验收,程序未签名。\n')
def ensure_draft(api, build): def ensure_draft(api, build):
tag = 'v' + build['version'] tag = build['version']
existing = api.repo('/commits/' + tag, missing=True) existing = api.repo('/commits/' + tag, missing=True)
if existing: require(existing and existing['sha'] == build['commit'], 'Expected the verified mirrored annotated tag')
require(existing['sha'] == build['commit'], 'Existing tag conflicts')
elif api.github:
api.repo('/git/refs', method='POST', data={'ref': 'refs/tags/' + tag, 'sha': build['commit']})
else:
api.repo('/tags', method='POST', data={'tag_name': tag, 'target': build['commit']})
release = api.release(build['version']) release = api.release(build['version'])
if not release: if not release:
release = api.repo('/releases', method='POST', data={ release = api.repo('/releases', method='POST', data={
'tag_name': 'v' + build['version'], 'target_commitish': build['commit'], 'tag_name': build['version'], 'target_commitish': build['commit'],
'name': 'RawMark ' + build['version'], 'body': notes(build), 'draft': True, 'name': 'RawMark ' + build['version'], 'body': notes(build), 'draft': True,
}) })
require(api.repo('/commits/v' + build['version'])['sha'] == build['commit'], 'Existing tag conflicts') require(api.repo('/commits/' + build['version'])['sha'] == build['commit'], 'Existing tag conflicts')
require(release['body'] == notes(build), 'Existing release notes conflict') require(release['body'] == notes(build), 'Existing release notes conflict')
return release return release
@@ -350,7 +456,8 @@ def checkpoint():
build = verify_bundle() build = verify_bundle()
gitea, _ = clients() gitea, _ = clients()
release = ensure_draft(gitea, build) release = ensure_draft(gitea, build)
gitea.upload(release, DOWNLOADS / 'build.json') for name in [*build['files'], 'SHA256SUMS', 'build.json']:
gitea.upload(release, DOWNLOADS / name)
def restore(gitea, release): def restore(gitea, release):
@@ -423,7 +530,19 @@ def promote_development(local_image):
token = os.environ['GITEA_RELEASE_TOKEN'] token = os.environ['GITEA_RELEASE_TOKEN']
subprocess.run(['docker', 'login', 'git.134333.xyz', '--username', 'eezd', '--password-stdin'], subprocess.run(['docker', 'login', 'git.134333.xyz', '--username', 'eezd', '--password-stdin'],
input=token, text=True, check=True) input=token, text=True, check=True)
candidate = GITEA_IMAGE + ':build-' + build['commit'] + '-' + os.environ['CI_RUN_ID'] + '-' + os.environ['CI_RUN_ATTEMPT'] existing = registry_digest(GITEA_DEV_IMAGE, build['version'])
if existing:
reference = GITEA_DEV_IMAGE + ':' + build['version']
run('docker', 'pull', reference)
info = json.loads(run('docker', 'image', 'inspect', reference))[0]
labels = info['Config'].get('Labels') or {}
require(labels.get('org.opencontainers.image.revision') == build['commit']
and labels.get('org.opencontainers.image.version') == build['version'],
'Existing development version belongs to another commit')
output('image', reference)
output('version', build['version'])
return
candidate = GITEA_DEV_IMAGE + ':build-' + build['commit'] + '-' + os.environ['CI_RUN_ID'] + '-' + os.environ['CI_RUN_ATTEMPT']
# Uncompressed layer sizes provide a conservative bound before any CDN upload. # Uncompressed layer sizes provide a conservative bound before any CDN upload.
with tempfile.TemporaryFile() as target: with tempfile.TemporaryFile() as target:
subprocess.run(['docker', 'image', 'save', local_image], stdout=target, check=True) subprocess.run(['docker', 'image', 'save', local_image], stdout=target, check=True)
@@ -434,15 +553,15 @@ def promote_development(local_image):
run('docker', 'tag', local_image, candidate) run('docker', 'tag', local_image, candidate)
try: try:
run('docker', 'push', candidate) run('docker', 'push', candidate)
value = registry_digest(GITEA_IMAGE, candidate.rsplit(':', 1)[1]) value = registry_digest(GITEA_DEV_IMAGE, candidate.rsplit(':', 1)[1])
tag = 'dev-' + build['commit'] tag = build['version']
existing = registry_digest(GITEA_IMAGE, tag) existing = registry_digest(GITEA_DEV_IMAGE, tag)
require(existing in (None, value), 'Immutable development tag already has different content') require(existing in (None, value), 'Immutable development tag already has different content')
if not existing: if not existing:
run('docker', 'buildx', 'imagetools', 'create', '--prefer-index=false', '--tag', run('docker', 'buildx', 'imagetools', 'create', '--prefer-index=false', '--tag',
GITEA_IMAGE + ':' + tag, GITEA_IMAGE + '@' + value) GITEA_DEV_IMAGE + ':' + tag, GITEA_DEV_IMAGE + '@' + value)
require(registry_digest(GITEA_IMAGE, tag) == value, 'Development tag digest changed') require(registry_digest(GITEA_DEV_IMAGE, tag) == value, 'Development tag digest changed')
output('image', GITEA_IMAGE + '@' + value) output('image', GITEA_DEV_IMAGE + ':' + build['version'])
output('version', build['version']) output('version', build['version'])
finally: finally:
run('docker', 'image', 'rm', candidate) run('docker', 'image', 'rm', candidate)
@@ -467,69 +586,9 @@ def verify_anonymous(image):
run('docker', 'pull', image, env=env) run('docker', 'pull', image, env=env)
def sync_source(github, build):
remote = fetch_main(github)
run('git', 'merge-base', '--is-ancestor', remote, build['commit'])
tag = 'v' + build['version']
existing = github.repo('/commits/' + tag, missing=True)
if existing:
require(existing['sha'] == build['commit'], 'GitHub tag already points to another commit')
# Bound the actual pack rather than checking individual tracked file sizes.
revision_input = f'{build["commit"]}\n^{remote}\n'
pack = subprocess.run(['git', 'pack-objects', '--revs', '--stdout'], input=revision_input.encode(),
stdout=subprocess.PIPE, check=True).stdout
require(len(pack) < UPLOAD_LIMIT, 'GitHub source push exceeds CDN budget')
run('git', 'push', 'https://github.com/' + REPOSITORY + '.git',
build['commit'] + ':refs/heads/main', env=git_env(github))
def sync_pages(github, build):
env = git_env(github)
with tempfile.TemporaryDirectory(prefix='rawmark-pages-') as directory:
target = Path(directory)
url = 'https://github.com/' + REPOSITORY + '.git'
run('git', 'init', '--initial-branch=gh-pages', directory)
existing = github.repo('/branches/gh-pages', missing=True)
if existing:
run('git', '-C', directory, 'fetch', '--depth=1', url, 'gh-pages', env=env)
run('git', '-C', directory, 'reset', '--hard', 'FETCH_HEAD')
run('git', '-C', directory, 'rm', '-r', '--ignore-unmatch', '.')
with zipfile.ZipFile(DOWNLOADS / 'demo.zip') as archive:
for info in archive.infolist():
path = Path(info.filename)
require(not path.is_absolute() and '..' not in path.parts and '.git' not in path.parts,
'Unsafe demo archive entry')
archive.extract(info, target)
run('git', '-C', directory, 'add', '.')
changed = run('git', '-C', directory, 'status', '--porcelain')
if changed:
run('git', '-C', directory, '-c', 'user.name=RawMark CI',
'-c', '[email protected]', 'commit',
'-m', 'docs(demo): publish RawMark ' + build['version'])
run('git', '-C', directory, 'push', url, 'HEAD:refs/heads/gh-pages', env=env)
pages_commit = run('git', '-C', directory, 'rev-parse', 'HEAD')
pages = github.repo('/pages')
if pages.get('build_type') != 'legacy' or pages.get('source') != {'branch': 'gh-pages', 'path': '/'}:
github.repo('/pages', method='PUT', data={'build_type': 'legacy', 'source': {'branch': 'gh-pages', 'path': '/'}})
deadline = time.monotonic() + 900
while time.monotonic() < deadline:
url = 'https://eezd.github.io/RawMark/rawmark-release.json?commit=' + build['commit']
try:
with urllib.request.urlopen(url, timeout=15) as response:
published = json.load(response)
except urllib.error.HTTPError as error:
if error.code not in (404, 503):
raise
else:
if published == {'version': build['version'], 'commit': build['commit']}:
return pages_commit
time.sleep(5)
raise TimeoutError('GitHub Pages did not serve the selected release; Gitea release remains a draft')
def publish(): def publish():
gitea, github = clients() gitea, github = clients()
release = gitea.release(Path('VERSION').read_text().strip()) release = gitea.release(selected_state()['version'])
require(release and release['draft'], 'Expected the build checkpoint draft') require(release and release['draft'], 'Expected the build checkpoint draft')
restore(gitea, release) restore(gitea, release)
build = verify_bundle() build = verify_bundle()
@@ -578,15 +637,15 @@ def publish():
str(Path(os.environ['DOCKER_CONFIG']) / 'config.json'), str(Path(os.environ['DOCKER_CONFIG']) / 'config.json'),
'docker://' + GITEA_IMAGE + '@' + value, 'docker://' + target) 'docker://' + GITEA_IMAGE + '@' + value, 'docker://' + target)
require(image_digest(target) == value, 'GHCR image differs from the tested candidate') require(image_digest(target) == value, 'GHCR image differs from the tested candidate')
package = github.request('/user/packages/container/rawmark')
require(package['visibility'] == 'public', 'GHCR package is private. Set Public at https://github.com/users/eezd/packages/container/rawmark/settings then retry this release ID; original artifacts are saved.')
verify_anonymous(GITHUB_IMAGE + '@' + value) verify_anonymous(GITHUB_IMAGE + '@' + value)
sync_source(github, build)
public_release = ensure_draft(github, build) public_release = ensure_draft(github, build)
for name in [*(archive_name(tag, p) for p in PLATFORMS), 'SHA256SUMS', 'build.json', 'demo.zip']: for name in [*(archive_name(tag, p) for p in PLATFORMS), 'SHA256SUMS', 'build.json', 'demo.zip']:
for api, destination in ((gitea, release), (github, public_release)): for api, destination in ((gitea, release), (github, public_release)):
api.upload(destination, DOWNLOADS / name) api.upload(destination, DOWNLOADS / name)
pages_commit = sync_pages(github, build)
write_json(DOWNLOADS / 'release.json', {'schema': 1, 'build_sha256': digest(DOWNLOADS / 'build.json'), write_json(DOWNLOADS / 'release.json', {'schema': 1, 'build_sha256': digest(DOWNLOADS / 'build.json'),
'image_digest': value, 'pages_commit': pages_commit}) 'image_digest': value})
for api, destination in ((gitea, release), (github, public_release)): for api, destination in ((gitea, release), (github, public_release)):
api.upload(destination, DOWNLOADS / 'release.json') api.upload(destination, DOWNLOADS / 'release.json')
# Match the internal checkpoint on GitHub too; both releases expose the same files. # Match the internal checkpoint on GitHub too; both releases expose the same files.
@@ -601,15 +660,20 @@ def publish():
run('docker', 'buildx', 'imagetools', 'create', '--tag', image + ':' + stable_tag, image + '@' + value) run('docker', 'buildx', 'imagetools', 'create', '--tag', image + ':' + stable_tag, image + '@' + value)
require(registry_digest(image, stable_tag) == value, 'Promoted image digest differs') require(registry_digest(image, stable_tag) == value, 'Promoted image digest differs')
gitea.repo(f'/releases/{release["id"]}', method='PATCH', data={'draft': False}) gitea.repo(f'/releases/{release["id"]}', method='PATCH', data={'draft': False})
release_receipt(gitea, github, tag) complete_publication(gitea, github, tag, build['commit'])
print(f'Published RawMark {tag}: {build["commit"]}, {value}') print(f'Published RawMark {tag}: {build["commit"]}, {value}')
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True) sub = parser.add_subparsers(dest='command', required=True)
sub.add_parser('reserve').add_argument('dev')
selection = sub.add_parser('select')
selection.add_argument('commit')
selection.add_argument('notes_file')
sub.add_parser('bundle').add_argument('mode', choices=('development', 'release')) sub.add_parser('bundle').add_argument('mode', choices=('development', 'release'))
sub.add_parser('prepare-image') sub.add_parser('prepare-image')
sub.add_parser('version')
pre = sub.add_parser('preflight') pre = sub.add_parser('preflight')
pre.add_argument('version') pre.add_argument('version')
pre.add_argument('commit') pre.add_argument('commit')
@@ -617,17 +681,29 @@ def main():
sub.add_parser('publish') sub.add_parser('publish')
sub.add_parser('promote-development').add_argument('image') sub.add_parser('promote-development').add_argument('image')
args = parser.parse_args() args = parser.parse_args()
if args.command == 'bundle': if args.command == 'reserve':
reserve(args.dev)
elif args.command == 'select':
select_release(args.commit, args.notes_file)
elif args.command == 'version':
print(build_version())
elif args.command == 'bundle':
bundle(args.mode) bundle(args.mode)
elif args.command == 'prepare-image': elif args.command == 'prepare-image':
prepare_image() prepare_image()
elif args.command == 'preflight': elif args.command == 'preflight':
with release_state() as state:
selected_state()
preflight(args.version, args.commit) preflight(args.version, args.commit)
elif args.command == 'checkpoint': elif args.command == 'checkpoint':
with release_state() as state:
selected_state()
checkpoint() checkpoint()
elif args.command == 'promote-development': elif args.command == 'promote-development':
promote_development(args.image) promote_development(args.image)
else: else:
with release_state() as state:
selected_state()
publish() publish()
+3 -1
View File
@@ -18,7 +18,9 @@ parser.add_argument('image')
parser.add_argument('--client-container', help='CI job container to connect to the test network') parser.add_argument('--client-container', help='CI job container to connect to the test network')
parser.add_argument('--resource-label', help='Label temporary resources for cancellation cleanup') parser.add_argument('--resource-label', help='Label temporary resources for cancellation cleanup')
args = parser.parse_args() args = parser.parse_args()
version = Path('VERSION').read_text().strip() from release import build_version
version = build_version()
name = 'rawmark-ci-' + secrets.token_hex(8) name = 'rawmark-ci-' + secrets.token_hex(8)
labels = ('--label', args.resource_label) if args.resource_label else () labels = ('--label', args.resource_label) if args.resource_label else ()
+3 -1
View File
@@ -18,7 +18,9 @@ import urllib.request
binary = str(Path(sys.argv[1]).resolve()) binary = str(Path(sys.argv[1]).resolve())
version = Path('VERSION').read_text().strip() from release import build_version
version = build_version()
assert subprocess.check_output([binary, '--version'], text=True).strip() == f'RawMark {version}' assert subprocess.check_output([binary, '--version'], text=True).strip() == f'RawMark {version}'
+158 -4
View File
@@ -117,7 +117,7 @@ class PublicationTests(unittest.TestCase):
gitea.release.return_value = {'draft': True, 'prerelease': False} gitea.release.return_value = {'draft': True, 'prerelease': False}
github.release.return_value = {'draft': False, 'prerelease': False} github.release.return_value = {'draft': False, 'prerelease': False}
with self.assertRaisesRegex(ValueError, 'not completely published'): with self.assertRaisesRegex(ValueError, 'not completely published'):
release.release_receipt(gitea, github, '0.1.1') release.release_receipt(gitea, github, '20261030-aaaaaaaa')
def test_registry_permission_error_is_not_treated_as_missing_image(self): def test_registry_permission_error_is_not_treated_as_missing_image(self):
result = Mock(returncode=1, stderr='403 Forbidden', stdout='') result = Mock(returncode=1, stderr='403 Forbidden', stdout='')
@@ -204,7 +204,7 @@ class DeploymentTests(unittest.TestCase):
(self.directory / '.env').write_text( (self.directory / '.env').write_text(
f'RAWMARK_CHANNEL=development\nRAWMARK_PROJECT=rawmark-ci-deploy\n' f'RAWMARK_CHANNEL=development\nRAWMARK_PROJECT=rawmark-ci-deploy\n'
f'RAWMARK_BIND=127.0.0.1\nRAWMARK_PORT=21899\nRAWMARK_DATA_DIR={self.directory}/data\n') f'RAWMARK_BIND=127.0.0.1\nRAWMARK_PORT=21899\nRAWMARK_DATA_DIR={self.directory}/data\n')
self.image = release.GITEA_IMAGE + '@' + DIGEST self.image = release.GITEA_DEV_IMAGE + ':0.1.1'
self.previous = {'Id': 'old-id', 'Image': 'old-image', 'State': {'Running': True}, self.previous = {'Id': 'old-id', 'Image': 'old-image', 'State': {'Running': True},
'Mounts': [{'Destination': '/data', 'Type': 'bind', 'Source': str(self.directory / 'data')}]} 'Mounts': [{'Destination': '/data', 'Type': 'bind', 'Source': str(self.directory / 'data')}]}
self.image_info = {'Id': 'new-image', 'Config': {'User': '65532:65532', 'Labels': { self.image_info = {'Id': 'new-image', 'Config': {'User': '65532:65532', 'Labels': {
@@ -236,10 +236,28 @@ class DeploymentTests(unittest.TestCase):
'test': {'NetworkID': 'network', 'IPAddress': '127.0.0.1'}}}}]) 'test': {'NetworkID': 'network', 'IPAddress': '127.0.0.1'}}}}])
return '' return ''
def test_existing_verified_build_outputs_a_tag(self):
build={'commit':SHA,'version':'0.1.1'}
info=json.dumps([{'Config':{'Labels':{'org.opencontainers.image.revision':SHA,
'org.opencontainers.image.version':'0.1.1'}}}])
with patch.object(release,'verify_bundle',return_value=build), \
patch.object(release.subprocess,'run'), patch.object(release,'registry_digest',return_value=DIGEST), \
patch.object(release,'run',return_value=info) as run, patch.object(release,'output') as output:
release.promote_development('synthetic')
output.assert_any_call('image',release.GITEA_DEV_IMAGE+':0.1.1')
self.assertEqual(run.call_args_list[0].args,('docker','pull',release.GITEA_DEV_IMAGE+':0.1.1'))
def test_digest_and_other_version_tags_cannot_deploy(self):
for image in (release.GITEA_DEV_IMAGE + '@' + DIGEST, release.GITEA_DEV_IMAGE + ':other'):
with self.subTest(image=image), patch.object(deploy, 'run') as docker:
with self.assertRaisesRegex(ValueError, 'version tag'):
deploy.deployment(self.directory, 'development', image, SHA, '0.1.1')
docker.assert_not_called()
def test_channel_mismatch_cannot_touch_docker(self): def test_channel_mismatch_cannot_touch_docker(self):
with patch.object(deploy, 'run') as docker: with patch.object(deploy, 'run') as docker:
with self.assertRaisesRegex(ValueError, 'channel'): with self.assertRaisesRegex(ValueError, 'channel'):
deploy.deployment(self.directory, 'production', self.image, SHA, '0.1.1') deploy.settings(self.directory, 'production')
docker.assert_not_called() docker.assert_not_called()
def test_unresolved_pending_deployment_cannot_be_overwritten(self): def test_unresolved_pending_deployment_cannot_be_overwritten(self):
@@ -249,7 +267,7 @@ class DeploymentTests(unittest.TestCase):
deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1') deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1')
docker.assert_not_called() docker.assert_not_called()
def test_stale_main_skips_without_stopping_existing_service(self): def test_stale_dev_skips_without_stopping_existing_service(self):
with patch.object(deploy, 'API') as api, patch.object(deploy, 'run', side_effect=self.command): with patch.object(deploy, 'API') as api, patch.object(deploy, 'run', side_effect=self.command):
api.return_value.repo.return_value = {'commit': {'id': 'c' * 40}} api.return_value.repo.return_value = {'commit': {'id': 'c' * 40}}
deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1') deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1')
@@ -277,6 +295,31 @@ class DeploymentTests(unittest.TestCase):
start = next(i for i,c in enumerate(self.commands) if c[:2] == ('docker', 'compose') and 'up' in c) start = next(i for i,c in enumerate(self.commands) if c[:2] == ('docker', 'compose') and 'up' in c)
self.assertLess(stop, start) self.assertLess(stop, start)
self.assertIn('RAWMARK_IMAGE=' + self.image, (self.directory / '.env').read_text()) self.assertIn('RAWMARK_IMAGE=' + self.image, (self.directory / '.env').read_text())
self.assertTrue((self.directory / 'start.sh').stat().st_mode & 0o111)
self.assertTrue((self.directory / 'stop.sh').stat().st_mode & 0o111)
def test_custom_control_script_is_preserved_before_stopping(self):
(self.directory / 'stop.sh').write_text('custom behavior\n')
with patch.object(deploy, 'run', side_effect=self.command):
with self.assertRaisesRegex(ValueError, 'Locally modified'):
deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1')
self.assertFalse(any(c[:2] == ('docker', 'stop') for c in self.commands))
self.assertEqual((self.directory / 'stop.sh').read_text(), 'custom behavior\n')
def test_existing_data_is_backed_up_without_a_container(self):
def without_container(*args, **kwargs):
if args[:2] == ('docker', 'ps'):
return ''
return self.command(*args, **kwargs)
opener = Mock()
opener.open.side_effect = [contextlib.nullcontext(io.BytesIO(b'<html>RawMark</html>')),
contextlib.nullcontext(io.BytesIO(b'{"code":0}'))]
with patch.object(deploy, 'run', side_effect=without_container), patch.object(
deploy.urllib.request, 'build_opener', return_value=opener):
deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1')
receipt = json.loads((self.directory / 'deployment.json').read_text())
self.assertTrue(Path(receipt['backup']).exists())
self.assertIsNone(receipt['previous_image'])
def test_failed_replacement_keeps_backup_and_does_not_downgrade_data(self): def test_failed_replacement_keeps_backup_and_does_not_downgrade_data(self):
def failure(*args, **kwargs): def failure(*args, **kwargs):
@@ -293,6 +336,15 @@ class DeploymentTests(unittest.TestCase):
self.assertNotIn('RAWMARK_IMAGE=', (self.directory / '.env').read_text()) self.assertNotIn('RAWMARK_IMAGE=', (self.directory / '.env').read_text())
class DevelopmentVersionTests(unittest.TestCase):
def test_version_uses_utc_commit_date_and_exact_eight_character_hash(self):
with patch.dict(os.environ, {'RAWMARK_BUILD_CHANNEL': 'development'}), patch.object(
release, 'run', side_effect=[SHA, '0']):
self.assertEqual(release.build_version(), '19700101-aaaaaaaa')
self.assertEqual(release.archive_name('19700101-aaaaaaaa', 'linux/amd64'),
'RawMark_19700101-aaaaaaaa_linux_amd64.tar.gz')
class CleanupTests(unittest.TestCase): class CleanupTests(unittest.TestCase):
def test_cleanup_rejects_broad_filter(self): def test_cleanup_rejects_broad_filter(self):
with patch('subprocess.check_output') as docker: with patch('subprocess.check_output') as docker:
@@ -303,3 +355,105 @@ class CleanupTests(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class ReleaseReservationTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.patch = patch.object(release, 'STATE_DIRECTORY', Path(self.directory.name))
self.patch.start()
self.addCleanup(self.patch.stop)
def test_other_candidate_cannot_replace_pending_release(self):
release.reserve(SHA)
with self.assertRaisesRegex(ValueError, 'Another release'):
release.reserve('c' * 40)
def test_fixed_notes_and_candidate_are_required_on_retry(self):
release.reserve(SHA)
notes = Path(self.directory.name) / 'notes.md'
notes.write_text('English\n\n中文')
with patch.object(release, 'version_for', return_value='20261030-aaaaaaaa'):
release.select_release(SHA, notes)
with patch.dict(os.environ, {'RELEASE_ID': SHA, 'RELEASE_COMMIT': SHA}):
self.assertEqual(release.selected_state()['notes'], notes.read_text())
with patch.dict(os.environ, {'RELEASE_COMMIT': 'b' * 40}):
with self.assertRaisesRegex(ValueError, 'reserved candidate'):
release.selected_state()
def test_release_matrix_includes_windows_arm64(self):
self.assertEqual(set(release.PLATFORMS), {'linux/amd64', 'linux/arm64',
'darwin/amd64', 'darwin/arm64', 'windows/amd64', 'windows/arm64'})
def test_release_version_uses_utc_commit_date_and_fixed_sha(self):
with patch.object(release, 'run', return_value='0'):
self.assertEqual(release.version_for(SHA), '19700101-aaaaaaaa')
class MainAdmissionTests(unittest.TestCase):
def test_pending_rows_do_not_hide_later_success(self):
api = Mock()
api.repo.side_effect = [
{'workflow_runs': [{'head_sha': SHA, 'event': 'push', 'path': 'mirror.yml@refs/heads/main'}]},
{'workflow_runs': [{'head_sha': SHA, 'event': 'push', 'path': 'main.yml@refs/heads/main', 'conclusion': 'success'}]},
]
self.assertTrue(release.successful_main_ci(api, SHA))
def test_other_sha_pr_or_dev_success_cannot_admit_main(self):
api = Mock()
api.repo.side_effect = [{'workflow_runs': [
{'head_sha': 'b' * 40, 'event': 'push', 'path': 'main.yml@refs/heads/main', 'conclusion': 'success'},
{'head_sha': SHA, 'event': 'pull_request', 'path': 'main.yml@refs/pull/3/head', 'conclusion': 'success'},
{'head_sha': SHA, 'event': 'push', 'path': 'dev.yml@refs/heads/dev', 'conclusion': 'success'},
]}, {'workflow_runs': []}]
self.assertFalse(release.successful_main_ci(api, SHA))
class PublicationCompletionTests(unittest.TestCase):
def test_success_finishes_reservation_without_deployment(self):
state = {'version': 'version', 'commit': SHA, 'status': 'selected'}
with patch.object(release, 'selected_state', return_value=state), \
patch.object(release, 'release_receipt', return_value=({'commit': SHA}, {})), \
patch.object(release, 'save_state') as save:
release.complete_publication(None, None, 'version', SHA)
self.assertEqual(state['status'], 'complete')
save.assert_called_once_with(state)
def test_partial_publication_remains_pending(self):
state = {'version': 'version', 'commit': SHA, 'status': 'selected'}
with patch.object(release, 'selected_state', return_value=state), \
patch.object(release, 'release_receipt', side_effect=ValueError('Incomplete mirror')), \
patch.object(release, 'save_state') as save:
with self.assertRaisesRegex(ValueError, 'Incomplete mirror'):
release.complete_publication(None, None, 'version', SHA)
self.assertEqual(state['status'], 'selected')
save.assert_not_called()
def test_published_retry_repairs_pending_reservation(self):
state = {'version': 'version', 'commit': SHA, 'status': 'selected'}
gitea, github = Mock(), Mock()
gitea.request.return_value = github.request.return_value = {'login': 'eezd'}
github.repo.return_value = {'permissions': {'push': True}}
github.headers = {'X-OAuth-Scopes': 'repo, workflow, write:packages'}
gitea.release.return_value = {'draft': False}
with patch.dict(os.environ, {'CI_RUN_ID': '42'}), \
patch.object(release, 'selected_state', return_value=state), \
patch.object(release, 'clients', return_value=(gitea, github)), \
patch.object(release, 'validate_identity'), \
patch.object(release, 'release_receipt', return_value=({'commit': SHA}, {})), \
patch.object(release, 'save_state'), patch.object(release, 'run') as run, \
patch.object(release, 'output') as output:
release.preflight('version', SHA)
self.assertEqual(state['status'], 'complete')
output.assert_any_call('completed', 'true')
run.assert_not_called()
def test_wrong_candidate_cannot_finish_reservation(self):
state = {'version': 'version', 'commit': SHA, 'status': 'selected'}
with patch.object(release, 'selected_state', return_value=state), \
patch.object(release, 'release_receipt', return_value=({'commit': 'c' * 40}, {})), \
patch.object(release, 'save_state') as save:
with self.assertRaisesRegex(ValueError, 'another commit'):
release.complete_publication(None, None, 'version', SHA)
save.assert_not_called()
+164
View File
@@ -0,0 +1,164 @@
"""Verify instance controls with synthetic files and no container mutations."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import deploy
SOURCES = Path(__file__).resolve().parents[2] / 'deploy'
class InstanceControlTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='rawmark controls ')
self.addCleanup(self.tmp.cleanup)
self.directory = Path(self.tmp.name)
self.bin = self.directory / 'bin'
self.bin.mkdir()
for name in ('start.sh', 'stop.sh'):
shutil.copy2(SOURCES / name, self.directory / name)
(self.directory / 'compose.yml').write_text(
'name: ${RAWMARK_PROJECT:?missing project}\nservices:\n app:\n'
' image: ${RAWMARK_IMAGE:?missing image}\n environment:\n'
' APP_SETTING: ${APP_SETTING:?missing setting}\n'
' volumes:\n - ${RAWMARK_DATA_DIR:?missing data}:/data\n')
(self.directory / '.env').write_text(
'RAWMARK_PROJECT=rawmark-controls\nRAWMARK_IMAGE=example.invalid/verified:old\n'
f'APP_SETTING=from-dotenv\nRAWMARK_DATA_DIR={self.directory}/data\n')
(self.directory / 'data').mkdir()
(self.directory / 'data/keep').write_text('persistent')
(self.bin / 'docker').write_text(f'#!{sys.executable}\n' + '''
import json
import os
from pathlib import Path
import subprocess
import sys
directory = Path(__file__).resolve().parents[1]
args = sys.argv[1:]
with (directory / 'calls.jsonl').open('a') as log:
log.write(json.dumps({'args': args, 'env': dict(os.environ)}) + '\\n')
if 'config' in args and (directory / 'real-docker').exists():
real = (directory / 'real-docker').read_text()
result = subprocess.run([real, *args[:-1], '--format', 'json'], capture_output=True, text=True)
(directory / 'resolved.json').write_text(result.stdout)
sys.stderr.write(result.stderr)
sys.exit(result.returncode)
status = directory / 'failure-code'
if 'config' not in args and (directory / 'action-failure-code').exists():
status = directory / 'action-failure-code'
sys.exit(int(status.read_text()) if status.exists() else 0)
''')
(self.bin / 'docker').chmod(0o755)
def invoke(self, name, **environment):
return subprocess.run(['bash', str(self.directory / name)], cwd='/',
env={**os.environ, 'PATH': str(self.bin) + ':' + os.environ['PATH'], **environment},
capture_output=True, text=True)
def calls(self):
path = self.directory / 'calls.jsonl'
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
def test_start_and_stop_use_instance_files_and_preserve_data(self):
(self.directory / 'images.env').write_text('RAWMARK_IMAGE=example.invalid/verified:new\n')
for name, action in (('start.sh', ['up', '-d', '--no-build', '--pull', 'never']),
('stop.sh', ['stop'])):
with self.subTest(name=name):
result = self.invoke(name, RAWMARK_PROJECT='other-project', RAWMARK_IMAGE='unverified',
RAWMARK_DATA_DIR='/other-data', APP_SETTING='caller',
COMPOSE_FILE='/wrong.yml', COMPOSE_PROJECT_NAME='other',
COMPOSE_PROFILES='extra', COMPOSE_DISABLE_ENV_FILE='true')
self.assertEqual(result.returncode, 0, result.stderr)
call = self.calls()[-1]
self.assertEqual(call['args'][-len(action):], action)
files = [call['args'][i + 1] for i, arg in enumerate(call['args']) if arg == '--env-file']
self.assertEqual(files, [str(self.directory / '.env'), str(self.directory / 'images.env')])
self.assertEqual(call['args'][call['args'].index('--project-directory') + 1], str(self.directory))
self.assertFalse(any(key.startswith(('RAWMARK_', 'COMPOSE_')) or key == 'APP_SETTING'
for key in call['env']))
self.assertEqual((self.directory / 'data/keep').read_text(), 'persistent')
def test_docker_client_configuration_is_preserved(self):
result = self.invoke('stop.sh', DOCKER_CONFIG='/private/docker-config')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.calls()[-1]['env']['DOCKER_CONFIG'], '/private/docker-config')
def test_pending_blocks_start_but_allows_stop(self):
(self.directory / 'deployment.pending.json').write_text('{}')
self.assertNotEqual(self.invoke('start.sh').returncode, 0)
self.assertEqual(self.calls(), [])
self.assertEqual(self.invoke('stop.sh').returncode, 0)
self.assertEqual(self.calls()[-1]['args'][-1], 'stop')
def test_lock_blocks_both_controls(self):
with (self.directory / '.deploy.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
for name in ('start.sh', 'stop.sh'):
self.assertNotEqual(self.invoke(name).returncode, 0)
self.assertEqual(self.calls(), [])
def test_invalid_compose_count_rejects_before_docker(self):
(self.directory / 'compose.yaml').write_text('services: {}')
for name in ('start.sh', 'stop.sh'):
self.assertNotEqual(self.invoke(name).returncode, 0)
self.assertEqual(self.calls(), [])
def test_config_failure_prevents_action_and_propagates_status(self):
(self.directory / 'failure-code').write_text('17')
for name in ('start.sh', 'stop.sh'):
result = self.invoke(name)
self.assertEqual(result.returncode, 17)
self.assertEqual(self.calls()[-1]['args'][-2:], ['config', '--quiet'])
def test_action_failure_propagates_without_success_message(self):
(self.directory / 'action-failure-code').write_text('19')
for name in ('start.sh', 'stop.sh'):
result = self.invoke(name)
self.assertEqual(result.returncode, 19)
self.assertEqual(result.stdout, '')
def test_real_compose_loads_dotenv_and_image_override_without_caller_overrides(self):
docker = shutil.which('docker')
if not docker or subprocess.run([docker, 'compose', 'version'], capture_output=True).returncode:
self.skipTest('Docker Compose is not installed')
(self.directory / 'real-docker').write_text(docker)
(self.directory / 'images.env').write_text('RAWMARK_IMAGE=example.invalid/verified:new\n')
for name in ('start.sh', 'stop.sh'):
result = self.invoke(name, RAWMARK_PROJECT='wrong', RAWMARK_IMAGE='wrong',
RAWMARK_DATA_DIR='/wrong', APP_SETTING='wrong')
self.assertEqual(result.returncode, 0, result.stderr)
resolved = json.loads((self.directory / 'resolved.json').read_text())
self.assertEqual(resolved['name'], 'rawmark-controls')
self.assertEqual(resolved['services']['app']['image'], 'example.invalid/verified:new')
self.assertEqual(resolved['services']['app']['environment']['APP_SETTING'], 'from-dotenv')
self.assertEqual(resolved['services']['app']['volumes'][0]['source'], str(self.directory / 'data'))
class ControlInstallationTests(unittest.TestCase):
def test_fresh_install_and_update_preserve_local_edits(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
deploy.install_controls(directory)
for name in ('start.sh', 'stop.sh'):
self.assertEqual((directory / name).stat().st_mode & 0o777, 0o755)
# A previously managed version can be updated using its recorded digest.
(directory / 'start.sh').write_text('previous managed version')
receipt = json.loads((directory / '.control-scripts.json').read_text())
receipt['start.sh'] = hashlib.sha256((directory / 'start.sh').read_bytes()).hexdigest()
(directory / '.control-scripts.json').write_text(json.dumps(receipt))
deploy.install_controls(directory)
self.assertEqual((directory / 'start.sh').read_bytes(), (SOURCES / 'start.sh').read_bytes())
(directory / 'stop.sh').write_text('custom user behavior')
with self.assertRaisesRegex(ValueError, 'Locally modified'):
deploy.install_controls(directory)
self.assertEqual((directory / 'stop.sh').read_text(), 'custom user behavior')
+36
View File
@@ -0,0 +1,36 @@
"""Verify strict mirroring with isolated local repositories."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mirror import git, references, synchronize
class MirrorTests(unittest.TestCase):
def test_full_history_tags_deletions_and_failed_source(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source, target = root / 'source', root / 'target.git'
subprocess.run(['git', 'init', '-b', 'main', str(source)], check=True, capture_output=True)
subprocess.run(['git', 'init', '--bare', str(target)], check=True, capture_output=True)
(source / 'note.md').write_text('synthetic note')
git(source, 'add', '.')
git(source, '-c', 'user.name=Test', '-c', '[email protected]', 'commit', '-m', 'test')
git(source, 'branch', 'dev')
git(source, '-c', 'user.name=Test', '-c', '[email protected]', 'tag', '-a', 'test-tag', '-m', 'test')
state = root / 'state'
synchronize(state, str(source), str(target), os.environ.copy())
self.assertEqual(references(source), references(target))
git(source, 'branch', '-D', 'dev')
git(source, 'tag', '-d', 'test-tag')
git(target, 'branch', 'target-only', 'main')
synchronize(state, str(source), str(target), os.environ.copy())
self.assertEqual(references(source), references(target))
before = references(target)
with self.assertRaises(subprocess.CalledProcessError):
synchronize(state, str(root / 'missing'), str(target), os.environ.copy())
self.assertEqual(before, references(target))
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-rm-fonts="loading"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-rm-fonts="loading"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+9 -11
View File
@@ -628,20 +628,18 @@ textarea {
} }
} }
/* opacity preserves layout and font requests; hide fallback fonts while waiting, including menus teleported to body. */
:root:is([data-rm-fonts='loading'], [data-rm-fonts='error']) body > :not(.rm-font-error) {
opacity: 0;
pointer-events: none;
}
.rm-font-error { .rm-font-error {
position: fixed; position: fixed;
inset: 0; z-index: 10000;
display: flex; right: 16px;
flex-direction: column; bottom: 16px;
align-items: center; max-width: min(360px, calc(100vw - 64px));
justify-content: center; padding: 16px;
padding: 24px; border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
color: var(--rm-text); color: var(--rm-text);
box-shadow: var(--rm-shadow);
font: font:
16px/1.5 system-ui, 16px/1.5 system-ui,
sans-serif; sans-serif;
+37 -67
View File
@@ -2,96 +2,66 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { watchFontLoading } from './font-loading' import { watchFontLoading } from './font-loading'
class Fonts extends EventTarget { function face(overrides = {}) {
status = 'loading' return { family: 'Maple Mono Normal NL NF CN', weight: '400', style: 'normal', status: 'unloaded', load: vi.fn().mockResolvedValue(undefined), ...overrides }
faces: { status: string }[] = []
resolve!: () => void
ready = this.pending()
pending() {
return new Promise<void>((resolve) => {
this.resolve = resolve
})
} }
class Fonts extends EventTarget {
faces = [face()];
[Symbol.iterator]() { [Symbol.iterator]() {
return this.faces[Symbol.iterator]() return this.faces[Symbol.iterator]()
} }
finish() {
this.status = 'loaded'; this.resolve()
}
load() {
this.status = 'loading'; this.ready = this.pending(); this.dispatchEvent(new Event('loading'))
}
} }
let fonts: Fonts let fonts: Fonts
let frame: FrameRequestCallback let frame: FrameRequestCallback
const descriptor = Object.getOwnPropertyDescriptor(document, 'fonts') const descriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers()
document.body.innerHTML = '<div id="app">正文</div>' document.body.innerHTML = '<div id="app">正文</div>'
fonts = new Fonts() fonts = new Fonts()
Object.defineProperty(document, 'fonts', { configurable: true, value: fonts }) Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frame = callback; return 1 frame = callback; return 1
}) })
watchFontLoading()
}) })
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() vi.useRealTimers(); vi.unstubAllGlobals()
delete document.documentElement.dataset.rmFonts
document.body.innerHTML = '' document.body.innerHTML = ''
if (descriptor) Object.defineProperty(document, 'fonts', descriptor) if (descriptor) Object.defineProperty(document, 'fonts', descriptor)
else Reflect.deleteProperty(document, 'fonts') else Reflect.deleteProperty(document, 'fonts')
}) })
it('首屏与后续字体加载均不隐藏或禁用应用,预下载不阻塞首屏', async () => {
it('挂载后的字体就绪前保持隐藏,等待结束才显示', async () => { watchFontLoading()
expect(document.documentElement.dataset.rmFonts).toBe('loading') expect(fonts.faces[0]!.load).not.toHaveBeenCalled()
fonts.dispatchEvent(new Event('loading'))
expect(document.documentElement.dataset.rmFonts).toBeUndefined()
expect(getComputedStyle(document.getElementById('app')!).pointerEvents).not.toBe('none')
frame(0) frame(0)
await Promise.resolve() expect(fonts.faces[0]!.load).not.toHaveBeenCalled()
expect(document.documentElement.dataset.rmFonts).toBe('loading') await vi.advanceTimersByTimeAsync(0)
fonts.finish() expect(fonts.faces[0]!.load).toHaveBeenCalledOnce()
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('ready')
}) })
it('只预下载常规Maple,每批两个,单片失败仍继续且不重复启动', async () => {
it('字体已在缓存中时直接显示,不依赖再次触发 loading 事件', async () => { let finish!: () => void
fonts.finish() fonts.faces = [face({ family: '\'Maple Mono Normal NL NF CN\'', load: vi.fn(() => new Promise<void>((resolve) => {
frame(0) finish = resolve
await Promise.resolve() })) }), face({ load: vi.fn().mockRejectedValue(new Error('offline')) }), face(), face({ weight: '700' }), face({ style: 'italic' }), face({ family: 'Other font' })]
expect(document.documentElement.dataset.rmFonts).toBe('ready') watchFontLoading(); frame(0)
}) await vi.advanceTimersByTimeAsync(0)
expect(fonts.faces[0]!.load).toHaveBeenCalledOnce()
it('切换笔记或字形需要新字体时重新等待,不用旧一轮完成信号提前显示', async () => { expect(fonts.faces[1]!.load).toHaveBeenCalledOnce()
frame(0) expect(fonts.faces[2]!.load).not.toHaveBeenCalled()
fonts.finish() finish(); await vi.advanceTimersByTimeAsync(0)
fonts.load() expect(fonts.faces[2]!.load).toHaveBeenCalledOnce()
await Promise.resolve() for (const font of fonts.faces.slice(3)) expect(font.load).not.toHaveBeenCalled()
expect(document.documentElement.dataset.rmFonts).toBe('loading') watchFontLoading(); await vi.advanceTimersByTimeAsync(0)
fonts.finish() expect(fonts.faces[0]!.load).toHaveBeenCalledOnce()
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('ready')
})
it('字体失败显示可刷新提示,之后 ready 完成和其他字体加载不能显示备用字体', async () => {
frame(0)
fonts.dispatchEvent(new Event('loadingerror'))
fonts.dispatchEvent(new Event('loadingerror'))
fonts.finish()
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('error')
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1) expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(document.querySelector('[role="alert"]')!.textContent).toContain('Fonts could not be loaded') })
it('字体失败只提示一次,保留页面和刷新重试入口', () => {
watchFontLoading()
fonts.dispatchEvent(new Event('loadingerror')); fonts.dispatchEvent(new Event('loadingerror'))
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(document.querySelector('[role="alert"]')!.textContent).toContain('Using fallback fonts')
expect(document.querySelector('[role="alert"] button')!.textContent).toBe('Refresh page') expect(document.querySelector('[role="alert"] button')!.textContent).toBe('Refresh page')
fonts.load() expect(document.documentElement.dataset.rmFonts).toBeUndefined()
expect(document.documentElement.dataset.rmFonts).toBe('error')
})
it('开始监听前已有字体失败,也不能被 resolved ready 当作成功', async () => {
fonts.faces = [{ status: 'error' }]
fonts.finish()
frame(0)
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('error')
expect(document.querySelector('[role="alert"]')).not.toBeNull()
}) })
+22 -23
View File
@@ -1,13 +1,16 @@
import { t as translate } from '@@/i18n/runtime' import { t as translate } from '@@/i18n/runtime'
// Use the font set requested by actual browser layout, covering initial render, note switches, and later glyphs.
export function watchFontLoading() {
const root = document.documentElement
const fonts = document.fonts
root.dataset.rmFonts = 'loading'
const started = new WeakSet<FontFaceSet>()
// Fonts may finish after interaction starts; never hide or disable the application.
export function watchFontLoading() {
const fonts = document.fonts
if (started.has(fonts)) return
started.add(fonts)
let reported = false
function failed() { function failed() {
if (root.dataset.rmFonts === 'error') return if (reported) return
root.dataset.rmFonts = 'error' reported = true
const panel = document.createElement('div') const panel = document.createElement('div')
panel.className = 'rm-font-error' panel.className = 'rm-font-error'
panel.setAttribute('role', 'alert') panel.setAttribute('role', 'alert')
@@ -19,24 +22,20 @@ export function watchFontLoading() {
panel.append(message, retry) panel.append(message, retry)
document.body.append(panel) document.body.append(panel)
} }
function ready() {
if (root.dataset.rmFonts === 'error') return
// fonts.ready also resolves after download failure; failure must not count as fonts being ready.
if ([...fonts].some(font => font.status === 'error')) failed()
else if (fonts.status === 'loaded') root.dataset.rmFonts = 'ready'
}
fonts.addEventListener('loading', () => {
if (root.dataset.rmFonts === 'error') return
root.dataset.rmFonts = 'loading'
void fonts.ready.then(ready)
})
fonts.addEventListener('loadingerror', failed) fonts.addEventListener('loadingerror', failed)
// Trigger layout after mounting so the wait covers fonts used by the current text. async function preload() {
const regular = [...fonts].filter(face => face.family.replace(/['"]/g, '') === 'Maple Mono Normal NL NF CN' && face.style === 'normal' && face.weight === '400')
for (let i = 0; i < regular.length; i += 2) {
const results = await Promise.allSettled(regular.slice(i, i + 2).map(face => face.load()))
if (results.some(result => result.status === 'rejected')) failed()
}
}
// Yield a paint before scheduling background downloads, including in the standalone demo.
requestAnimationFrame(() => { requestAnimationFrame(() => {
document.body.getBoundingClientRect() setTimeout(() => {
void fonts.ready.then(ready) if ([...fonts].some(face => face.status === 'error')) failed()
void preload()
}, 0)
}) })
} }
+113 -113
View File
@@ -3,7 +3,7 @@
font-family: 'JetBrains Mono'; font-family: 'JetBrains Mono';
font-style: normal; font-style: normal;
font-weight: 400 600; font-weight: 400 600;
font-display: block; font-display: swap;
src: url(./fonts/6b5e37b52d26eeac.woff2) format('woff2'); src: url(./fonts/6b5e37b52d26eeac.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
@@ -12,7 +12,7 @@
font-family: 'JetBrains Mono'; font-family: 'JetBrains Mono';
font-style: normal; font-style: normal;
font-weight: 400 600; font-weight: 400 600;
font-display: block; font-display: swap;
src: url(./fonts/eb83d8d40af4411f.woff2) format('woff2'); src: url(./fonts/eb83d8d40af4411f.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@@ -21,7 +21,7 @@
font-family: 'JetBrains Mono'; font-family: 'JetBrains Mono';
font-style: normal; font-style: normal;
font-weight: 400 600; font-weight: 400 600;
font-display: block; font-display: swap;
src: url(./fonts/99e400330f36ae77.woff2) format('woff2'); src: url(./fonts/99e400330f36ae77.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
} }
@@ -30,7 +30,7 @@
font-family: 'JetBrains Mono'; font-family: 'JetBrains Mono';
font-style: normal; font-style: normal;
font-weight: 400 600; font-weight: 400 600;
font-display: block; font-display: swap;
src: url(./fonts/c4064400db3750ce.woff2) format('woff2'); src: url(./fonts/c4064400db3750ce.woff2) format('woff2');
unicode-range: unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309,
@@ -41,7 +41,7 @@
font-family: 'JetBrains Mono'; font-family: 'JetBrains Mono';
font-style: normal; font-style: normal;
font-weight: 400 600; font-weight: 400 600;
font-display: block; font-display: swap;
src: url(./fonts/b19c2080652a3c2b.woff2) format('woff2'); src: url(./fonts/b19c2080652a3c2b.woff2) format('woff2');
unicode-range: unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
@@ -52,7 +52,7 @@
font-family: 'JetBrains Mono'; font-family: 'JetBrains Mono';
font-style: normal; font-style: normal;
font-weight: 400 600; font-weight: 400 600;
font-display: block; font-display: swap;
src: url(./fonts/cc27b30471250057.woff2) format('woff2'); src: url(./fonts/cc27b30471250057.woff2) format('woff2');
unicode-range: unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC,
@@ -63,7 +63,7 @@
font-family: 'Manrope'; font-family: 'Manrope';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/842459da81f909f2.woff2) format('woff2'); src: url(./fonts/842459da81f909f2.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
@@ -72,7 +72,7 @@
font-family: 'Manrope'; font-family: 'Manrope';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/9b2d027172a3481c.woff2) format('woff2'); src: url(./fonts/9b2d027172a3481c.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@@ -81,7 +81,7 @@
font-family: 'Manrope'; font-family: 'Manrope';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/ed7393f42d27db3d.woff2) format('woff2'); src: url(./fonts/ed7393f42d27db3d.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
} }
@@ -90,7 +90,7 @@
font-family: 'Manrope'; font-family: 'Manrope';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/1f3ea86e40d947e2.woff2) format('woff2'); src: url(./fonts/1f3ea86e40d947e2.woff2) format('woff2');
unicode-range: unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309,
@@ -101,7 +101,7 @@
font-family: 'Manrope'; font-family: 'Manrope';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/ee75508933e6fd45.woff2) format('woff2'); src: url(./fonts/ee75508933e6fd45.woff2) format('woff2');
unicode-range: unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
@@ -112,7 +112,7 @@
font-family: 'Manrope'; font-family: 'Manrope';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/4872f2a6dc9a8250.woff2) format('woff2'); src: url(./fonts/4872f2a6dc9a8250.woff2) format('woff2');
unicode-range: unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC,
@@ -122,7 +122,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/7b3ddaeea40a0fe5.woff2) format('woff2'); src: url(./fonts/7b3ddaeea40a0fe5.woff2) format('woff2');
unicode-range: unicode-range:
U+1f1e9-1f1f5, U+1f1f7-1f1ff, U+1f21a, U+1f232, U+1f234-1f237, U+1f250-1f251, U+1f300, U+1f302-1f308, U+1f30a-1f311, U+1f1e9-1f1f5, U+1f1f7-1f1ff, U+1f21a, U+1f232, U+1f234-1f237, U+1f250-1f251, U+1f300, U+1f302-1f308, U+1f30a-1f311,
@@ -132,7 +132,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/9af011928c669ba6.woff2) format('woff2'); src: url(./fonts/9af011928c669ba6.woff2) format('woff2');
unicode-range: unicode-range:
U+fee3, U+fef3, U+ff03-ff04, U+ff07, U+ff0a, U+ff17-ff19, U+ff1c-ff1d, U+ff20-ff3a, U+ff3c, U+ff3e-ff5b, U+ff5d, U+fee3, U+fef3, U+ff03-ff04, U+ff07, U+ff0a, U+ff17-ff19, U+ff1c-ff1d, U+ff20-ff3a, U+ff3c, U+ff3e-ff5b, U+ff5d,
@@ -144,7 +144,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/5f6c87a981ef2def.woff2) format('woff2'); src: url(./fonts/5f6c87a981ef2def.woff2) format('woff2');
unicode-range: unicode-range:
U+f0a7, U+f0b2, U+f0b7, U+f0c9, U+f0d8, U+f0da, U+f0dc-f0dd, U+f0e0, U+f0e6, U+f0eb, U+f0fc, U+f101, U+f104-f105, U+f0a7, U+f0b2, U+f0b7, U+f0c9, U+f0d8, U+f0da, U+f0dc-f0dd, U+f0e0, U+f0e6, U+f0eb, U+f0fc, U+f101, U+f104-f105,
@@ -159,7 +159,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/8953b4246f39fce9.woff2) format('woff2'); src: url(./fonts/8953b4246f39fce9.woff2) format('woff2');
unicode-range: unicode-range:
U+9f83, U+9f85-9f8d, U+9f90-9f91, U+9f94-9f96, U+9f98, U+9f9b-9f9c, U+9f9e, U+9fa0, U+9fa2, U+9ff0-9fff, U+a001, U+9f83, U+9f85-9f8d, U+9f90-9f91, U+9f94-9f96, U+9f98, U+9f9b-9f9c, U+9f9e, U+9fa0, U+9fa2, U+9ff0-9fff, U+a001,
@@ -175,7 +175,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/e55b19a3912a75d1.woff2) format('woff2'); src: url(./fonts/e55b19a3912a75d1.woff2) format('woff2');
unicode-range: unicode-range:
U+9e30-9e33, U+9e35-9e3b, U+9e3e, U+9e40-9e44, U+9e46-9e4e, U+9e51, U+9e53, U+9e55-9e58, U+9e5a-9e5c, U+9e5e-9e63, U+9e30-9e33, U+9e35-9e3b, U+9e3e, U+9e40-9e44, U+9e46-9e4e, U+9e51, U+9e53, U+9e55-9e58, U+9e5a-9e5c, U+9e5e-9e63,
@@ -190,7 +190,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/935e2cf08e92673e.woff2) format('woff2'); src: url(./fonts/935e2cf08e92673e.woff2) format('woff2');
unicode-range: unicode-range:
U+9c82-9c83, U+9c85-9c8c, U+9c8e-9c92, U+9c94-9c9b, U+9c9e-9ca3, U+9ca5-9ca7, U+9ca9, U+9cab, U+9cad-9cae, U+9c82-9c83, U+9c85-9c8c, U+9c8e-9c92, U+9c94-9c9b, U+9c9e-9ca3, U+9ca5-9ca7, U+9ca9, U+9cab, U+9cad-9cae,
@@ -204,7 +204,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/b5ae0757f6ecaa47.woff2) format('woff2'); src: url(./fonts/b5ae0757f6ecaa47.woff2) format('woff2');
unicode-range: unicode-range:
U+9a80, U+9a83, U+9a85, U+9a88-9a8a, U+9a8d-9a8e, U+9a90, U+9a92-9a93, U+9a95-9a96, U+9a98-9a99, U+9a9b-9aa2, U+9a80, U+9a83, U+9a85, U+9a88-9a8a, U+9a8d-9a8e, U+9a90, U+9a92-9a93, U+9a95-9a96, U+9a98-9a99, U+9a9b-9aa2,
@@ -220,7 +220,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/4e47f619496a388f.woff2) format('woff2'); src: url(./fonts/4e47f619496a388f.woff2) format('woff2');
unicode-range: unicode-range:
U+98dd, U+98e1-98e2, U+98e7-98ea, U+98ec, U+98ee-98ef, U+98f2, U+98f4, U+98fc-98fe, U+9903, U+9905, U+9908, U+990a, U+98dd, U+98e1-98e2, U+98e7-98ea, U+98ec, U+98ee-98ef, U+98f2, U+98f4, U+98fc-98fe, U+9903, U+9905, U+9908, U+990a,
@@ -236,7 +236,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/3bc0783456e12fcc.woff2) format('woff2'); src: url(./fonts/3bc0783456e12fcc.woff2) format('woff2');
unicode-range: unicode-range:
U+975b-975c, U+9763, U+9765-9766, U+976c-976d, U+9773, U+9776, U+977a, U+977c, U+9784-9785, U+978e-978f, U+975b-975c, U+9763, U+9765-9766, U+976c-976d, U+9773, U+9776, U+977a, U+977c, U+9784-9785, U+978e-978f,
@@ -252,7 +252,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/4168a698bef8858e.woff2) format('woff2'); src: url(./fonts/4168a698bef8858e.woff2) format('woff2');
unicode-range: unicode-range:
U+95c4-95ca, U+95cc-95cd, U+95d4-95d6, U+95d8, U+95e1-95e2, U+95e9, U+95f0-95f1, U+95f3, U+95f6, U+95fc, U+95c4-95ca, U+95cc-95cd, U+95d4-95d6, U+95d8, U+95e1-95e2, U+95e9, U+95f0-95f1, U+95f3, U+95f6, U+95fc,
@@ -268,7 +268,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/7a0b612893faf498.woff2) format('woff2'); src: url(./fonts/7a0b612893faf498.woff2) format('woff2');
unicode-range: unicode-range:
U+94f5, U+94f7, U+94f9, U+94fb-94fd, U+94ff, U+9503-9504, U+9506-9507, U+9509-950a, U+950d-950f, U+9511-9518, U+94f5, U+94f7, U+94f9, U+94fb-94fd, U+94ff, U+9503-9504, U+9506-9507, U+9509-950a, U+950d-950f, U+9511-9518,
@@ -280,7 +280,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/95d37f56ac503320.woff2) format('woff2'); src: url(./fonts/95d37f56ac503320.woff2) format('woff2');
unicode-range: unicode-range:
U+941c-942b, U+942d-942e, U+9432-9433, U+9435, U+9438, U+943a, U+943e, U+9444, U+944a, U+9451-9452, U+945a, U+941c-942b, U+942d-942e, U+9432-9433, U+9435, U+9438, U+943a, U+943e, U+9444, U+944a, U+9451-9452, U+945a,
@@ -291,7 +291,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/7e74d2ffc02d78d0.woff2) format('woff2'); src: url(./fonts/7e74d2ffc02d78d0.woff2) format('woff2');
unicode-range: unicode-range:
U+92ec-92ed, U+92f0, U+92f3, U+92f8, U+92fc, U+9304, U+9306, U+9310, U+9312, U+9315, U+9318, U+931a, U+931e, U+92ec-92ed, U+92f0, U+92f3, U+92f8, U+92fc, U+9304, U+9306, U+9310, U+9312, U+9315, U+9318, U+931a, U+931e,
@@ -304,7 +304,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/70a0301ffa863394.woff2) format('woff2'); src: url(./fonts/70a0301ffa863394.woff2) format('woff2');
unicode-range: unicode-range:
U+9163-9164, U+9169, U+9170, U+9172, U+9174, U+9179-917a, U+917d-917e, U+9182-9183, U+9185, U+918c-918d, U+9163-9164, U+9169, U+9170, U+9172, U+9174, U+9179-917a, U+917d-917e, U+9182-9183, U+9185, U+918c-918d,
@@ -320,7 +320,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/f65e41fe796f55cb.woff2) format('woff2'); src: url(./fonts/f65e41fe796f55cb.woff2) format('woff2');
unicode-range: unicode-range:
U+9004, U+900b, U+9011, U+9015-9016, U+901e, U+9021, U+9026, U+902d, U+902f, U+9031, U+9035-9036, U+9039-903a, U+9004, U+900b, U+9011, U+9015-9016, U+901e, U+9021, U+9026, U+902d, U+902f, U+9031, U+9035-9036, U+9039-903a,
@@ -336,7 +336,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/1a993e7ab856f9c8.woff2) format('woff2'); src: url(./fonts/1a993e7ab856f9c8.woff2) format('woff2');
unicode-range: unicode-range:
U+8e41-8e42, U+8e47, U+8e49-8e4b, U+8e50-8e53, U+8e59-8e5a, U+8e5f-8e60, U+8e64, U+8e69, U+8e6c, U+8e70, U+8e74, U+8e41-8e42, U+8e47, U+8e49-8e4b, U+8e50-8e53, U+8e59-8e5a, U+8e5f-8e60, U+8e64, U+8e69, U+8e6c, U+8e70, U+8e74,
@@ -352,7 +352,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/50a18da9b4927dbb.woff2) format('woff2'); src: url(./fonts/50a18da9b4927dbb.woff2) format('woff2');
unicode-range: unicode-range:
U+8cbd, U+8cbf-8cc4, U+8cc7-8cc8, U+8cca, U+8ccd, U+8cd1, U+8cd3, U+8cdb-8cdc, U+8cde, U+8ce0, U+8ce2-8ce4, U+8cbd, U+8cbf-8cc4, U+8cc7-8cc8, U+8cca, U+8ccd, U+8cd1, U+8cd3, U+8cdb-8cdc, U+8cde, U+8ce0, U+8ce2-8ce4,
@@ -368,7 +368,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/a806fd74a8216e5f.woff2) format('woff2'); src: url(./fonts/a806fd74a8216e5f.woff2) format('woff2');
unicode-range: unicode-range:
U+8b80, U+8b83, U+8b8a, U+8b8c, U+8b90, U+8b93, U+8b99-8b9a, U+8ba0, U+8ba3, U+8ba5-8ba7, U+8baa-8bac, U+8bb4-8bb5, U+8b80, U+8b83, U+8b8a, U+8b8c, U+8b90, U+8b93, U+8b99-8b9a, U+8ba0, U+8ba3, U+8ba5-8ba7, U+8baa-8bac, U+8bb4-8bb5,
@@ -383,7 +383,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/78bd32786a1b31ff.woff2) format('woff2'); src: url(./fonts/78bd32786a1b31ff.woff2) format('woff2');
unicode-range: unicode-range:
U+8a15-8a18, U+8a1a-8a1b, U+8a1d, U+8a1f, U+8a22-8a23, U+8a25, U+8a2b, U+8a2d, U+8a31, U+8a33-8a34, U+8a36-8a38, U+8a15-8a18, U+8a1a-8a1b, U+8a1d, U+8a1f, U+8a22-8a23, U+8a25, U+8a2b, U+8a2d, U+8a31, U+8a33-8a34, U+8a36-8a38,
@@ -399,7 +399,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/f44e78d8ce83767d.woff2) format('woff2'); src: url(./fonts/f44e78d8ce83767d.woff2) format('woff2');
unicode-range: unicode-range:
U+8882, U+8884-8886, U+8888, U+888f, U+8892-8893, U+889b, U+88a2, U+88a4, U+88a6, U+88a8, U+88aa, U+88ae, U+88b1, U+8882, U+8884-8886, U+8888, U+888f, U+8892-8893, U+889b, U+88a2, U+88a4, U+88a6, U+88a8, U+88aa, U+88ae, U+88b1,
@@ -415,7 +415,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/63e1e25c88076e71.woff2) format('woff2'); src: url(./fonts/63e1e25c88076e71.woff2) format('woff2');
unicode-range: unicode-range:
U+86f4, U+86f8-86f9, U+86fb, U+86fe, U+8703, U+8706-870a, U+870d, U+8711-8713, U+871a, U+871e, U+8722-8723, U+8725, U+86f4, U+86f8-86f9, U+86fb, U+86fe, U+8703, U+8706-870a, U+870d, U+8711-8713, U+871a, U+871e, U+8722-8723, U+8725,
@@ -431,7 +431,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/badb08d4ed31aab3.woff2) format('woff2'); src: url(./fonts/badb08d4ed31aab3.woff2) format('woff2');
unicode-range: unicode-range:
U+8548, U+854e, U+8553, U+8556-8557, U+8559, U+855e, U+8561, U+8564-8565, U+8568-856a, U+856d, U+856f-8570, U+8572, U+8548, U+854e, U+8553, U+8556-8557, U+8559, U+855e, U+8561, U+8564-8565, U+8568-856a, U+856d, U+856f-8570, U+8572,
@@ -447,7 +447,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/9d0550296822bc88.woff2) format('woff2'); src: url(./fonts/9d0550296822bc88.woff2) format('woff2');
unicode-range: unicode-range:
U+83c5, U+83c8-83c9, U+83cb, U+83d1, U+83d3-83d6, U+83d8, U+83db, U+83dd, U+83df, U+83e1, U+83e5, U+83ea-83eb, U+83c5, U+83c8-83c9, U+83cb, U+83d1, U+83d3-83d6, U+83d8, U+83db, U+83dd, U+83df, U+83e1, U+83e5, U+83ea-83eb,
@@ -464,7 +464,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/ea65d613de6262b7.woff2) format('woff2'); src: url(./fonts/ea65d613de6262b7.woff2) format('woff2');
unicode-range: unicode-range:
U+82bc, U+82be, U+82c0-82c2, U+82c4-82c8, U+82ca-82cc, U+82ce, U+82d0, U+82d2-82d3, U+82d5-82d6, U+82d8-82d9, U+82bc, U+82be, U+82c0-82c2, U+82c4-82c8, U+82ca-82cc, U+82ce, U+82d0, U+82d2-82d3, U+82d5-82d6, U+82d8-82d9,
@@ -479,7 +479,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/f624e6edcc42557f.woff2) format('woff2'); src: url(./fonts/f624e6edcc42557f.woff2) format('woff2');
unicode-range: unicode-range:
U+8166-8169, U+816b, U+816d, U+8171, U+8173-8174, U+8178, U+817c-817d, U+8182, U+8188, U+8191, U+8198-819b, U+81a0, U+8166-8169, U+816b, U+816d, U+8171, U+8173-8174, U+8178, U+817c-817d, U+8182, U+8188, U+8191, U+8198-819b, U+81a0,
@@ -495,7 +495,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/af97bfb909af51ae.woff2) format('woff2'); src: url(./fonts/af97bfb909af51ae.woff2) format('woff2');
unicode-range: unicode-range:
U+8016, U+8018-8019, U+801c, U+801e, U+8026-802a, U+8031, U+8034-8035, U+8037, U+8043, U+804b, U+804d, U+8052, U+8016, U+8018-8019, U+801c, U+801e, U+8026-802a, U+8031, U+8034-8035, U+8037, U+8043, U+804b, U+804d, U+8052,
@@ -510,7 +510,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/84a684f5b97032b6.woff2) format('woff2'); src: url(./fonts/84a684f5b97032b6.woff2) format('woff2');
unicode-range: unicode-range:
U+7ef0-7ef2, U+7ef6, U+7efa-7efb, U+7efe, U+7f01-7f04, U+7f08, U+7f0a-7f12, U+7f17, U+7f19, U+7f1b-7f1c, U+7f1f, U+7ef0-7ef2, U+7ef6, U+7efa-7efb, U+7efe, U+7f01-7f04, U+7f08, U+7f0a-7f12, U+7f17, U+7f19, U+7f1b-7f1c, U+7f1f,
@@ -525,7 +525,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/4af13061394e55b3.woff2) format('woff2'); src: url(./fonts/4af13061394e55b3.woff2) format('woff2');
unicode-range: unicode-range:
U+7dd2, U+7dd4, U+7dd6-7dd8, U+7dda-7de0, U+7de2-7de6, U+7de8-7ded, U+7def, U+7df1-7df5, U+7df7, U+7df9, U+7dd2, U+7dd4, U+7dd6-7dd8, U+7dda-7de0, U+7de2-7de6, U+7de8-7ded, U+7def, U+7df1-7df5, U+7df7, U+7df9,
@@ -540,7 +540,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/a3739966a326e377.woff2) format('woff2'); src: url(./fonts/a3739966a326e377.woff2) format('woff2');
unicode-range: unicode-range:
U+7ce8, U+7cec, U+7cf0, U+7cf5-7cf9, U+7cfc, U+7cfe, U+7d00, U+7d04-7d0b, U+7d0d, U+7d10-7d14, U+7d17-7d19, U+7ce8, U+7cec, U+7cf0, U+7cf5-7cf9, U+7cfc, U+7cfe, U+7d00, U+7d04-7d0b, U+7d0d, U+7d10-7d14, U+7d17-7d19,
@@ -554,7 +554,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/6c7df51c2d3405f8.woff2) format('woff2'); src: url(./fonts/6c7df51c2d3405f8.woff2) format('woff2');
unicode-range: unicode-range:
U+7bd3-7bd4, U+7bd9-7bda, U+7bdd, U+7be0-7be1, U+7be4-7be6, U+7be9-7bea, U+7bef, U+7bf4, U+7bf6, U+7bfc, U+7bfe, U+7bd3-7bd4, U+7bd9-7bda, U+7bdd, U+7be0-7be1, U+7be4-7be6, U+7be9-7bea, U+7bef, U+7bf4, U+7bf6, U+7bfc, U+7bfe,
@@ -568,7 +568,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/a8901603a8f60b5c.woff2) format('woff2'); src: url(./fonts/a8901603a8f60b5c.woff2) format('woff2');
unicode-range: unicode-range:
U+7ae6, U+7af4-7af7, U+7afa-7afb, U+7afd-7b0a, U+7b0c, U+7b0e-7b0f, U+7b13, U+7b15-7b16, U+7b18-7b19, U+7b1e-7b20, U+7ae6, U+7af4-7af7, U+7afa-7afb, U+7afd-7b0a, U+7b0c, U+7b0e-7b0f, U+7b13, U+7b15-7b16, U+7b18-7b19, U+7b1e-7b20,
@@ -581,7 +581,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/62d567322492159f.woff2) format('woff2'); src: url(./fonts/62d567322492159f.woff2) format('woff2');
unicode-range: unicode-range:
U+798b-798e, U+7992, U+7994-7995, U+7997-7998, U+799a-799c, U+799f, U+79a3-79a6, U+79a8-79ac, U+79ae-79b1, U+798b-798e, U+7992, U+7994-7995, U+7997-7998, U+799a-799c, U+799f, U+79a3-79a6, U+79a8-79ac, U+79ae-79b1,
@@ -597,7 +597,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/4ca36ec0898d9f4b.woff2) format('woff2'); src: url(./fonts/4ca36ec0898d9f4b.woff2) format('woff2');
unicode-range: unicode-range:
U+784c, U+784e-7854, U+7856-7857, U+7859-785a, U+7865, U+7869-786a, U+786d, U+786f, U+7876-7877, U+787c, U+784c, U+784e-7854, U+7856-7857, U+7859-785a, U+7865, U+7869-786a, U+786d, U+786f, U+7876-7877, U+787c,
@@ -613,7 +613,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/dc00089f866f9369.woff2) format('woff2'); src: url(./fonts/dc00089f866f9369.woff2) format('woff2');
unicode-range: unicode-range:
U+7722, U+7726, U+7728, U+772b-7730, U+7732-7736, U+7739-773a, U+773d-773f, U+7743, U+7746-7747, U+774c-774f, U+7722, U+7726, U+7728, U+772b-7730, U+7732-7736, U+7739-773a, U+773d-773f, U+7743, U+7746-7747, U+774c-774f,
@@ -628,7 +628,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/b14a1ed0396f00cf.woff2) format('woff2'); src: url(./fonts/b14a1ed0396f00cf.woff2) format('woff2');
unicode-range: unicode-range:
U+7613-7619, U+761b-761d, U+761f-7622, U+7625, U+7627-762a, U+762e-7630, U+7632-7635, U+7638-763a, U+763c-763d, U+7613-7619, U+761b-761d, U+761f-7622, U+7625, U+7627-762a, U+762e-7630, U+7632-7635, U+7638-763a, U+763c-763d,
@@ -643,7 +643,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/beed123d60cc6155.woff2) format('woff2'); src: url(./fonts/beed123d60cc6155.woff2) format('woff2');
unicode-range: unicode-range:
U+750d, U+750f, U+7511, U+7513, U+7515, U+7517, U+7519, U+7521-7527, U+752a, U+752c-752d, U+752f, U+7534, U+7536, U+750d, U+750f, U+7511, U+7513, U+7515, U+7517, U+7519, U+7521-7527, U+752a, U+752c-752d, U+752f, U+7534, U+7536,
@@ -658,7 +658,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/9fcc77355f5a0d52.woff2) format('woff2'); src: url(./fonts/9fcc77355f5a0d52.woff2) format('woff2');
unicode-range: unicode-range:
U+73f0, U+73f2, U+73f4-73f5, U+73f7, U+73f9-73fa, U+73fc-73fd, U+73ff-7402, U+7404, U+7407-7408, U+740a-740f, U+73f0, U+73f2, U+73f4-73f5, U+73f7, U+73f9-73fa, U+73fc-73fd, U+73ff-7402, U+7404, U+7407-7408, U+740a-740f,
@@ -673,7 +673,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/500793f7237d7337.woff2) format('woff2'); src: url(./fonts/500793f7237d7337.woff2) format('woff2');
unicode-range: unicode-range:
U+72e6, U+72e8, U+72ef-72f0, U+72f2-72f4, U+72f6-72f7, U+72f9-72fb, U+72fd, U+7300-7304, U+7307, U+730a-730c, U+72e6, U+72e8, U+72ef-72f0, U+72f2-72f4, U+72f6-72f7, U+72f9-72fb, U+72fd, U+7300-7304, U+7307, U+730a-730c,
@@ -688,7 +688,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/c4c44fadfc63ac54.woff2) format('woff2'); src: url(./fonts/c4c44fadfc63ac54.woff2) format('woff2');
unicode-range: unicode-range:
U+71a8, U+71af, U+71b1-71bc, U+71be, U+71c1-71c2, U+71c4, U+71c8-71cb, U+71ce-71d0, U+71d2, U+71d4, U+71d9-71da, U+71a8, U+71af, U+71b1-71bc, U+71be, U+71c1-71c2, U+71c4, U+71c8-71cb, U+71ce-71d0, U+71d2, U+71d4, U+71d9-71da,
@@ -703,7 +703,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/cf9cd646bc0d0f19.woff2) format('woff2'); src: url(./fonts/cf9cd646bc0d0f19.woff2) format('woff2');
unicode-range: unicode-range:
U+700b, U+700d, U+7015, U+7018, U+701b, U+701d-701f, U+7023, U+7026-7028, U+702c, U+702e-7030, U+7035, U+7037, U+700b, U+700d, U+7015, U+7018, U+701b, U+701d-701f, U+7023, U+7026-7028, U+702c, U+702e-7030, U+7035, U+7037,
@@ -720,7 +720,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/620f5f28321be052.woff2) format('woff2'); src: url(./fonts/620f5f28321be052.woff2) format('woff2');
unicode-range: unicode-range:
U+6ed9, U+6edb, U+6edd, U+6edf-6ee0, U+6ee2, U+6ee6, U+6eea, U+6eec, U+6eee-6eef, U+6ef2-6ef3, U+6ef7-6efa, U+6efe, U+6ed9, U+6edb, U+6edd, U+6edf-6ee0, U+6ee2, U+6ee6, U+6eea, U+6eec, U+6eee-6eef, U+6ef2-6ef3, U+6ef7-6efa, U+6efe,
@@ -735,7 +735,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/658210c7f683a557.woff2) format('woff2'); src: url(./fonts/658210c7f683a557.woff2) format('woff2');
unicode-range: unicode-range:
U+6dc3, U+6dc5-6dc6, U+6dc9, U+6dcc, U+6dcf, U+6dd2-6dd3, U+6dd6, U+6dd9-6dde, U+6de0, U+6de4, U+6de6, U+6de8-6dea, U+6dc3, U+6dc5-6dc6, U+6dc9, U+6dcc, U+6dcf, U+6dd2-6dd3, U+6dd6, U+6dd9-6dde, U+6de0, U+6de4, U+6de6, U+6de8-6dea,
@@ -750,7 +750,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/795b87224e8dcbc4.woff2) format('woff2'); src: url(./fonts/795b87224e8dcbc4.woff2) format('woff2');
unicode-range: unicode-range:
U+6cb1-6cb2, U+6cb4-6cb5, U+6cb7, U+6cba, U+6cbc-6cbd, U+6cc1-6cc3, U+6cc5-6cc7, U+6cd0-6cd4, U+6cd6-6cd7, U+6cb1-6cb2, U+6cb4-6cb5, U+6cb7, U+6cba, U+6cbc-6cbd, U+6cc1-6cc3, U+6cc5-6cc7, U+6cd0-6cd4, U+6cd6-6cd7,
@@ -765,7 +765,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/e9d83a6fe76066e1.woff2) format('woff2'); src: url(./fonts/e9d83a6fe76066e1.woff2) format('woff2');
unicode-range: unicode-range:
U+6b83-6b86, U+6b89, U+6b8d, U+6b91-6b93, U+6b95, U+6b97-6b98, U+6b9a-6b9b, U+6b9e, U+6ba1-6ba4, U+6ba9-6baa, U+6b83-6b86, U+6b89, U+6b8d, U+6b91-6b93, U+6b95, U+6b97-6b98, U+6b9a-6b9b, U+6b9e, U+6ba1-6ba4, U+6ba9-6baa,
@@ -780,7 +780,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/df78bcc0618fdcb2.woff2) format('woff2'); src: url(./fonts/df78bcc0618fdcb2.woff2) format('woff2');
unicode-range: unicode-range:
U+69fe-6a01, U+6a06, U+6a09, U+6a0b, U+6a11, U+6a13, U+6a17-6a19, U+6a1b, U+6a1e, U+6a23, U+6a28-6a29, U+6a2b, U+69fe-6a01, U+6a06, U+6a09, U+6a0b, U+6a11, U+6a13, U+6a17-6a19, U+6a1b, U+6a1e, U+6a23, U+6a28-6a29, U+6a2b,
@@ -796,7 +796,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/cf0b566ed87b1702.woff2) format('woff2'); src: url(./fonts/cf0b566ed87b1702.woff2) format('woff2');
unicode-range: unicode-range:
U+68d3, U+68d7, U+68dd, U+68df, U+68e1, U+68e3-68e4, U+68e6-68ed, U+68ef-68f0, U+68f2, U+68f4, U+68f6-68f7, U+68f9, U+68d3, U+68d7, U+68dd, U+68df, U+68e1, U+68e3-68e4, U+68e6-68ed, U+68ef-68f0, U+68f2, U+68f4, U+68f6-68f7, U+68f9,
@@ -811,7 +811,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/b4bf867aff4a6bde.woff2) format('woff2'); src: url(./fonts/b4bf867aff4a6bde.woff2) format('woff2');
unicode-range: unicode-range:
U+678b-678d, U+678f, U+6792-6793, U+6796, U+6798, U+679e-67a1, U+67a5, U+67a7-67a9, U+67ac-67ad, U+67b0-67b1, U+678b-678d, U+678f, U+6792-6793, U+6796, U+6798, U+679e-67a1, U+67a5, U+67a7-67a9, U+67ac-67ad, U+67b0-67b1,
@@ -827,7 +827,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/23f09a3fd29c803a.woff2) format('woff2'); src: url(./fonts/23f09a3fd29c803a.woff2) format('woff2');
unicode-range: unicode-range:
U+6631, U+6633-6634, U+6636, U+663a-663b, U+663d, U+6641, U+6644-6645, U+6649, U+664c, U+664f, U+6654, U+6659, U+6631, U+6633-6634, U+6636, U+663a-663b, U+663d, U+6641, U+6644-6645, U+6649, U+664c, U+664f, U+6654, U+6659,
@@ -843,7 +843,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/e110453047bfe034.woff2) format('woff2'); src: url(./fonts/e110453047bfe034.woff2) format('woff2');
unicode-range: unicode-range:
U+64f1-64f2, U+64f4, U+64f7-64f8, U+64fa, U+64fc, U+64fe-64ff, U+6503, U+6509, U+650f, U+6514, U+6518, U+651c-651e, U+64f1-64f2, U+64f4, U+64f7-64f8, U+64fa, U+64fc, U+64fe-64ff, U+6503, U+6509, U+650f, U+6514, U+6518, U+651c-651e,
@@ -859,7 +859,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/b7668e18fe9cce6c.woff2) format('woff2'); src: url(./fonts/b7668e18fe9cce6c.woff2) format('woff2');
unicode-range: unicode-range:
U+63b8-63bc, U+63be, U+63c0, U+63c3-63c4, U+63c6, U+63c8, U+63cd-63ce, U+63d1, U+63d6, U+63da-63db, U+63de, U+63e0, U+63b8-63bc, U+63be, U+63c0, U+63c3-63c4, U+63c6, U+63c8, U+63cd-63ce, U+63d1, U+63d6, U+63da-63db, U+63de, U+63e0,
@@ -874,7 +874,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/2306716a58a58a05.woff2) format('woff2'); src: url(./fonts/2306716a58a58a05.woff2) format('woff2');
unicode-range: unicode-range:
U+622c, U+622e-6230, U+6232, U+6238, U+623b, U+623d-623e, U+6243, U+6246, U+6248-6249, U+624c, U+6255, U+6259, U+622c, U+622e-6230, U+6232, U+6238, U+623b, U+623d-623e, U+6243, U+6246, U+6248-6249, U+624c, U+6255, U+6259,
@@ -890,7 +890,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/b37a954f47c08e8e.woff2) format('woff2'); src: url(./fonts/b37a954f47c08e8e.woff2) format('woff2');
unicode-range: unicode-range:
U+60ed-60ee, U+60f0-60f1, U+60f4, U+60f6, U+60fa, U+6100, U+6106, U+610d-610e, U+6112, U+6114-6115, U+6119, U+611c, U+60ed-60ee, U+60f0-60f1, U+60f4, U+60f6, U+60fa, U+6100, U+6106, U+610d-610e, U+6112, U+6114-6115, U+6119, U+611c,
@@ -905,7 +905,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/41d1d19f7bebcf26.woff2) format('woff2'); src: url(./fonts/41d1d19f7bebcf26.woff2) format('woff2');
unicode-range: unicode-range:
U+5fc4, U+5fc9, U+5fcb, U+5fce-5fd6, U+5fda-5fde, U+5fe1-5fe2, U+5fe4-5fe5, U+5fea, U+5fed-5fee, U+5ff1-5ff3, U+5fc4, U+5fc9, U+5fcb, U+5fce-5fd6, U+5fda-5fde, U+5fe1-5fe2, U+5fe4-5fe5, U+5fea, U+5fed-5fee, U+5ff1-5ff3,
@@ -920,7 +920,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/3c66c61b08f9dc28.woff2) format('woff2'); src: url(./fonts/3c66c61b08f9dc28.woff2) format('woff2');
unicode-range: unicode-range:
U+5e98, U+5e9b, U+5e9d, U+5ea0-5ea5, U+5ea8, U+5eab, U+5eaf, U+5eb3, U+5eb5-5eb6, U+5eb9, U+5ebe, U+5ec1-5ec3, U+5e98, U+5e9b, U+5e9d, U+5ea0-5ea5, U+5ea8, U+5eab, U+5eaf, U+5eb3, U+5eb5-5eb6, U+5eb9, U+5ebe, U+5ec1-5ec3,
@@ -935,7 +935,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/8a51ce32a2d6a2b0.woff2) format('woff2'); src: url(./fonts/8a51ce32a2d6a2b0.woff2) format('woff2');
unicode-range: unicode-range:
U+5d26-5d27, U+5d2e-5d34, U+5d3c-5d3e, U+5d41-5d44, U+5d46-5d48, U+5d4a-5d4b, U+5d4e, U+5d50, U+5d52, U+5d55-5d58, U+5d26-5d27, U+5d2e-5d34, U+5d3c-5d3e, U+5d41-5d44, U+5d46-5d48, U+5d4a-5d4b, U+5d4e, U+5d50, U+5d52, U+5d55-5d58,
@@ -950,7 +950,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/ffa6ae4b9eb5ffdc.woff2) format('woff2'); src: url(./fonts/ffa6ae4b9eb5ffdc.woff2) format('woff2');
unicode-range: unicode-range:
U+5bec, U+5bee-5bf0, U+5bf2-5bf3, U+5bf5-5bf6, U+5bfe, U+5c02-5c03, U+5c05, U+5c07-5c09, U+5c0b-5c0c, U+5c0e, U+5bec, U+5bee-5bf0, U+5bf2-5bf3, U+5bf5-5bf6, U+5bfe, U+5c02-5c03, U+5c05, U+5c07-5c09, U+5c0b-5c0c, U+5c0e,
@@ -965,7 +965,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/96088f438d29f54b.woff2) format('woff2'); src: url(./fonts/96088f438d29f54b.woff2) format('woff2');
unicode-range: unicode-range:
U+5aa0, U+5aa3-5aa4, U+5aaa, U+5aae-5aaf, U+5ab1-5ab2, U+5ab4-5ab5, U+5ab7-5aba, U+5abd-5abf, U+5ac3-5ac4, U+5aa0, U+5aa3-5aa4, U+5aaa, U+5aae-5aaf, U+5ab1-5ab2, U+5ab4-5ab5, U+5ab7-5aba, U+5abd-5abf, U+5ac3-5ac4,
@@ -980,7 +980,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/e0d8fdb665a16cd2.woff2) format('woff2'); src: url(./fonts/e0d8fdb665a16cd2.woff2) format('woff2');
unicode-range: unicode-range:
U+596a, U+596c-596e, U+5977, U+597b-597c, U+5981, U+598f, U+5997-5998, U+599a, U+599c-599d, U+59a0-59a1, U+596a, U+596c-596e, U+5977, U+597b-597c, U+5981, U+598f, U+5997-5998, U+599a, U+599c-599d, U+59a0-59a1,
@@ -995,7 +995,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/20f6ba8c0c269b1a.woff2) format('woff2'); src: url(./fonts/20f6ba8c0c269b1a.woff2) format('woff2');
unicode-range: unicode-range:
U+5820, U+5822-5823, U+5825-5826, U+582c, U+582f, U+5831, U+583a, U+583d, U+583f-5842, U+5844-5846, U+5848, U+584a, U+5820, U+5822-5823, U+5825-5826, U+582c, U+582f, U+5831, U+583a, U+583d, U+583f-5842, U+5844-5846, U+5848, U+584a,
@@ -1011,7 +1011,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/847818b9c19663fd.woff2) format('woff2'); src: url(./fonts/847818b9c19663fd.woff2) format('woff2');
unicode-range: unicode-range:
U+56f9, U+56fc, U+56ff-5700, U+5703-5704, U+5709-570a, U+570c-570d, U+570f, U+5712-5713, U+5718-5719, U+571c, U+56f9, U+56fc, U+56ff-5700, U+5703-5704, U+5709-570a, U+570c-570d, U+570f, U+5712-5713, U+5718-5719, U+571c,
@@ -1026,7 +1026,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/a7c81fbdd8cc5eb5.woff2) format('woff2'); src: url(./fonts/a7c81fbdd8cc5eb5.woff2) format('woff2');
unicode-range: unicode-range:
U+55f5-55f7, U+55fb, U+55fe, U+5600-5601, U+5605-5606, U+5608, U+560c-560d, U+560f, U+5614, U+5616-5617, U+561a, U+55f5-55f7, U+55fb, U+55fe, U+5600-5601, U+5605-5606, U+5608, U+560c-560d, U+560f, U+5614, U+5616-5617, U+561a,
@@ -1040,7 +1040,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/23acd90334da40b5.woff2) format('woff2'); src: url(./fonts/23acd90334da40b5.woff2) format('woff2');
unicode-range: unicode-range:
U+550f, U+5511-5514, U+5516-5517, U+5519, U+551b, U+551d-551e, U+5520, U+5522-5523, U+5526-5527, U+552a-552c, U+550f, U+5511-5514, U+5516-5517, U+5519, U+551b, U+551d-551e, U+5520, U+5522-5523, U+5526-5527, U+552a-552c,
@@ -1054,7 +1054,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/aebf42c37a635878.woff2) format('woff2'); src: url(./fonts/aebf42c37a635878.woff2) format('woff2');
unicode-range: unicode-range:
U+53e7-53e9, U+53f1, U+53f4-53f5, U+53fa-5400, U+5402, U+5405-5407, U+540b, U+540f, U+5412, U+5414, U+5416, U+53e7-53e9, U+53f1, U+53f4-53f5, U+53fa-5400, U+5402, U+5405-5407, U+540b, U+540f, U+5412, U+5414, U+5416,
@@ -1069,7 +1069,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/c08b4b4d95cfb2ea.woff2) format('woff2'); src: url(./fonts/c08b4b4d95cfb2ea.woff2) format('woff2');
unicode-range: unicode-range:
U+5289, U+528b, U+528d, U+528f, U+5291-5293, U+529a, U+52a2, U+52a6-52a7, U+52ac-52ad, U+52af, U+52b4-52b5, U+52b9, U+5289, U+528b, U+528d, U+528f, U+5291-5293, U+529a, U+52a2, U+52a6-52a7, U+52ac-52ad, U+52af, U+52b4-52b5, U+52b9,
@@ -1085,7 +1085,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/5b4fc0b27a8a1464.woff2) format('woff2'); src: url(./fonts/5b4fc0b27a8a1464.woff2) format('woff2');
unicode-range: unicode-range:
U+5104, U+5106-5107, U+5109-510b, U+510d, U+510f-5110, U+5113, U+5115, U+5117-5118, U+511a-511c, U+511e-511f, U+5104, U+5106-5107, U+5109-510b, U+510d, U+510f-5110, U+5113, U+5115, U+5117-5118, U+511a-511c, U+511e-511f,
@@ -1101,7 +1101,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/14213d7fbd6ab158.woff2) format('woff2'); src: url(./fonts/14213d7fbd6ab158.woff2) format('woff2');
unicode-range: unicode-range:
U+4fd1, U+4fd3, U+4fda-4fdc, U+4fdf-4fe0, U+4fe2-4fe4, U+4fe6, U+4fe8, U+4feb-4fed, U+4ff3, U+4ff5-4ff6, U+4ff8, U+4fd1, U+4fd3, U+4fda-4fdc, U+4fdf-4fe0, U+4fe2-4fe4, U+4fe6, U+4fe8, U+4feb-4fed, U+4ff3, U+4ff5-4ff6, U+4ff8,
@@ -1116,7 +1116,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/3ff1abbbffc86b31.woff2) format('woff2'); src: url(./fonts/3ff1abbbffc86b31.woff2) format('woff2');
unicode-range: unicode-range:
U+4ea3, U+4ea5, U+4eb0-4eb1, U+4eb3-4eb6, U+4eb8-4eb9, U+4ebb-4ebe, U+4ec2-4ec4, U+4ec8-4ec9, U+4ecc, U+4ecf-4ed0, U+4ea3, U+4ea5, U+4eb0-4eb1, U+4eb3-4eb6, U+4eb8-4eb9, U+4ebb-4ebe, U+4ec2-4ec4, U+4ec8-4ec9, U+4ecc, U+4ecf-4ed0,
@@ -1131,7 +1131,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/5e6011e3010d50bd.woff2) format('woff2'); src: url(./fonts/5e6011e3010d50bd.woff2) format('woff2');
unicode-range: unicode-range:
U+3129, U+3131, U+3134, U+3137, U+3139, U+3141-3142, U+3145, U+3147-3148, U+314b, U+314d-314e, U+315c, U+3160-3161, U+3129, U+3131, U+3134, U+3137, U+3139, U+3141-3142, U+3145, U+3147-3148, U+314b, U+314d-314e, U+315c, U+3160-3161,
@@ -1147,7 +1147,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/65a11622c84af2c7.woff2) format('woff2'); src: url(./fonts/65a11622c84af2c7.woff2) format('woff2');
unicode-range: unicode-range:
U+279f-27a2, U+27a4-27a5, U+27a8, U+27b0, U+27b2-27b3, U+27b9, U+27e8-27e9, U+27f6, U+2800, U+28ec, U+2913, U+279f-27a2, U+27a4-27a5, U+27a8, U+27b0, U+27b2-27b3, U+27b9, U+27e8-27e9, U+27f6, U+2800, U+28ec, U+2913,
@@ -1162,7 +1162,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/6d4e9520e29f6d02.woff2) format('woff2'); src: url(./fonts/6d4e9520e29f6d02.woff2) format('woff2');
unicode-range: unicode-range:
U+2651-2655, U+2658, U+265a-265b, U+265d-265e, U+2660-266d, U+266f, U+267b, U+2688, U+2693-2696, U+2698-2699, U+2651-2655, U+2658, U+265a-265b, U+265d-265e, U+2660-266d, U+266f, U+267b, U+2688, U+2693-2696, U+2698-2699,
@@ -1175,7 +1175,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/497141c2daba02b0.woff2) format('woff2'); src: url(./fonts/497141c2daba02b0.woff2) format('woff2');
unicode-range: unicode-range:
U+2550-2551, U+2554, U+2557, U+255a-255b, U+255d, U+255f-2560, U+2562-2563, U+2565-2567, U+2569-256a, U+256c-2572, U+2550-2551, U+2554, U+2557, U+255a-255b, U+255d, U+255f-2560, U+2562-2563, U+2565-2567, U+2569-256a, U+256c-2572,
@@ -1187,7 +1187,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/eb4969d421026bd0.woff2) format('woff2'); src: url(./fonts/eb4969d421026bd0.woff2) format('woff2');
unicode-range: unicode-range:
U+23f0, U+23f3, U+2445, U+2449, U+2465-2471, U+2474-249b, U+24b8, U+24c2, U+24c7, U+24c9, U+24d0, U+24d2, U+24d4, U+23f0, U+23f3, U+2445, U+2449, U+2465-2471, U+2474-249b, U+24b8, U+24c2, U+24c7, U+24c9, U+24d0, U+24d2, U+24d4,
@@ -1198,7 +1198,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/48c235f25cd265a1.woff2) format('woff2'); src: url(./fonts/48c235f25cd265a1.woff2) format('woff2');
unicode-range: unicode-range:
U+207c-2083, U+208c-208e, U+2092, U+20a6, U+20a8-20ad, U+20af, U+20b1, U+20b4-20b5, U+20b8-20ba, U+20bd, U+20db, U+207c-2083, U+208c-208e, U+2092, U+20a6, U+20a8-20ad, U+20af, U+20b1, U+20b4-20b5, U+20b8-20ba, U+20bd, U+20db,
@@ -1213,7 +1213,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/0a8cf734a0b6b153.woff2) format('woff2'); src: url(./fonts/0a8cf734a0b6b153.woff2) format('woff2');
unicode-range: unicode-range:
U+1d34-1d35, U+1d38-1d3a, U+1d3c, U+1d3f-1d40, U+1d49, U+1d4e-1d4f, U+1d52, U+1d55, U+1d5b, U+1d5e, U+1d9c, U+1da0, U+1d34-1d35, U+1d38-1d3a, U+1d3c, U+1d3f-1d40, U+1d49, U+1d4e-1d4f, U+1d52, U+1d55, U+1d5b, U+1d5e, U+1d9c, U+1da0,
@@ -1227,7 +1227,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/b42723fe53599b90.woff2) format('woff2'); src: url(./fonts/b42723fe53599b90.woff2) format('woff2');
unicode-range: unicode-range:
U+2ae-2b3, U+2b5-2bf, U+2c2-2c3, U+2c6-2d1, U+2d8-2da, U+2dc, U+2e1-2e3, U+2e5, U+2eb, U+2ee-2f0, U+2f2-2f7, U+2ae-2b3, U+2b5-2bf, U+2c2-2c3, U+2c6-2d1, U+2d8-2da, U+2dc, U+2e1-2e3, U+2e5, U+2eb, U+2ee-2f0, U+2f2-2f7,
@@ -1239,7 +1239,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/05a7d600a249b837.woff2) format('woff2'); src: url(./fonts/05a7d600a249b837.woff2) format('woff2');
unicode-range: unicode-range:
U+176-17f, U+192, U+194, U+19a-19b, U+19d, U+1a0-1a1, U+1a3-1a4, U+1aa, U+1ac-1ad, U+1af-1bf, U+1d2, U+1d4, U+1d6, U+176-17f, U+192, U+194, U+19a-19b, U+19d, U+1a0-1a1, U+1a3-1a4, U+1aa, U+1ac-1ad, U+1af-1bf, U+1d2, U+1d4, U+1d6,
@@ -1252,7 +1252,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/b73283458ef26d55.woff2) format('woff2'); src: url(./fonts/b73283458ef26d55.woff2) format('woff2');
unicode-range: unicode-range:
U+a1-a4, U+a6-a8, U+aa, U+ac, U+af, U+b1, U+b3-b6, U+b8-ba, U+bc-d6, U+d8-de, U+e6, U+eb, U+ee-f0, U+f5, U+f7-f8, U+a1-a4, U+a6-a8, U+aa, U+ac, U+af, U+b1, U+b3-b6, U+b8-ba, U+bc-d6, U+d8-de, U+e6, U+eb, U+ee-f0, U+f5, U+f7-f8,
@@ -1264,7 +1264,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/2944f60aea487c2a.woff2) format('woff2'); src: url(./fonts/2944f60aea487c2a.woff2) format('woff2');
unicode-range: unicode-range:
U+221a, U+2264, U+2464, U+25a0, U+3008, U+4e10, U+512a, U+5152, U+5201, U+5241, U+5340, U+5352, U+549a, U+54b2, U+221a, U+2264, U+2464, U+25a0, U+3008, U+4e10, U+512a, U+5152, U+5201, U+5241, U+5340, U+5352, U+549a, U+54b2,
@@ -1286,7 +1286,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/b140f9d60ba877f5.woff2) format('woff2'); src: url(./fonts/b140f9d60ba877f5.woff2) format('woff2');
unicode-range: unicode-range:
U+161, U+926, U+928, U+939, U+93f-940, U+94d, U+e17, U+e22, U+e44, U+25c7, U+25ce, U+2764, U+3009, U+3016-3017, U+161, U+926, U+928, U+939, U+93f-940, U+94d, U+e17, U+e22, U+e44, U+25c7, U+25ce, U+2764, U+3009, U+3016-3017,
@@ -1307,7 +1307,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/3bf9956fe5c594f4.woff2) format('woff2'); src: url(./fonts/3bf9956fe5c594f4.woff2) format('woff2');
unicode-range: unicode-range:
U+10c, U+627-629, U+639, U+644, U+64a, U+203b, U+2265, U+2463, U+2573, U+25b2, U+3448-3449, U+4e1e, U+4e5e, U+4f3a, U+10c, U+627-629, U+639, U+644, U+64a, U+203b, U+2265, U+2463, U+2573, U+25b2, U+3448-3449, U+4e1e, U+4e5e, U+4f3a,
@@ -1329,7 +1329,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/47d3dc9f8403f684.woff2) format('woff2'); src: url(./fonts/47d3dc9f8403f684.woff2) format('woff2');
unicode-range: unicode-range:
U+60, U+631, U+2606, U+3014-3015, U+309c, U+33a1, U+4e52, U+4ec6, U+4f86, U+4f8d, U+4fde, U+4fef, U+500b, U+502a, U+60, U+631, U+2606, U+3014-3015, U+309c, U+33a1, U+4e52, U+4ec6, U+4f86, U+4f8d, U+4fde, U+4fef, U+500b, U+502a,
@@ -1351,7 +1351,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/4a7ea73dc15f2b63.woff2) format('woff2'); src: url(./fonts/4a7ea73dc15f2b63.woff2) format('woff2');
unicode-range: unicode-range:
U+2003, U+2193, U+2462, U+4e19, U+4e2b, U+4e36, U+4ea8, U+4ed1, U+4ed7, U+4f51, U+4f63, U+4f83, U+50e7, U+5112, U+2003, U+2193, U+2462, U+4e19, U+4e2b, U+4e36, U+4ea8, U+4ed1, U+4ed7, U+4f51, U+4f63, U+4f83, U+50e7, U+5112,
@@ -1373,7 +1373,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/f0721a3949f84d42.woff2) format('woff2'); src: url(./fonts/f0721a3949f84d42.woff2) format('woff2');
unicode-range: unicode-range:
U+5e, U+2190, U+250a, U+25bc, U+25cf, U+4e56, U+4ea9, U+4f3d, U+4f6c, U+4f88, U+4fa8, U+4fcf, U+5029, U+5188, U+5e, U+2190, U+250a, U+25bc, U+25cf, U+4e56, U+4ea9, U+4f3d, U+4f6c, U+4f88, U+4fa8, U+4fcf, U+5029, U+5188,
@@ -1395,7 +1395,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/71139ee70c1b1fce.woff2) format('woff2'); src: url(./fonts/71139ee70c1b1fce.woff2) format('woff2');
unicode-range: unicode-range:
U+b0, U+2191, U+2460-2461, U+25c6, U+300e-300f, U+4e1b, U+4e7e, U+4ed5, U+4ef2, U+4f10, U+4f1e, U+4f50, U+4fa6, U+b0, U+2191, U+2460-2461, U+25c6, U+300e-300f, U+4e1b, U+4e7e, U+4ed5, U+4ef2, U+4f10, U+4f1e, U+4f50, U+4fa6,
@@ -1417,7 +1417,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/75c0090760a68ac5.woff2) format('woff2'); src: url(./fonts/75c0090760a68ac5.woff2) format('woff2');
unicode-range: unicode-range:
U+200b, U+2103, U+4e18, U+4e27-4e28, U+4e38, U+4e59, U+4e8f, U+4ead, U+4ec7, U+4fe9, U+503a, U+5085, U+5146, U+51af, U+200b, U+2103, U+4e18, U+4e27-4e28, U+4e38, U+4e59, U+4e8f, U+4ead, U+4ec7, U+4fe9, U+503a, U+5085, U+5146, U+51af,
@@ -1439,7 +1439,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/8eae1f2d9b73ca52.woff2) format('woff2'); src: url(./fonts/8eae1f2d9b73ca52.woff2) format('woff2');
unicode-range: unicode-range:
U+24, U+4e08, U+4e43, U+4e4f, U+4ef0, U+4f2a, U+507f, U+50ac, U+50bb, U+5151, U+51bb, U+51f6, U+51fd, U+5272, U+24, U+4e08, U+4e43, U+4e4f, U+4ef0, U+4f2a, U+507f, U+50ac, U+50bb, U+5151, U+51bb, U+51f6, U+51fd, U+5272,
@@ -1461,7 +1461,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/a263283eb6ff7679.woff2) format('woff2'); src: url(./fonts/a263283eb6ff7679.woff2) format('woff2');
unicode-range: unicode-range:
U+a5, U+2192, U+2605, U+4e11, U+4e22, U+4e32, U+4f0d, U+4f0f, U+4f69, U+4ff1, U+50b2, U+5154, U+51dd, U+51f0, U+a5, U+2192, U+2605, U+4e11, U+4e22, U+4e32, U+4f0d, U+4f0f, U+4f69, U+4ff1, U+50b2, U+5154, U+51dd, U+51f0,
@@ -1483,7 +1483,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/09c325c9da420816.woff2) format('woff2'); src: url(./fonts/09c325c9da420816.woff2) format('woff2');
unicode-range: unicode-range:
U+300c-300d, U+4e54, U+4e58, U+4e95, U+4ec1, U+4f2f, U+4f38, U+4fa3, U+4fca, U+503e, U+5141, U+5144, U+517c, U+51cc, U+300c-300d, U+4e54, U+4e58, U+4e95, U+4ec1, U+4f2f, U+4f38, U+4fa3, U+4fca, U+503e, U+5141, U+5144, U+517c, U+51cc,
@@ -1505,7 +1505,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/053609e6b57c594f.woff2) format('woff2'); src: url(./fonts/053609e6b57c594f.woff2) format('woff2');
unicode-range: unicode-range:
U+4e4c, U+4e88, U+4ea1, U+4ea6, U+4ed3-4ed4, U+4eff, U+4f30, U+4fa7, U+4fc4, U+4fd7, U+500d, U+504f, U+5076-5077, U+4e4c, U+4e88, U+4ea1, U+4ea6, U+4ed3-4ed4, U+4eff, U+4f30, U+4fa7, U+4fc4, U+4fd7, U+500d, U+504f, U+5076-5077,
@@ -1527,7 +1527,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/5c92b2016607caeb.woff2) format('woff2'); src: url(./fonts/5c92b2016607caeb.woff2) format('woff2');
unicode-range: unicode-range:
U+23, U+3d, U+4e01, U+4e39, U+4e73, U+4ecd, U+4ed9, U+4eea, U+4f0a, U+4f1f, U+4f5b, U+4fa0, U+4fc3, U+501f, U+50a8, U+23, U+3d, U+4e01, U+4e39, U+4e73, U+4ecd, U+4ed9, U+4eea, U+4f0a, U+4f1f, U+4f5b, U+4fa0, U+4fc3, U+501f, U+50a8,
@@ -1548,7 +1548,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/f192aec3fa1f17f4.woff2) format('woff2'); src: url(./fonts/f192aec3fa1f17f4.woff2) format('woff2');
unicode-range: unicode-range:
U+26, U+3c, U+d7, U+4e4e, U+4e61, U+4e71, U+4ebf, U+4f26, U+5012, U+51ac, U+51b0, U+51b2, U+51b7, U+5218, U+521a, U+26, U+3c, U+d7, U+4e4e, U+4e61, U+4e71, U+4ebf, U+4f26, U+5012, U+51ac, U+51b0, U+51b2, U+51b7, U+5218, U+521a,
@@ -1569,7 +1569,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/f6255320a8ecc997.woff2) format('woff2'); src: url(./fonts/f6255320a8ecc997.woff2) format('woff2');
unicode-range: unicode-range:
U+7e, U+2026, U+4e03, U+4e25, U+4e30, U+4e34, U+4e45, U+4e5d, U+4e89, U+4eae, U+4ed8, U+4f11, U+4f19, U+4f24, U+7e, U+2026, U+4e03, U+4e25, U+4e30, U+4e34, U+4e45, U+4e5d, U+4e89, U+4eae, U+4ed8, U+4f11, U+4f19, U+4f24,
@@ -1591,7 +1591,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/5849283072442ca2.woff2) format('woff2'); src: url(./fonts/5849283072442ca2.woff2) format('woff2');
unicode-range: unicode-range:
U+25, U+4e14, U+4e1d, U+4e3d, U+4e49, U+4e60, U+4e9a, U+4eb2, U+4ec5, U+4efd, U+4f3c, U+4f4f, U+4f8b, U+4fbf, U+25, U+4e14, U+4e1d, U+4e3d, U+4e49, U+4e60, U+4e9a, U+4eb2, U+4ec5, U+4efd, U+4f3c, U+4f4f, U+4f8b, U+4fbf,
@@ -1613,7 +1613,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/04eaf1a999368419.woff2) format('woff2'); src: url(./fonts/04eaf1a999368419.woff2) format('woff2');
unicode-range: unicode-range:
U+2b, U+40, U+3000, U+300a-300b, U+4e16, U+4e66, U+4e70, U+4e91-4e92, U+4e94, U+4e9b, U+4ec0, U+4eca, U+4f01, U+2b, U+40, U+3000, U+300a-300b, U+4e16, U+4e66, U+4e70, U+4e91-4e92, U+4e94, U+4e9b, U+4ec0, U+4eca, U+4f01,
@@ -1635,7 +1635,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/2ef4d8c6c25aaa84.woff2) format('woff2'); src: url(./fonts/2ef4d8c6c25aaa84.woff2) format('woff2');
unicode-range: unicode-range:
U+4e, U+201c-201d, U+3010-3011, U+4e07, U+4e1c, U+4e24, U+4e3e, U+4e48, U+4e50, U+4e5f, U+4e8b-4e8c, U+4ea4, U+4e, U+201c-201d, U+3010-3011, U+4e07, U+4e1c, U+4e24, U+4e3e, U+4e48, U+4e50, U+4e5f, U+4e8b-4e8c, U+4ea4,
@@ -1657,7 +1657,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/f63d970a7b2b5321.woff2) format('woff2'); src: url(./fonts/f63d970a7b2b5321.woff2) format('woff2');
unicode-range: unicode-range:
U+d, U+3e, U+5f, U+7c, U+a0, U+a9, U+4e09-4e0b, U+4e0d-4e0e, U+4e13, U+4e1a, U+4e2a, U+4e3a-4e3b, U+4e4b, U+4e86, U+d, U+3e, U+5f, U+7c, U+a0, U+a9, U+4e09-4e0b, U+4e0d-4e0e, U+4e13, U+4e1a, U+4e2a, U+4e3a-4e3b, U+4e4b, U+4e86,
@@ -1678,7 +1678,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/ca6307f70603bdbf.woff2) format('woff2'); src: url(./fonts/ca6307f70603bdbf.woff2) format('woff2');
unicode-range: unicode-range:
U+20-22, U+27-2a, U+2c-3b, U+3f, U+41-4d, U+4f-5d, U+61-7b, U+7d, U+ab, U+ae, U+b2, U+b7, U+bb, U+df-e5, U+e7-ea, U+20-22, U+27-2a, U+2c-3b, U+3f, U+41-4d, U+4f-5d, U+61-7b, U+7d, U+ab, U+ae, U+b2, U+b7, U+bb, U+df-e5, U+e7-ea,
@@ -1694,7 +1694,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/89c8b72714b2fb05.woff2) format('woff2'); src: url(./fonts/89c8b72714b2fb05.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@@ -1703,7 +1703,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/38475678e2d17cfd.woff2) format('woff2'); src: url(./fonts/38475678e2d17cfd.woff2) format('woff2');
unicode-range: unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309,
@@ -1714,7 +1714,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/59aba4ce37e0d3de.woff2) format('woff2'); src: url(./fonts/59aba4ce37e0d3de.woff2) format('woff2');
unicode-range: unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
@@ -1725,7 +1725,7 @@
font-family: 'Noto Serif SC'; font-family: 'Noto Serif SC';
font-style: normal; font-style: normal;
font-weight: 400 700; font-weight: 400 700;
font-display: block; font-display: swap;
src: url(./fonts/d8c61f0c834f348a.woff2) format('woff2'); src: url(./fonts/d8c61f0c834f348a.woff2) format('woff2');
unicode-range: unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC,
+1 -1
View File
@@ -14,7 +14,7 @@ export function applyFontCatalog(fonts: CustomFont[]) {
style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style) style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style)
} }
// Names are display-only; CSS identifiers and resource paths use only server-generated content hashes. // Names are display-only; CSS identifiers and resource paths use only server-generated content hashes.
const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('${fontResourceRoot()}${font.id}/file'); font-display: block; }`).join('\n') const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('${fontResourceRoot()}${font.id}/file'); font-display: swap; }`).join('\n')
if (style.textContent !== css) style.textContent = css if (style.textContent !== css) style.textContent = css
} }
+60 -60
View File
@@ -1,61 +1,61 @@
/* Maple Mono v7.9; generated by scripts/prepare-maple-font.py. See fonts/maple/LICENSE.txt for licensing. */ /* Maple Mono v7.9; generated by scripts/prepare-maple-font.py. See fonts/maple/LICENSE.txt for licensing. */
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-00.woff2') format('woff2'); unicode-range: U+0-FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-00.woff2') format('woff2'); unicode-range: U+0-FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-03.woff2') format('woff2'); unicode-range: U+3000-3FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-03.woff2') format('woff2'); unicode-range: U+3000-3FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-04.woff2') format('woff2'); unicode-range: U+4000-4FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-04.woff2') format('woff2'); unicode-range: U+4000-4FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-05.woff2') format('woff2'); unicode-range: U+5000-5FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-05.woff2') format('woff2'); unicode-range: U+5000-5FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-06.woff2') format('woff2'); unicode-range: U+6000-6FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-06.woff2') format('woff2'); unicode-range: U+6000-6FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-07.woff2') format('woff2'); unicode-range: U+7000-7FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-07.woff2') format('woff2'); unicode-range: U+7000-7FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-08.woff2') format('woff2'); unicode-range: U+8000-8FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-08.woff2') format('woff2'); unicode-range: U+8000-8FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-09.woff2') format('woff2'); unicode-range: U+9000-9FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-09.woff2') format('woff2'); unicode-range: U+9000-9FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-0e.woff2') format('woff2'); unicode-range: U+E000-EFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-0e.woff2') format('woff2'); unicode-range: U+E000-EFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-0f.woff2') format('woff2'); unicode-range: U+F000-FFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-0f.woff2') format('woff2'); unicode-range: U+F000-FFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-1f.woff2') format('woff2'); unicode-range: U+1F000-1FFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-1f.woff2') format('woff2'); unicode-range: U+1F000-1FFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-f0.woff2') format('woff2'); unicode-range: U+F0000-F0FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-f0.woff2') format('woff2'); unicode-range: U+F0000-F0FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-f1.woff2') format('woff2'); unicode-range: U+F1000-F1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/maple/regular-f1.woff2') format('woff2'); unicode-range: U+F1000-F1FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-00.woff2') format('woff2'); unicode-range: U+0-FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-00.woff2') format('woff2'); unicode-range: U+0-FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-03.woff2') format('woff2'); unicode-range: U+3000-3FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-03.woff2') format('woff2'); unicode-range: U+3000-3FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-04.woff2') format('woff2'); unicode-range: U+4000-4FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-04.woff2') format('woff2'); unicode-range: U+4000-4FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-05.woff2') format('woff2'); unicode-range: U+5000-5FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-05.woff2') format('woff2'); unicode-range: U+5000-5FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-06.woff2') format('woff2'); unicode-range: U+6000-6FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-06.woff2') format('woff2'); unicode-range: U+6000-6FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-07.woff2') format('woff2'); unicode-range: U+7000-7FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-07.woff2') format('woff2'); unicode-range: U+7000-7FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-08.woff2') format('woff2'); unicode-range: U+8000-8FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-08.woff2') format('woff2'); unicode-range: U+8000-8FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-09.woff2') format('woff2'); unicode-range: U+9000-9FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-09.woff2') format('woff2'); unicode-range: U+9000-9FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-0e.woff2') format('woff2'); unicode-range: U+E000-EFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-0e.woff2') format('woff2'); unicode-range: U+E000-EFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-0f.woff2') format('woff2'); unicode-range: U+F000-FFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-0f.woff2') format('woff2'); unicode-range: U+F000-FFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-1f.woff2') format('woff2'); unicode-range: U+1F000-1FFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-1f.woff2') format('woff2'); unicode-range: U+1F000-1FFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-f0.woff2') format('woff2'); unicode-range: U+F0000-F0FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-f0.woff2') format('woff2'); unicode-range: U+F0000-F0FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: block; src: url('./fonts/maple/bold-f1.woff2') format('woff2'); unicode-range: U+F1000-F1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/maple/bold-f1.woff2') format('woff2'); unicode-range: U+F1000-F1FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-00.woff2') format('woff2'); unicode-range: U+0-FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-00.woff2') format('woff2'); unicode-range: U+0-FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-03.woff2') format('woff2'); unicode-range: U+3000-3FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-03.woff2') format('woff2'); unicode-range: U+3000-3FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-04.woff2') format('woff2'); unicode-range: U+4000-4FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-04.woff2') format('woff2'); unicode-range: U+4000-4FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-05.woff2') format('woff2'); unicode-range: U+5000-5FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-05.woff2') format('woff2'); unicode-range: U+5000-5FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-06.woff2') format('woff2'); unicode-range: U+6000-6FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-06.woff2') format('woff2'); unicode-range: U+6000-6FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-07.woff2') format('woff2'); unicode-range: U+7000-7FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-07.woff2') format('woff2'); unicode-range: U+7000-7FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-08.woff2') format('woff2'); unicode-range: U+8000-8FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-08.woff2') format('woff2'); unicode-range: U+8000-8FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-09.woff2') format('woff2'); unicode-range: U+9000-9FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-09.woff2') format('woff2'); unicode-range: U+9000-9FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-0e.woff2') format('woff2'); unicode-range: U+E000-EFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-0e.woff2') format('woff2'); unicode-range: U+E000-EFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-0f.woff2') format('woff2'); unicode-range: U+F000-FFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-0f.woff2') format('woff2'); unicode-range: U+F000-FFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-1f.woff2') format('woff2'); unicode-range: U+1F000-1FFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-1f.woff2') format('woff2'); unicode-range: U+1F000-1FFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-f0.woff2') format('woff2'); unicode-range: U+F0000-F0FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-f0.woff2') format('woff2'); unicode-range: U+F0000-F0FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: block; src: url('./fonts/maple/italic-f1.woff2') format('woff2'); unicode-range: U+F1000-F1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 400; font-display: swap; src: url('./fonts/maple/italic-f1.woff2') format('woff2'); unicode-range: U+F1000-F1FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-00.woff2') format('woff2'); unicode-range: U+0-FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-00.woff2') format('woff2'); unicode-range: U+0-FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-03.woff2') format('woff2'); unicode-range: U+3000-3FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-03.woff2') format('woff2'); unicode-range: U+3000-3FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-04.woff2') format('woff2'); unicode-range: U+4000-4FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-04.woff2') format('woff2'); unicode-range: U+4000-4FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-05.woff2') format('woff2'); unicode-range: U+5000-5FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-05.woff2') format('woff2'); unicode-range: U+5000-5FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-06.woff2') format('woff2'); unicode-range: U+6000-6FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-06.woff2') format('woff2'); unicode-range: U+6000-6FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-07.woff2') format('woff2'); unicode-range: U+7000-7FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-07.woff2') format('woff2'); unicode-range: U+7000-7FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-08.woff2') format('woff2'); unicode-range: U+8000-8FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-08.woff2') format('woff2'); unicode-range: U+8000-8FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-09.woff2') format('woff2'); unicode-range: U+9000-9FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-09.woff2') format('woff2'); unicode-range: U+9000-9FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-0e.woff2') format('woff2'); unicode-range: U+E000-EFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-0e.woff2') format('woff2'); unicode-range: U+E000-EFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-0f.woff2') format('woff2'); unicode-range: U+F000-FFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-0f.woff2') format('woff2'); unicode-range: U+F000-FFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-1f.woff2') format('woff2'); unicode-range: U+1F000-1FFFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-1f.woff2') format('woff2'); unicode-range: U+1F000-1FFFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-f0.woff2') format('woff2'); unicode-range: U+F0000-F0FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-f0.woff2') format('woff2'); unicode-range: U+F0000-F0FFF; }
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: block; src: url('./fonts/maple/bolditalic-f1.woff2') format('woff2'); unicode-range: U+F1000-F1FFF; } @font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: italic; font-weight: 700; font-display: swap; src: url('./fonts/maple/bolditalic-f1.woff2') format('woff2'); unicode-range: U+F1000-F1FFF; }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"demo_notice": "Editor preview only. Changes are not saved; refreshing restores the sample.", "demo_notice": "Editor preview only. Changes are not saved; refreshing restores the sample.",
"retry_b8784c": "Retry", "retry_b8784c": "Retry",
"fonts_could_not_be_loaded_42a574": "Fonts could not be loaded. Check your connection and refresh the page.", "fonts_could_not_be_loaded_42a574": "Some fonts could not be loaded. Using fallback fonts; refresh to try again.",
"refresh_page_9366e1": "Refresh page", "refresh_page_9366e1": "Refresh page",
"invalid_font_catalog_b577df": "Invalid font catalog", "invalid_font_catalog_b577df": "Invalid font catalog",
"manrope_sans_serif_a8923c": "Manrope · Sans serif", "manrope_sans_serif_a8923c": "Manrope · Sans serif",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"demo_notice": "仅供编辑器预览。修改不会保存,刷新后恢复示例。", "demo_notice": "仅供编辑器预览。修改不会保存,刷新后恢复示例。",
"retry_b8784c": "重试", "retry_b8784c": "重试",
"fonts_could_not_be_loaded_42a574": "字体加载失败,请检查网络后刷新页面。", "fonts_could_not_be_loaded_42a574": "部分字体加载失败,正在使用备用字体,可刷新页面重试。",
"refresh_page_9366e1": "刷新页面", "refresh_page_9366e1": "刷新页面",
"invalid_font_catalog_b577df": "字体列表格式无效", "invalid_font_catalog_b577df": "字体列表格式无效",
"manrope_sans_serif_a8923c": "Manrope · 清晰无衬线", "manrope_sans_serif_a8923c": "Manrope · 清晰无衬线",
+1 -2
View File
@@ -13,7 +13,6 @@ import '@@/assets/styles/app.css'
import '@@/assets/styles/element-plus.scss' import '@@/assets/styles/element-plus.scss'
import 'virtual:uno.css' import 'virtual:uno.css'
watchFontLoading()
const app = createApp(App) const app = createApp(App)
app.use(i18n).use(pinia).use(router) app.use(i18n).use(pinia).use(router)
const user = useUserStore(pinia) const user = useUserStore(pinia)
@@ -28,5 +27,5 @@ watch(() => user.sessionVersion, () => {
}) })
// Mount a retryable error interface even if identity restoration temporarily fails. // Mount a retryable error interface even if identity restoration temporarily fails.
void router.isReady().catch(() => {}).finally(() => { void router.isReady().catch(() => {}).finally(() => {
app.mount('#app'); void registerPwa() app.mount('#app'); watchFontLoading(); void registerPwa()
}) })
@@ -74,6 +74,9 @@ watch(() => [ws.active, tab.value?.status, ws.view, ws.images], () => {
<div v-if="!tab" class="rm-empty"> <div v-if="!tab" class="rm-empty">
<p>{{ translate('workspace.select_or_create_a_note_669f82') }}</p> <p>{{ translate('workspace.select_or_create_a_note_669f82') }}</p>
</div> </div>
<div v-else-if="tab.status === 'loading'" class="rm-empty" role="status">
<p>{{ translate('workspace.loading_4927a5') }}</p>
</div>
<div v-else-if="tab.status === 'error'" class="rm-empty"> <div v-else-if="tab.status === 'error'" class="rm-empty">
<p>{{ translate('workspace.could_not_open_this_note_634236') }}</p> <p>{{ translate('workspace.could_not_open_this_note_634236') }}</p>
</div> </div>
@@ -6,7 +6,7 @@ import { t as translate } from '@@/i18n/runtime'
import { commandTitle } from '@@/utils/commands' import { commandTitle } from '@@/utils/commands'
import { noteTitle } from '@@/utils/paths' import { noteTitle } from '@@/utils/paths'
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
import { closeTab, createNote, ws } from '../workspace' import { closeTab, createNote, toggleTabPin, ws } from '../workspace'
// Drag sorting: dropping on a tab's left half inserts before it; its right half inserts after it. // Drag sorting: dropping on a tab's left half inserts before it; its right half inserts after it.
const TAB_TYPE = 'application/x-rawmark-tab' const TAB_TYPE = 'application/x-rawmark-tab'
@@ -113,7 +113,7 @@ function closeRight(path: string) {
<template v-if="menu"> <template v-if="menu">
<div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" /> <div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" />
<div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: `${menu.x}px`, top: `${menu.y}px` }"> <div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: `${menu.x}px`, top: `${menu.y}px` }">
<button @click="menu.tab.pinned = !menu.tab.pinned; menu = null"> <button @click="toggleTabPin(menu.tab.path); menu = null">
{{ menu.tab.pinned ? translate('workspace.unpin_20590a') : translate('workspace.pin_d4b3f4') }} {{ menu.tab.pinned ? translate('workspace.unpin_20590a') : translate('workspace.pin_d4b3f4') }}
</button> </button>
<button @click="closeTab(menu.tab.path); menu = null"> <button @click="closeTab(menu.tab.path); menu = null">
+245 -1
View File
@@ -18,12 +18,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError } from '@/http/axios' import { ApiError } from '@/http/axios'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { apiSuccess } from '../../../tests/api-response' import { apiSuccess } from '../../../tests/api-response'
import { pluginEvents } from '../../common/plugins/registry' import { pluginEvents } from '../../common/plugins/registry'
import { drafts } from './drafts' import { drafts } from './drafts'
import { insertImageFiles } from './editor/paste' import { insertImageFiles } from './editor/paste'
import { attachView, closeTab, createNoteAt, editorStateOf, flush, importNotes, keepLocalVersion, move, openNote, readyToReload, refreshTree, remove, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace' import { attachView, closeTab, createNoteAt, editorStateOf, flush, importNotes, keepLocalVersion, move, openNote, readyToReload, refreshTree, remove, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, toggleTabPin, ws } from './workspace'
vi.mock('@@/apis/events', () => ({ connectEventsApi: vi.fn(() => () => {}) })) vi.mock('@@/apis/events', () => ({ connectEventsApi: vi.fn(() => () => {}) }))
@@ -116,6 +117,249 @@ describe('事件同步', () => {
}) })
}) })
describe('固定标签跨设备同步', () => {
const { settings, flushSettings } = useSettingsStore(pinia)
let serverPins: string[] | undefined
beforeEach(() => {
serverPins = undefined
settings.pinnedTabs = undefined
localStorage.removeItem('rawmark.tabs')
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: ['a.md', 'b.md', 'from/note.md'].map(path => ({ path, dir: false, size: 0, mtime: 0 })) }))
vi.spyOn(settingsApi, 'getSettingsApi').mockImplementation(async () => apiSuccess(serverPins === undefined ? {} : { pinnedTabs: [...serverPins] }))
vi.spyOn(settingsApi, 'patchSettingsApi').mockImplementation(async (patch) => {
serverPins = patch.pinnedTabs as string[]
return apiSuccess({ pinnedTabs: serverPins })
})
vi.spyOn(fontsApi, 'getFontsApi').mockResolvedValue(apiSuccess({ fonts: [] }))
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] }))
vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '服务器正文', rev: 'r1' })
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
})
afterEach(async () => {
stopWorkspace()
await flushSettings().catch(() => {})
await flushPromises()
settings.pinnedTabs = undefined
localStorage.removeItem('rawmark.tabs')
})
it('固定后保存服务器,另一设备没有本地标签也能恢复', async () => {
await startWorkspace()
await openNote('a.md')
await toggleTabPin('a.md')
expect(serverPins).toEqual(['a.md'])
expect(ws.tabs[0].pinned).toBe(true)
stopWorkspace()
await flushPromises()
localStorage.removeItem('rawmark.tabs')
settings.pinnedTabs = undefined
await startWorkspace()
expect(ws.tabs.map(tab => [tab.path, tab.pinned])).toEqual([['a.md', true]])
expect(editorStateOf('a.md')?.doc.toString()).toBe('服务器正文')
expect(ws.active).toBe('a.md')
})
it('只在服务器尚无固定列表时迁移本地固定标签', async () => {
localStorage.setItem('rawmark.tabs', JSON.stringify({ paths: ['a.md'], active: 'a.md', pinned: ['a.md'] }))
await startWorkspace()
expect(serverPins).toEqual(['a.md'])
expect(settingsApi.patchSettingsApi).toHaveBeenCalledTimes(1)
await flushPromises()
expect(JSON.parse(localStorage.getItem('rawmark.tabs')!)).not.toHaveProperty('pinned')
})
it('服务器已取消所有固定时,旧设备缓存不能重新固定', async () => {
serverPins = []
localStorage.setItem('rawmark.tabs', JSON.stringify({ paths: ['a.md'], active: 'a.md', pinned: ['a.md'] }))
await startWorkspace()
expect(ws.tabs[0].pinned).toBe(false)
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
})
it('加载服务器失败时保留本地固定状态,不上传未确认的列表', async () => {
vi.mocked(settingsApi.getSettingsApi).mockRejectedValueOnce(new ApiError(503, '设置暂不可用'))
localStorage.setItem('rawmark.tabs', JSON.stringify({ paths: ['a.md'], active: 'a.md', pinned: ['a.md'] }))
await startWorkspace()
expect(ws.tabs[0].pinned).toBe(true)
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
expect(JSON.parse(localStorage.getItem('rawmark.tabs')!).pinned).toEqual(['a.md'])
})
it('其他设备固定和取消固定,不切换视图或关闭本机未保存标签', async () => {
serverPins = ['a.md']
await startWorkspace()
const tab = ws.tabs[0]
tab.dirty = true
const state = editorStateOf('a.md')
ws.view = 'graph'
ws.images = ''
ws.sidebarOpen = true
serverPins = ['b.md']
vi.mocked(connectEventsApi).mock.lastCall![0].onEvents([{ type: 'settings' }])
await vi.waitFor(() => expect(ws.tabs.find(t => t.path === 'b.md')?.status).toBe('ready'))
expect(ws.active).toBe('a.md')
expect(ws.view).toBe('graph')
expect(ws.images).toBe('')
expect(ws.sidebarOpen).toBe(true)
expect(tab.pinned).toBe(false)
expect(tab.dirty).toBe(true)
expect(editorStateOf('a.md')).toBe(state)
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
tab.dirty = false
})
it('断线重连补回错过的固定设置', async () => {
serverPins = []
await startWorkspace()
serverPins = ['b.md']
vi.mocked(connectEventsApi).mock.lastCall![0].onResync()
await vi.waitFor(() => expect(ws.tabs.find(tab => tab.path === 'b.md')?.pinned).toBe(true))
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
})
it('保存失败时提示并恢复服务器状态', async () => {
serverPins = []
await startWorkspace()
await openNote('a.md')
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new ApiError(503, '固定保存失败'))
await toggleTabPin('a.md')
expect(ws.tabs[0].pinned).toBe(false)
expect(settings.pinnedTabs).toEqual([])
expect(toasts.some(toast => toast.text.includes('固定保存失败'))).toBe(true)
})
it('快速固定和取消固定按操作顺序保存', async () => {
serverPins = []
await startWorkspace()
await openNote('a.md')
await Promise.all([toggleTabPin('a.md'), toggleTabPin('a.md'), toggleTabPin('a.md')])
expect(vi.mocked(settingsApi.patchSettingsApi).mock.calls.map(([patch]) => patch.pinnedTabs)).toEqual([['a.md'], [], ['a.md']])
expect(serverPins).toEqual(['a.md'])
expect(ws.tabs[0].pinned).toBe(true)
})
it('主动关闭固定标签也从服务器固定列表中移除', async () => {
serverPins = ['a.md', 'b.md']
await startWorkspace()
expect(await closeTab('a.md')).toBe(true)
expect(serverPins).toEqual(['b.md'])
expect(ws.tabs.some(tab => tab.path === 'a.md')).toBe(false)
})
it('取消固定保存失败时保留标签,不丢失固定状态', async () => {
serverPins = ['a.md']
await startWorkspace()
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new ApiError(503, '保存失败'))
expect(await closeTab('a.md')).toBe(false)
expect(ws.tabs.map(tab => [tab.path, tab.pinned])).toEqual([['a.md', true]])
})
it('关闭时草稿保存失败,保留标签和服务器固定记录', async () => {
serverPins = ['a.md']
await startWorkspace()
const tab = ws.tabs[0]
Object.assign(tab, { status: 'conflict', dirty: true })
const put = vi.spyOn(drafts, 'put').mockRejectedValueOnce(new Error('草稿存储不可用'))
expect(await closeTab('a.md')).toBe(false)
expect(ws.tabs).toContain(tab)
expect(tab.pinned).toBe(true)
expect(serverPins).toEqual(['a.md'])
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
put.mockResolvedValue('a.md')
tab.dirty = false
})
it('取消固定请求期间又输入,保留新内容并恢复固定', async () => {
serverPins = ['a.md']
await startWorkspace()
const v = await edit('a.md')
const original = v.state.doc.toString()
vi.spyOn(drafts, 'put').mockResolvedValue('a.md')
let finish!: () => void
vi.mocked(settingsApi.patchSettingsApi).mockImplementationOnce(async (patch) => {
await new Promise<void>((resolve) => {
finish = resolve
})
serverPins = patch.pinnedTabs as string[]
return apiSuccess({ pinnedTabs: serverPins })
})
const closing = closeTab('a.md')
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
v.dispatch({ changes: { from: 0, insert: '新增输入' } })
finish()
expect(await closing).toBe(false)
expect(serverPins).toEqual(['a.md'])
expect(ws.tabs[0].pinned).toBe(true)
expect(ws.tabs[0].dirty).toBe(true)
expect(v.state.doc.toString()).toBe(`新增输入${original}`)
ws.tabs[0].dirty = false
})
it('重置设置后同步取消固定,重新登录不会迁回旧记录', async () => {
serverPins = ['a.md']
await startWorkspace()
const tab = ws.tabs[0]
tab.dirty = true
const state = editorStateOf('a.md')
serverPins = []
vi.mocked(connectEventsApi).mock.lastCall![0].onEvents([{ type: 'settings' }])
await vi.waitFor(() => expect(tab.pinned).toBe(false))
expect(ws.tabs).toContain(tab)
expect(tab.dirty).toBe(true)
expect(editorStateOf('a.md')).toBe(state)
await flushPromises()
expect(JSON.parse(localStorage.getItem('rawmark.tabs')!)).not.toHaveProperty('pinned')
tab.dirty = false
stopWorkspace()
await flushPromises()
// Another device may still hold a legacy cache; the reset must remain authoritative.
localStorage.setItem('rawmark.tabs', JSON.stringify({ paths: ['a.md'], active: 'a.md', pinned: ['a.md'] }))
await startWorkspace()
expect(ws.tabs[0].pinned).toBe(false)
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
})
it('在本机重命名固定笔记后,新设备仍能按新路径恢复', async () => {
serverPins = ['from/note.md']
await startWorkspace()
vi.spyOn(filesApi, 'moveFileApi').mockResolvedValue(apiSuccess({ ok: true }))
vi.mocked(treeApi.getTreeApi).mockResolvedValue(apiSuccess({ entries: [{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }] }))
expect(await move('from', 'to')).toEqual({ ok: true })
expect(serverPins).toEqual(['to/note.md'])
stopWorkspace()
await flushPromises()
localStorage.removeItem('rawmark.tabs')
await startWorkspace()
expect(ws.tabs.map(tab => [tab.path, tab.pinned])).toEqual([['to/note.md', true]])
})
it('目录重命名后,服务器固定列表跟随新路径', async () => {
serverPins = ['from/note.md']
await startWorkspace()
vi.mocked(treeApi.getTreeApi).mockResolvedValue(apiSuccess({ entries: [{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }] }))
vi.mocked(connectEventsApi).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
await vi.waitFor(() => expect(serverPins).toEqual(['to/note.md']))
await vi.waitFor(() => expect(ws.tabs.map(tab => [tab.path, tab.pinned])).toEqual([['to/note.md', true]]))
})
it('旧工作区未完成的标签读取不覆盖新工作区同名笔记', async () => {
let finish!: (result: { content: string, rev: string }) => void
vi.mocked(noteFiles.readNoteFile).mockImplementationOnce(() => new Promise((resolve) => {
finish = resolve
}))
const opening = openNote('a.md', { background: true })
stopWorkspace()
await openNote('a.md')
finish({ content: '旧工作区正文', rev: 'old' })
await opening
expect(editorStateOf('a.md')?.doc.toString()).toBe('服务器正文')
expect(ws.tabs[0].rev).toBe('r1')
})
})
async function edit(path: string) { async function edit(path: string) {
vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '原文', rev: 'r1' }) vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '原文', rev: 'r1' })
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined) vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
+98 -14
View File
@@ -29,7 +29,7 @@ import { CanceledError, isCancel } from 'axios'
import { nextTick, reactive, watch } from 'vue' import { nextTick, reactive, watch } from 'vue'
import { ApiError } from '@/http/axios' import { ApiError } from '@/http/axios'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings' import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { drafts } from './drafts' import { drafts } from './drafts'
import { createEditorState, insertImageFiles, languageCompartment, pasteImages, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from './editor' import { createEditorState, insertImageFiles, languageCompartment, pasteImages, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from './editor'
@@ -38,7 +38,7 @@ import { editorLocale, localeCompartment } from './editor/locale'
import { editorCommands } from './editor/shortcuts' import { editorCommands } from './editor/shortcuts'
import { refreshMetadata } from './metadata' import { refreshMetadata } from './metadata'
const { loadSettings, settings } = useSettingsStore(pinia) const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error' export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
@@ -515,35 +515,90 @@ export function revealPending(v: EditorView, path: string): boolean {
// ---- Tabs ---- // ---- Tabs ----
export async function openNote(path: string) { export async function openNote(path: string, options?: { background?: boolean }) {
const version = user.getSessionVersion() const version = user.getSessionVersion()
const epoch = workspaceEpoch
if (!options?.background) {
ws.view = 'notes' ws.view = 'notes'
ws.images = null ws.images = null
ws.sidebarOpen = false ws.sidebarOpen = false
}
// New tree notes appear optimistically; wait for actual creation before reading them. // New tree notes appear optimistically; wait for actual creation before reading them.
const pending = creating.get(path) const pending = creating.get(path)
if (pending && !(await pending)) return if (pending && !(await pending)) return
user.assertSession(version) user.assertSession(version)
if (tabOf(path)) { if (tabOf(path)) {
ws.active = path if (!options?.background) ws.active = path
return return
} }
const tab: Tab = { path, rev: '', status: 'loading', dirty: false } const tab = reactive<Tab>({ path, pinned: settings.pinnedTabs?.includes(path) ?? false, rev: '', status: 'loading', dirty: false })
ws.tabs.push(tab) ws.tabs.push(tab)
ws.active = path if (!options?.background) ws.active = path
try { try {
const { content, rev } = await readNoteFile(path) const { content, rev } = await readNoteFile(path)
user.assertSession(version)
if (epoch !== workspaceEpoch || tabOf(path) !== tab) return
editorStates.set(path, buildState(path, content)) editorStates.set(path, buildState(path, content))
const t = tabOf(path) const t = tabOf(path)
if (t) Object.assign(t, { rev, status: 'ready' }) if (t) Object.assign(t, { rev, status: 'ready' })
} catch (err) { } catch (err) {
if (isCancel(err)) return if (isCancel(err)) return
if (epoch !== workspaceEpoch || tabOf(path) !== tab) return
const t = tabOf(path) const t = tabOf(path)
if (t) t.status = 'error' if (t) t.status = 'error'
toast(translate('workspace.could_not_open_0ed1e1', { p0: errorText(err) })) toast(translate('workspace.could_not_open_0ed1e1', { p0: errorText(err) }))
} }
} }
async function syncPinnedTabs() {
if (settings.pinnedTabs === undefined) return
const pinned = new Set(settings.pinnedTabs)
for (const tab of ws.tabs) tab.pinned = pinned.has(tab.path)
// Remote unpins never close tabs or discard local drafts, and remote pins never steal focus.
const notes = new Set(ws.entries.filter(entry => !entry.dir && entry.path.endsWith(NOTE_EXT)).map(entry => entry.path))
await Promise.all([...pinned].filter(path => notes.has(path) && !tabOf(path)).map(path => openNote(path, { background: true })))
}
async function syncSettings() {
await loadSettings()
await syncPinnedTabs()
}
async function savePinnedTabs(paths: string[]): Promise<boolean> {
try {
const saving = updateSettings({ pinnedTabs: paths })
for (const tab of ws.tabs) tab.pinned = paths.includes(tab.path)
await saving
return true
} catch (err) {
if (isCancel(err)) return false
if (err instanceof SettingsAppearanceError) {
toast(err.message)
return true
}
toast(translate('common.could_not_save_settings_1cdd7f', { p0: err instanceof Error ? err.message : errorText(err) }))
try {
await syncSettings()
} catch (reload) {
if (!isCancel(reload)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(reload) }))
}
return false
}
}
export async function toggleTabPin(path: string) {
if (!tabOf(path)) return
const pinned = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path)
await savePinnedTabs(pinned.includes(path) ? pinned.filter(p => p !== path) : [...pinned, path])
}
async function remapPinnedTabs(from: string, to: string) {
const pinned = settings.pinnedTabs
if (pinned?.some(path => remapPath(path, from, to) !== null)) {
await savePinnedTabs(pinned.map(path => remapPath(path, from, to) ?? path))
}
}
export async function closeTab(path: string): Promise<boolean> { export async function closeTab(path: string): Promise<boolean> {
const version = user.getSessionVersion() const version = user.getSessionVersion()
const busy = () => Boolean(slots.get(path)?.uploads || slots.get(path)?.paused) const busy = () => Boolean(slots.get(path)?.uploads || slots.get(path)?.paused)
@@ -575,6 +630,19 @@ export async function closeTab(path: string): Promise<boolean> {
if (tabOf(path) !== tab || slot(path).seq !== seq || busy()) return false if (tabOf(path) !== tab || slot(path).seq !== seq || busy()) return false
toast(translate('workspace.unsaved_changes_retained_as_a_8cb2ab')) toast(translate('workspace.unsaved_changes_retained_as_a_8cb2ab'))
} }
if (tab.pinned) {
const seq = slot(path).seq
const pinned = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path)
if (!(await savePinnedTabs(pinned.filter(p => p !== path)))) return false
user.assertSession(version)
if (!ws.tabs.includes(tab)) return true
// Saving the pin is asynchronous; retain a tab changed by new input or another operation while it was pending.
if (tab.path !== path || slot(path).seq !== seq || busy()) {
const current = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path)
if (!current.includes(tab.path)) await savePinnedTabs([...current, tab.path])
return false
}
}
const index = ws.tabs.indexOf(tab) const index = ws.tabs.indexOf(tab)
if (index < 0) return true if (index < 0) return true
ws.tabs.splice(index, 1) ws.tabs.splice(index, 1)
@@ -875,6 +943,8 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
} finally { } finally {
paused.forEach(s => s.paused--) paused.forEach(s => s.paused--)
} }
await remapPinnedTabs(from, to)
user.assertSession(version)
// The server rewrites image links when renaming notes, changing their revisions and requiring resynchronization. // The server rewrites image links when renaming notes, changing their revisions and requiring resynchronization.
await Promise.all(ws.tabs.map(t => t.path).map(async (p) => { await Promise.all(ws.tabs.map(t => t.path).map(async (p) => {
const tab = tabOf(p) const tab = tabOf(p)
@@ -994,7 +1064,7 @@ async function handleEvents(evs: VaultEvent[]) {
if (ev.type === 'indexed') { if (ev.type === 'indexed') {
void refreshMetadata() void refreshMetadata()
} else if (ev.type === 'settings') { } else if (ev.type === 'settings') {
void loadSettings().catch((err) => { await syncSettings().catch((err) => {
if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) })) if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) }))
}) })
await refreshPlugins() await refreshPlugins()
@@ -1024,6 +1094,7 @@ async function handleEvents(evs: VaultEvent[]) {
if (epoch !== workspaceEpoch) return if (epoch !== workspaceEpoch) return
if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!) if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
remapRecent(ev.from!, ev.path!) remapRecent(ev.from!, ev.path!)
await remapPinnedTabs(ev.from!, ev.path!)
} finally { } finally {
paused.forEach(s => s.paused--) paused.forEach(s => s.paused--)
} }
@@ -1049,6 +1120,11 @@ async function resync() {
await refreshTree() await refreshTree()
user.assertSession(version) user.assertSession(version)
if (epoch !== workspaceEpoch) return if (epoch !== workspaceEpoch) return
await syncSettings().catch((err) => {
if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) }))
})
user.assertSession(version)
if (epoch !== workspaceEpoch) return
await Promise.all(ws.tabs.map(syncOpenTab)) await Promise.all(ws.tabs.map(syncOpenTab))
user.assertSession(version) user.assertSession(version)
if (epoch !== workspaceEpoch) return if (epoch !== workspaceEpoch) return
@@ -1060,6 +1136,10 @@ async function resync() {
const TABS_KEY = 'rawmark.tabs' const TABS_KEY = 'rawmark.tabs'
function localTabs() {
return { paths: ws.tabs.map(t => t.path), active: ws.active, ...(settings.pinnedTabs === undefined ? { pinned: ws.tabs.filter(t => t.pinned).map(t => t.path) } : {}) }
}
// Restore unsaved drafts: continue saving if the server revision is unchanged; otherwise enter conflict state for user resolution. // Restore unsaved drafts: continue saving if the server revision is unchanged; otherwise enter conflict state for user resolution.
async function recoverDrafts() { async function recoverDrafts() {
const version = user.getSessionVersion() const version = user.getSessionVersion()
@@ -1101,15 +1181,21 @@ export async function startWorkspace() {
await prepareResources() await prepareResources()
user.assertSession(version) user.assertSession(version)
// Settings-read failures do not prevent use; retain locally cached values. // Settings-read failures do not prevent use; retain locally cached values.
const settingsLoaded = loadSettings().catch((err) => { const settingsLoaded = loadSettings().then(() => true).catch((err) => {
if (!isCancel(err)) toast(translate('workspace.could_not_load_settings_246415', { p0: errorText(err) })) if (!isCancel(err)) toast(translate('workspace.could_not_load_settings_246415', { p0: errorText(err) }))
return false
}) })
await refreshTree() await refreshTree()
await settingsLoaded const confirmedSettings = await settingsLoaded
user.assertSession(version) user.assertSession(version)
const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[], active: string | null, pinned?: string[] } const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[], active: string | null, pinned?: string[] }
const exists = new Set(ws.entries.map(e => e.path)) const exists = new Set(ws.entries.map(e => e.path))
await Promise.all(saved.paths.filter(p => exists.has(p)).map(openNote)) await Promise.all(saved.paths.filter(p => exists.has(p)).map(path => openNote(path)))
if (settings.pinnedTabs === undefined) {
for (const tab of ws.tabs) tab.pinned = saved.pinned?.includes(tab.path) ?? false
if (confirmedSettings && saved.pinned?.length) await savePinnedTabs(saved.pinned.filter(path => exists.has(path)))
}
if (settings.pinnedTabs !== undefined) await syncPinnedTabs()
try { try {
await recoverDrafts() await recoverDrafts()
} catch (err) { } catch (err) {
@@ -1117,9 +1203,9 @@ export async function startWorkspace() {
toast(translate('workspace.could_not_restore_the_local_a275e8', { p0: errorText(err) })) toast(translate('workspace.could_not_restore_the_local_a275e8', { p0: errorText(err) }))
} }
user.assertSession(version) user.assertSession(version)
for (const tab of ws.tabs) tab.pinned = saved.pinned?.includes(tab.path) ?? false
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null) ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
workspaceRunning = true workspaceRunning = true
localStorage.setItem(TABS_KEY, JSON.stringify(localTabs()))
let eventQueue = Promise.resolve() let eventQueue = Promise.resolve()
let stopped = false let stopped = false
const enqueue = (work: () => Promise<void>) => { const enqueue = (work: () => Promise<void>) => {
@@ -1151,12 +1237,10 @@ export async function startWorkspace() {
if (listenersInstalled) return if (listenersInstalled) return
listenersInstalled = true listenersInstalled = true
watch( watch(
() => ({ paths: ws.tabs.map(t => t.path), active: ws.active, pinned: ws.tabs.filter(t => t.pinned).map(t => t.path) }), localTabs,
(tabs) => { (tabs) => {
if (workspaceRunning) localStorage.setItem(TABS_KEY, JSON.stringify(tabs)) if (workspaceRunning) localStorage.setItem(TABS_KEY, JSON.stringify(tabs))
}, },
// Record tabs opened during startup immediately, including while waiting for tree refresh.
{ immediate: true },
) )
watch( watch(
() => ws.active, () => ws.active,
+11
View File
@@ -78,6 +78,17 @@ async function search(selector: string, query: string) {
} }
describe('设置查找与重置', () => { describe('设置查找与重置', () => {
it('服务器固定标签去重并过滤无效数据,区分首次迁移与明确清空', async () => {
vi.mocked(settingsApi.getSettingsApi).mockResolvedValueOnce(apiSuccess({ pinnedTabs: ['a.md', 1, '', 'a.md', '目录/b.md'] }))
await loadSettings()
expect(settings.pinnedTabs).toEqual(['a.md', '目录/b.md'])
vi.mocked(settingsApi.getSettingsApi).mockResolvedValueOnce(apiSuccess({ pinnedTabs: [] }))
await loadSettings()
expect(settings.pinnedTabs).toEqual([])
await loadSettings()
expect(settings.pinnedTabs).toBeUndefined()
})
it('分类按约定排序,令牌归入账户与安全', async () => { it('分类按约定排序,令牌归入账户与安全', async () => {
ws.settings = 'account' ws.settings = 'account'
vi.spyOn(tokensApi, 'getTokensApi').mockResolvedValue(apiSuccess({ tokens: [] })) vi.spyOn(tokensApi, 'getTokensApi').mockResolvedValue(apiSuccess({ tokens: [] }))
+3 -1
View File
@@ -36,7 +36,7 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 } export const SIDEBAR_WIDTH = { min: 200, max: 480 }
export const DEFAULTS = { locale: DEFAULT_LOCALE, trashRetentionDays: 30, hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 } export const DEFAULTS = { locale: DEFAULT_LOCALE, trashRetentionDays: 30, hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> } export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]>, pinnedTabs?: string[] }
const CACHE_KEY = 'rawmark.settings' const CACHE_KEY = 'rawmark.settings'
@@ -55,6 +55,8 @@ function sanitize(raw: Record<string, unknown>): Settings {
return { return {
locale: isLocale(raw.locale) ? raw.locale : DEFAULT_LOCALE, locale: isLocale(raw.locale) ? raw.locale : DEFAULT_LOCALE,
hiddenFonts, hiddenFonts,
// Absence permits a one-time migration from browser-only pinned tabs; an empty array is authoritative.
pinnedTabs: raw.pinnedTabs === undefined ? undefined : [...new Set(Array.isArray(raw.pinnedTabs) ? raw.pinnedTabs.filter((path): path is string => typeof path === 'string' && path.length > 0) : [])],
trashRetentionDays: Number.isSafeInteger(raw.trashRetentionDays) && (raw.trashRetentionDays as number) >= 0 ? raw.trashRetentionDays as number : DEFAULTS.trashRetentionDays, trashRetentionDays: Number.isSafeInteger(raw.trashRetentionDays) && (raw.trashRetentionDays as number) >= 0 ? raw.trashRetentionDays as number : DEFAULTS.trashRetentionDays,
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts), ...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true, selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
+11 -4
View File
@@ -1,13 +1,15 @@
const CACHE = 'rawmark-shell-__VERSION__' const CACHE = 'rawmark-shell-__VERSION__'
const FILES = __PRECACHE__ const FILES = __PRECACHE__
const paths = new Set(FILES) const paths = new Set(FILES)
const isFont = path => /\.(?:woff2?|ttf|otf)$/.test(path)
const shell = FILES.filter(path => !isFont(path))
globalThis.addEventListener('install', (event) => { globalThis.addEventListener('install', (event) => {
event.waitUntil((async () => { event.waitUntil((async () => {
const cache = await caches.open(CACHE) const cache = await caches.open(CACHE)
// Download in batches instead of requesting hundreds of syntax packages and fonts concurrently. // Download in batches instead of requesting hundreds of syntax packages concurrently.
for (let i = 0; i < FILES.length; i += 16) { for (let i = 0; i < shell.length; i += 16) {
await cache.addAll(FILES.slice(i, i + 16).map(url => new Request(url, { cache: 'reload' }))) await cache.addAll(shell.slice(i, i + 16).map(url => new Request(url, { cache: 'reload' })))
} }
})()) })())
}) })
@@ -67,6 +69,11 @@ globalThis.addEventListener('fetch', (event) => {
if (previous) return previous if (previous) return previous
} }
} }
return fetch(request) const response = await fetch(request)
// Cache public build fonts only after use; private resource tickets never reach this branch.
if (paths.has(url.pathname) && isFont(url.pathname) && response.ok) {
event.waitUntil(cache.put(url.pathname, response.clone()))
}
return response
})()) })())
}) })
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { expect, it, vi } from 'vitest'
function worker() {
const handlers: Record<string, (event: { request: Request, waitUntil: (promise: Promise<unknown>) => void, respondWith: (promise: Promise<Response>) => void }) => void> = {}
const cache = { addAll: vi.fn().mockResolvedValue(undefined), match: vi.fn<() => Promise<Response | undefined>>().mockResolvedValue(undefined), put: vi.fn().mockResolvedValue(undefined) }
const fetch = vi.fn().mockResolvedValue(new Response('font'))
const source = readFileSync(new URL('./service-worker.js', import.meta.url), 'utf8').replace('__VERSION__', 'test').replace('__PRECACHE__', JSON.stringify(['/index.html', '/assets/main.js', '/assets/regular.woff2', '/assets/bold.woff2']))
runInNewContext(source, { URL, Request: class extends Request {
constructor(url: string, options?: RequestInit) {
super(new URL(url, 'https://test.invalid'), options)
}
}, fetch, caches: { open: async () => cache, keys: async () => [] }, globalThis: { location: { origin: 'https://test.invalid' }, addEventListener: (name: string, handler: (event: { request: Request, waitUntil: (promise: Promise<unknown>) => void, respondWith: (promise: Promise<Response>) => void }) => void) => {
handlers[name] = handler
} } })
return { handlers, cache, fetch }
}
it('安装缓存外壳但不预下载字体', async () => {
const { handlers, cache } = worker()
let done!: Promise<unknown>
handlers.install!({ request: new Request('https://test.invalid'), respondWith: vi.fn(), waitUntil: (promise: Promise<unknown>) => {
done = promise
} })
await done
expect(cache.addAll.mock.calls.flat(2).map((request: Request) => request.url)).toEqual(['https://test.invalid/index.html', 'https://test.invalid/assets/main.js'])
})
it('使用后缓存公开构建字体,缓存命中不再请求网络', async () => {
const { handlers, cache, fetch } = worker()
const pending: Promise<unknown>[] = []
let response!: Promise<Response>
const event = { request: new Request('https://test.invalid/assets/bold.woff2'), respondWith: (promise: Promise<Response>) => {
response = promise
}, waitUntil: (promise: Promise<unknown>) => pending.push(promise) }
handlers.fetch!(event); expect((await response).status).toBe(200); await Promise.all(pending)
expect(cache.put).toHaveBeenCalledOnce()
cache.match.mockResolvedValue(new Response('cached'))
handlers.fetch!(event); expect(await (await response).text()).toBe('cached')
expect(fetch).toHaveBeenCalledOnce()
})
it('不缓存失败响应,不拦截私有字体票据', async () => {
const { handlers, cache, fetch } = worker()
fetch.mockResolvedValue(new Response('missing', { status: 404 }))
let response!: Promise<Response>
const respondWith = vi.fn((promise: Promise<Response>) => {
response = promise
})
handlers.fetch!({ request: new Request('https://test.invalid/assets/regular.woff2'), respondWith, waitUntil: vi.fn() })
expect((await response).status).toBe(404); expect(cache.put).not.toHaveBeenCalled()
respondWith.mockClear()
handlers.fetch!({ request: new Request('https://test.invalid/api/v1/resources/ticket/fonts/custom/file'), respondWith, waitUntil: vi.fn() })
expect(respondWith).not.toHaveBeenCalled()
})
+1 -1
View File
@@ -8,7 +8,7 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
const releaseVersion = readFileSync(new URL('../VERSION', import.meta.url), 'utf8').trim() const releaseVersion = process.env.RAWMARK_BUILD_VERSION ?? readFileSync(new URL('../VERSION', import.meta.url), 'utf8').trim()
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
define: { __RAWMARK_RELEASE__: JSON.stringify(releaseVersion) }, define: { __RAWMARK_RELEASE__: JSON.stringify(releaseVersion) },