paperlint 6.2.3 → 7.0.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.
- package/CONTRIBUTING.md +4 -2
- package/README.md +10 -3
- package/dist/adapters/hotcrp/index.d.ts +7 -0
- package/dist/adapters/hotcrp/index.d.ts.map +1 -0
- package/dist/adapters/hotcrp/index.js +7 -0
- package/dist/adapters/hotcrp/index.js.map +1 -0
- package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
- package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
- package/dist/adapters/hotcrp/portal.io.js +90 -0
- package/dist/adapters/hotcrp/portal.io.js.map +1 -0
- package/dist/adapters/hotcrp/response.d.ts +13 -0
- package/dist/adapters/hotcrp/response.d.ts.map +1 -0
- package/dist/adapters/hotcrp/response.js +138 -0
- package/dist/adapters/hotcrp/response.js.map +1 -0
- package/dist/adapters/latex/conditionals.d.ts +21 -0
- package/dist/adapters/latex/conditionals.d.ts.map +1 -0
- package/dist/adapters/latex/conditionals.js +75 -0
- package/dist/adapters/latex/conditionals.js.map +1 -0
- package/dist/adapters/latex/headings.d.ts +10 -0
- package/dist/adapters/latex/headings.d.ts.map +1 -0
- package/dist/adapters/latex/headings.js +129 -0
- package/dist/adapters/latex/headings.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +3 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +3 -2
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -1
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +4 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +126 -37
- package/dist/cli.js.map +1 -1
- package/dist/domain/heading-case.d.ts +100 -0
- package/dist/domain/heading-case.d.ts.map +1 -0
- package/dist/domain/heading-case.js +365 -0
- package/dist/domain/heading-case.js.map +1 -0
- package/dist/domain/result.d.ts +4 -0
- package/dist/domain/result.d.ts.map +1 -1
- package/dist/domain/result.js +2 -0
- package/dist/domain/result.js.map +1 -1
- package/dist/domain/submission.d.ts +106 -0
- package/dist/domain/submission.d.ts.map +1 -0
- package/dist/domain/submission.js +39 -0
- package/dist/domain/submission.js.map +1 -0
- package/dist/domain/tex-document.d.ts +32 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js +11 -0
- package/dist/domain/tex-document.js.map +1 -1
- package/dist/domain/venue-name.d.ts +38 -0
- package/dist/domain/venue-name.d.ts.map +1 -0
- package/dist/domain/venue-name.js +71 -0
- package/dist/domain/venue-name.js.map +1 -0
- package/dist/eslint-rules/latex-language.d.ts.map +1 -1
- package/dist/eslint-rules/latex-language.js +16 -3
- package/dist/eslint-rules/latex-language.js.map +1 -1
- package/dist/facts-file.d.ts +3 -3
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +3 -3
- package/dist/facts-file.js.map +1 -1
- package/dist/folder-venue-rule.d.ts +51 -0
- package/dist/folder-venue-rule.d.ts.map +1 -0
- package/dist/folder-venue-rule.js +96 -0
- package/dist/folder-venue-rule.js.map +1 -0
- package/dist/heading-case.d.ts +48 -0
- package/dist/heading-case.d.ts.map +1 -0
- package/dist/heading-case.js +191 -0
- package/dist/heading-case.js.map +1 -0
- package/dist/init.d.ts +6 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +3 -3
- package/dist/init.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +5 -1
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/new-paper.d.ts +19 -5
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +23 -4
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +20 -4
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/latex.d.ts +7 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/ports/submission-portal.d.ts +19 -0
- package/dist/ports/submission-portal.d.ts.map +1 -0
- package/dist/ports/submission-portal.js +2 -0
- package/dist/ports/submission-portal.js.map +1 -0
- package/dist/preset-card-rule.d.ts +100 -0
- package/dist/preset-card-rule.d.ts.map +1 -0
- package/dist/preset-card-rule.js +221 -0
- package/dist/preset-card-rule.js.map +1 -0
- package/dist/presets.d.ts +43 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +130 -63
- package/dist/presets.js.map +1 -1
- package/dist/rules-config.js +1 -1
- package/dist/submission.d.ts +61 -0
- package/dist/submission.d.ts.map +1 -0
- package/dist/submission.js +211 -0
- package/dist/submission.js.map +1 -0
- package/dist/tex-requirements.d.ts +36 -3
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +30 -2
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +7 -8
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +10 -14
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/configuration.md +30 -11
- package/docs/optional-rules.md +4 -3
- package/docs/rules/format/page-limit.md +1 -1
- package/docs/rules/paper/folder-venue-leftover.md +104 -0
- package/docs/rules/talk/captions-cover.md +1 -1
- package/docs/rules/talk/duration-floor.md +1 -1
- package/docs/rules/talk/profile.md +1 -1
- package/docs/rules/tex/heading-case.md +424 -0
- package/docs/rules.md +64 -53
- package/docs/skills.md +5 -4
- package/docs/submission.md +64 -0
- package/docs/talk.md +1 -1
- package/eslint-rules/latex-language.test.mjs +12 -0
- package/eslint-rules/latex-language.ts +17 -4
- package/eslint-rules/paper-typography.test.mjs +12 -0
- package/eslint.config.mjs +59 -1
- package/fixtures/build-e2e/acmart/paper.tex +1 -1
- package/fixtures/build-e2e/fallback/expect.json +3 -3
- package/lib/paper-config.ts +6 -1
- package/lib/skill-checks.d.mts +7 -0
- package/lib/skill-checks.mjs +31 -0
- package/lib/skill-trigger-cases.mjs +11 -0
- package/package.json +8 -2
- package/presets/acm-sigconf.jsonc +5 -0
- package/presets/acm-sigconf.md +43 -0
- package/presets/agenticdev.jsonc +27 -6
- package/presets/agenticdev.md +151 -0
- package/presets/aidc.jsonc +12 -0
- package/presets/aidc.md +75 -0
- package/presets/aisec.jsonc +5 -1
- package/presets/aisec.md +117 -0
- package/presets/ieee-conference.jsonc +5 -0
- package/presets/ieee-conference.md +38 -0
- package/presets/realm.jsonc +7 -2
- package/presets/realm.md +166 -0
- package/presets/tex/agenticdev.tex +1 -1
- package/presets/tex-base.jsonc +6 -0
- package/presets/tex-base.md +30 -0
- package/presets/venue-profile.schema.json +57 -2
- package/skills/find-venue/SKILL.md +6 -5
- package/skills/find-venue/SKILL.md.spec.ts +5 -4
- package/skills/paper-pipeline/SKILL.md +5 -6
- package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
- package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
- package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
- package/skills/study-accepted-papers/SKILL.md +3 -3
- package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
- package/skills/submission-portal/SKILL.md +69 -0
- package/skills/submission-portal/SKILL.md.spec.ts +71 -0
- package/skills/submission-portal/submission-portal.eval.mjs +15 -0
- package/skills/submission-portal/submission-portal.test.ts +81 -0
- package/skills/submit-paper/SKILL.md +27 -22
- package/skills/submit-paper/SKILL.md.spec.ts +26 -21
- package/skills/submit-paper/references/publishers/acm.md +1 -1
- package/src/adapters/hotcrp/cassette/README.md +24 -0
- package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
- package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
- package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
- package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
- package/src/adapters/hotcrp/index.ts +11 -0
- package/src/adapters/hotcrp/portal.io.ts +130 -0
- package/src/adapters/hotcrp/portal.test.ts +503 -0
- package/src/adapters/hotcrp/response.ts +181 -0
- package/src/adapters/latex/conditionals.ts +130 -0
- package/src/adapters/latex/headings.ts +163 -0
- package/src/adapters/latex/index.test.ts +65 -0
- package/src/adapters/latex/index.ts +3 -0
- package/src/adapters/latex/parse.ts +3 -2
- package/src/build.ts +2 -2
- package/src/cli.test.ts +155 -3
- package/src/cli.ts +161 -25
- package/src/domain/heading-case.test.ts +660 -0
- package/src/domain/heading-case.ts +538 -0
- package/src/domain/result.test.ts +9 -1
- package/src/domain/result.ts +5 -0
- package/src/domain/submission.test.ts +45 -0
- package/src/domain/submission.ts +141 -0
- package/src/domain/tex-document.ts +36 -0
- package/src/domain/venue-name.test.ts +121 -0
- package/src/domain/venue-name.ts +129 -0
- package/src/facts-file.test.ts +4 -4
- package/src/facts-file.ts +6 -6
- package/src/folder-venue-rule.test.ts +182 -0
- package/src/folder-venue-rule.ts +141 -0
- package/src/heading-case.test.ts +500 -0
- package/src/heading-case.ts +275 -0
- package/src/init.test.ts +22 -0
- package/src/init.ts +9 -2
- package/src/new-paper.harness.mjs +11 -9
- package/src/new-paper.test.ts +135 -10
- package/src/new-paper.ts +38 -7
- package/src/paper-settings-commands.test.ts +25 -10
- package/src/paper-settings.test.ts +12 -1
- package/src/paper-settings.ts +25 -5
- package/src/parse-args.test.ts +19 -0
- package/src/ports/latex.ts +7 -0
- package/src/ports/submission-portal.ts +26 -0
- package/src/preset-card-rule.test.ts +266 -0
- package/src/preset-card-rule.ts +384 -0
- package/src/presets.test.ts +212 -37
- package/src/presets.ts +183 -71
- package/src/rule-cases.test.ts +147 -42
- package/src/rules-config.ts +1 -1
- package/src/submission.test.ts +497 -0
- package/src/submission.ts +311 -0
- package/src/talk-rules.test.ts +6 -3
- package/src/tex-requirements.harness.ts +25 -11
- package/src/tex-requirements.test.ts +127 -1
- package/src/tex-requirements.ts +78 -8
- package/src/tex-venue-rules.test.ts +36 -22
- package/src/tex-venue-rules.ts +14 -26
- package/src/types.ts +13 -0
- package/src/venue-rules.test.ts +7 -6
- package/templates/paper/paper.tex +1 -1
- package/skills/submit-paper/references/venues/agenticdev.md +0 -171
- package/skills/submit-paper/references/venues/aisec.md +0 -105
- package/skills/submit-paper/references/venues/realm.md +0 -155
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HotCRP's answers, parsed at the boundary into paperlint's submission values. Pure.
|
|
3
|
+
*
|
|
4
|
+
* Only the fields the command uses are read; every other key HotCRP sends is dropped here. The field
|
|
5
|
+
* names are HotCRP's (`devel/openapi.json` and `devel/apidoc/submissions.md` in kohler/hotcrp):
|
|
6
|
+
* `GET /api/paper` answers `{ ok, paper, message_list? }`, `POST /api/paper` answers
|
|
7
|
+
* `{ ok, valid, change_list, message_list, dry_run?, pid }`. A document's `hash` is
|
|
8
|
+
* `sha2-<64 hex>` when HotCRP stored a sha256; timestamps are Unix seconds.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { err, ok, type Result } from "../../domain/result.ts";
|
|
12
|
+
import { parseSha256, type Sha256 } from "../../domain/sha256.ts";
|
|
13
|
+
import type {
|
|
14
|
+
PortalDocument,
|
|
15
|
+
PortalFailure,
|
|
16
|
+
PortalMessage,
|
|
17
|
+
SubmissionView,
|
|
18
|
+
UpdateOutcome,
|
|
19
|
+
} from "../../domain/submission.ts";
|
|
20
|
+
|
|
21
|
+
const Message = z.object({
|
|
22
|
+
message: z.string().optional(),
|
|
23
|
+
field: z.string().optional(),
|
|
24
|
+
status: z.number().optional(),
|
|
25
|
+
});
|
|
26
|
+
const Messages = z.array(Message).optional();
|
|
27
|
+
|
|
28
|
+
const Document = z.object({
|
|
29
|
+
hash: z.string().optional(),
|
|
30
|
+
size: z.number().optional(),
|
|
31
|
+
mimetype: z.string().optional(),
|
|
32
|
+
timestamp: z.number().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const Paper = z.object({
|
|
36
|
+
pid: z.number(),
|
|
37
|
+
status: z.string(),
|
|
38
|
+
title: z.string().optional(),
|
|
39
|
+
abstract: z.string().optional(),
|
|
40
|
+
paper_type: z.unknown().optional(),
|
|
41
|
+
topics: z.array(z.string()).optional(),
|
|
42
|
+
submission: Document.nullable().optional(),
|
|
43
|
+
submitted_at: z.number().optional(),
|
|
44
|
+
modified_at: z.number().optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const ShowBody = z.object({
|
|
48
|
+
ok: z.literal(true),
|
|
49
|
+
paper: Paper,
|
|
50
|
+
message_list: Messages,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const UpdateBody = z.object({
|
|
54
|
+
valid: z.boolean(),
|
|
55
|
+
change_list: z.array(z.string()).optional(),
|
|
56
|
+
message_list: Messages,
|
|
57
|
+
dry_run: z.boolean().optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const Envelope = z.object({ message_list: Messages });
|
|
61
|
+
|
|
62
|
+
const SHA2 = /^sha2-([0-9a-f]{64})$/;
|
|
63
|
+
|
|
64
|
+
/** HotCRP's `sha2-<hex>` → the sha256; null for any other hash it may name. */
|
|
65
|
+
export function sha256OfHash(hash: string): Sha256 | null {
|
|
66
|
+
const hex = SHA2.exec(hash)?.[1];
|
|
67
|
+
return hex === undefined ? null : parseSha256(hex);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** HotCRP prefixes a message with its format (`<0>` plain text, `<5>` HTML); the text is the rest. */
|
|
71
|
+
const textOf = (message: string | undefined): string =>
|
|
72
|
+
(message ?? "").replace(/^<\d+>/, "").trim();
|
|
73
|
+
|
|
74
|
+
/** The messages that say something: HotCRP also sends entries with no text, which print as blanks. */
|
|
75
|
+
const messagesOf = (
|
|
76
|
+
list: Readonly<z.infer<typeof Messages>>,
|
|
77
|
+
): readonly PortalMessage[] =>
|
|
78
|
+
(list ?? [])
|
|
79
|
+
.map((m) => ({
|
|
80
|
+
message: textOf(m.message),
|
|
81
|
+
field: m.field ?? null,
|
|
82
|
+
status: m.status ?? null,
|
|
83
|
+
}))
|
|
84
|
+
.filter((m) => m.message !== "");
|
|
85
|
+
|
|
86
|
+
const documentOf = (
|
|
87
|
+
d: Readonly<z.infer<typeof Document>> | null | undefined,
|
|
88
|
+
): PortalDocument | null =>
|
|
89
|
+
d?.hash === undefined
|
|
90
|
+
? null
|
|
91
|
+
: {
|
|
92
|
+
sha256: sha256OfHash(d.hash),
|
|
93
|
+
hash: d.hash,
|
|
94
|
+
size: d.size ?? null,
|
|
95
|
+
mimetype: d.mimetype ?? null,
|
|
96
|
+
uploadedAt: d.timestamp ?? null,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** A paper type as text: HotCRP sends the option's name, or a value of another shape. */
|
|
100
|
+
const typeText = (v: unknown): string | null =>
|
|
101
|
+
v === undefined || v === null
|
|
102
|
+
? null
|
|
103
|
+
: typeof v === "string"
|
|
104
|
+
? v
|
|
105
|
+
: JSON.stringify(v);
|
|
106
|
+
|
|
107
|
+
function viewOf(b: Readonly<z.infer<typeof ShowBody>>): SubmissionView {
|
|
108
|
+
const p = b.paper;
|
|
109
|
+
return {
|
|
110
|
+
id: p.pid,
|
|
111
|
+
status: p.status,
|
|
112
|
+
title: p.title ?? null,
|
|
113
|
+
paperType: typeText(p.paper_type),
|
|
114
|
+
topics: p.topics ?? [],
|
|
115
|
+
abstract: p.abstract ?? null,
|
|
116
|
+
pdf: documentOf(p.submission),
|
|
117
|
+
submittedAt: p.submitted_at ?? null,
|
|
118
|
+
modifiedAt: p.modified_at ?? null,
|
|
119
|
+
messages: messagesOf(b.message_list),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const is2xx = (status: number): boolean => status >= 200 && status < 300;
|
|
124
|
+
|
|
125
|
+
const StatusCode = z.object({ status_code: z.number() });
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The status HotCRP meant. Its error envelope repeats the status in the body (`status_code: 401`
|
|
129
|
+
* beside `ok: false`, recorded in `cassette/get-noauth.json`); when something between us and HotCRP
|
|
130
|
+
* turns the transport status into a 2xx, the body's code still decides.
|
|
131
|
+
*/
|
|
132
|
+
function statusOf(transport: number, body: unknown): number {
|
|
133
|
+
const c = StatusCode.safeParse(body);
|
|
134
|
+
return is2xx(transport) && c.success ? c.data.status_code : transport;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A body that is no answer of HotCRP's: the portal's messages when it sent any, else "malformed". */
|
|
138
|
+
function refusal(
|
|
139
|
+
httpStatus: number,
|
|
140
|
+
body: unknown,
|
|
141
|
+
what: string,
|
|
142
|
+
): PortalFailure {
|
|
143
|
+
const e = Envelope.safeParse(body);
|
|
144
|
+
const messages = e.success ? messagesOf(e.data.message_list) : [];
|
|
145
|
+
return messages.length > 0 || !is2xx(httpStatus)
|
|
146
|
+
? { kind: "refused", httpStatus, messages }
|
|
147
|
+
: { kind: "malformed", httpStatus, detail: what };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `GET /api/paper` → the submission, or why there is none. */
|
|
151
|
+
export function parseShow(
|
|
152
|
+
httpStatus: number,
|
|
153
|
+
body: unknown,
|
|
154
|
+
): Result<SubmissionView, PortalFailure> {
|
|
155
|
+
const status = statusOf(httpStatus, body);
|
|
156
|
+
const b = ShowBody.safeParse(body);
|
|
157
|
+
return b.success && is2xx(status)
|
|
158
|
+
? ok(viewOf(b.data))
|
|
159
|
+
: err(refusal(status, body, "the answer has no submission in `paper`"));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `POST /api/paper` → the outcome, valid or not. A body carrying `valid` is HotCRP's verdict on
|
|
164
|
+
* the change whatever the HTTP status; the caller decides the exit from both.
|
|
165
|
+
*/
|
|
166
|
+
export function parseUpdate(
|
|
167
|
+
httpStatus: number,
|
|
168
|
+
body: unknown,
|
|
169
|
+
): Result<UpdateOutcome, PortalFailure> {
|
|
170
|
+
const status = statusOf(httpStatus, body);
|
|
171
|
+
const b = UpdateBody.safeParse(body);
|
|
172
|
+
if (!b.success)
|
|
173
|
+
return err(refusal(status, body, "the answer has no `valid`"));
|
|
174
|
+
return ok({
|
|
175
|
+
httpStatus: status,
|
|
176
|
+
valid: b.data.valid,
|
|
177
|
+
changes: b.data.change_list ?? [],
|
|
178
|
+
messages: messagesOf(b.data.message_list),
|
|
179
|
+
dryRun: b.data.dry_run ?? false,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `\iffalse … \fi`: text TeX never reads. Authors park old titles, sections and paragraphs there,
|
|
3
|
+
* and every reader of the tree — the outline, the rendered text, the headings the case judge reads,
|
|
4
|
+
* the includes — would otherwise take them for the paper. So the branch is cut out of the tree once,
|
|
5
|
+
* when the source is parsed (`parseLatex`), rather than pruned per walk like `hidden.ts` prunes: a
|
|
6
|
+
* walk can skip a node, but the rendered text reads the strings of a node list directly, and they
|
|
7
|
+
* would come back.
|
|
8
|
+
*
|
|
9
|
+
* What is cut: from `\iffalse` to its matching `\fi`, both included, counting the conditionals
|
|
10
|
+
* nested in between. An `\else` at the same depth ends the dead part: what follows it is read, and
|
|
11
|
+
* the matching `\fi` is cut. A conditional is a macro whose name starts with `if`, except
|
|
12
|
+
* `\ifthenelse` (the `ifthen` package's command, which takes arguments and has no `\fi`). An
|
|
13
|
+
* `\iffalse` with no `\fi` cuts to the end of its list, as TeX would read to the end of the file.
|
|
14
|
+
*
|
|
15
|
+
* Only `\iffalse` is decided. `\iftrue`, `\if\venue1` and every switch made with `\newif` depend on
|
|
16
|
+
* a value this reader does not compute, so both of their branches stay.
|
|
17
|
+
*/
|
|
18
|
+
import type * as Ast from "@unified-latex/unified-latex-types";
|
|
19
|
+
|
|
20
|
+
type Node = Readonly<Ast.Node>;
|
|
21
|
+
|
|
22
|
+
/** Where the scan of one node list stands: reading, inside a dead branch, or in its `\else`. */
|
|
23
|
+
type Mode =
|
|
24
|
+
| { readonly kind: "live" }
|
|
25
|
+
| { readonly kind: "dead"; readonly depth: number }
|
|
26
|
+
| { readonly kind: "else"; readonly depth: number };
|
|
27
|
+
|
|
28
|
+
/** One node against the mode before it: the mode after it, and whether the node is kept. */
|
|
29
|
+
interface Step {
|
|
30
|
+
readonly mode: Mode;
|
|
31
|
+
readonly keep: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LIVE: Mode = { kind: "live" };
|
|
35
|
+
|
|
36
|
+
const isMacro = (n: Node, name: string): boolean =>
|
|
37
|
+
n.type === "macro" && n.content === name;
|
|
38
|
+
|
|
39
|
+
/** A macro that opens a conditional TeX closes with `\fi`. */
|
|
40
|
+
const opensConditional = (n: Node): boolean =>
|
|
41
|
+
n.type === "macro" &&
|
|
42
|
+
n.content.startsWith("if") &&
|
|
43
|
+
n.content !== "ifthenelse";
|
|
44
|
+
|
|
45
|
+
/** Inside the dead branch: everything is cut; nesting is counted, `\else` at depth 1 ends it. */
|
|
46
|
+
function stepDead(depth: number, n: Node): Step {
|
|
47
|
+
if (opensConditional(n))
|
|
48
|
+
return { mode: { kind: "dead", depth: depth + 1 }, keep: false };
|
|
49
|
+
if (isMacro(n, "fi"))
|
|
50
|
+
return {
|
|
51
|
+
mode: depth === 1 ? LIVE : { kind: "dead", depth: depth - 1 },
|
|
52
|
+
keep: false,
|
|
53
|
+
};
|
|
54
|
+
if (isMacro(n, "else") && depth === 1)
|
|
55
|
+
return { mode: { kind: "else", depth }, keep: false };
|
|
56
|
+
return { mode: { kind: "dead", depth }, keep: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** In the `\else` branch: everything is kept but the `\fi` that closes the `\iffalse`. */
|
|
60
|
+
function stepElse(depth: number, n: Node): Step {
|
|
61
|
+
if (opensConditional(n))
|
|
62
|
+
return { mode: { kind: "else", depth: depth + 1 }, keep: true };
|
|
63
|
+
if (isMacro(n, "fi"))
|
|
64
|
+
return depth === 1
|
|
65
|
+
? { mode: LIVE, keep: false }
|
|
66
|
+
: { mode: { kind: "else", depth: depth - 1 }, keep: true };
|
|
67
|
+
return { mode: { kind: "else", depth }, keep: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function step(mode: Mode, n: Node): Step {
|
|
71
|
+
switch (mode.kind) {
|
|
72
|
+
case "live":
|
|
73
|
+
return isMacro(n, "iffalse")
|
|
74
|
+
? { mode: { kind: "dead", depth: 1 }, keep: false }
|
|
75
|
+
: { mode, keep: true };
|
|
76
|
+
case "dead":
|
|
77
|
+
return stepDead(mode.depth, n);
|
|
78
|
+
case "else":
|
|
79
|
+
return stepElse(mode.depth, n);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A node list without its `\iffalse` branches, each kept node cleaned the same way. */
|
|
84
|
+
// eslint-disable-next-line functional/prefer-immutable-types -- the tree's own content type is a mutable array
|
|
85
|
+
function liveList(nodes: readonly Node[]): Ast.Node[] {
|
|
86
|
+
const { kept } = nodes.reduce<{
|
|
87
|
+
readonly mode: Mode;
|
|
88
|
+
readonly kept: readonly Ast.Node[];
|
|
89
|
+
}>(
|
|
90
|
+
(acc, n) => {
|
|
91
|
+
const next = step(acc.mode, n);
|
|
92
|
+
return {
|
|
93
|
+
mode: next.mode,
|
|
94
|
+
kept: next.keep ? [...acc.kept, liveNode(n)] : acc.kept,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
{ mode: LIVE, kept: [] },
|
|
98
|
+
);
|
|
99
|
+
return [...kept];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// eslint-disable-next-line functional/prefer-immutable-types -- the tree's own argument type is mutable
|
|
103
|
+
const liveArgs = (args: readonly Readonly<Ast.Argument>[]): Ast.Argument[] =>
|
|
104
|
+
args.map((a) => ({ ...a, content: liveList(a.content) }));
|
|
105
|
+
|
|
106
|
+
/** A node with its content and its arguments' content cleaned; a node with neither, as it is. */
|
|
107
|
+
// eslint-disable-next-line functional/prefer-immutable-types -- the tree's own node type is mutable
|
|
108
|
+
function liveNode(n: Node): Ast.Node {
|
|
109
|
+
if (n.type === "macro")
|
|
110
|
+
return n.args === undefined ? n : { ...n, args: liveArgs(n.args) };
|
|
111
|
+
if (n.type === "environment" || n.type === "mathenv")
|
|
112
|
+
return {
|
|
113
|
+
...n,
|
|
114
|
+
content: liveList(n.content),
|
|
115
|
+
...(n.args === undefined ? {} : { args: liveArgs(n.args) }),
|
|
116
|
+
};
|
|
117
|
+
if (n.type === "group" || n.type === "inlinemath" || n.type === "displaymath")
|
|
118
|
+
return { ...n, content: liveList(n.content) };
|
|
119
|
+
return n;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The tree without what `\iffalse … \fi` hides; the same tree when the source has no `\iffalse`. */
|
|
123
|
+
export const withoutFalseBranches = (
|
|
124
|
+
src: string,
|
|
125
|
+
root: Readonly<Ast.Root>,
|
|
126
|
+
// eslint-disable-next-line functional/prefer-immutable-types -- `ParsedTex.root` is the tree's own mutable type
|
|
127
|
+
): Ast.Root =>
|
|
128
|
+
src.includes("\\iffalse")
|
|
129
|
+
? { ...root, content: liveList(root.content) }
|
|
130
|
+
: root;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paper's `\title` and the titles of its headings as the case judge reads them: each as one run
|
|
3
|
+
* of text, every character with the source offset it came from.
|
|
4
|
+
*
|
|
5
|
+
* What a title says is its strings and spaces, and the words a formatting command wraps
|
|
6
|
+
* (`\emph{very}` says "very"). A label, a footnote or an index entry says nothing in the title and
|
|
7
|
+
* is left out. Anything else — math, `\texttt`, `\cite`, `\textsc`, an accent, an environment — is
|
|
8
|
+
* something the reader sees and the judge does not read, so it stands as `OPAQUE`, one character,
|
|
9
|
+
* where it is: the judge then knows a word is first or last only when nothing stands before or
|
|
10
|
+
* after it, and never reads a word that touches one.
|
|
11
|
+
*/
|
|
12
|
+
import { hasAtLeast } from "remeda";
|
|
13
|
+
import type {
|
|
14
|
+
HeadingLevel,
|
|
15
|
+
Segment,
|
|
16
|
+
TitleArgument,
|
|
17
|
+
TitledCommand,
|
|
18
|
+
TitledHeading,
|
|
19
|
+
} from "../../domain/tex-document.ts";
|
|
20
|
+
import { LINE_BREAK, OPAQUE } from "../../domain/tex-document.ts";
|
|
21
|
+
import { isDefinition, isUnrendered } from "./hidden.ts";
|
|
22
|
+
import {
|
|
23
|
+
inPlace,
|
|
24
|
+
isNode,
|
|
25
|
+
mandatory,
|
|
26
|
+
optional,
|
|
27
|
+
placeOf,
|
|
28
|
+
stringText,
|
|
29
|
+
visited,
|
|
30
|
+
type Macro,
|
|
31
|
+
type Node,
|
|
32
|
+
} from "./nodes.ts";
|
|
33
|
+
import type { ParsedTex } from "./parse.ts";
|
|
34
|
+
|
|
35
|
+
const LEVELS: ReadonlyMap<string, HeadingLevel> = new Map<string, HeadingLevel>(
|
|
36
|
+
[
|
|
37
|
+
["section", "section"],
|
|
38
|
+
["subsection", "subsection"],
|
|
39
|
+
["subsubsection", "subsubsection"],
|
|
40
|
+
["paragraph", "paragraph"],
|
|
41
|
+
["subparagraph", "subparagraph"],
|
|
42
|
+
],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Formatting commands: they say what their argument says. */
|
|
46
|
+
const TRANSPARENT: ReadonlySet<string> = new Set([
|
|
47
|
+
"emph",
|
|
48
|
+
"textit",
|
|
49
|
+
"textbf",
|
|
50
|
+
"textsl",
|
|
51
|
+
"textup",
|
|
52
|
+
"textmd",
|
|
53
|
+
"textnormal",
|
|
54
|
+
"textsf",
|
|
55
|
+
"textrm",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** Commands that break the line: the title goes on as a second part, a subtitle. */
|
|
59
|
+
const LINE_BREAKS: ReadonlySet<string> = new Set([
|
|
60
|
+
"\\\\",
|
|
61
|
+
"newline",
|
|
62
|
+
"linebreak",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** Commands that put nothing in the title's words: a label, a footnote, an index entry. */
|
|
66
|
+
const INVISIBLE: ReadonlySet<string> = new Set([
|
|
67
|
+
"label",
|
|
68
|
+
"footnote",
|
|
69
|
+
"thanks",
|
|
70
|
+
"index",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The command a macro is, for the case judge: the paper's `\title`, a heading at its level, or none.
|
|
75
|
+
* `\title` is read wherever it stands — in the preamble, where acmart and IEEEtran put it, or in a
|
|
76
|
+
* file the paper includes.
|
|
77
|
+
*/
|
|
78
|
+
const commandOf = (m: Macro): readonly TitledCommand[] => {
|
|
79
|
+
if (m.content === "title") return [{ kind: "title" }];
|
|
80
|
+
const level = LEVELS.get(m.content);
|
|
81
|
+
return level === undefined ? [] : [{ kind: "heading", level }];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** A macro that is neither left out of the title nor read as the words it wraps. */
|
|
85
|
+
const isOpaqueMacro = (m: Macro): boolean =>
|
|
86
|
+
!INVISIBLE.has(m.content) &&
|
|
87
|
+
!TRANSPARENT.has(m.content) &&
|
|
88
|
+
!LINE_BREAKS.has(m.content);
|
|
89
|
+
|
|
90
|
+
/** What one macro says in a title. */
|
|
91
|
+
function macroSegments(m: Macro): readonly Segment[] {
|
|
92
|
+
if (INVISIBLE.has(m.content)) return [];
|
|
93
|
+
if (LINE_BREAKS.has(m.content)) return textAt(m, LINE_BREAK);
|
|
94
|
+
if (TRANSPARENT.has(m.content))
|
|
95
|
+
return segmentsOf(mandatory(m).at(-1)?.content);
|
|
96
|
+
return textAt(m, OPAQUE);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A piece of text at the start of a node. */
|
|
100
|
+
const textAt = (n: Node, text: string): readonly Segment[] =>
|
|
101
|
+
inPlace(placeOf(n), (s) => [{ text, at: s.start }]);
|
|
102
|
+
|
|
103
|
+
/** What one node says in a title: text as itself, a formatting command's words, anything else opaque. */
|
|
104
|
+
function nodeSegments(n: Node): readonly Segment[] {
|
|
105
|
+
if (n.type === "string") return textAt(n, stringText(n.content));
|
|
106
|
+
if (n.type === "whitespace" || n.type === "parbreak") return textAt(n, " ");
|
|
107
|
+
// A comment ends its line: the words on either side of it are two words.
|
|
108
|
+
if (n.type === "comment") return textAt(n, " ");
|
|
109
|
+
if (n.type === "group") return segmentsOf(n.content);
|
|
110
|
+
if (n.type === "macro") return macroSegments(n);
|
|
111
|
+
return textAt(n, OPAQUE);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A group that is the very next node after a command the parser has no signature for (`\ours{…}`, a macro of the
|
|
116
|
+
* author's own): the parser leaves such a group beside the macro, but TeX hands it to the macro as an
|
|
117
|
+
* argument, so it is part of what the macro prints and not words of the title. A chain of groups
|
|
118
|
+
* (`\findingdes{label}{a sentence}`) is all argument.
|
|
119
|
+
*/
|
|
120
|
+
function isArgumentGroup(nodes: readonly Node[], n: Node, i: number): boolean {
|
|
121
|
+
const prev = nodes[i - 1];
|
|
122
|
+
if (prev === undefined || n.type !== "group") return false;
|
|
123
|
+
return prev.type === "macro"
|
|
124
|
+
? isOpaqueMacro(prev)
|
|
125
|
+
: isArgumentGroup(nodes, prev, i - 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const segmentsOf = (nodes: readonly Node[] | undefined): readonly Segment[] => {
|
|
129
|
+
const list = nodes ?? [];
|
|
130
|
+
return list.flatMap((n, i) =>
|
|
131
|
+
isArgumentGroup(list, n, i) ? [] : nodeSegments(n),
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** One argument's contents → the heading it makes, or none when it says nothing. */
|
|
136
|
+
function titled(
|
|
137
|
+
command: TitledCommand,
|
|
138
|
+
argument: TitleArgument,
|
|
139
|
+
nodes: readonly Node[] | undefined,
|
|
140
|
+
): readonly TitledHeading[] {
|
|
141
|
+
const segments = segmentsOf(nodes);
|
|
142
|
+
return hasAtLeast(segments, 1)
|
|
143
|
+
? [{ command, argument, title: { segments } }]
|
|
144
|
+
: [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One titled command → the short title (`[…]`) and the title (the last `{…}`), each judged. */
|
|
148
|
+
const headingsOfMacro = (m: Macro): readonly TitledHeading[] =>
|
|
149
|
+
commandOf(m).flatMap((command) => [
|
|
150
|
+
...titled(command, "short", optional(m)?.content),
|
|
151
|
+
...titled(command, "title", mandatory(m).at(-1)?.content),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The paper's `\title` and every `\section`, `\subsection`, `\subsubsection`, `\paragraph` and
|
|
156
|
+
* `\subparagraph`, starred or not, each with its optional short title, in document order. What the
|
|
157
|
+
* PDF never shows is pruned (`isUnrendered`), and so is a macro definition's body: a heading in a
|
|
158
|
+
* comment environment or in a definition is not a heading where it is written.
|
|
159
|
+
*/
|
|
160
|
+
export const headingsOf = (t: ParsedTex): readonly TitledHeading[] =>
|
|
161
|
+
visited(t.root, (v, parent) => isUnrendered(v, parent) || isDefinition(v))
|
|
162
|
+
.filter(isNode)
|
|
163
|
+
.flatMap((n) => (n.type === "macro" ? headingsOfMacro(n) : []));
|
|
@@ -324,6 +324,71 @@ describe("outlineOf — a heading that is never rendered is not in the outline",
|
|
|
324
324
|
});
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
+
describe("\\iffalse … \\fi — a branch TeX never reads is not in the tree any reader walks", () => {
|
|
328
|
+
const dead = [
|
|
329
|
+
"A",
|
|
330
|
+
"\\iffalse",
|
|
331
|
+
"\\section{Old Title}",
|
|
332
|
+
"dead words",
|
|
333
|
+
"\\ifx\\a\\b nested\\fi",
|
|
334
|
+
"still dead",
|
|
335
|
+
"\\fi",
|
|
336
|
+
"B",
|
|
337
|
+
].join("\n");
|
|
338
|
+
const doc = (body: string) => `\\begin{document}\n${body}\n\\end{document}`;
|
|
339
|
+
|
|
340
|
+
it("🔴 outline, headings and rendered text skip it, nested conditionals included", () => {
|
|
341
|
+
const t = parseLatex(doc(dead));
|
|
342
|
+
expect(outlineOf(t).sections).toEqual([]);
|
|
343
|
+
expect(latexReader.headings(doc(dead))).toEqual([]);
|
|
344
|
+
// Guards: the dead strings are gone from the list itself, so A and B are one run.
|
|
345
|
+
expect(
|
|
346
|
+
renderedRuns(t).map((r) => runText(r).replace(/\s+/gu, " ").trim()),
|
|
347
|
+
).toEqual(["A B"]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("the \\else branch of an \\iffalse is read, and the text after its \\fi", () => {
|
|
351
|
+
const src = doc(
|
|
352
|
+
"\\iffalse\n\\section{Old}\n\\else\n\\section{New}\n\\fi\n\\section{After}",
|
|
353
|
+
);
|
|
354
|
+
expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
|
|
355
|
+
"New",
|
|
356
|
+
"After",
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("inside a group too, and `\\ifthenelse` (which has no \\fi) does not swallow the rest", () => {
|
|
361
|
+
const src = doc(
|
|
362
|
+
"{\\iffalse\\section{Old}\\ifthenelse{x}{y}{z}\\fi}\\section{Real}",
|
|
363
|
+
);
|
|
364
|
+
expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
|
|
365
|
+
"Real",
|
|
366
|
+
]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("inside an environment with arguments, in math, and an \\else holding its own conditional", () => {
|
|
370
|
+
const src = doc(
|
|
371
|
+
[
|
|
372
|
+
"\\begin{minipage}{4cm}\\iffalse\\section{Old}\\fi\\section{Kept}\\end{minipage}",
|
|
373
|
+
"$x \\iffalse y \\fi$ \\[ z \\] \\begin{align*} a \\end{align*}",
|
|
374
|
+
"\\iffalse\\section{Old}\\else\\ifx\\a\\b\\section{Inner}\\fi\\section{New}\\fi",
|
|
375
|
+
].join("\n"),
|
|
376
|
+
);
|
|
377
|
+
expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
|
|
378
|
+
"Kept",
|
|
379
|
+
"Inner",
|
|
380
|
+
"New",
|
|
381
|
+
]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("an \\iffalse that is never closed leaves out everything after it, as TeX does", () => {
|
|
385
|
+
const src = doc("\\section{Real}\n\\iffalse\n\\section{Old}");
|
|
386
|
+
expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
|
|
387
|
+
"Real",
|
|
388
|
+
]);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
327
392
|
describe("renderedRuns", () => {
|
|
328
393
|
it("runs of strings and spaces, each character mapped to its source offset", () => {
|
|
329
394
|
const src = "A~B \\emph{C D}\n\n E";
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
parseTemplate,
|
|
27
27
|
replaceDocumentClass,
|
|
28
28
|
} from "./document-class.ts";
|
|
29
|
+
import { headingsOf } from "./headings.ts";
|
|
29
30
|
import { documentBodyOf, includesOf } from "./includes.ts";
|
|
30
31
|
import { outlineOf } from "./outline.ts";
|
|
31
32
|
import { parseLatex } from "./parse.ts";
|
|
@@ -48,6 +49,7 @@ export {
|
|
|
48
49
|
textOf,
|
|
49
50
|
} from "./nodes.ts";
|
|
50
51
|
export { documentBodyOf, includesOf } from "./includes.ts";
|
|
52
|
+
export { headingsOf } from "./headings.ts";
|
|
51
53
|
export { outlineOf } from "./outline.ts";
|
|
52
54
|
export { parseLatex, type ParsedTex } from "./parse.ts";
|
|
53
55
|
export { bodyEmphasis, bodyProse } from "./prose.ts";
|
|
@@ -58,6 +60,7 @@ export { layoutOverridesOf } from "./layout.ts";
|
|
|
58
60
|
export const latexReader: LatexReader = {
|
|
59
61
|
documentClass: (src) => documentClassOf(parseLatex(src)),
|
|
60
62
|
outline: (src) => outlineOf(parseLatex(src)),
|
|
63
|
+
headings: (src) => headingsOf(parseLatex(src)),
|
|
61
64
|
renderedRuns: (src) => renderedRuns(parseLatex(src)),
|
|
62
65
|
bodyProse: (src) => bodyProse(parseLatex(src)),
|
|
63
66
|
bodyEmphasis: (src) => bodyEmphasis(parseLatex(src)),
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type * as Ast from "@unified-latex/unified-latex-types";
|
|
13
13
|
import { getParser } from "@unified-latex/unified-latex-util-parse";
|
|
14
14
|
import { once } from "remeda";
|
|
15
|
+
import { withoutFalseBranches } from "./conditionals.ts";
|
|
15
16
|
|
|
16
17
|
const signed = (
|
|
17
18
|
names: readonly string[],
|
|
@@ -133,9 +134,9 @@ const parser = once(() =>
|
|
|
133
134
|
}),
|
|
134
135
|
);
|
|
135
136
|
|
|
136
|
-
/** A LaTeX source → its tree. */
|
|
137
|
+
/** A LaTeX source → its tree, without the branches `\iffalse … \fi` hides (`conditionals.ts`). */
|
|
137
138
|
export const parseLatex = (src: string): ParsedTex => ({
|
|
138
139
|
src,
|
|
139
|
-
root: parser().parse(src),
|
|
140
|
+
root: withoutFalseBranches(src, parser().parse(src)),
|
|
140
141
|
[PARSED]: true,
|
|
141
142
|
});
|
package/src/build.ts
CHANGED
|
@@ -121,7 +121,7 @@ export interface PaperFacts {
|
|
|
121
121
|
readonly name: string;
|
|
122
122
|
readonly options: readonly string[];
|
|
123
123
|
} | null;
|
|
124
|
-
/** The
|
|
124
|
+
/** The id of the venue preset `paperlint.json` extends (`agenticdev`), or null. */
|
|
125
125
|
readonly venue: string | null;
|
|
126
126
|
/** Paper-supplied build scripts found on disk — reported as ignored. */
|
|
127
127
|
readonly ignoredScripts: readonly string[];
|
|
@@ -239,7 +239,7 @@ export function readFacts(paperDir: string): PaperFacts {
|
|
|
239
239
|
return {
|
|
240
240
|
main,
|
|
241
241
|
documentclass: documentclassOf(ast),
|
|
242
|
-
venue: venue?.
|
|
242
|
+
venue: venue?.id ?? null,
|
|
243
243
|
ignoredScripts: IGNORED_SCRIPTS.filter((s) =>
|
|
244
244
|
existsSync(join(paperDir, s)),
|
|
245
245
|
),
|