paperlint 6.3.0 → 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 +2 -2
- 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 +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +17 -3
- 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/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/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.js +6 -6
- package/dist/folder-venue-rule.js.map +1 -1
- 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/ports/latex.d.ts +7 -1
- package/dist/ports/latex.d.ts.map +1 -1
- 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 +19 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +97 -62
- package/dist/presets.js.map +1 -1
- package/dist/tex-requirements.d.ts +33 -3
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +27 -2
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +1 -1
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js.map +1 -1
- package/docs/configuration.md +13 -1
- package/docs/optional-rules.md +4 -3
- package/docs/rules/format/page-limit.md +1 -1
- package/docs/rules/paper/folder-venue-leftover.md +1 -1
- package/docs/rules/talk/profile.md +1 -1
- package/docs/rules/tex/heading-case.md +424 -0
- package/docs/rules.md +62 -53
- 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/skill-checks.mjs +31 -0
- package/package.json +7 -1
- 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 +6 -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 +40 -1
- 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/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/submit-paper/SKILL.md +26 -22
- package/skills/submit-paper/SKILL.md.spec.ts +25 -21
- package/skills/submit-paper/references/publishers/acm.md +1 -1
- 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 +50 -7
- package/src/cli.ts +21 -4
- package/src/domain/heading-case.test.ts +660 -0
- package/src/domain/heading-case.ts +538 -0
- package/src/domain/tex-document.ts +36 -0
- package/src/domain/venue-name.test.ts +13 -7
- package/src/facts-file.test.ts +4 -4
- package/src/facts-file.ts +6 -6
- package/src/folder-venue-rule.test.ts +10 -10
- package/src/folder-venue-rule.ts +6 -6
- package/src/heading-case.test.ts +500 -0
- package/src/heading-case.ts +275 -0
- package/src/init.test.ts +1 -1
- package/src/new-paper.test.ts +10 -6
- package/src/paper-settings-commands.test.ts +25 -10
- package/src/ports/latex.ts +7 -0
- package/src/preset-card-rule.test.ts +266 -0
- package/src/preset-card-rule.ts +384 -0
- package/src/presets.test.ts +193 -40
- package/src/presets.ts +139 -70
- package/src/rule-cases.test.ts +136 -50
- package/src/submission.test.ts +2 -1
- 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 +70 -8
- package/src/tex-venue-rules.test.ts +35 -21
- package/src/tex-venue-rules.ts +2 -1
- 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,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HEADING CASE — what a venue's capitalization style asks of the words of the paper's title and of
|
|
3
|
+
* its headings, decided on the text the LaTeX adapter hands over (`TitledHeading`), never on the
|
|
4
|
+
* source.
|
|
5
|
+
*
|
|
6
|
+
* ── A STYLE IS NAMED AFTER THE TEXT THAT DEFINES IT ──────────────────────────────
|
|
7
|
+
* Venues name the authority precisely and the scope loosely: «headline-style capitalization
|
|
8
|
+
* (according to the Chicago Manual of Style …)» for «the title and all headings». Headline variants
|
|
9
|
+
* disagree on real words — Chicago lowercases `With`, `Between` and `From` «regardless of length»,
|
|
10
|
+
* APA title case and IEEE's manual capitalize them — so there is no style called just «headline»:
|
|
11
|
+
* one word would let a preset apply Chicago where a venue asks APA. A new variant is a new member of
|
|
12
|
+
* `CaseStyle`, added with the venue text that defines it and its own word table.
|
|
13
|
+
*
|
|
14
|
+
* ── CHICAGO-HEADLINE, THE PUBLISHER'S WORDS ──────────────────────────────────────
|
|
15
|
+
* Conference Publishing Consulting's quote of the Chicago Manual of Style 8.157–8.159 (the text is
|
|
16
|
+
* quoted on the rule's page): capitalize the first and the last word, the first word after a colon,
|
|
17
|
+
* and every major word (nouns, pronouns, verbs, adjectives, adverbs); lowercase articles,
|
|
18
|
+
* prepositions «regardless of length», the conjunctions and, but, for, or, nor, and «to» and «as»; in
|
|
19
|
+
* a hyphenated compound always capitalize the first element and lowercase the others when they are
|
|
20
|
+
* an article, a preposition or a conjunction, or when the first is a prefix that cannot stand by
|
|
21
|
+
* itself.
|
|
22
|
+
*
|
|
23
|
+
* ── SILENCE IS THE DEFAULT ───────────────────────────────────────────────────────
|
|
24
|
+
* A finding here is an error at a venue that rejects on it, and a wrong one costs more than a miss.
|
|
25
|
+
* So every word gets one of four classes, and only two of them can be wrong:
|
|
26
|
+
*
|
|
27
|
+
* lower what the publisher lists, and prepositions that are nothing else: lowercase between the
|
|
28
|
+
* first and the last word, capitalized at either end and after a colon
|
|
29
|
+
* major every word not in a list — nouns, verbs, adjectives, adverbs, pronouns: capitalized
|
|
30
|
+
* either a word that is a preposition in one sentence and a particle, an adverb or a subordinating
|
|
31
|
+
* conjunction in another (`up`, `down`, `over`, `out`, `off`, `on`, `in`, `through`, `if`,
|
|
32
|
+
* `while`, `that`…): capitalized at the ends and after a colon, whatever it is; anywhere
|
|
33
|
+
* else not read, in either case, and never fixed
|
|
34
|
+
* name the particle of a surname (`von`, `de`): never read
|
|
35
|
+
*
|
|
36
|
+
* What the rule cannot tell from a word is not read at all: a word with a capital inside (`LLM`,
|
|
37
|
+
* `GitHub`, `iOS`), a single letter, anything with a digit, a dot, a slash or an accent in it, a
|
|
38
|
+
* piece followed by a full stop before the title ends (`al.`, `etc.`, `vs.`), the abbreviations
|
|
39
|
+
* `et al etc cf eg ie approx viz`, a word whose capital is not one letter (a ligature, `fi`), and
|
|
40
|
+
* everything `OPAQUE` in the title (math, code, citations). A word right beside such a thing is
|
|
41
|
+
* not known to be first or last, so a minor word there is left as it is.
|
|
42
|
+
*
|
|
43
|
+
* A title comes in parts — a subtitle after a colon, a question mark, a dash or a line break
|
|
44
|
+
* (`LINE_BREAK`). The first word of a part is a start: a minor word there is not asked either way.
|
|
45
|
+
* The last word of a part is capitalized by Chicago and may be a particle (_What Is It Good For:_),
|
|
46
|
+
* so a minor word there is not asked either way either.
|
|
47
|
+
*/
|
|
48
|
+
import {
|
|
49
|
+
LINE_BREAK,
|
|
50
|
+
runText,
|
|
51
|
+
type HeadingLevel,
|
|
52
|
+
type Span,
|
|
53
|
+
type TitledCommand,
|
|
54
|
+
type TitledHeading,
|
|
55
|
+
} from "./tex-document.ts";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A capitalization style, named after the text that defines it. Add a member only with a venue's
|
|
59
|
+
* quote and a word table of its own.
|
|
60
|
+
*
|
|
61
|
+
* chicago-headline Conference Publishing's quote of the Chicago Manual of Style 8.157–8.159
|
|
62
|
+
* sentence the first word capitalized; nothing else judged, never fixed (proper nouns)
|
|
63
|
+
* any this scope is unconstrained
|
|
64
|
+
*/
|
|
65
|
+
export type CaseStyle = "chicago-headline" | "sentence" | "any";
|
|
66
|
+
|
|
67
|
+
/** Every style, in the order a message lists them. */
|
|
68
|
+
export const CASE_STYLES: readonly CaseStyle[] = [
|
|
69
|
+
"chicago-headline",
|
|
70
|
+
"sentence",
|
|
71
|
+
"any",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Every heading level, in the order of the outline. */
|
|
75
|
+
export const HEADING_LEVELS: readonly HeadingLevel[] = [
|
|
76
|
+
"section",
|
|
77
|
+
"subsection",
|
|
78
|
+
"subsubsection",
|
|
79
|
+
"paragraph",
|
|
80
|
+
"subparagraph",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What a venue asks, in the two scopes venues speak in: the paper's title, and every heading — with
|
|
85
|
+
* a level the venue names on its own as an exception.
|
|
86
|
+
*/
|
|
87
|
+
export interface HeadingCaseOptions {
|
|
88
|
+
/** `\title{…}`, and its optional short title `\title[short]{long}`. */
|
|
89
|
+
readonly title: CaseStyle;
|
|
90
|
+
/** Every heading command, starred or not, short title included. */
|
|
91
|
+
readonly headings: CaseStyle;
|
|
92
|
+
/** A level whose style differs from `headings`. */
|
|
93
|
+
readonly levels?: Readonly<Partial<Record<HeadingLevel, CaseStyle>>>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The style a title is judged by: the title's own, or its level's, else every heading's. */
|
|
97
|
+
export function styleFor(
|
|
98
|
+
options: HeadingCaseOptions,
|
|
99
|
+
command: TitledCommand,
|
|
100
|
+
): CaseStyle {
|
|
101
|
+
switch (command.kind) {
|
|
102
|
+
case "title":
|
|
103
|
+
return options.title;
|
|
104
|
+
case "heading":
|
|
105
|
+
return options.levels?.[command.level] ?? options.headings;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** An edit that makes a finding go away: replace `span` of the source with `text`. */
|
|
110
|
+
export interface CaseFix {
|
|
111
|
+
readonly span: Span;
|
|
112
|
+
readonly text: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One word that is not in the case the style asks. `fix` is null where the edit is not certain. */
|
|
116
|
+
export interface CaseFinding {
|
|
117
|
+
readonly messageId: "capitalize" | "lowercase" | "sentenceFirst";
|
|
118
|
+
readonly data: Readonly<Record<string, string>>;
|
|
119
|
+
readonly at: Span;
|
|
120
|
+
readonly fix: CaseFix | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type Kind = "lower" | "major" | "either" | "name";
|
|
124
|
+
|
|
125
|
+
const kinds = (
|
|
126
|
+
kind: Kind,
|
|
127
|
+
words: string,
|
|
128
|
+
): readonly (readonly [string, Kind])[] =>
|
|
129
|
+
words.split(" ").map((w) => [w, kind] as const);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The words that are not «major». Articles, the conjunctions the publisher names, «to» and «as»,
|
|
133
|
+
* and prepositions that are used as nothing else in a title. A word on the `either` list may be
|
|
134
|
+
* an adverb or a particle («break through», «scale up») and is never read mid-title.
|
|
135
|
+
*/
|
|
136
|
+
const KINDS: ReadonlyMap<string, Kind> = new Map([
|
|
137
|
+
...kinds("lower", "the a an and but for or nor to as"),
|
|
138
|
+
...kinds(
|
|
139
|
+
"lower",
|
|
140
|
+
"of at by from into onto upon via per with within without during among amongst against between across throughout toward towards amid despite versus beneath beside",
|
|
141
|
+
),
|
|
142
|
+
...kinds(
|
|
143
|
+
"either",
|
|
144
|
+
"up down over out off on in through under around about above below along after before behind near past inside outside beyond like than since until till vs",
|
|
145
|
+
),
|
|
146
|
+
...kinds(
|
|
147
|
+
"either",
|
|
148
|
+
"if because while when where whether although though whereas unless once so yet that how",
|
|
149
|
+
),
|
|
150
|
+
...kinds(
|
|
151
|
+
"either",
|
|
152
|
+
"using following including regarding concerning given excluding considering besides",
|
|
153
|
+
),
|
|
154
|
+
...kinds("name", "von van de der den da di du del della la le el"),
|
|
155
|
+
// Abbreviations a title borrows from running text: never words of it, in any place.
|
|
156
|
+
...kinds("name", "et al etc cf eg ie approx viz"),
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Prefixes that cannot stand by themselves: after one, the second element of a compound is not
|
|
161
|
+
* read (`Multi-turn` and `Multi-Turn` both pass). Not `self`, `cross`, `meta`, `over`, `under`,
|
|
162
|
+
* `mini`, `micro`, `super`, `mid`, `counter`, `pro`: each stands alone, or is not certain to.
|
|
163
|
+
*/
|
|
164
|
+
export const BOUND_PREFIXES: ReadonlySet<string> = new Set([
|
|
165
|
+
"anti",
|
|
166
|
+
"co",
|
|
167
|
+
"de",
|
|
168
|
+
"inter",
|
|
169
|
+
"intra",
|
|
170
|
+
"multi",
|
|
171
|
+
"non",
|
|
172
|
+
"post",
|
|
173
|
+
"pre",
|
|
174
|
+
"pseudo",
|
|
175
|
+
"quasi",
|
|
176
|
+
"re",
|
|
177
|
+
"semi",
|
|
178
|
+
"sub",
|
|
179
|
+
"trans",
|
|
180
|
+
"ultra",
|
|
181
|
+
"un",
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
type Want = "upper" | "lower" | "any";
|
|
185
|
+
|
|
186
|
+
/** Where a word stands in its title: what is certain, and what only cannot be ruled out. */
|
|
187
|
+
interface Place {
|
|
188
|
+
readonly first: boolean;
|
|
189
|
+
readonly last: boolean;
|
|
190
|
+
readonly afterColon: boolean;
|
|
191
|
+
/** No word before it, but something unread may stand there. */
|
|
192
|
+
readonly mayBeFirst: boolean;
|
|
193
|
+
readonly mayBeLast: boolean;
|
|
194
|
+
/** After a dash, a line break, a question mark, a full stop: a new start, capitalized or not. */
|
|
195
|
+
readonly afterBreak: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Before a colon, a question or exclamation mark, a dash or a line break: the last word of a part
|
|
198
|
+
* of the title. Chicago capitalizes it, and a minor word there may be a particle (_Good For_) or a
|
|
199
|
+
* preposition, so it is not asked either way.
|
|
200
|
+
*/
|
|
201
|
+
readonly beforeBreak: boolean;
|
|
202
|
+
/** Right beside something the rule does not read: not known to be first or last of anything. */
|
|
203
|
+
readonly besideOpaque: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A word the rule reads: its elements (a compound has several) and where its letters stand in the title. */
|
|
207
|
+
interface Word {
|
|
208
|
+
readonly elements: readonly [string, ...string[]];
|
|
209
|
+
readonly from: number;
|
|
210
|
+
readonly to: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
type Token =
|
|
214
|
+
| { readonly kind: "word"; readonly word: Word; readonly trail: string }
|
|
215
|
+
| { readonly kind: "opaque"; readonly trail: string }
|
|
216
|
+
| { readonly kind: "break" };
|
|
217
|
+
|
|
218
|
+
const isNonEmpty = <T>(a: readonly T[]): a is readonly [T, ...T[]] =>
|
|
219
|
+
a.length > 0;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A piece of the title: up to a space, a dash or a line break. A lone hyphen is a piece; two or more
|
|
223
|
+
* are a dash. A dash and a line break each break the title into parts.
|
|
224
|
+
*/
|
|
225
|
+
const PIECES = new RegExp(
|
|
226
|
+
`(?:[^\\s—–${LINE_BREAK}-]|-(?!-))+|-{2,}|[—–${LINE_BREAK}]`,
|
|
227
|
+
"gu",
|
|
228
|
+
);
|
|
229
|
+
const BREAK = new RegExp(`^(?:-+|[—–${LINE_BREAK}])$`, "u");
|
|
230
|
+
const LEADING = /^[("'“‘`[{¿¡]+/u;
|
|
231
|
+
const TRAILING = /[.,;:!?)"'”’\]}»]+$/u;
|
|
232
|
+
const READABLE = /^\p{L}[\p{L}'’]*(?:-\p{L}[\p{L}'’]*)*$/u;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* An element whose capital is not one letter (a ligature: `fi` → `FI`): capitalizing it would
|
|
236
|
+
* rewrite the word, so it is not read.
|
|
237
|
+
*/
|
|
238
|
+
const hasPlainCapital = (el: string): boolean =>
|
|
239
|
+
el.charAt(0).toUpperCase().length === 1;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* One piece → what it is: a break, a word the rule reads, or something it does not. A piece followed
|
|
243
|
+
* by a full stop that does not end the title is an abbreviation (`al.`, `etc.`, `vs.`), not a word.
|
|
244
|
+
*/
|
|
245
|
+
function tokenOf(piece: string, index: number, final: boolean): Token {
|
|
246
|
+
if (BREAK.test(piece)) return { kind: "break" };
|
|
247
|
+
const unopened = piece.replace(LEADING, "");
|
|
248
|
+
const core = unopened.replace(TRAILING, "");
|
|
249
|
+
const trail = unopened.slice(core.length);
|
|
250
|
+
const elements = core.split("-");
|
|
251
|
+
const abbreviated = trail.startsWith(".") && !final;
|
|
252
|
+
if (
|
|
253
|
+
!READABLE.test(core) ||
|
|
254
|
+
!isNonEmpty(elements) ||
|
|
255
|
+
abbreviated ||
|
|
256
|
+
!elements.every(hasPlainCapital)
|
|
257
|
+
)
|
|
258
|
+
return { kind: "opaque", trail };
|
|
259
|
+
const from = index + piece.length - unopened.length;
|
|
260
|
+
return {
|
|
261
|
+
kind: "word",
|
|
262
|
+
word: { elements, from, to: from + core.length },
|
|
263
|
+
trail,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const tokensOf = (text: string): readonly Token[] => {
|
|
268
|
+
const pieces = [...text.matchAll(PIECES)];
|
|
269
|
+
return pieces.map((m, k) => tokenOf(m[0], m.index, k === pieces.length - 1));
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const isWord = (t: Token): boolean => t.kind === "word";
|
|
273
|
+
const trailOf = (t: Token | undefined): string =>
|
|
274
|
+
t === undefined || t.kind === "break" ? "" : t.trail;
|
|
275
|
+
|
|
276
|
+
function placeOf(tokens: readonly Token[], i: number): Place {
|
|
277
|
+
const prev = tokens[i - 1];
|
|
278
|
+
const next = tokens[i + 1];
|
|
279
|
+
return {
|
|
280
|
+
first: i === 0,
|
|
281
|
+
last: i === tokens.length - 1,
|
|
282
|
+
afterColon: trailOf(prev).includes(":"),
|
|
283
|
+
mayBeFirst: !tokens.slice(0, i).some(isWord),
|
|
284
|
+
mayBeLast: !tokens.slice(i + 1).some(isWord),
|
|
285
|
+
afterBreak: prev?.kind === "break" || /[.?!]/u.test(trailOf(prev)),
|
|
286
|
+
beforeBreak: next?.kind === "break" || /[:?!]/u.test(trailOf(tokens[i])),
|
|
287
|
+
besideOpaque: prev?.kind === "opaque" || next?.kind === "opaque",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const kindOf = (element: string): Kind =>
|
|
292
|
+
KINDS.get(element.toLowerCase()) ?? "major";
|
|
293
|
+
|
|
294
|
+
/** A word with a capital anywhere but its first letter (`LLM`, `GitHub`, `iOS`): a name, not read. */
|
|
295
|
+
const isMixed = (el: string): boolean =>
|
|
296
|
+
el.slice(1) !== el.slice(1).toLowerCase();
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A single letter other than a lowercase article: a symbol, not a word. A capital «A» in the middle
|
|
300
|
+
* of a title is a label (_Module A_, _Appendix A_, _Vitamin A_), never an article set wrong.
|
|
301
|
+
*/
|
|
302
|
+
const isSymbol = (el: string): boolean => el.length === 1 && el !== "a";
|
|
303
|
+
|
|
304
|
+
const capitalized = (el: string): string =>
|
|
305
|
+
el.charAt(0).toUpperCase() + el.slice(1);
|
|
306
|
+
|
|
307
|
+
/** A word of the `lower` class: capitalized at either end and after a colon, else lowercase unless unsure. */
|
|
308
|
+
function wantOfMinor(edge: boolean, p: Place): Want {
|
|
309
|
+
if (edge) return "upper";
|
|
310
|
+
const unsure =
|
|
311
|
+
p.mayBeFirst ||
|
|
312
|
+
p.mayBeLast ||
|
|
313
|
+
p.afterBreak ||
|
|
314
|
+
p.beforeBreak ||
|
|
315
|
+
p.besideOpaque;
|
|
316
|
+
return unsure ? "any" : "lower";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** What chicago-headline asks of a whole word standing alone. */
|
|
320
|
+
function wantOfWord(el: string, p: Place): Want {
|
|
321
|
+
if (isSymbol(el)) return "any";
|
|
322
|
+
const edge = p.first || p.last || p.afterColon;
|
|
323
|
+
switch (kindOf(el)) {
|
|
324
|
+
case "name":
|
|
325
|
+
return "any";
|
|
326
|
+
case "major":
|
|
327
|
+
return "upper";
|
|
328
|
+
case "either":
|
|
329
|
+
return edge ? "upper" : "any";
|
|
330
|
+
case "lower":
|
|
331
|
+
return wantOfMinor(edge, p);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** What chicago-headline asks of the `k`-th element (k > 0) of a compound whose first element is `first`. */
|
|
336
|
+
function wantOfLater(el: string, k: number, first: string): Want {
|
|
337
|
+
switch (kindOf(el)) {
|
|
338
|
+
case "lower":
|
|
339
|
+
return "lower";
|
|
340
|
+
case "major":
|
|
341
|
+
return k === 1 && BOUND_PREFIXES.has(first.toLowerCase())
|
|
342
|
+
? "any"
|
|
343
|
+
: "upper";
|
|
344
|
+
case "either":
|
|
345
|
+
case "name":
|
|
346
|
+
return "any";
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** An element as `want` asks, or as it is when it is a name or the want is open. */
|
|
351
|
+
function applied(el: string, want: Want): string {
|
|
352
|
+
if (want === "any" || isMixed(el)) return el;
|
|
353
|
+
return want === "upper" ? capitalized(el) : el.toLowerCase();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** One element of a word: as written, what chicago-headline asks of it, and the element that results. */
|
|
357
|
+
interface Judged {
|
|
358
|
+
readonly k: number;
|
|
359
|
+
readonly want: Want;
|
|
360
|
+
readonly got: string;
|
|
361
|
+
readonly expected: string;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* What chicago-headline asks of each element of a word. A compound with a single-letter element
|
|
366
|
+
* (`k-means`, `e-mail`) is not read; otherwise the first element is capitalized always and the rest
|
|
367
|
+
* follow `wantOfLater`.
|
|
368
|
+
*/
|
|
369
|
+
function judgedElements(word: Word, p: Place): readonly Judged[] {
|
|
370
|
+
const [first] = word.elements;
|
|
371
|
+
const wantOf = (el: string, k: number): Want => {
|
|
372
|
+
if (word.elements.length === 1) return wantOfWord(el, p);
|
|
373
|
+
if (word.elements.some(isSymbol)) return "any";
|
|
374
|
+
return k === 0 ? "upper" : wantOfLater(el, k, first);
|
|
375
|
+
};
|
|
376
|
+
return word.elements.map((got, k) => {
|
|
377
|
+
const want = wantOf(got, k);
|
|
378
|
+
return { k, want, got, expected: applied(got, want) };
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** What a message calls the thing a word stands in. */
|
|
383
|
+
function nounOf(c: TitledCommand): string {
|
|
384
|
+
switch (c.kind) {
|
|
385
|
+
case "title":
|
|
386
|
+
return "title";
|
|
387
|
+
case "heading":
|
|
388
|
+
return "heading";
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Why an element is wanted the way it is, as the message says it. */
|
|
393
|
+
function whyOf(j: Judged, p: Place, h: TitledHeading): string {
|
|
394
|
+
if (j.want === "lower")
|
|
395
|
+
return "an article, preposition or conjunction is lowercase";
|
|
396
|
+
if (j.k > 0)
|
|
397
|
+
return "the elements of a hyphenated compound are capitalized unless one is an article, a preposition or a conjunction, or follows a prefix such as «non-»";
|
|
398
|
+
if (p.first) return `the first word of a ${nounOf(h.command)} is capitalized`;
|
|
399
|
+
if (p.last) return `the last word of a ${nounOf(h.command)} is capitalized`;
|
|
400
|
+
if (p.afterColon) return "the first word after a colon is capitalized";
|
|
401
|
+
return "nouns, pronouns, verbs, adjectives and adverbs are capitalized";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The letters of a word, as the title says them. */
|
|
405
|
+
const wordText = (h: TitledHeading, w: Word): string =>
|
|
406
|
+
runText(h.title).slice(w.from, w.to);
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Where the letters `[from, to)` of a title stand in the source: from its first character to past its
|
|
410
|
+
* last. Total, no null: the title's characters are the source's, each at its own offset.
|
|
411
|
+
*/
|
|
412
|
+
function spanOf(h: TitledHeading, from: number, to: number): Span {
|
|
413
|
+
const offsets = h.title.segments
|
|
414
|
+
.flatMap((s) => s.text.split("").map((_, k) => s.at + k))
|
|
415
|
+
.slice(from, to);
|
|
416
|
+
return { start: Math.min(...offsets), end: Math.max(...offsets) + 1 };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The fix for a word, only where the source holds exactly the letters read, side by side. */
|
|
420
|
+
function fixOf(
|
|
421
|
+
h: TitledHeading,
|
|
422
|
+
w: Word,
|
|
423
|
+
expected: string,
|
|
424
|
+
source: string,
|
|
425
|
+
): CaseFix | null {
|
|
426
|
+
const span = spanOf(h, w.from, w.to);
|
|
427
|
+
const contiguous = span.end - span.start === w.to - w.from;
|
|
428
|
+
if (!contiguous || source.slice(span.start, span.end) !== wordText(h, w))
|
|
429
|
+
return null;
|
|
430
|
+
return { span, text: expected };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The macro a title is the argument of. */
|
|
434
|
+
function macroOf(c: TitledCommand): string {
|
|
435
|
+
switch (c.kind) {
|
|
436
|
+
case "title":
|
|
437
|
+
return "title";
|
|
438
|
+
case "heading":
|
|
439
|
+
return c.level;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** `\section` for a title, `\section[…]` for the optional short one: how a message names the heading. */
|
|
444
|
+
const whereOf = (h: TitledHeading): string =>
|
|
445
|
+
h.argument === "short"
|
|
446
|
+
? `\\${macroOf(h.command)}[…]`
|
|
447
|
+
: `\\${macroOf(h.command)}`;
|
|
448
|
+
|
|
449
|
+
/** One word against chicago-headline: nothing, or a finding with the fix where it is certain. */
|
|
450
|
+
function judgeHeadlineWord(
|
|
451
|
+
h: TitledHeading,
|
|
452
|
+
word: Word,
|
|
453
|
+
p: Place,
|
|
454
|
+
source: string,
|
|
455
|
+
): readonly CaseFinding[] {
|
|
456
|
+
const [changed] = judgedElements(word, p).filter((j) => j.expected !== j.got);
|
|
457
|
+
if (changed === undefined) return [];
|
|
458
|
+
const expected = judgedElements(word, p)
|
|
459
|
+
.map((j) => j.expected)
|
|
460
|
+
.join("-");
|
|
461
|
+
return [
|
|
462
|
+
{
|
|
463
|
+
messageId: changed.want === "upper" ? "capitalize" : "lowercase",
|
|
464
|
+
data: {
|
|
465
|
+
word: wordText(h, word),
|
|
466
|
+
expected,
|
|
467
|
+
why: whyOf(changed, p, h),
|
|
468
|
+
where: whereOf(h),
|
|
469
|
+
},
|
|
470
|
+
at: spanOf(h, word.from, word.to),
|
|
471
|
+
fix: fixOf(h, word, expected, source),
|
|
472
|
+
},
|
|
473
|
+
];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Sentence: the first word of a title is capitalized, and nothing else is read. */
|
|
477
|
+
function judgeSentenceWord(
|
|
478
|
+
h: TitledHeading,
|
|
479
|
+
word: Word,
|
|
480
|
+
p: Place,
|
|
481
|
+
): readonly CaseFinding[] {
|
|
482
|
+
const [first] = word.elements;
|
|
483
|
+
const got = wordText(h, word);
|
|
484
|
+
const readable =
|
|
485
|
+
kindOf(first) !== "name" && !isSymbol(first) && !isMixed(first);
|
|
486
|
+
const expected = p.first && readable ? capitalized(got) : got;
|
|
487
|
+
if (expected === got) return [];
|
|
488
|
+
return [
|
|
489
|
+
{
|
|
490
|
+
messageId: "sentenceFirst",
|
|
491
|
+
data: { word: got, expected, where: whereOf(h) },
|
|
492
|
+
at: spanOf(h, word.from, word.to),
|
|
493
|
+
fix: null,
|
|
494
|
+
},
|
|
495
|
+
];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The findings of one title, each word judged where it stands by `judge`. */
|
|
499
|
+
function judgeWords(
|
|
500
|
+
h: TitledHeading,
|
|
501
|
+
judge: (word: Word, p: Place) => readonly CaseFinding[],
|
|
502
|
+
): readonly CaseFinding[] {
|
|
503
|
+
const tokens = tokensOf(runText(h.title));
|
|
504
|
+
return tokens.flatMap((t, i) =>
|
|
505
|
+
t.kind === "word" ? judge(t.word, placeOf(tokens, i)) : [],
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** The findings of one title under the style its scope asks. */
|
|
510
|
+
function judgeTitle(
|
|
511
|
+
h: TitledHeading,
|
|
512
|
+
style: CaseStyle,
|
|
513
|
+
source: string,
|
|
514
|
+
): readonly CaseFinding[] {
|
|
515
|
+
switch (style) {
|
|
516
|
+
case "any":
|
|
517
|
+
return [];
|
|
518
|
+
case "chicago-headline":
|
|
519
|
+
return judgeWords(h, (w, p) => judgeHeadlineWord(h, w, p, source));
|
|
520
|
+
case "sentence":
|
|
521
|
+
return judgeWords(h, (w, p) => judgeSentenceWord(h, w, p));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The paper's title and every heading, each against the style its scope asks (`styleFor`), in
|
|
527
|
+
* document order. `source` is the text the titles were read from: a fix is offered only where it
|
|
528
|
+
* holds the very letters the judge read.
|
|
529
|
+
*/
|
|
530
|
+
export function judgeHeadingCase(
|
|
531
|
+
headings: readonly TitledHeading[],
|
|
532
|
+
options: HeadingCaseOptions,
|
|
533
|
+
source: string,
|
|
534
|
+
): readonly CaseFinding[] {
|
|
535
|
+
return headings.flatMap((h) =>
|
|
536
|
+
judgeTitle(h, styleFor(options, h.command), source),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
@@ -185,3 +185,39 @@ export interface LayoutOverride {
|
|
|
185
185
|
readonly command: string;
|
|
186
186
|
readonly place: Place;
|
|
187
187
|
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What stands in a heading's text for something the reader sees and the case judge does not read —
|
|
191
|
+
* math, code, a citation, any command whose text is not plain: one character no title contains.
|
|
192
|
+
*/
|
|
193
|
+
export const OPAQUE = "";
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What stands in a heading's text for a forced line break (`\\`, `\newline`, `\linebreak`): one
|
|
197
|
+
* character no title contains. A title broken over two lines is two parts — a title and its
|
|
198
|
+
* subtitle — so the case judge reads the word after it as a start, like the word after a dash.
|
|
199
|
+
*/
|
|
200
|
+
export const LINE_BREAK = "\u2028";
|
|
201
|
+
|
|
202
|
+
/** The headings whose title a venue sets in a case style. */
|
|
203
|
+
export type HeadingLevel =
|
|
204
|
+
"section" | "subsection" | "subsubsection" | "paragraph" | "subparagraph";
|
|
205
|
+
|
|
206
|
+
/** Which command a title belongs to: the paper's own `\title`, or a heading at its level. */
|
|
207
|
+
export type TitledCommand =
|
|
208
|
+
| { readonly kind: "title" }
|
|
209
|
+
| { readonly kind: "heading"; readonly level: HeadingLevel };
|
|
210
|
+
|
|
211
|
+
/** Which argument of the command: the title, or the optional short title in brackets. */
|
|
212
|
+
export type TitleArgument = "title" | "short";
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One title as the case judge reads it — the paper's or a heading's: its text, each character with
|
|
216
|
+
* the source offset it came from, and `OPAQUE` where something it does not read stands. A label, a
|
|
217
|
+
* footnote and a comment are not in it; a title with nothing else has no `TitledHeading`.
|
|
218
|
+
*/
|
|
219
|
+
export interface TitledHeading {
|
|
220
|
+
readonly command: TitledCommand;
|
|
221
|
+
readonly argument: TitleArgument;
|
|
222
|
+
readonly title: TextRun;
|
|
223
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
withoutVenues,
|
|
12
12
|
type NamedVenue,
|
|
13
13
|
} from "./venue-name.ts";
|
|
14
|
+
import { venuePreset } from "../../test/support.ts";
|
|
14
15
|
|
|
15
16
|
describe("nameTokens — a name as the words it is made of", () => {
|
|
16
17
|
it.each<[string, readonly string[]]>([
|
|
@@ -39,21 +40,21 @@ const VENUES: readonly NamedVenue[] = shippedVenueNames({
|
|
|
39
40
|
|
|
40
41
|
describe("venuesNamedBy — which venues a folder name names", () => {
|
|
41
42
|
it.each<[string, string, readonly { venue: string; name: string }[]]>([
|
|
42
|
-
["the label", "aisec-2026", [{ venue: "
|
|
43
|
-
["an alias", "acsac-2026", [{ venue: "
|
|
43
|
+
["the label", "aisec-2026", [{ venue: "AISec", name: "AISec" }]],
|
|
44
|
+
["an alias", "acsac-2026", [{ venue: "AIDC", name: "ACSAC" }]],
|
|
44
45
|
[
|
|
45
46
|
"a two-word alias, as two tokens in a row",
|
|
46
47
|
"acm-ccs-agents",
|
|
47
|
-
[{ venue: "
|
|
48
|
+
[{ venue: "AISec", name: "ACM CCS" }],
|
|
48
49
|
],
|
|
49
50
|
["the two words of an alias apart are not it", "acm-agents-ccs", []],
|
|
50
|
-
["glued to a year", "realm2026", [{ venue: "
|
|
51
|
+
["glued to a year", "realm2026", [{ venue: "REALM", name: "REALM" }]],
|
|
51
52
|
[
|
|
52
53
|
"two venues, each once, in the order they are listed",
|
|
53
54
|
"realm-aisec-aisec",
|
|
54
55
|
[
|
|
55
|
-
{ venue: "
|
|
56
|
-
{ venue: "
|
|
56
|
+
{ venue: "AISec", name: "AISec" },
|
|
57
|
+
{ venue: "REALM", name: "REALM" },
|
|
57
58
|
],
|
|
58
59
|
],
|
|
59
60
|
// Whole tokens only: a venue's name inside another word is that other word.
|
|
@@ -85,7 +86,12 @@ describe("venuesNamedBy — which venues a folder name names", () => {
|
|
|
85
86
|
cpSync(presetsDir(), dir, { recursive: true });
|
|
86
87
|
writeFileSync(
|
|
87
88
|
join(dir, "dash.jsonc"),
|
|
88
|
-
JSON.stringify(
|
|
89
|
+
JSON.stringify(
|
|
90
|
+
venuePreset("dash", {
|
|
91
|
+
extends: "paperlint:acm-sigconf",
|
|
92
|
+
aliases: ["—"],
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
89
95
|
);
|
|
90
96
|
const venues = shippedVenueNames({ files: nodeFiles, venuesDir: dir });
|
|
91
97
|
expect(venues.find((v) => v.label === "dash")?.aliases).toContain("—");
|
package/src/facts-file.test.ts
CHANGED
|
@@ -267,24 +267,24 @@ test("writeFactsFile: <paper>/_build/paper.facts.json holds exactly the document
|
|
|
267
267
|
assert.deepEqual(JSON.parse(text), m.value.facts);
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
-
test("declaredVenue: none without paperlint.json; no
|
|
270
|
+
test("declaredVenue: none without paperlint.json; no id when it extends no preset", () => {
|
|
271
271
|
assert.equal(declaredVenue(memoryFiles(), PAPER), null);
|
|
272
272
|
const files = memoryFiles({
|
|
273
273
|
[`${PAPER}/paperlint.json`]: '{"kind":"x","pdf":"b/p.pdf"}',
|
|
274
274
|
});
|
|
275
275
|
assert.deepEqual(declaredVenue(files, PAPER), {
|
|
276
|
-
|
|
276
|
+
id: null,
|
|
277
277
|
kind: "x",
|
|
278
278
|
pdf: "b/p.pdf",
|
|
279
279
|
});
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
-
test("declaredVenue: the
|
|
282
|
+
test("declaredVenue: the id is the preset's — its file name, not its display name — and an extends that resolves nowhere is refused", () => {
|
|
283
283
|
const ok = memoryFiles({
|
|
284
284
|
...SHIPPED_PRESETS,
|
|
285
285
|
[`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
|
|
286
286
|
});
|
|
287
|
-
assert.equal(declaredVenue(ok, PAPER)?.
|
|
287
|
+
assert.equal(declaredVenue(ok, PAPER)?.id, "aisec");
|
|
288
288
|
const typo = memoryFiles({
|
|
289
289
|
...SHIPPED_PRESETS,
|
|
290
290
|
[`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisek"}',
|