papilot 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +16 -4
  2. package/README.zh-CN.md +15 -3
  3. package/bin/doctor.mjs +239 -0
  4. package/bin/papilot.mjs +96 -87
  5. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/assets/index-CqhlgnOu.css +1 -0
  65. package/dist/assets/index-Czf1eCno.js +185 -0
  66. package/dist/assets/math-DEcVZfaU.css +1 -0
  67. package/dist/assets/math-Ebyrobf9.js +263 -0
  68. package/dist/assets/{pdf-view-DJo0X38w.js → pdf-view-BhJZr4qw.js} +18 -18
  69. package/dist/index.html +11 -2
  70. package/examples/papilot-paper/preamble.tex +17 -11
  71. package/examples/papilot-paper/sections/02-using.tex +3 -3
  72. package/package.json +12 -2
  73. package/scripts/service.ts +8 -2
  74. package/skills/check-math.md +12 -0
  75. package/skills/consistency.md +11 -0
  76. package/skills/latex-warnings.md +12 -0
  77. package/skills/polish.md +13 -0
  78. package/skills/proofread.md +12 -0
  79. package/skills/reviewer-response.md +11 -0
  80. package/skills/shorten.md +12 -0
  81. package/src/conversation.ts +37 -0
  82. package/src/notes.ts +5 -0
  83. package/src/server/agents.ts +4 -3
  84. package/src/server/codex.ts +2 -2
  85. package/src/server/main.ts +21 -7
  86. package/src/server/paths.ts +2 -1
  87. package/src/server/review.ts +1 -1
  88. package/src/server/service-environment.ts +22 -0
  89. package/src/server/skills.ts +57 -0
  90. package/src/server/workspace.ts +43 -19
  91. package/src/shared.ts +10 -2
  92. package/vendor/latex-review/dist/web/assets/index-Db7pq4Cj.css +1 -0
  93. package/vendor/latex-review/dist/web/assets/index-DqkrTw0t.js +19 -0
  94. package/vendor/latex-review/dist/web/index.html +2 -2
  95. package/vendor/latex-review/src/client.ts +10 -3
  96. package/vendor/latex-review/src/core/build.ts +53 -7
  97. package/vendor/latex-review/src/core/outline.ts +6 -3
  98. package/vendor/latex-review/src/core/publish.ts +8 -6
  99. package/vendor/latex-review/src/runtime.ts +14 -0
  100. package/vendor/latex-review/src/server.ts +2 -1
  101. package/vendor/latex-review/src/types.ts +16 -1
  102. package/vendor/latex-review/src/web/ChangeMap.tsx +21 -9
  103. package/vendor/latex-review/src/web/PdfStrip.tsx +5 -1
  104. package/vendor/latex-review/src/web/i18n.ts +48 -0
  105. package/vendor/latex-review/src/web/model.ts +23 -4
  106. package/vendor/latex-review/src/web/overview.tsx +25 -20
  107. package/vendor/latex-review/src/web/review.tsx +387 -143
  108. package/vendor/latex-review/src/web/style.css +106 -0
  109. package/vendor/latex-review/src/web/zh-CN.ts +186 -0
  110. package/dist/assets/index-BhQFCMTx.js +0 -131
  111. package/dist/assets/index-CAV3-87U.css +0 -1
  112. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +0 -1
  113. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +0 -19
@@ -9,8 +9,8 @@
9
9
  rel="icon"
10
10
  href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232b6e5c'/%3E%3Cpath d='M4 8.5l2.5 2.5L12 5' stroke='white' stroke-width='2' fill='none'/%3E%3C/svg%3E"
11
11
  />
12
- <script type="module" crossorigin src="/assets/index-HxBK-dls.js"></script>
13
- <link rel="stylesheet" crossorigin href="/assets/index-DyzYnIGk.css">
12
+ <script type="module" crossorigin src="/assets/index-DqkrTw0t.js"></script>
13
+ <link rel="stylesheet" crossorigin href="/assets/index-Db7pq4Cj.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { ROOT, codeVersion } from "./runtime.ts";
4
+ import { ROOT, codeVersion, environmentVersion } from "./runtime.ts";
5
5
  import { Repo } from "./core/git.ts";
6
6
  import { readJson, sleep, withLock } from "./core/files.ts";
7
7
  export interface Connection {
@@ -14,6 +14,7 @@ export interface Connection {
14
14
  export interface ServerInfo {
15
15
  root: string;
16
16
  version: string;
17
+ environment?: string;
17
18
  pid: number;
18
19
  busy: boolean;
19
20
  csrf: string;
@@ -153,7 +154,8 @@ export async function connect(
153
154
  root: string,
154
155
  start = true,
155
156
  ): Promise<Client | null> {
156
- root = path.resolve(root);
157
+ // Match the canonical repository path reported by the server (e.g. macOS /var).
158
+ root = fs.realpathSync(root);
157
159
  const home = serverHome(root);
158
160
  return withLock(path.join(home, "start.lock"), async () => {
159
161
  const last = readJson<{ port: number } | null>(
@@ -167,7 +169,12 @@ export async function connect(
167
169
  if (!info && last)
168
170
  info = { url: `http://127.0.0.1:${last.port}`, port: last.port, pid: 0 };
169
171
  let answer = await probe(root, info);
170
- if (answer && (!start || answer.version === codeVersion()))
172
+ if (
173
+ answer &&
174
+ (!start ||
175
+ (answer.version === codeVersion() &&
176
+ answer.environment === environmentVersion()))
177
+ )
171
178
  return new Client(info!.url, answer.csrf);
172
179
  if (!start) return null;
173
180
  if (answer && info) {
@@ -59,21 +59,63 @@ export function affectedTargets(
59
59
  return [...selected].sort();
60
60
  }
61
61
  export function errors(text: string) {
62
- const lines = text.split("\n"),
62
+ const lines = text.split(/\r?\n/),
63
63
  result: string[] = [];
64
+ const startsError = (line: string) =>
65
+ /^(\S[^\n]*):(\d+): (.+)$/.test(line) || line.startsWith("! ");
64
66
  for (const [i, line] of lines.entries())
65
- if (/^(\S[^:\n]*):(\d+): (.+)$/.test(line) || line.startsWith("! ")) {
67
+ if (startsError(line)) {
68
+ let message = line,
69
+ last = line;
70
+ // TeX hard-wraps even in the middle of words ("P\nackage"). Keep
71
+ // continuations, but stop before help text, source context or a new error.
72
+ let end = i + 1;
73
+ for (; end < lines.length && end < i + 20; end++) {
74
+ const next = lines[end];
75
+ if (
76
+ !next.trim() ||
77
+ startsError(next) ||
78
+ /^(?:See |Type |l\.\d+ |\s*\.\.\.|\s*<)/.test(next)
79
+ )
80
+ break;
81
+ message += (last.length >= 78 ? "" : " ") + next;
82
+ last = next;
83
+ }
66
84
  const context = lines
67
- .slice(i + 1, i + 8)
85
+ .slice(end, end + 12)
68
86
  .find((l) => /^l\.\d+ /.test(l))
69
87
  ?.trim();
70
88
  result.push(
71
- line.replace(/^! |^\.\//, "") + (context ? ` [${context}]` : ""),
89
+ message
90
+ .replace(/^! |^\.\//, "")
91
+ .replace(/\s+/g, " ")
92
+ .trim() + (context ? ` [${context}]` : ""),
72
93
  );
73
94
  if (result.length === 8) break;
74
95
  }
75
96
  return result;
76
97
  }
98
+ export function compilerDiagnostics(log: string, transcript: string) {
99
+ const details = errors(log);
100
+ // Errors from shell-escape helpers go to latexmk's stderr, not the TeX log.
101
+ for (const traceback of transcript
102
+ .split("Traceback (most recent call last):")
103
+ .slice(1)) {
104
+ const exception = traceback
105
+ .split(/\r?\n/)
106
+ .find((line) => /^[\w.]+(?:Error|Exception): /.test(line));
107
+ if (!exception) continue;
108
+ const minted = traceback
109
+ .slice(0, traceback.indexOf(exception))
110
+ .includes("latexminted");
111
+ details.push((minted ? "latexminted: " : "") + exception);
112
+ if (minted && exception.includes("unexpected keyword argument 'color'"))
113
+ details.push(
114
+ "latexminted is incompatible with this Python version. Update latexminted or select a compatible Python on PATH, then restart the service and retry.",
115
+ );
116
+ }
117
+ return [...new Set(details)];
118
+ }
77
119
  export function warnings(text: string) {
78
120
  const patterns: [RegExp, string][] = [
79
121
  [/LaTeX Warning: Reference `([^']+)' on page/g, "Undefined reference: "],
@@ -247,11 +289,15 @@ export class Builder {
247
289
  log = path.join(out, job + ".log"),
248
290
  logText = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "";
249
291
  if (failure || !fs.existsSync(pdf)) {
250
- const details = errors(logText),
292
+ const transcriptText = fs.readFileSync(transcript, "utf8"),
293
+ details = compilerDiagnostics(logText, transcriptText),
251
294
  kept = path.join(p.state, "logs", key + ".log");
252
295
  fs.mkdirSync(path.dirname(kept), { recursive: true });
253
- fs.copyFileSync(fs.existsSync(log) ? log : transcript, kept);
254
- const tail = fs.readFileSync(transcript, "utf8").split("\n").slice(-25);
296
+ fs.writeFileSync(
297
+ kept,
298
+ transcriptText + (logText ? "\n\n--- TeX log ---\n" + logText : ""),
299
+ );
300
+ const tail = transcriptText.split("\n").slice(-25);
255
301
  fs.rmSync(folder, { recursive: true, force: true });
256
302
  throw new BuildError(
257
303
  failure?.message.includes("ENOENT")
@@ -4,12 +4,13 @@ import type { Heading, Ranges, SectionRef, Span } from "../types.ts";
4
4
  const LEVELS: Record<string, number> = {
5
5
  part: 0,
6
6
  chapter: 1,
7
+ addchap: 1,
7
8
  section: 2,
8
9
  subsection: 3,
9
10
  subsubsection: 4,
10
11
  };
11
12
  const COMMAND =
12
- /^\s*\\(part|chapter|section|subsection|subsubsection|input|include|subfile|appendix|mainmatter|frontmatter|backmatter)\b(\*)?/;
13
+ /^\s*\\(part|chapter|addchap|section|subsection|subsubsection|input|include|subfile|appendix|mainmatter|frontmatter|backmatter)\b(\*)?/;
13
14
  export const stripComments = (text: string) =>
14
15
  text.replace(/(?<!\\)%[^\n]*/g, "");
15
16
  export function sourceLines(text: string) {
@@ -190,7 +191,9 @@ export function buildOutline(
190
191
  ":" +
191
192
  createHash("sha256").update(`${file}:${i}`).digest("hex").slice(0, 8);
192
193
  used.add(id);
193
- const isNumbered = !match[2] && (numbered || level === 0);
194
+ // KOMA-Script's addchap never advances the chapter counter.
195
+ const isNumbered =
196
+ command !== "addchap" && !match[2] && (numbered || level === 0);
194
197
  if (isNumbered) {
195
198
  counters[level]++;
196
199
  if (level) counters.fill(0, level + 1);
@@ -198,7 +201,7 @@ export function buildOutline(
198
201
  const node: Draft = {
199
202
  id,
200
203
  title: plainTitle(value),
201
- kind: command,
204
+ kind: command === "addchap" ? "chapter" : command,
202
205
  level,
203
206
  file,
204
207
  line: i + 1,
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
- import { ROOT } from "../runtime.ts";
4
+ import { ROOT, codeVersion } from "../runtime.ts";
5
5
  import { Builder, BuildError } from "./build.ts";
6
6
  import { diffUnits, scopeViolations } from "./changes.ts";
7
7
  import { isFigure } from "./config.ts";
@@ -218,7 +218,8 @@ export class Publisher {
218
218
  if (
219
219
  latest &&
220
220
  latest.before === before &&
221
- app.repo.treeId(latest.after) === app.repo.treeId(after)
221
+ app.repo.treeId(latest.after) === app.repo.treeId(after) &&
222
+ latest.summary.renderer === codeVersion()
222
223
  )
223
224
  return {
224
225
  outcome: "unchanged",
@@ -242,9 +243,9 @@ export class Publisher {
242
243
  } catch (error) {
243
244
  if (error instanceof BuildError)
244
245
  throw new PublishError(
245
- "The accepted text does not compile, so there is nothing to compare against.",
246
+ "The comparison baseline could not be compiled.",
246
247
  error.details,
247
- "Fix the accepted document or undo the change that broke it, then publish again.",
248
+ "Check the compiler diagnostics, fix the document or build environment, then publish again.",
248
249
  error.log,
249
250
  );
250
251
  throw error;
@@ -254,9 +255,9 @@ export class Publisher {
254
255
  } catch (error) {
255
256
  if (error instanceof BuildError)
256
257
  throw new PublishError(
257
- "Your edits do not compile.",
258
+ "The revised document could not be compiled.",
258
259
  error.details,
259
- "Fix the LaTeX errors in the worktree, then publish again.",
260
+ "Check the compiler diagnostics, fix the document or build environment, then publish again.",
260
261
  error.log,
261
262
  );
262
263
  throw error;
@@ -270,6 +271,7 @@ export class Publisher {
270
271
  output = final + ".pending-" + randomBytes(4).toString("hex");
271
272
  fs.mkdirSync(output, { recursive: true });
272
273
  const summary = {
274
+ renderer: codeVersion(),
273
275
  warnings,
274
276
  targets,
275
277
  builds: [seed, old, next].filter((b) => b !== null).map((b) => b.key),
@@ -5,6 +5,20 @@ import { createHash } from "node:crypto";
5
5
  import { listFiles } from "./core/files.ts";
6
6
  export const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
7
7
  export const WEB = path.join(ROOT, "dist/web");
8
+ export function environmentVersion(env: NodeJS.ProcessEnv = process.env) {
9
+ const keys = [
10
+ "PATH",
11
+ "PYTHONHOME",
12
+ "PYTHONPATH",
13
+ "BIBINPUTS",
14
+ "BSTINPUTS",
15
+ ...Object.keys(env).filter((key) => key.startsWith("TEX")),
16
+ ].sort();
17
+ return createHash("sha256")
18
+ .update(JSON.stringify(keys.map((key) => [key, env[key] ?? null])))
19
+ .digest("hex")
20
+ .slice(0, 16);
21
+ }
8
22
  let version: string | undefined;
9
23
  export function codeVersion() {
10
24
  if (!version) {
@@ -4,7 +4,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import Fastify from "fastify";
5
5
  import staticFiles from "@fastify/static";
6
6
  import compress from "@fastify/compress";
7
- import { WEB, codeVersion } from "./runtime.ts";
7
+ import { WEB, codeVersion, environmentVersion } from "./runtime.ts";
8
8
  import { Repo } from "./core/git.ts";
9
9
  import { CONFIG_NAME, loadProject, type Project } from "./core/config.ts";
10
10
  import { Store } from "./core/store.ts";
@@ -118,6 +118,7 @@ export function createServer(app: App) {
118
118
  server.get("/api/info", () => ({
119
119
  root: app.root,
120
120
  version: codeVersion(),
121
+ environment: environmentVersion(),
121
122
  pid: process.pid,
122
123
  busy: app.publisher.busy(),
123
124
  build_workers: app.project.build_workers,
@@ -69,7 +69,12 @@ export interface Round {
69
69
  before: string;
70
70
  after: string;
71
71
  units: Unit[];
72
- summary: { warnings: string[]; targets: string[]; builds: string[] };
72
+ summary: {
73
+ warnings: string[];
74
+ targets: string[];
75
+ builds: string[];
76
+ renderer?: string;
77
+ };
73
78
  created: number;
74
79
  }
75
80
  export type ActionKind = "accept" | "reject" | "comment";
@@ -246,6 +251,16 @@ export interface PageState {
246
251
  /** Optional host-provided reason that file decisions or comments are unavailable. */
247
252
  decisionLock?: string | null;
248
253
  commentLock?: string | null;
254
+ /**
255
+ * Host activity, undelivered feedback (all updates, and the comments among them),
256
+ * and how many manuscript files this task's accepted changes touched.
257
+ */
258
+ workspace?: {
259
+ status: string;
260
+ unsent: number;
261
+ comments?: number;
262
+ accepted?: number;
263
+ };
249
264
  state: TaskState | null;
250
265
  latest: number | null;
251
266
  round: number | null;
@@ -1,12 +1,14 @@
1
1
  import { useEffect, useRef, useState, type RefObject } from "react";
2
2
  import type { Decision } from "../types.ts";
3
- import { stopState, type Stop } from "./model.ts";
3
+ import { STATE, pendingUnits, stopState, type Stop } from "./model.ts";
4
+ import { t } from "./i18n.ts";
4
5
 
5
6
  type Region = { top: number; height: number };
6
7
 
7
8
  export function ChangeMap({
8
9
  scrollRoot,
9
10
  stops,
11
+ showReviewed = true,
10
12
  locations,
11
13
  decisions,
12
14
  current,
@@ -15,6 +17,7 @@ export function ChangeMap({
15
17
  }: {
16
18
  scrollRoot: RefObject<HTMLElement | null>;
17
19
  stops: Stop[];
20
+ showReviewed?: boolean;
18
21
  locations: string[];
19
22
  decisions: Record<string, Decision>;
20
23
  current: number;
@@ -25,6 +28,10 @@ export function ChangeMap({
25
28
  drag = useRef<{ pointer: number; offset: number } | null>(null),
26
29
  [regions, setRegions] = useState<Region[]>([]),
27
30
  [viewport, setViewport] = useState<Region>({ top: 0, height: 0 });
31
+ const visible = stops
32
+ .map((stop, index) => ({ stop, index }))
33
+ .filter(({ stop }) => showReviewed || pendingUnits(stop, decisions).length)
34
+ .map(({ index }) => index);
28
35
 
29
36
  useEffect(() => {
30
37
  const root = scrollRoot.current,
@@ -125,7 +132,7 @@ export function ChangeMap({
125
132
  <nav
126
133
  ref={rail}
127
134
  className="change-map"
128
- aria-label="Change map"
135
+ aria-label={t("Change map")}
129
136
  onPointerDown={(event) => {
130
137
  if (
131
138
  event.button !== 0 ||
@@ -165,10 +172,11 @@ export function ChangeMap({
165
172
  aria-hidden="true"
166
173
  />
167
174
  {stops.map((stop, index) => {
175
+ if (!visible.includes(index)) return null;
168
176
  const region = regions[index];
169
177
  if (!region) return null;
170
178
  const state = stopState(stop.units, decisions),
171
- label = `Change ${index + 1} · ${state} · ${locations[index]}`;
179
+ label = `${t("Change {n}", { n: index + 1 })} · ${t(STATE[state] ?? state).toLowerCase()} · ${locations[index]}`;
172
180
  return (
173
181
  <button
174
182
  key={stop.key}
@@ -178,22 +186,26 @@ export function ChangeMap({
178
186
  data-state={state}
179
187
  aria-label={label}
180
188
  aria-current={index === current ? "location" : undefined}
181
- tabIndex={index === Math.max(0, current) ? 0 : -1}
189
+ tabIndex={
190
+ index === (visible.includes(current) ? current : visible[0])
191
+ ? 0
192
+ : -1
193
+ }
182
194
  disabled={disabled}
183
195
  style={{ top: region.top, height: region.height }}
184
196
  onClick={() => onSelect(index)}
185
197
  onKeyDown={(event) => {
186
198
  const target =
187
199
  event.key === "ArrowDown"
188
- ? index + 1
200
+ ? (visible[visible.indexOf(index) + 1] ?? index)
189
201
  : event.key === "ArrowUp"
190
- ? index - 1
202
+ ? (visible[visible.indexOf(index) - 1] ?? index)
191
203
  : event.key === "Home"
192
- ? 0
204
+ ? visible[0]
193
205
  : event.key === "End"
194
- ? stops.length - 1
206
+ ? visible.at(-1)
195
207
  : null;
196
- if (target === null) return;
208
+ if (target == null) return;
197
209
  event.preventDefault();
198
210
  event.stopPropagation();
199
211
  rail.current
@@ -1,5 +1,6 @@
1
1
  import { memo, useMemo, type CSSProperties } from "react";
2
2
  import type { Strip, TextGlyph } from "../types.ts";
3
+ import { t } from "./i18n.ts";
3
4
  let context: CanvasRenderingContext2D | null | undefined;
4
5
  const widths = new Map<string, number>();
5
6
  function width(text: string) {
@@ -67,7 +68,10 @@ export const PdfStrip = memo(function PdfStrip({
67
68
  <div
68
69
  className="pdf-text"
69
70
  role="region"
70
- aria-label={`${label}, PDF page ${image.label}`}
71
+ aria-label={t("{label}, PDF page {page}", {
72
+ label,
73
+ page: image.label,
74
+ })}
71
75
  >
72
76
  {text}
73
77
  </div>
@@ -0,0 +1,48 @@
1
+ import { zhCN } from "./zh-CN.ts";
2
+
3
+ // The viewer reads in English unless the page asks for another language with
4
+ // ?lang=zh-CN (Papilot passes its interface language). The English text is the
5
+ // message key, so a string without a translation still reads correctly.
6
+ export type Language = "en" | "zh-CN";
7
+ const messages: Record<Language, Record<string, string>> = {
8
+ en: {},
9
+ "zh-CN": zhCN,
10
+ };
11
+ export function pageLanguage(search = location.search): Language {
12
+ return new URLSearchParams(search).get("lang")?.toLowerCase().startsWith("zh")
13
+ ? "zh-CN"
14
+ : "en";
15
+ }
16
+ const current: Language =
17
+ typeof location === "undefined" ? "en" : pageLanguage();
18
+ if (typeof document !== "undefined") document.documentElement.lang = current;
19
+
20
+ export const language = () => current;
21
+ // Dates and numbers follow the interface language.
22
+ export const locale = () => (current === "zh-CN" ? "zh-CN" : "en-US");
23
+
24
+ // Links inside the viewer keep the page's language.
25
+ export function withLanguage(href: string) {
26
+ return current === "en"
27
+ ? href
28
+ : `${href}${href.includes("?") ? "&" : "?"}lang=${current}`;
29
+ }
30
+
31
+ // t("Round {round}", { round }) fills {placeholders} after translating.
32
+ export function t(text: string, values?: Record<string, string | number>) {
33
+ const template = messages[current][text] ?? text;
34
+ return values
35
+ ? template.replace(/\{(\w+)\}/g, (match, name: string) =>
36
+ name in values ? String(values[name]) : match,
37
+ )
38
+ : template;
39
+ }
40
+ // English picks between one and other by count; {n} is the count.
41
+ export function tn(
42
+ count: number,
43
+ one: string,
44
+ other: string,
45
+ values?: Record<string, string | number>,
46
+ ) {
47
+ return t(count === 1 ? one : other, { n: count, ...values });
48
+ }
@@ -38,6 +38,14 @@ export function nextPending(
38
38
  }
39
39
  return -1;
40
40
  }
41
+ // Names for a change's state; the viewer translates them where it shows them.
42
+ export const STATE: Record<string, string> = {
43
+ pending: "Pending",
44
+ accepted: "Accepted",
45
+ rejected: "Rejected",
46
+ commented: "Commented",
47
+ mixed: "Mixed",
48
+ };
41
49
  export function stopState(
42
50
  units: string[],
43
51
  decisions: Record<string, Decision>,
@@ -122,14 +130,24 @@ export function wordDiff(before: string, after: string) {
122
130
  }
123
131
  return result;
124
132
  }
125
- export function describeLocation(units: (Unit & Partial<VisualUnit>)[]) {
133
+ type Say = (text: string, values?: Record<string, string | number>) => string;
134
+ const english: Say = (text, values = {}) =>
135
+ text.replace(/\{(\w+)\}/g, (match, name: string) =>
136
+ name in values ? String(values[name]) : match,
137
+ );
138
+ // The viewer passes its translator for display; copied references and the
139
+ // default stay in English.
140
+ export function describeLocation(
141
+ units: (Unit & Partial<VisualUnit>)[],
142
+ say: Say = english,
143
+ ) {
126
144
  const files = new Map<string, string[]>();
127
145
  for (const unit of units) {
128
146
  const where =
129
147
  unit.status === "added"
130
- ? "new file"
148
+ ? say("new file")
131
149
  : unit.status === "deleted"
132
- ? "deleted"
150
+ ? say("deleted")
133
151
  : unit.new_start === unit.new_end
134
152
  ? String(unit.new_start)
135
153
  : `${unit.new_start}–${unit.new_end}`;
@@ -139,7 +157,8 @@ export function describeLocation(units: (Unit & Partial<VisualUnit>)[]) {
139
157
  return (
140
158
  [...files]
141
159
  .map(([file, places]) => `${file}:${places.join(", ")}`)
142
- .join("; ") + (pages.length ? ` · p. ${pages.join(", ")}` : "")
160
+ .join("; ") +
161
+ (pages.length ? ` · ${say("p. {pages}", { pages: pages.join(", ") })}` : "")
143
162
  );
144
163
  }
145
164
  export function copyReference(
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import type { Summary } from "../types.ts";
3
+ import { t, tn, withLanguage } from "./i18n.ts";
3
4
  export const STATUS: Record<string, string> = {
4
5
  editing: "Editing",
5
6
  reviewing: "In review",
@@ -45,34 +46,36 @@ export function Overview() {
45
46
  return (
46
47
  <>
47
48
  <header className="bar">
48
- <a className="brand" href="/">
49
+ <a className="brand" href={withLanguage("/")}>
49
50
  LaTeX Review
50
51
  </a>
51
- <span className="status">{tasks.length} open reviews</span>
52
+ <span className="status">
53
+ {tn(tasks.length, "{n} open review", "{n} open reviews")}
54
+ </span>
52
55
  </header>
53
56
  <main id="paper" className="index">
54
57
  <div className="document">
55
58
  <div className="overview-heading">
56
- <h1>All reviews</h1>
59
+ <h1>{t("All reviews")}</h1>
57
60
  </div>
58
61
  <div className="task-tools">
59
62
  <input
60
63
  type="search"
61
- aria-label="Search reviews"
62
- placeholder="Search by title or task name"
64
+ aria-label={t("Search reviews")}
65
+ placeholder={t("Search by title or task name")}
63
66
  value={search}
64
67
  onChange={(e) => setSearch(e.target.value)}
65
68
  />
66
69
  <select
67
- aria-label="Filter reviews"
70
+ aria-label={t("Filter reviews")}
68
71
  value={filter}
69
72
  onChange={(e) => setFilter(e.target.value)}
70
73
  >
71
- <option value="all">All tasks</option>
72
- <option value="reviewing">Awaiting your review</option>
73
- <option value="building">Publishing</option>
74
- <option value="revise">Awaiting revision</option>
75
- <option value="done">All decided</option>
74
+ <option value="all">{t("All tasks")}</option>
75
+ <option value="reviewing">{t("Awaiting your review")}</option>
76
+ <option value="building">{t("Publishing")}</option>
77
+ <option value="revise">{t("Awaiting revision")}</option>
78
+ <option value="done">{t("All decided")}</option>
76
79
  </select>
77
80
  </div>
78
81
  {error && <p role="alert">{error}</p>}
@@ -87,20 +90,20 @@ export function Overview() {
87
90
  <li key={task.task}>
88
91
  <a
89
92
  className="task-card"
90
- href={`/?task=${encodeURIComponent(task.task)}`}
93
+ href={withLanguage(`/?task=${encodeURIComponent(task.task)}`)}
91
94
  >
92
95
  <div className="task-heading">
93
96
  <h2>{task.title}</h2>
94
97
  <span className="status" data-state={task.state}>
95
- {STATUS[task.state]}
98
+ {t(STATUS[task.state])}
96
99
  </span>
97
100
  </div>
98
101
  <div className="task-meta">
99
102
  <span className="task-name">{task.task}</span>
100
103
  <span>
101
104
  {task.round
102
- ? `Round ${task.round}`
103
- : "Not published yet"}
105
+ ? t("Round {round}", { round: task.round })
106
+ : t("Not published yet")}
104
107
  </span>
105
108
  </div>
106
109
  {total > 0 && (
@@ -109,7 +112,7 @@ export function Overview() {
109
112
  <i style={{ width: `${(100 * decided) / total}%` }} />
110
113
  </span>
111
114
  <span>
112
- {decided} of {total} decided
115
+ {t("{decided} of {total} decided", { decided, total })}
113
116
  </span>
114
117
  </div>
115
118
  )}
@@ -119,12 +122,12 @@ export function Overview() {
119
122
  .filter(([, n]) => n)
120
123
  .map(([kind, n]) => (
121
124
  <span key={kind} data-kind={kind}>
122
- {n} {kind}
125
+ {t(`{n} ${kind}`, { n })}
123
126
  </span>
124
127
  ))}
125
128
  </div>
126
129
  <span className="task-open">
127
- {task.counts.pending ? "Review →" : "Open →"}
130
+ {task.counts.pending ? t("Review →") : t("Open →")}
128
131
  </span>
129
132
  </div>
130
133
  </a>
@@ -135,8 +138,10 @@ export function Overview() {
135
138
  {!visible.length && (
136
139
  <p className="empty">
137
140
  {tasks.length
138
- ? "No reviews match this filter."
139
- : "No open reviews yet. Start a task from your document repository."}
141
+ ? t("No reviews match this filter.")
142
+ : t(
143
+ "No open reviews yet. Start a task from your document repository.",
144
+ )}
140
145
  </p>
141
146
  )}
142
147
  </div>