feat: 增加项目无关的本地清洗评审器

This commit is contained in:
2026-08-28 19:06:10 +08:00
parent 10c026c7ad
commit 6c0dd5974b
82 changed files with 9335 additions and 48 deletions
+40
View File
@@ -0,0 +1,40 @@
import eslint from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage", "node_modules"] },
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.flat.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/no-confusing-void-expression": "off",
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
"react-hooks/set-state-in-effect": "off",
},
},
{
files: ["tests/**/*.{ts,tsx}"],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
},
},
);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>mdpolish 评审器</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>
+3578
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "mdpolish-reviewer-frontend",
"version": "0.7.0",
"private": true,
"type": "module",
"engines": {
"node": "^24.0.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src tests vite.config.ts",
"test": "vitest run",
"check": "npm run lint && npm run test && npm run build"
},
"dependencies": {
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/merge": "6.12.2",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.9",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.2",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.1.0",
"eslint": "10.9.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.4",
"globals": "17.11.0",
"jsdom": "30.0.1",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"vite": "8.2.2",
"vitest": "4.1.11"
}
}
+29
View File
@@ -0,0 +1,29 @@
mdpolish reviewer third-party notices
The bundled browser interface contains the following production dependencies.
Each dependency's complete license text is included at the referenced path.
@codemirror/autocomplete 6.20.3 — MIT — licenses/codemirror__autocomplete.txt
@codemirror/lang-css 6.3.1 — MIT — licenses/codemirror__lang-css.txt
@codemirror/lang-html 6.4.12 — MIT — licenses/codemirror__lang-html.txt
@codemirror/lang-javascript 6.2.5 — MIT — licenses/codemirror__lang-javascript.txt
@codemirror/lang-markdown 6.5.2 — MIT — licenses/codemirror__lang-markdown.txt
@codemirror/language 6.12.4 — MIT — licenses/codemirror__language.txt
@codemirror/lint 6.9.7 — MIT — licenses/codemirror__lint.txt
@codemirror/merge 6.12.2 — MIT — licenses/codemirror__merge.txt
@codemirror/state 6.7.1 — MIT — licenses/codemirror__state.txt
@codemirror/view 6.43.9 — MIT — licenses/codemirror__view.txt
@lezer/common 1.5.2 — MIT — licenses/lezer__common.txt
@lezer/css 1.3.6 — MIT — licenses/lezer__css.txt
@lezer/highlight 1.2.3 — MIT — licenses/lezer__highlight.txt
@lezer/html 1.3.13 — MIT — licenses/lezer__html.txt
@lezer/javascript 1.5.4 — MIT — licenses/lezer__javascript.txt
@lezer/lr 1.4.10 — MIT — licenses/lezer__lr.txt
@lezer/markdown 1.7.2 — MIT — licenses/lezer__markdown.txt
@marijn/find-cluster-break 1.0.4 — MIT — licenses/marijn__find-cluster-break.txt
crelt 1.0.7 — MIT — licenses/crelt.txt
style-mod 4.1.3 — MIT — licenses/style-mod.txt
w3c-keyname 2.2.8 — MIT — licenses/w3c-keyname.txt
react 19.2.8 — MIT — licenses/react.txt
react-dom 19.2.8 — MIT — licenses/react-dom.txt
scheduler 0.27.0 — MIT — licenses/scheduler.txt
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2022 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
Copyright (C) 2020 by Marijn Haverbeke <marijn@haverbeke.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2020 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2024 by Marijn Haverbeke <marijn@haverbeke.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
Copyright (C) 2016 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+381
View File
@@ -0,0 +1,381 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type {
ChangeDetail,
CollectionSummaryResponse,
DocumentComparisonResponse,
ModifierStageResponse,
RunStatus,
} from "../shared/api.js";
import { fetchCollection, fetchDocument, fetchModifierStage } from "./api-client.js";
const DiffView = lazy(async () => {
const module = await import("./DiffView.js");
return { default: module.DiffView };
});
interface AsyncState<T> {
loading: boolean;
value: T | null;
error: string | null;
}
const emptyState = <T,>(): AsyncState<T> => ({ loading: true, value: null, error: null });
function statusLabel(status: RunStatus): string {
switch (status) {
case "success":
return "成功";
case "failed":
return "失败";
case "unstable":
return "不稳定";
}
}
function shortHash(hash: string): string {
return `${hash.slice(0, 8)}${hash.slice(-6)}`;
}
function ErrorPanel({ message }: { message: string }) {
return (
<div className="state-panel state-panel--error" role="alert">
<span className="eyebrow"></span>
<p>{message}</p>
</div>
);
}
function LoadingPanel() {
return (
<div className="state-panel" role="status">
<span className="loading-dot" />
<p></p>
</div>
);
}
function ChangeList({
changes,
onSelect,
canJump,
}: {
changes: ChangeDetail[];
onSelect: (change: ChangeDetail) => void;
canJump: boolean;
}) {
if (changes.length === 0) {
return <p className="quiet-message"> Modifier </p>;
}
return (
<ol className="change-list">
{changes.map((change) => (
<li
key={`${change.modifier_position}-${change.proposal_index}-${change.edit_index}`}
>
<button type="button" onClick={() => onSelect(change)} disabled={!canJump}>
<span className="change-location">
{change.location.line} {change.location.column} ·
{change.proposal_index + 1} / {change.edit_index + 1}
</span>
<strong>{change.reason}</strong>
<span className="change-sample">
<del>{change.before || "∅"}</del>
<span aria-hidden="true"></span>
<ins>{change.after || "∅"}</ins>
</span>
</button>
</li>
))}
</ol>
);
}
export function App() {
const [collectionState, setCollectionState] =
useState<AsyncState<CollectionSummaryResponse>>(emptyState);
const [selectedDocument, setSelectedDocument] = useState<string | null>(null);
const [documentState, setDocumentState] = useState<AsyncState<DocumentComparisonResponse>>({
loading: false,
value: null,
error: null,
});
const [selectedModifier, setSelectedModifier] = useState<number | null>(null);
const [stageState, setStageState] = useState<AsyncState<ModifierStageResponse>>({
loading: false,
value: null,
error: null,
});
const [focusRange, setFocusRange] = useState<{ start: number; end: number } | null>(null);
useEffect(() => {
const controller = new AbortController();
fetchCollection(controller.signal)
.then((collection) => {
setCollectionState({ loading: false, value: collection, error: null });
setSelectedDocument(collection.documents[0]?.document_id ?? null);
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setCollectionState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取评审集合摘要。",
});
}
});
return () => controller.abort();
}, []);
useEffect(() => {
setSelectedModifier(null);
setFocusRange(null);
if (selectedDocument === null) {
setDocumentState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setDocumentState(emptyState());
fetchDocument(selectedDocument, controller.signal)
.then((document) => setDocumentState({ loading: false, value: document, error: null }))
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setDocumentState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取文档。",
});
}
});
return () => controller.abort();
}, [selectedDocument]);
useEffect(() => {
if (selectedDocument === null || selectedModifier === null) {
setStageState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setStageState(emptyState());
fetchModifierStage(selectedDocument, selectedModifier, controller.signal)
.then((stage) => setStageState({ loading: false, value: stage, error: null }))
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setStageState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取 Modifier 阶段。",
});
}
});
return () => controller.abort();
}, [selectedDocument, selectedModifier]);
const visibleChanges = useMemo(() => {
const document = documentState.value;
if (document === null) {
return [];
}
if (selectedModifier === null) {
return document.changes;
}
return document.changes.filter((change) => change.modifier_position === selectedModifier);
}, [documentState.value, selectedModifier]);
const selectChange = (change: ChangeDetail): void => {
setSelectedModifier(change.modifier_position);
setFocusRange(change.editor_range);
};
if (collectionState.loading) {
return <LoadingPanel />;
}
if (collectionState.error !== null || collectionState.value === null) {
return <ErrorPanel message={collectionState.error ?? "评审集合摘要为空。"} />;
}
const collection = collectionState.value;
const document = documentState.value;
const selectedSummary = collection.documents.find(
(item) => item.document_id === selectedDocument,
);
const selectedStage = stageState.value;
const canCompare =
document?.document.status === "success" && document.current_markdown !== null;
const beforeText = selectedStage?.before_markdown ?? document?.input_markdown ?? "";
const afterText = selectedStage?.after_markdown ?? document?.current_markdown ?? "";
const beforeLabel =
selectedStage === null
? "清洗前"
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行前`;
const afterLabel =
selectedStage === null
? "清洗后"
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行后`;
return (
<div className="app-shell">
<header className="topbar">
<div>
<span className="brand-mark">md</span>
<div>
<p className="eyebrow"></p>
<h1>{collection.collection.label}</h1>
</div>
</div>
<div className="run-facts">
<span className={`status status--${collection.collection.status}`}>
{statusLabel(collection.collection.status)}
</span>
<span>{collection.summary.document_count} </span>
<span>{collection.summary.change_count} </span>
</div>
</header>
<div className="layout">
<aside className="sidebar" aria-label="评审导航">
<section>
<div className="section-heading">
<h2></h2>
<span>{collection.documents.length}</span>
</div>
<nav className="document-list" aria-label="文档列表">
{collection.documents.map((item) => (
<button
type="button"
key={item.document_id}
className={item.document_id === selectedDocument ? "is-active" : ""}
onClick={() => setSelectedDocument(item.document_id)}
aria-current={item.document_id === selectedDocument ? "page" : undefined}
>
<span className={`status-dot status-dot--${item.status}`} />
<span>
<strong>{item.source_label}</strong>
<small>{item.change_count} </small>
</span>
</button>
))}
</nav>
</section>
<section className="modifier-section">
<div className="section-heading">
<h2>Modifier 线</h2>
<button
type="button"
className="text-button"
onClick={() => {
setSelectedModifier(null);
setFocusRange(null);
}}
disabled={selectedModifier === null}
>
</button>
</div>
<ol className="modifier-list">
{(document?.modifiers ?? []).map((modifier) => (
<li key={`${modifier.modifier_position}-${modifier.modifier_id}`}>
<button
type="button"
className={modifier.modifier_position === selectedModifier ? "is-active" : ""}
onClick={() => {
setSelectedModifier(modifier.modifier_position);
setFocusRange(null);
}}
disabled={!canCompare || !modifier.stage_available}
>
<span className="modifier-index">{modifier.modifier_position + 1}</span>
<span>
<strong>{modifier.modifier_id}</strong>
<small>
v{modifier.modifier_version} · {modifier.change_count}
</small>
</span>
</button>
</li>
))}
</ol>
</section>
</aside>
<main className="workspace">
<section className="document-header">
<div>
<p className="eyebrow"></p>
<h2>{selectedSummary?.source_label ?? "未选择"}</h2>
</div>
{selectedSummary === undefined ? null : (
<div className="document-meta">
<span className={`status status--${selectedSummary.status}`}>
{statusLabel(selectedSummary.status)}
</span>
<span title={selectedSummary.input_sha256}>
{shortHash(selectedSummary.input_sha256)}
</span>
<span title={selectedSummary.current_sha256}>
{shortHash(selectedSummary.current_sha256)}
</span>
</div>
)}
</section>
{documentState.loading || stageState.loading ? <LoadingPanel /> : null}
{documentState.error !== null ? <ErrorPanel message={documentState.error} /> : null}
{stageState.error !== null ? <ErrorPanel message={stageState.error} /> : null}
{!documentState.loading && document !== null && document.document.status !== "success" ? (
<div className="diagnostic-panel">
<p className="eyebrow"></p>
<h3>{statusLabel(document.document.status)}</h3>
<p>partial output </p>
{document.errors.map((error) => (
<article key={`${error.modifier_position}-${error.stage}-${error.code}`}>
<strong>{error.diagnostic_type}</strong>
<span>{error.message}</span>
</article>
))}
{document.residual_proposals.map((proposal) => (
<article key={`${proposal.modifier_position}-${proposal.proposal_index}`}>
<strong> {proposal.edits.length} </strong>
<span>{proposal.reason}</span>
</article>
))}
</div>
) : null}
{!documentState.loading && !stageState.loading && canCompare && stageState.error === null ? (
<Suspense fallback={<LoadingPanel />}>
<DiffView
before={beforeText}
after={afterText}
beforeLabel={beforeLabel}
afterLabel={afterLabel}
focusRange={focusRange}
/>
</Suspense>
) : null}
{document !== null ? (
<section className="changes-panel" aria-label="修改详情">
<div className="changes-heading">
<div>
<p className="eyebrow"></p>
<h3>
{selectedModifier === null
? `全部 Modifier · ${visibleChanges.length}`
: `${document.modifiers[selectedModifier]?.modifier_id ?? "Modifier"} · ${
visibleChanges.length
}`}
</h3>
</div>
{selectedStage === null ? null : <p>{selectedStage.modifier.applicability}</p>}
</div>
<ChangeList changes={visibleChanges} onSelect={selectChange} canJump={canCompare} />
</section>
) : null}
</main>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { markdown } from "@codemirror/lang-markdown";
import { MergeView } from "@codemirror/merge";
import { EditorSelection, EditorState } from "@codemirror/state";
import { drawSelection, EditorView, lineNumbers } from "@codemirror/view";
import { useEffect, useRef } from "react";
interface DiffViewProps {
before: string;
after: string;
beforeLabel: string;
afterLabel: string;
focusRange?: { start: number; end: number } | null;
}
const editorTheme = EditorView.theme({
"&": {
height: "100%",
backgroundColor: "#fbfaf7",
color: "#262822",
fontSize: "13px",
},
".cm-scroller": {
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
lineHeight: "1.68",
},
".cm-gutters": {
backgroundColor: "#f2f0ea",
color: "#8a877e",
border: "none",
},
".cm-content": {
padding: "18px 0 36px",
},
".cm-line": {
padding: "0 14px",
},
"&.cm-focused": {
outline: "2px solid #a7b9ac",
outlineOffset: "-2px",
},
});
const readOnlyExtensions = [
lineNumbers(),
markdown(),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
drawSelection(),
EditorView.lineWrapping,
editorTheme,
];
export function DiffView({ before, after, beforeLabel, afterLabel, focusRange }: DiffViewProps) {
const host = useRef<HTMLDivElement>(null);
const merge = useRef<MergeView | null>(null);
useEffect(() => {
if (host.current === null) {
return undefined;
}
const view = new MergeView({
parent: host.current,
a: { doc: before, extensions: readOnlyExtensions },
b: { doc: after, extensions: readOnlyExtensions },
orientation: "a-b",
gutter: true,
highlightChanges: true,
});
merge.current = view;
return () => {
view.destroy();
merge.current = null;
};
}, [before, after]);
// 必须在对应阶段文本重建完成后再次聚焦,不能只依赖 focusRange。
useEffect(() => {
const view = merge.current;
if (view === null || focusRange === null || focusRange === undefined) {
return;
}
const anchor = Math.min(Math.max(focusRange.start, 0), view.a.state.doc.length);
const head = Math.min(Math.max(focusRange.end, anchor), view.a.state.doc.length);
view.a.dispatch({
selection: EditorSelection.range(anchor, head),
effects: EditorView.scrollIntoView(anchor, { y: "center" }),
});
view.a.focus();
}, [focusRange, before, after]);
return (
<section className="diff-shell" aria-label={`${beforeLabel}${afterLabel}对比`}>
<div className="diff-labels" aria-hidden="true">
<span>{beforeLabel}</span>
<span>{afterLabel}</span>
</div>
<div className="diff-host" ref={host} />
</section>
);
}
+282
View File
@@ -0,0 +1,282 @@
import type {
ApiErrorResponse,
ChangeDetail,
CollectionSummaryResponse,
DocumentComparisonResponse,
DocumentSummary,
ErrorDetail,
ModifierStageResponse,
ModifierSummary,
ResidualProposalDetail,
RunStatus,
} from "../shared/api.js";
export class ReviewerApiError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "ReviewerApiError";
this.code = code;
}
}
type JsonRecord = Record<string, unknown>;
function invalid(label: string): never {
throw new ReviewerApiError("invalid_response", `本地服务返回的 ${label} 格式不正确。`);
}
function record(value: unknown, label: string): JsonRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return invalid(label);
}
return value as JsonRecord;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
return invalid(label);
}
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string") {
return invalid(label);
}
return value;
}
function nullableString(value: unknown, label: string): string | null {
return value === null ? null : string(value, label);
}
function integer(value: unknown, label: string, minimum = 0): number {
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
return invalid(label);
}
return value as number;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
return invalid(label);
}
return value;
}
function hash(value: unknown, label: string): string {
const digest = string(value, label);
if (!/^[0-9a-f]{64}$/.test(digest)) {
return invalid(label);
}
return digest;
}
function status(value: unknown): RunStatus {
if (value !== "success" && value !== "failed" && value !== "unstable") {
return invalid("status");
}
return value;
}
function documentSummary(value: unknown): DocumentSummary {
const item = record(value, "document summary");
const runStatus = status(item.status);
const currentKind = item.current_kind;
if (currentKind !== "success_output" && currentKind !== "partial_output") {
return invalid("current_kind");
}
if ((runStatus === "success") !== (currentKind === "success_output")) {
return invalid("document status/current_kind");
}
return {
document_id: string(item.document_id, "document_id"),
source_label: string(item.source_label, "source_label"),
status: runStatus,
current_kind: currentKind,
input_sha256: hash(item.input_sha256, "input_sha256"),
current_sha256: hash(item.current_sha256, "current_sha256"),
modifier_count: integer(item.modifier_count, "modifier_count"),
completed_stage_count: integer(item.completed_stage_count, "completed_stage_count"),
change_count: integer(item.change_count, "change_count"),
error_count: integer(item.error_count, "error_count"),
residual_proposal_count: integer(item.residual_proposal_count, "residual_proposal_count"),
};
}
function modifier(value: unknown): ModifierSummary {
const item = record(value, "modifier");
return {
modifier_position: integer(item.modifier_position, "modifier_position"),
modifier_id: string(item.modifier_id, "modifier_id"),
modifier_version: string(item.modifier_version, "modifier_version"),
parameters: item.parameters,
applicability: string(item.applicability, "applicability"),
change_count: integer(item.change_count, "modifier change_count"),
stage_available: boolean(item.stage_available, "stage_available"),
};
}
function range(value: unknown, label: string): { start: number; end: number } {
const item = record(value, label);
const start = integer(item.start, `${label}.start`);
const end = integer(item.end, `${label}.end`);
if (end < start) {
return invalid(label);
}
return { start, end };
}
function change(value: unknown): ChangeDetail {
const item = record(value, "change");
const location = record(item.location, "change location");
return {
modifier_position: integer(item.modifier_position, "change modifier_position"),
modifier_id: string(item.modifier_id, "change modifier_id"),
modifier_version: string(item.modifier_version, "change modifier_version"),
proposal_index: integer(item.proposal_index, "proposal_index"),
edit_index: integer(item.edit_index, "edit_index"),
reason: string(item.reason, "reason"),
location: {
line: integer(location.line, "location.line", 1),
column: integer(location.column, "location.column", 1),
},
span: range(item.span, "span"),
editor_range: range(item.editor_range, "editor_range"),
before: string(item.before, "before"),
after: string(item.after, "after"),
before_sha256: hash(item.before_sha256, "before_sha256"),
after_sha256: hash(item.after_sha256, "after_sha256"),
};
}
function runError(value: unknown): ErrorDetail {
const item = record(value, "run error");
return {
code: string(item.code, "error code"),
stage: string(item.stage, "error stage"),
modifier_position: integer(item.modifier_position, "error modifier_position"),
modifier_id: string(item.modifier_id, "error modifier_id"),
modifier_version: string(item.modifier_version, "error modifier_version"),
diagnostic_type: string(item.diagnostic_type, "diagnostic_type"),
message: string(item.message, "error message"),
};
}
function residual(value: unknown): ResidualProposalDetail {
const item = record(value, "residual proposal");
return {
modifier_position: integer(item.modifier_position, "residual modifier_position"),
modifier_id: string(item.modifier_id, "residual modifier_id"),
modifier_version: string(item.modifier_version, "residual modifier_version"),
proposal_index: integer(item.proposal_index, "residual proposal_index"),
snapshot_sha256: hash(item.snapshot_sha256, "residual snapshot_sha256"),
reason: string(item.reason, "residual reason"),
edits: array(item.edits, "residual edits"),
};
}
function parseCollection(value: unknown): CollectionSummaryResponse {
const payload = record(value, "collection response");
if (payload.schema_version !== 1) {
return invalid("collection schema_version");
}
const collection = record(payload.collection, "collection");
const summary = record(payload.summary, "summary");
return {
schema_version: 1,
collection: {
label: string(collection.label, "collection label"),
status: status(collection.status),
},
documents: array(payload.documents, "documents").map(documentSummary),
summary: {
document_count: integer(summary.document_count, "document_count"),
success_count: integer(summary.success_count, "success_count"),
failed_count: integer(summary.failed_count, "failed_count"),
unstable_count: integer(summary.unstable_count, "unstable_count"),
change_count: integer(summary.change_count, "change_count"),
},
};
}
function parseDocument(value: unknown): DocumentComparisonResponse {
const payload = record(value, "document response");
if (payload.schema_version !== 1) {
return invalid("document schema_version");
}
return {
schema_version: 1,
document: documentSummary(payload.document),
modifiers: array(payload.modifiers, "modifiers").map(modifier),
input_markdown: string(payload.input_markdown, "input_markdown"),
current_markdown: nullableString(payload.current_markdown, "current_markdown"),
changes: array(payload.changes, "changes").map(change),
errors: array(payload.errors, "errors").map(runError),
residual_proposals: array(payload.residual_proposals, "residual_proposals").map(residual),
};
}
function parseStage(value: unknown): ModifierStageResponse {
const payload = record(value, "modifier stage response");
if (payload.schema_version !== 1) {
return invalid("modifier stage schema_version");
}
return {
schema_version: 1,
document_id: string(payload.document_id, "document_id"),
modifier: modifier(payload.modifier),
before_sha256: hash(payload.before_sha256, "before_sha256"),
after_sha256: hash(payload.after_sha256, "after_sha256"),
before_markdown: string(payload.before_markdown, "before_markdown"),
after_markdown: string(payload.after_markdown, "after_markdown"),
changes: array(payload.changes, "changes").map(change),
};
}
async function getJson<T>(
pathname: string,
parse: (payload: unknown) => T,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(pathname, {
method: "GET",
cache: "no-store",
credentials: "same-origin",
signal,
});
const payload: unknown = await response.json();
if (!response.ok) {
const errorPayload = payload as Partial<ApiErrorResponse>;
throw new ReviewerApiError(
errorPayload.error?.code ?? "request_failed",
errorPayload.error?.message ?? `请求失败(HTTP ${response.status})。`,
);
}
return parse(payload);
}
export function fetchCollection(signal?: AbortSignal): Promise<CollectionSummaryResponse> {
return getJson("/api/v1/collection", parseCollection, signal);
}
export function fetchDocument(
documentId: string,
signal?: AbortSignal,
): Promise<DocumentComparisonResponse> {
return getJson(`/api/v1/documents/${encodeURIComponent(documentId)}`, parseDocument, signal);
}
export function fetchModifierStage(
documentId: string,
modifierPosition: number,
signal?: AbortSignal,
): Promise<ModifierStageResponse> {
return getJson(
`/api/v1/documents/${encodeURIComponent(documentId)}/modifiers/${modifierPosition}`,
parseStage,
signal,
);
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
const root = document.getElementById("root");
if (root === null) {
throw new Error("missing #root element");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+539
View File
@@ -0,0 +1,539 @@
:root {
color: #252720;
background: #ecebe5;
font-family:
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 1180px;
min-height: 100%;
margin: 0;
}
button {
color: inherit;
font: inherit;
}
button:focus-visible {
outline: 2px solid #315f4b;
outline-offset: 2px;
}
.app-shell {
min-height: 100vh;
background:
radial-gradient(circle at 12% 0%, rgb(255 255 255 / 72%), transparent 34%),
#ecebe5;
}
.topbar {
position: sticky;
z-index: 20;
top: 0;
display: flex;
min-height: 76px;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid #d7d5cd;
background: rgb(248 247 242 / 94%);
backdrop-filter: blur(18px);
}
.topbar > div:first-child {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 12px;
background: #284d3d;
color: #f3f4ed;
font-family: Georgia, serif;
font-size: 19px;
letter-spacing: -0.08em;
}
.eyebrow {
margin: 0 0 3px;
color: #78796f;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
.topbar h1 {
margin: 0;
font-family: Georgia, "Songti SC", serif;
font-size: 19px;
font-weight: 600;
}
.run-facts,
.document-meta {
display: flex;
align-items: center;
gap: 10px;
color: #66685f;
font-size: 12px;
}
.run-facts > span:not(.status),
.document-meta > span:not(.status) {
padding-left: 10px;
border-left: 1px solid #d5d2c9;
}
.status {
display: inline-flex;
align-items: center;
border: 1px solid currentcolor;
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
}
.status--success {
color: #277052;
background: #edf6ef;
}
.status--failed {
color: #a04338;
background: #fff0ed;
}
.status--unstable {
color: #986617;
background: #fff7df;
}
.layout {
display: grid;
min-height: calc(100vh - 76px);
grid-template-columns: 300px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 76px;
overflow-y: auto;
height: calc(100vh - 76px);
border-right: 1px solid #d7d5cd;
background: #f7f6f1;
}
.sidebar section {
padding: 20px 16px;
}
.sidebar section + section {
border-top: 1px solid #dfddd5;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-heading h2 {
margin: 0;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-heading > span {
color: #888980;
font-size: 11px;
}
.document-list,
.modifier-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.document-list button,
.modifier-list button {
display: grid;
width: 100%;
align-items: center;
border: 0;
border-radius: 9px;
background: transparent;
cursor: pointer;
text-align: left;
}
.document-list button {
grid-template-columns: 9px 1fr;
gap: 10px;
padding: 9px 10px;
}
.document-list button:hover,
.modifier-list button:hover:not(:disabled) {
background: #eceae2;
}
.document-list button.is-active,
.modifier-list button.is-active {
background: #e0e8e0;
color: #234b39;
}
.document-list strong,
.modifier-list strong {
display: block;
overflow: hidden;
font-size: 12px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-list small,
.modifier-list small {
display: block;
margin-top: 3px;
color: #7d7e75;
font-size: 10px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #999;
}
.status-dot--success {
background: #348361;
}
.status-dot--failed {
background: #b64b3f;
}
.status-dot--unstable {
background: #bd831c;
}
.text-button {
border: 0;
background: transparent;
color: #315f4b;
cursor: pointer;
font-size: 11px;
}
.text-button:disabled {
color: #aaa99f;
cursor: default;
}
.modifier-list button {
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
padding: 8px;
}
.modifier-list button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.modifier-index {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 1px solid #d3d1c8;
border-radius: 50%;
color: #74766e;
font-family: Georgia, serif;
font-size: 11px;
}
.workspace {
display: grid;
min-width: 0;
align-content: start;
gap: 14px;
padding: 18px 20px 30px;
}
.document-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.document-header h2 {
margin: 0;
font-family: Georgia, "Songti SC", serif;
font-size: 21px;
font-weight: 600;
}
.diff-shell,
.changes-panel,
.diagnostic-panel,
.state-panel {
overflow: hidden;
border: 1px solid #d5d3ca;
border-radius: 13px;
background: #fbfaf7;
box-shadow: 0 12px 36px rgb(55 57 48 / 7%);
}
.diff-shell {
min-height: 510px;
}
.diff-labels {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px solid #dcdbd3;
background: #f4f2ec;
color: #6f7168;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.diff-labels span {
padding: 9px 14px;
}
.diff-labels span + span {
border-left: 1px solid #dcdbd3;
}
.diff-host {
height: 510px;
}
.diff-host > .cm-mergeView {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.diff-host .cm-mergeViewEditors {
min-height: 100%;
}
.diff-host .cm-editor {
min-width: 0;
}
.changes-panel {
min-height: 120px;
}
.changes-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 15px 18px;
border-bottom: 1px solid #e0ded6;
}
.changes-heading h3 {
margin: 0;
font-size: 14px;
}
.changes-heading > p {
max-width: 58%;
margin: 0;
color: #77786f;
font-size: 11px;
line-height: 1.5;
}
.change-list {
display: grid;
max-height: 310px;
gap: 1px;
overflow-y: auto;
margin: 0;
padding: 0;
background: #e4e2da;
list-style: none;
}
.change-list button {
display: grid;
width: 100%;
grid-template-columns: 145px minmax(240px, 1fr) minmax(260px, 0.9fr);
align-items: center;
gap: 16px;
border: 0;
padding: 11px 18px;
background: #fbfaf7;
cursor: pointer;
text-align: left;
}
.change-list button:hover {
background: #f4f5ef;
}
.change-list button:disabled {
cursor: default;
}
.change-location {
color: #73756c;
font-size: 11px;
}
.change-list strong {
font-size: 12px;
font-weight: 600;
}
.change-sample {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 11px;
}
.change-sample del,
.change-sample ins {
overflow: hidden;
max-width: 46%;
border-radius: 4px;
padding: 2px 5px;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.change-sample del {
background: #f9ded9;
color: #913e34;
}
.change-sample ins {
background: #dcecdf;
color: #276348;
}
.quiet-message {
margin: 0;
padding: 22px 18px;
color: #77786f;
font-size: 12px;
}
.state-panel,
.diagnostic-panel {
padding: 28px;
}
.state-panel {
display: grid;
min-height: 180px;
place-items: center;
align-content: center;
color: #66685f;
}
.state-panel p {
margin: 8px 0 0;
}
.state-panel--error {
border-color: #e2b6ae;
color: #8f3e34;
}
.loading-dot {
width: 11px;
height: 11px;
border-radius: 50%;
background: #3d735b;
box-shadow: 0 0 0 7px #dce9df;
animation: pulse 1.25s ease-in-out infinite;
}
.diagnostic-panel h3 {
margin-bottom: 8px;
}
.diagnostic-panel > p:not(.eyebrow) {
color: #686a61;
font-size: 13px;
}
.diagnostic-panel article {
display: grid;
grid-template-columns: 220px 1fr;
gap: 12px;
padding: 10px 0;
border-top: 1px solid #e0ded6;
font-size: 12px;
}
@keyframes pulse {
0%,
100% {
opacity: 0.45;
transform: scale(0.82);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 1280px) {
.layout {
grid-template-columns: 270px minmax(0, 1fr);
}
.change-list button {
grid-template-columns: 125px minmax(180px, 1fr) minmax(220px, 0.8fr);
}
}
+115
View File
@@ -0,0 +1,115 @@
export type RunStatus = "success" | "failed" | "unstable";
export interface ModifierSummary {
modifier_position: number;
modifier_id: string;
modifier_version: string;
parameters: unknown;
applicability: string;
change_count: number;
stage_available: boolean;
}
export interface DocumentSummary {
document_id: string;
source_label: string;
status: RunStatus;
current_kind: "success_output" | "partial_output";
input_sha256: string;
current_sha256: string;
modifier_count: number;
completed_stage_count: number;
change_count: number;
error_count: number;
residual_proposal_count: number;
}
export interface CollectionSummaryResponse {
schema_version: 1;
collection: {
label: string;
status: RunStatus;
};
documents: DocumentSummary[];
summary: {
document_count: number;
success_count: number;
failed_count: number;
unstable_count: number;
change_count: number;
};
}
export interface ChangeDetail {
modifier_position: number;
modifier_id: string;
modifier_version: string;
proposal_index: number;
edit_index: number;
reason: string;
location: {
line: number;
column: number;
};
span: {
start: number;
end: number;
};
editor_range: {
start: number;
end: number;
};
before: string;
after: string;
before_sha256: string;
after_sha256: string;
}
export interface ErrorDetail {
code: string;
stage: string;
modifier_position: number;
modifier_id: string;
modifier_version: string;
diagnostic_type: string;
message: string;
}
export interface ResidualProposalDetail {
modifier_position: number;
modifier_id: string;
modifier_version: string;
proposal_index: number;
snapshot_sha256: string;
reason: string;
edits: unknown[];
}
export interface DocumentComparisonResponse {
schema_version: 1;
document: DocumentSummary;
modifiers: ModifierSummary[];
input_markdown: string;
current_markdown: string | null;
changes: ChangeDetail[];
errors: ErrorDetail[];
residual_proposals: ResidualProposalDetail[];
}
export interface ModifierStageResponse {
schema_version: 1;
document_id: string;
modifier: ModifierSummary;
before_sha256: string;
after_sha256: string;
before_markdown: string;
after_markdown: string;
changes: ChangeDetail[];
}
export interface ApiErrorResponse {
error: {
code: string;
message: string;
};
}
+235
View File
@@ -0,0 +1,235 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "../src/client/App.js";
import type {
CollectionSummaryResponse,
DocumentComparisonResponse,
ModifierStageResponse,
} from "../src/shared/api.js";
vi.mock("../src/client/DiffView.js", () => ({
DiffView: ({ beforeLabel, afterLabel }: { beforeLabel: string; afterLabel: string }) => (
<div data-testid="diff-view">
{beforeLabel} / {afterLabel}
</div>
),
}));
const modifiers = [
{
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
parameters: [],
applicability: "替换测试单词。",
change_count: 1,
stage_available: true,
},
{
modifier_position: 1,
modifier_id: "paper.zero",
modifier_version: "1.0.0",
parameters: [],
applicability: "不修改当前测试文档。",
change_count: 0,
stage_available: true,
},
];
const documentSummary = {
document_id: "paper",
source_label: "paper.md",
status: "success" as const,
current_kind: "success_output" as const,
input_sha256: "1".repeat(64),
current_sha256: "2".repeat(64),
modifier_count: 2,
completed_stage_count: 2,
change_count: 1,
error_count: 0,
residual_proposal_count: 0,
};
const collectionResponse: CollectionSummaryResponse = {
schema_version: 1,
collection: { label: "合成评审", status: "success" },
documents: [documentSummary],
summary: {
document_count: 1,
success_count: 1,
failed_count: 0,
unstable_count: 0,
change_count: 1,
},
};
const change = {
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
proposal_index: 0,
edit_index: 0,
reason: "替换测试单词",
location: { line: 1, column: 1 },
span: { start: 0, end: 3 },
editor_range: { start: 0, end: 3 },
before: "old",
after: "new",
before_sha256: "1".repeat(64),
after_sha256: "2".repeat(64),
};
const documentResponse: DocumentComparisonResponse = {
schema_version: 1,
document: documentSummary,
modifiers,
input_markdown: "old",
current_markdown: "new",
changes: [change],
errors: [],
residual_proposals: [],
};
const stageResponse: ModifierStageResponse = {
schema_version: 1,
document_id: "paper",
modifier: modifiers[0]!,
before_sha256: "1".repeat(64),
after_sha256: "2".repeat(64),
before_markdown: "old",
after_markdown: "new",
changes: [change],
};
function response(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("App", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("shows documents, Modifier order and the selected stage", async () => {
const fetchMock = vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (pathname === "/api/v1/collection") {
return Promise.resolve(response(collectionResponse));
}
if (pathname.endsWith("/modifiers/0")) {
return Promise.resolve(response(stageResponse));
}
return Promise.resolve(response(documentResponse));
});
vi.stubGlobal("fetch", fetchMock);
render(<App />);
expect(await screen.findByRole("heading", { name: "合成评审" })).toBeInTheDocument();
expect(await screen.findByTestId("diff-view")).toHaveTextContent("清洗前 / 清洗后");
expect(screen.getByText("paper.rule")).toBeInTheDocument();
expect(screen.getByText("paper.zero")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /paper\.rule/ }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("modifiers/0"), expect.anything());
});
expect(await screen.findByTestId("diff-view")).toHaveTextContent(
"Modifier 1 执行前 / Modifier 1 执行后",
);
});
it("clicks a change through its Modifier stage", async () => {
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (pathname === "/api/v1/collection") {
return Promise.resolve(response(collectionResponse));
}
if (pathname.endsWith("/modifiers/0")) {
return Promise.resolve(response(stageResponse));
}
return Promise.resolve(response(documentResponse));
}),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: /替换测试单词/ }));
expect(await screen.findByTestId("diff-view")).toHaveTextContent(
"Modifier 1 执行前 / Modifier 1 执行后",
);
});
it("shows failed diagnostics without a cleaned comparison", async () => {
const failedSummary = {
...documentSummary,
status: "failed" as const,
current_kind: "partial_output" as const,
current_sha256: documentSummary.input_sha256,
completed_stage_count: 0,
change_count: 0,
error_count: 1,
};
const failedCollection: CollectionSummaryResponse = {
...collectionResponse,
collection: { ...collectionResponse.collection, status: "failed" },
documents: [failedSummary],
summary: {
...collectionResponse.summary,
success_count: 0,
failed_count: 1,
change_count: 0,
},
};
const failedDocument: DocumentComparisonResponse = {
schema_version: 1,
document: failedSummary,
modifiers: modifiers.map((modifier) => ({
...modifier,
change_count: 0,
stage_available: false,
})),
input_markdown: "原文",
current_markdown: null,
changes: [],
errors: [
{
code: "run.transform_failed",
stage: "transform",
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
diagnostic_type: "SyntheticError",
message: "测试 Modifier 失败。",
},
],
residual_proposals: [],
};
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return Promise.resolve(
response(
pathname === "/api/v1/collection" ? failedCollection : failedDocument,
),
);
}),
);
render(<App />);
expect(await screen.findByRole("heading", { name: "失败文档只展示审计证据" })).toBeInTheDocument();
expect(screen.getByText("测试 Modifier 失败。")).toBeInTheDocument();
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { EditorView } from "@codemirror/view";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DiffView } from "../src/client/DiffView.js";
function leftPaneSelection(): { from: number; to: number } | null {
const pane = document.querySelector(".diff-host .cm-editor");
if (pane === null) {
return null;
}
const view = EditorView.findFromDOM(pane as HTMLElement);
if (view === null) {
return null;
}
const { from, to } = view.state.selection.main;
return { from, to };
}
describe("DiffView", () => {
it("keeps Markdown and raw HTML as inert editor text", () => {
render(
<DiffView
before={'# title\n<img src="https://example.com/private.png" onerror="alert(1)">'}
after={'# title\n<script>alert("x")</script>'}
beforeLabel="清洗前"
afterLabel="清洗后"
/>,
);
expect(screen.getByRole("region", { name: "清洗前与清洗后对比" })).toBeInTheDocument();
expect(document.querySelector("img")).toBeNull();
expect(document.querySelector("script")).toBeNull();
});
it("keeps long unchanged sections available in the full document view", () => {
const before = Array.from({ length: 30 }, (_, index) => `line ${index + 1}`);
const after = [...before];
after[14] = "changed line 15";
render(
<DiffView
before={before.join("\n")}
after={after.join("\n")}
beforeLabel="清洗前"
afterLabel="清洗后"
/>,
);
expect(document.querySelector(".cm-collapsedLines")).toBeNull();
expect(document.querySelector(".cm-mergeView")).toBeInTheDocument();
});
it("re-applies the focus selection after the compared texts change", () => {
const longText = (mark: string) =>
Array.from({ length: 30 }, (_, index) => (index === 14 ? mark : `line ${index + 1}`)).join("\n");
const { rerender } = render(
<DiffView before={longText("old")} after={longText("new")} beforeLabel="清洗前" afterLabel="清洗后" />,
);
rerender(
<DiffView
before={longText("old")}
after={longText("new")}
beforeLabel="Modifier 1 执行前"
afterLabel="Modifier 1 执行后"
focusRange={{ start: 58, end: 61 }}
/>,
);
rerender(
<DiffView
before={longText("stage before")}
after={longText("stage after")}
beforeLabel="Modifier 2 执行前"
afterLabel="Modifier 2 执行后"
focusRange={{ start: 58, end: 71 }}
/>,
);
expect(
screen.getByRole("region", { name: "Modifier 2 执行前与Modifier 2 执行后对比" }),
).toBeInTheDocument();
expect(leftPaneSelection()).toEqual({ from: 58, to: 71 });
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { fetchCollection } from "../src/client/api-client.js";
describe("API response validation", () => {
it("rejects a successful HTTP response with an unknown schema", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ schema_version: 2 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
),
);
await expect(fetchCollection()).rejects.toMatchObject({ code: "invalid_response" });
});
it("uses the server error instead of guessing a partial schema", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "unsupported_review_schema",
message: "reviewer 与 JSON schema 不匹配。",
},
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
),
),
);
await expect(fetchCollection()).rejects.toMatchObject({
code: "unsupported_review_schema",
message: "reviewer 与 JSON schema 不匹配。",
});
});
});
+6
View File
@@ -0,0 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => cleanup());
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node", "vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"]
},
"include": ["src", "tests", "vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
proxy: {
"/api": {
target: "http://127.0.0.1:4174",
changeOrigin: true,
configure(proxy) {
proxy.on("proxyReq", (request) => request.removeHeader("origin"));
},
},
},
},
build: {
outDir: "../src/mdpolish/_reviewer_static",
emptyOutDir: true,
},
test: {
environment: "jsdom",
setupFiles: "./tests/setup.ts",
css: true,
},
});