paperlint 6.1.0 → 6.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.
- package/dist/adapters/mp4box/index.d.ts +4 -0
- package/dist/adapters/mp4box/index.d.ts.map +1 -0
- package/dist/adapters/mp4box/index.js +46 -0
- package/dist/adapters/mp4box/index.js.map +1 -0
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-dir.io.d.ts +3 -0
- package/dist/adapters/node/list-dir.io.d.ts.map +1 -0
- package/dist/adapters/node/list-dir.io.js +15 -0
- package/dist/adapters/node/list-dir.io.js.map +1 -0
- package/dist/adapters/png/index.d.ts +11 -0
- package/dist/adapters/png/index.d.ts.map +1 -0
- package/dist/adapters/png/index.js +21 -0
- package/dist/adapters/png/index.js.map +1 -0
- package/dist/adapters/srt/index.d.ts +4 -0
- package/dist/adapters/srt/index.d.ts.map +1 -0
- package/dist/adapters/srt/index.js +12 -0
- package/dist/adapters/srt/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -4
- package/dist/cli.js.map +1 -1
- package/dist/domain/talk.d.ts +121 -0
- package/dist/domain/talk.d.ts.map +1 -0
- package/dist/domain/talk.js +121 -0
- package/dist/domain/talk.js.map +1 -0
- package/dist/lib/paper-config.d.ts +2 -0
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +3 -0
- package/dist/lib/paper-config.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 +15 -9
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/talk-media.d.ts +18 -0
- package/dist/ports/talk-media.d.ts.map +1 -0
- package/dist/ports/talk-media.js +2 -0
- package/dist/ports/talk-media.js.map +1 -0
- package/dist/presets.d.ts +3 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +6 -10
- package/dist/presets.js.map +1 -1
- package/dist/talk-rules.d.ts +65 -0
- package/dist/talk-rules.d.ts.map +1 -0
- package/dist/talk-rules.js +451 -0
- package/dist/talk-rules.js.map +1 -0
- package/dist/tex-requirements.d.ts +3 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +2 -0
- package/dist/tex-requirements.js.map +1 -1
- package/docs/configuration.md +2 -1
- package/docs/rules/talk/captions-cover.md +47 -0
- package/docs/rules/talk/duration-floor.md +38 -0
- package/docs/rules/talk/duration.md +43 -0
- package/docs/rules/talk/one-slide-size.md +38 -0
- package/docs/rules/talk/profile.md +64 -0
- package/docs/rules/talk/required-files.md +39 -0
- package/docs/rules/talk/undeclared.md +35 -0
- package/docs/rules/talk/video-format.md +43 -0
- package/docs/rules.md +11 -0
- package/docs/talk.md +94 -0
- package/fixtures/talk/audio-only.mp4 +0 -0
- package/fixtures/talk/one-slide.png +0 -0
- package/fixtures/talk/short-audio.mp4 +0 -0
- package/fixtures/talk/talk.mov +0 -0
- package/fixtures/talk/talk.mp4 +0 -0
- package/fixtures/talk/talk.srt +7 -0
- package/lib/paper-config.harness.mjs +5 -2
- package/lib/paper-config.ts +4 -0
- package/package.json +3 -1
- package/presets/agenticdev.jsonc +29 -0
- package/presets/venue-profile.schema.json +132 -0
- package/src/adapters/mp4box/index.test.ts +63 -0
- package/src/adapters/mp4box/index.ts +50 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-dir.io.ts +15 -0
- package/src/adapters/node/list-dir.test.ts +30 -0
- package/src/adapters/png/index.test.ts +43 -0
- package/src/adapters/png/index.ts +29 -0
- package/src/adapters/srt/index.test.ts +24 -0
- package/src/adapters/srt/index.ts +13 -0
- package/src/cli.test.ts +12 -0
- package/src/cli.ts +31 -3
- package/src/domain/talk.test.ts +125 -0
- package/src/domain/talk.ts +274 -0
- package/src/new-paper.test.ts +1 -0
- package/src/paper-settings.test.ts +18 -1
- package/src/paper-settings.ts +21 -11
- package/src/ports/talk-media.ts +19 -0
- package/src/presets.ts +9 -10
- package/src/rule-cases.test.ts +110 -3
- package/src/talk-rules.test.ts +459 -0
- package/src/talk-rules.ts +672 -0
- package/src/tex-requirements.ts +9 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TALK — what a venue asks a presenter to send, and what one paper declares about its talk.
|
|
3
|
+
*
|
|
4
|
+
* paperlint does not make a talk. It judges the finished files (the video, the one-slide image, the
|
|
5
|
+
* captions) against the venue's numbers: however the video was made, the venue limits the file.
|
|
6
|
+
*
|
|
7
|
+
* venue preset `talk` the modes the venue allows, what each mode owes, the slot of each kind
|
|
8
|
+
* of paper, the video's format, the one-slide's size
|
|
9
|
+
* paper `talk` the mode this paper presents in, the folder the files are in, and their
|
|
10
|
+
* names when they are not the defaults
|
|
11
|
+
*/
|
|
12
|
+
import { err, ok, type Result } from "./result.ts";
|
|
13
|
+
import { fieldOf, isRecord } from "./record.ts";
|
|
14
|
+
|
|
15
|
+
/** How a paper is presented. */
|
|
16
|
+
export type TalkMode = "in-person" | "remote-video" | "remote-live";
|
|
17
|
+
export const TALK_MODES: readonly TalkMode[] = [
|
|
18
|
+
"in-person",
|
|
19
|
+
"remote-video",
|
|
20
|
+
"remote-live",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** A file a mode can owe. */
|
|
24
|
+
export type TalkArtifact =
|
|
25
|
+
"video" | "one-slide" | "captions" | "slides-pdf" | "poster";
|
|
26
|
+
export const TALK_ARTIFACTS: readonly TalkArtifact[] = [
|
|
27
|
+
"video",
|
|
28
|
+
"one-slide",
|
|
29
|
+
"captions",
|
|
30
|
+
"slides-pdf",
|
|
31
|
+
"poster",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** What one mode owes. */
|
|
35
|
+
export interface TalkObligation {
|
|
36
|
+
readonly required: readonly TalkArtifact[];
|
|
37
|
+
readonly optional: readonly TalkArtifact[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The talk slot of one kind of paper, in seconds. */
|
|
41
|
+
export interface TalkSlot {
|
|
42
|
+
/** The whole slot, talk and questions. Informational. */
|
|
43
|
+
readonly slotS: number;
|
|
44
|
+
/** The shortest talk the venue asks for; null when it sets no floor. */
|
|
45
|
+
readonly talkSMin: number | null;
|
|
46
|
+
/** The longest talk. */
|
|
47
|
+
readonly talkSMax: number;
|
|
48
|
+
/** The questions' share. Informational. */
|
|
49
|
+
readonly qaS: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A pixel size. */
|
|
53
|
+
export interface PixelSize {
|
|
54
|
+
readonly width: number;
|
|
55
|
+
readonly height: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The venue's limits on the video file. Every field null when the venue says nothing of it. */
|
|
59
|
+
export interface VideoLimits {
|
|
60
|
+
readonly container: "mp4" | null;
|
|
61
|
+
readonly minHeightPx: number | null;
|
|
62
|
+
readonly maxBytes: number | null;
|
|
63
|
+
readonly captions: "required" | "optional" | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A venue preset's `talk` block, parsed. */
|
|
67
|
+
export interface VenueTalk {
|
|
68
|
+
/** The modes the venue allows; empty when the block names none. */
|
|
69
|
+
readonly modes: readonly TalkMode[];
|
|
70
|
+
readonly artifacts: ReadonlyMap<TalkMode, TalkObligation>;
|
|
71
|
+
/** Slots by paper kind — the names of `format.kinds`. */
|
|
72
|
+
readonly kinds: ReadonlyMap<string, TalkSlot>;
|
|
73
|
+
readonly video: VideoLimits;
|
|
74
|
+
/** The one-slide image's size; null when the venue does not state one. */
|
|
75
|
+
readonly oneSlide: PixelSize | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The artifact → the file name it is looked for under, in the talk folder. */
|
|
79
|
+
export type TalkFiles = Readonly<Record<TalkArtifact, string>>;
|
|
80
|
+
|
|
81
|
+
/** The default talk folder, relative to the paper. */
|
|
82
|
+
export const DEFAULT_TALK_DIR = "talk";
|
|
83
|
+
|
|
84
|
+
/** The file each artifact is looked for under, unless the paper names another. */
|
|
85
|
+
export const DEFAULT_TALK_FILES: TalkFiles = {
|
|
86
|
+
video: "talk.mp4",
|
|
87
|
+
"one-slide": "one-slide.png",
|
|
88
|
+
captions: "talk.srt",
|
|
89
|
+
"slides-pdf": "slides.pdf",
|
|
90
|
+
poster: "poster.pdf",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** A paper's `talk` key, parsed. */
|
|
94
|
+
export interface PaperTalk {
|
|
95
|
+
readonly mode: TalkMode;
|
|
96
|
+
/** The talk folder, relative to the paper. */
|
|
97
|
+
readonly dir: string;
|
|
98
|
+
readonly files: TalkFiles;
|
|
99
|
+
/** The one-slide's size when the paper states it (the venue did not); null otherwise. */
|
|
100
|
+
readonly oneSlide: PixelSize | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── measurements the rules judge ──────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** What the video file says about itself. */
|
|
106
|
+
export interface VideoProbe {
|
|
107
|
+
/** The container's duration (movie header), in seconds. */
|
|
108
|
+
readonly durationS: number;
|
|
109
|
+
/** The first audio track's duration, in seconds; null when the file has no audio track. */
|
|
110
|
+
readonly audioS: number | null;
|
|
111
|
+
/** The first video track's height in pixels; null when the file has no video track. */
|
|
112
|
+
readonly heightPx: number | null;
|
|
113
|
+
readonly widthPx: number | null;
|
|
114
|
+
/** The file's brands (ftyp): `isom`, `mp41`… for MP4, `qt ` for QuickTime. */
|
|
115
|
+
readonly brands: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One caption cue, in seconds. */
|
|
119
|
+
export interface Cue {
|
|
120
|
+
readonly startS: number;
|
|
121
|
+
readonly endS: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── the preset's `talk` block, as the schema admits it ───────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** A preset's `talk` block after `venue-profile.schema.json` accepted it. */
|
|
127
|
+
export interface VenueTalkJson {
|
|
128
|
+
readonly modes?: readonly TalkMode[];
|
|
129
|
+
readonly artifacts?: Readonly<
|
|
130
|
+
Partial<
|
|
131
|
+
Record<
|
|
132
|
+
TalkMode,
|
|
133
|
+
{
|
|
134
|
+
readonly required: readonly TalkArtifact[];
|
|
135
|
+
readonly optional?: readonly TalkArtifact[];
|
|
136
|
+
}
|
|
137
|
+
>
|
|
138
|
+
>
|
|
139
|
+
>;
|
|
140
|
+
readonly kinds?: Readonly<
|
|
141
|
+
Record<
|
|
142
|
+
string,
|
|
143
|
+
{
|
|
144
|
+
readonly slot_s: number;
|
|
145
|
+
readonly talk_s_min?: number;
|
|
146
|
+
readonly talk_s_max: number;
|
|
147
|
+
readonly qa_s?: number;
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
>;
|
|
151
|
+
readonly video?: {
|
|
152
|
+
readonly container?: "mp4";
|
|
153
|
+
readonly min_height_px?: number;
|
|
154
|
+
readonly max_bytes?: number;
|
|
155
|
+
readonly captions?: "required" | "optional";
|
|
156
|
+
};
|
|
157
|
+
readonly one_slide?: {
|
|
158
|
+
readonly width_px: number;
|
|
159
|
+
readonly height_px: number;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type ArtifactsJson = NonNullable<VenueTalkJson["artifacts"]>;
|
|
164
|
+
type KindsJson = NonNullable<VenueTalkJson["kinds"]>;
|
|
165
|
+
|
|
166
|
+
const artifactsOf = (j: ArtifactsJson): ReadonlyMap<TalkMode, TalkObligation> =>
|
|
167
|
+
new Map(
|
|
168
|
+
TALK_MODES.flatMap((mode) => {
|
|
169
|
+
const o = j[mode];
|
|
170
|
+
return o === undefined
|
|
171
|
+
? []
|
|
172
|
+
: [[mode, { required: o.required, optional: o.optional ?? [] }]];
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const kindsOf = (j: KindsJson): ReadonlyMap<string, TalkSlot> =>
|
|
177
|
+
new Map(
|
|
178
|
+
Object.entries(j).map(([name, k]) => [
|
|
179
|
+
name,
|
|
180
|
+
{
|
|
181
|
+
slotS: k.slot_s,
|
|
182
|
+
talkSMin: k.talk_s_min ?? null,
|
|
183
|
+
talkSMax: k.talk_s_max,
|
|
184
|
+
qaS: k.qa_s ?? null,
|
|
185
|
+
},
|
|
186
|
+
]),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const videoOf = (v: VenueTalkJson["video"] = {}): VideoLimits => ({
|
|
190
|
+
container: v.container ?? null,
|
|
191
|
+
minHeightPx: v.min_height_px ?? null,
|
|
192
|
+
maxBytes: v.max_bytes ?? null,
|
|
193
|
+
captions: v.captions ?? null,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** The schema-checked block → the typed venue talk. Pure. */
|
|
197
|
+
export const venueTalkOf = (j: VenueTalkJson): VenueTalk => ({
|
|
198
|
+
modes: j.modes ?? [],
|
|
199
|
+
artifacts: artifactsOf(j.artifacts ?? {}),
|
|
200
|
+
kinds: kindsOf(j.kinds ?? {}),
|
|
201
|
+
video: videoOf(j.video),
|
|
202
|
+
oneSlide:
|
|
203
|
+
j.one_slide === undefined
|
|
204
|
+
? null
|
|
205
|
+
: { width: j.one_slide.width_px, height: j.one_slide.height_px },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── a paper's `talk` key ───────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
const PAPER_TALK_KEYS = ["mode", "dir", "files", "one_slide"];
|
|
211
|
+
|
|
212
|
+
const isMode = (v: unknown): v is TalkMode => TALK_MODES.some((m) => m === v);
|
|
213
|
+
const isArtifact = (v: string): v is TalkArtifact =>
|
|
214
|
+
TALK_ARTIFACTS.some((a) => a === v);
|
|
215
|
+
const isPlainName = (v: unknown): v is string =>
|
|
216
|
+
typeof v === "string" && v !== "" && !v.includes("/") && !v.includes("\\");
|
|
217
|
+
const isPositiveInt = (v: unknown): v is number =>
|
|
218
|
+
typeof v === "number" && Number.isInteger(v) && v > 0;
|
|
219
|
+
|
|
220
|
+
/** `files`: each key an artifact, each value a file name in the talk folder. */
|
|
221
|
+
function filesOf(v: unknown): Result<TalkFiles, string> {
|
|
222
|
+
if (v === undefined) return ok(DEFAULT_TALK_FILES);
|
|
223
|
+
if (!isRecord(v)) return err(`"talk.files" must be an object`);
|
|
224
|
+
const bad = Object.entries(v).find(
|
|
225
|
+
([k, name]) => !isArtifact(k) || !isPlainName(name),
|
|
226
|
+
);
|
|
227
|
+
if (bad !== undefined)
|
|
228
|
+
return err(
|
|
229
|
+
`"talk.files": "${bad[0]}" must be one of ${TALK_ARTIFACTS.join(", ")}, naming a file in the talk folder (no slashes)`,
|
|
230
|
+
);
|
|
231
|
+
const named = Object.fromEntries(
|
|
232
|
+
Object.entries(v).filter(
|
|
233
|
+
(e): e is [string, string] => typeof e[1] === "string",
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
return ok({ ...DEFAULT_TALK_FILES, ...named });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `one_slide`: `{ width_px, height_px }`, both positive integers. */
|
|
240
|
+
function oneSlideOf(v: unknown): Result<PixelSize | null, string> {
|
|
241
|
+
if (v === undefined) return ok(null);
|
|
242
|
+
const w = fieldOf(v, "width_px");
|
|
243
|
+
const h = fieldOf(v, "height_px");
|
|
244
|
+
return isPositiveInt(w) && isPositiveInt(h)
|
|
245
|
+
? ok({ width: w, height: h })
|
|
246
|
+
: err(
|
|
247
|
+
`"talk.one_slide" must be { "width_px": <integer>, "height_px": <integer> }`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A paper's `talk` value → the typed declaration; null when absent. Strict like the rest of the
|
|
253
|
+
* file: an unknown key is refused by name, or a typo would read as "not set".
|
|
254
|
+
*/
|
|
255
|
+
export function parsePaperTalk(v: unknown): Result<PaperTalk | null, string> {
|
|
256
|
+
if (v === undefined) return ok(null);
|
|
257
|
+
if (!isRecord(v)) return err(`"talk" must be an object with a "mode"`);
|
|
258
|
+
const unknown = Object.keys(v).find((k) => !PAPER_TALK_KEYS.includes(k));
|
|
259
|
+
if (unknown !== undefined)
|
|
260
|
+
return err(
|
|
261
|
+
`"talk" has an unknown key "${unknown}" — known: ${PAPER_TALK_KEYS.join(", ")}`,
|
|
262
|
+
);
|
|
263
|
+
const mode = v["mode"];
|
|
264
|
+
if (!isMode(mode))
|
|
265
|
+
return err(`"talk.mode" must be one of ${TALK_MODES.join(", ")}`);
|
|
266
|
+
const dir = v["dir"] ?? DEFAULT_TALK_DIR;
|
|
267
|
+
if (typeof dir !== "string" || dir === "")
|
|
268
|
+
return err(`"talk.dir" must be a folder relative to the paper`);
|
|
269
|
+
const files = filesOf(v["files"]);
|
|
270
|
+
if (!files.ok) return files;
|
|
271
|
+
const oneSlide = oneSlideOf(v["one_slide"]);
|
|
272
|
+
if (!oneSlide.ok) return oneSlide;
|
|
273
|
+
return ok({ mode, dir, files: files.value, oneSlide: oneSlide.value });
|
|
274
|
+
}
|
package/src/new-paper.test.ts
CHANGED
|
@@ -31,6 +31,7 @@ describe("parsePaperSettings", () => {
|
|
|
31
31
|
pdf: null,
|
|
32
32
|
rules: null,
|
|
33
33
|
identity: null,
|
|
34
|
+
talk: null,
|
|
34
35
|
},
|
|
35
36
|
});
|
|
36
37
|
expect(parsePaperSettings({})).toEqual({
|
|
@@ -41,6 +42,7 @@ describe("parsePaperSettings", () => {
|
|
|
41
42
|
pdf: null,
|
|
42
43
|
rules: null,
|
|
43
44
|
identity: null,
|
|
45
|
+
talk: null,
|
|
44
46
|
},
|
|
45
47
|
});
|
|
46
48
|
});
|
|
@@ -54,6 +56,7 @@ describe("parsePaperSettings", () => {
|
|
|
54
56
|
pdf: null,
|
|
55
57
|
rules: null,
|
|
56
58
|
identity: null,
|
|
59
|
+
talk: null,
|
|
57
60
|
},
|
|
58
61
|
});
|
|
59
62
|
});
|
|
@@ -152,7 +155,7 @@ describe("readPaperSettings — a root paperlint.json that cannot be used", () =
|
|
|
152
155
|
});
|
|
153
156
|
|
|
154
157
|
/** The fields a case does not set. */
|
|
155
|
-
const UNSET = { pdf: null, rules: null, identity: null };
|
|
158
|
+
const UNSET = { pdf: null, rules: null, identity: null, talk: null };
|
|
156
159
|
|
|
157
160
|
describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
|
|
158
161
|
const ROOT = "/work";
|
|
@@ -206,6 +209,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
|
|
|
206
209
|
// The root's rules are the project's blocks (cli.ts), not this paper's.
|
|
207
210
|
rules: { "pdf/fonts": "off" },
|
|
208
211
|
identity: null,
|
|
212
|
+
talk: null,
|
|
209
213
|
});
|
|
210
214
|
});
|
|
211
215
|
|
|
@@ -248,6 +252,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
|
|
|
248
252
|
pdf: null,
|
|
249
253
|
rules,
|
|
250
254
|
identity: null,
|
|
255
|
+
talk: null,
|
|
251
256
|
});
|
|
252
257
|
|
|
253
258
|
it("no rules: none", () => {
|
|
@@ -324,3 +329,15 @@ describe("readPaperSettings — identity, at both levels", () => {
|
|
|
324
329
|
);
|
|
325
330
|
});
|
|
326
331
|
});
|
|
332
|
+
|
|
333
|
+
describe("talk in a paper's paperlint.json", () => {
|
|
334
|
+
it("is parsed into the paper's settings", () => {
|
|
335
|
+
const r = parsePaperSettings({ talk: { mode: "remote-video" } });
|
|
336
|
+
expect(r.ok && r.value.talk?.mode).toBe("remote-video");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("refuses a talk it cannot parse, naming the key", () => {
|
|
340
|
+
const r = parsePaperSettings({ talk: { mode: "zoom" } });
|
|
341
|
+
expect(r.ok ? "" : r.error).toMatch(/"talk.mode"/);
|
|
342
|
+
});
|
|
343
|
+
});
|
package/src/paper-settings.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type RuleEntry,
|
|
32
32
|
} from "./rules-config.ts";
|
|
33
33
|
import { messageOf } from "./domain/text.ts";
|
|
34
|
+
import { parsePaperTalk, type PaperTalk } from "./domain/talk.ts";
|
|
34
35
|
|
|
35
36
|
/** What one paper declares. Every absent field is null. */
|
|
36
37
|
export interface PaperSettings {
|
|
@@ -51,6 +52,8 @@ export interface PaperSettings {
|
|
|
51
52
|
* and cannot drop them by accident. Null when neither declares one.
|
|
52
53
|
*/
|
|
53
54
|
readonly identity: readonly string[] | null;
|
|
55
|
+
/** How this paper is presented (`talk/*`); null when it declares no talk. Never the root's. */
|
|
56
|
+
readonly talk: PaperTalk | null;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Why a paper's settings cannot be read. */
|
|
@@ -109,6 +112,19 @@ export function identityField(
|
|
|
109
112
|
);
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/** `rules` (checked for shape only) and `talk`, each null when absent. */
|
|
116
|
+
function rulesAndTalk(
|
|
117
|
+
json: Readonly<Record<string, unknown>>,
|
|
118
|
+
): Result<Pick<PaperSettings, "rules" | "talk">, string> {
|
|
119
|
+
const rules = json["rules"];
|
|
120
|
+
if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
|
|
121
|
+
return err(
|
|
122
|
+
`"rules" must be an object of rule id → severity, or a list of ESLint blocks`,
|
|
123
|
+
);
|
|
124
|
+
const talk = parsePaperTalk(json["talk"]);
|
|
125
|
+
return talk.ok ? ok({ rules: rules ?? null, talk: talk.value }) : talk;
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
/** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
|
|
113
129
|
export function parsePaperSettings(
|
|
114
130
|
json: unknown,
|
|
@@ -128,16 +144,9 @@ export function parsePaperSettings(
|
|
|
128
144
|
if (!fields.ok) return fields;
|
|
129
145
|
const identity = identityField(json);
|
|
130
146
|
if (!identity.ok) return identity;
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
`"rules" must be an object of rule id → severity, or a list of ESLint blocks`,
|
|
135
|
-
);
|
|
136
|
-
return ok({
|
|
137
|
-
...fields.value,
|
|
138
|
-
rules: rules ?? null,
|
|
139
|
-
identity: identity.value,
|
|
140
|
-
});
|
|
147
|
+
const rest = rulesAndTalk(json);
|
|
148
|
+
if (!rest.ok) return rest;
|
|
149
|
+
return ok({ ...fields.value, ...rest.value, identity: identity.value });
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
/** A JSON file through `files`: undefined when absent, the problem when it does not parse. */
|
|
@@ -223,13 +232,14 @@ function merge(
|
|
|
223
232
|
paper: PaperSettings | null,
|
|
224
233
|
): PaperSettings | null {
|
|
225
234
|
const r = root ?? NO_DEFAULTS;
|
|
226
|
-
const p = paper ?? { ...NO_DEFAULTS, rules: null };
|
|
235
|
+
const p = paper ?? { ...NO_DEFAULTS, rules: null, talk: null };
|
|
227
236
|
const merged: PaperSettings = {
|
|
228
237
|
extends: p.extends ?? r.extends,
|
|
229
238
|
kind: p.kind ?? r.kind,
|
|
230
239
|
pdf: p.pdf ?? r.pdf,
|
|
231
240
|
rules: p.rules,
|
|
232
241
|
identity: joined(r.identity, p.identity),
|
|
242
|
+
talk: p.talk,
|
|
233
243
|
};
|
|
234
244
|
return paper === null && Object.values(merged).every((v) => v === null)
|
|
235
245
|
? null
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a talk's files: what the talk rules need to know about a video, an image and captions.
|
|
3
|
+
* Shaped by the questions the rules ask, not by any tool: the adapters behind it are MP4Box.js,
|
|
4
|
+
* a PNG header reader and an SRT parser (`src/adapters/`).
|
|
5
|
+
*/
|
|
6
|
+
import type { AbsolutePath } from "../domain/paths.ts";
|
|
7
|
+
import type { Cue, PixelSize, VideoProbe } from "../domain/talk.ts";
|
|
8
|
+
|
|
9
|
+
export interface TalkMedia {
|
|
10
|
+
/** The video's durations, size and brands; null when the bytes are not an ISO media file. */
|
|
11
|
+
probeVideo(bytes: Uint8Array): VideoProbe | null;
|
|
12
|
+
/** The image's size; null when the bytes are not a PNG. */
|
|
13
|
+
imageSize(bytes: Uint8Array): PixelSize | null;
|
|
14
|
+
/** The cues of a captions file, in file order; empty when it holds none. */
|
|
15
|
+
captions(text: string): readonly Cue[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The names in a directory; empty when there is no such directory. */
|
|
19
|
+
export type ListDir = (dir: AbsolutePath) => readonly string[];
|
package/src/presets.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* tex union — a child never removes a package its parent needs
|
|
20
20
|
* format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
|
|
21
21
|
* rules per rule id, the child wins
|
|
22
|
-
* template the child wins; so does `name`, and `required_sections` and `
|
|
22
|
+
* template the child wins; so does `name`, and `required_sections`, `register` and `talk` (each whole)
|
|
23
23
|
* aliases union, with every `name` — what the venue is called along the chain
|
|
24
24
|
*
|
|
25
25
|
* ── THE LABEL ─────────────────────────────────────────────────────────────────────
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
import { CONFIG_FILE } from "#lib/paper-config";
|
|
53
53
|
import { messageOf } from "./domain/text.ts";
|
|
54
54
|
import type { RegisterAnchor } from "./domain/register.ts";
|
|
55
|
+
import type { VenueTalk } from "./domain/talk.ts";
|
|
55
56
|
|
|
56
57
|
/** The prefix of a shipped preset's spec. */
|
|
57
58
|
export const SHIPPED_PREFIX = "paperlint:";
|
|
@@ -92,6 +93,8 @@ export interface Preset {
|
|
|
92
93
|
readonly rules: Readonly<Record<string, unknown>>;
|
|
93
94
|
/** The accepted papers the register rules measure a body against; empty when no file names any. */
|
|
94
95
|
readonly registerAnchors: readonly RegisterAnchor[];
|
|
96
|
+
/** What the venue asks a presenter to send (`talk/*`); null when no file of the chain says. */
|
|
97
|
+
readonly talk: VenueTalk | null;
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
/** Why a spec does not resolve. */
|
|
@@ -223,6 +226,7 @@ function merged(
|
|
|
223
226
|
format: NO_FORMAT,
|
|
224
227
|
rules: {},
|
|
225
228
|
registerAnchors: [],
|
|
229
|
+
talk: null,
|
|
226
230
|
};
|
|
227
231
|
const m = rootFirst.reduce(
|
|
228
232
|
(acc, { preset: p, file }) => ({
|
|
@@ -241,20 +245,15 @@ function merged(
|
|
|
241
245
|
format: mergeFormat(acc.format, p.format),
|
|
242
246
|
rules: { ...acc.rules, ...p.rules },
|
|
243
247
|
registerAnchors: p.registerAnchors ?? acc.registerAnchors,
|
|
248
|
+
talk: p.talk ?? acc.talk,
|
|
244
249
|
}),
|
|
245
250
|
base,
|
|
246
251
|
);
|
|
252
|
+
const { name, ...rest } = m;
|
|
247
253
|
return {
|
|
248
|
-
label:
|
|
254
|
+
label: name ?? labelOf(spec),
|
|
249
255
|
chain: rootFirst.map((x) => x.file),
|
|
250
|
-
|
|
251
|
-
aliases: m.aliases,
|
|
252
|
-
blind: m.blind,
|
|
253
|
-
requiredSections: m.requiredSections,
|
|
254
|
-
tex: m.tex,
|
|
255
|
-
format: m.format,
|
|
256
|
-
rules: m.rules,
|
|
257
|
-
registerAnchors: m.registerAnchors,
|
|
256
|
+
...rest,
|
|
258
257
|
};
|
|
259
258
|
}
|
|
260
259
|
|
package/src/rule-cases.test.ts
CHANGED
|
@@ -145,10 +145,65 @@ const switched = (second: string): string =>
|
|
|
145
145
|
const aidc = (body: string, cls = IEEE) =>
|
|
146
146
|
paper(tex(`\\section{Introduction}\n${body}\n${STATEMENT}`, cls), AIDC);
|
|
147
147
|
|
|
148
|
+
// ── a paper that declares a talk: a preset of its own over agenticdev, and synthetic media ───────
|
|
149
|
+
|
|
150
|
+
const media = (name: string): Uint8Array =>
|
|
151
|
+
readFileSync(join(ROOT, "fixtures", "talk", name));
|
|
152
|
+
/** The talk block of the tree's own preset: every limit the 3 s, 1280×720 fixture meets. */
|
|
153
|
+
const TALK_VENUE = {
|
|
154
|
+
modes: ["remote-video"],
|
|
155
|
+
artifacts: {
|
|
156
|
+
"remote-video": { required: ["video", "one-slide", "captions"] },
|
|
157
|
+
},
|
|
158
|
+
kinds: { short: { slot_s: 10, talk_s_min: 2, talk_s_max: 4 } },
|
|
159
|
+
video: { container: "mp4", min_height_px: 720, max_bytes: 100_000 },
|
|
160
|
+
one_slide: { width_px: 16, height_px: 9 },
|
|
161
|
+
};
|
|
162
|
+
/** A paper declaring a talk, its preset's `talk` patched, its files replaced or (null) removed. */
|
|
163
|
+
const talkPaper = (
|
|
164
|
+
venue: Record<string, unknown> = {},
|
|
165
|
+
talk: Record<string, unknown> | null = { mode: "remote-video" },
|
|
166
|
+
files: Record<string, string | Uint8Array | null> = {},
|
|
167
|
+
): Record<string, string | Uint8Array> => {
|
|
168
|
+
const all: Record<string, string | Uint8Array | null> = {
|
|
169
|
+
[`${P}/talk/talk.mp4`]: media("talk.mp4"),
|
|
170
|
+
[`${P}/talk/one-slide.png`]: media("one-slide.png"),
|
|
171
|
+
[`${P}/talk/talk.srt`]: media("talk.srt"),
|
|
172
|
+
...files,
|
|
173
|
+
};
|
|
174
|
+
return {
|
|
175
|
+
...paper(ACM_TEX, {
|
|
176
|
+
[`${P}/talk-venue.jsonc`]: JSON.stringify({
|
|
177
|
+
extends: "paperlint:agenticdev",
|
|
178
|
+
talk: { ...TALK_VENUE, ...venue },
|
|
179
|
+
}),
|
|
180
|
+
[`${P}/paperlint.json`]: JSON.stringify({
|
|
181
|
+
extends: "./talk-venue.jsonc",
|
|
182
|
+
kind: "short",
|
|
183
|
+
...(talk === null ? {} : { talk }),
|
|
184
|
+
}),
|
|
185
|
+
}),
|
|
186
|
+
...Object.fromEntries(
|
|
187
|
+
Object.entries(all).filter(
|
|
188
|
+
(e): e is [string, string | Uint8Array] => e[1] !== null,
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
const onTalk = (
|
|
194
|
+
tree: Record<string, string | Uint8Array>,
|
|
195
|
+
severity: 1 | 2 = 2,
|
|
196
|
+
) => ({
|
|
197
|
+
tree,
|
|
198
|
+
file: TEX_FILE,
|
|
199
|
+
severity,
|
|
200
|
+
line: 1,
|
|
201
|
+
});
|
|
202
|
+
|
|
148
203
|
// ── the case table ─────────────────────────────────────────────────────────────────────────────
|
|
149
204
|
|
|
150
205
|
interface Reports {
|
|
151
|
-
readonly tree: Record<string, string>;
|
|
206
|
+
readonly tree: Record<string, string | Uint8Array>;
|
|
152
207
|
/** The file the finding is on, relative to the project. */
|
|
153
208
|
readonly file: string;
|
|
154
209
|
readonly severity: 1 | 2;
|
|
@@ -157,7 +212,7 @@ interface Reports {
|
|
|
157
212
|
interface RuleCases {
|
|
158
213
|
readonly reports: Reports;
|
|
159
214
|
/** A tree on which the rule says nothing, on any file. */
|
|
160
|
-
readonly silent: Record<string, string>;
|
|
215
|
+
readonly silent: Record<string, string | Uint8Array>;
|
|
161
216
|
}
|
|
162
217
|
|
|
163
218
|
const TEX_FILE = `${P}/paper.tex`;
|
|
@@ -463,6 +518,56 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
463
518
|
{ ...AGENTICDEV, rules: { "pdf/last-page-balance": "error" } },
|
|
464
519
|
),
|
|
465
520
|
},
|
|
521
|
+
"talk/profile": {
|
|
522
|
+
reports: onTalk(talkPaper({}, { mode: "in-person" })),
|
|
523
|
+
silent: talkPaper(),
|
|
524
|
+
},
|
|
525
|
+
"talk/required-files": {
|
|
526
|
+
reports: onTalk(
|
|
527
|
+
talkPaper({}, undefined, { [`${P}/talk/one-slide.png`]: null }),
|
|
528
|
+
),
|
|
529
|
+
silent: talkPaper(),
|
|
530
|
+
},
|
|
531
|
+
"talk/undeclared": {
|
|
532
|
+
reports: onTalk(talkPaper({}, null), 1),
|
|
533
|
+
silent: talkPaper(),
|
|
534
|
+
},
|
|
535
|
+
"talk/duration": {
|
|
536
|
+
reports: onTalk(
|
|
537
|
+
talkPaper({ kinds: { short: { slot_s: 10, talk_s_max: 2 } } }),
|
|
538
|
+
),
|
|
539
|
+
silent: talkPaper(),
|
|
540
|
+
},
|
|
541
|
+
"talk/duration-floor": {
|
|
542
|
+
reports: onTalk(
|
|
543
|
+
talkPaper({
|
|
544
|
+
kinds: { short: { slot_s: 10, talk_s_min: 5, talk_s_max: 9 } },
|
|
545
|
+
}),
|
|
546
|
+
1,
|
|
547
|
+
),
|
|
548
|
+
silent: talkPaper(),
|
|
549
|
+
},
|
|
550
|
+
"talk/video-format": {
|
|
551
|
+
reports: onTalk(
|
|
552
|
+
talkPaper({ video: { container: "mp4", min_height_px: 1080 } }),
|
|
553
|
+
),
|
|
554
|
+
silent: talkPaper(),
|
|
555
|
+
},
|
|
556
|
+
"talk/one-slide-size": {
|
|
557
|
+
reports: onTalk(
|
|
558
|
+
talkPaper({ one_slide: { width_px: 1920, height_px: 1080 } }),
|
|
559
|
+
),
|
|
560
|
+
silent: talkPaper(),
|
|
561
|
+
},
|
|
562
|
+
"talk/captions-cover": {
|
|
563
|
+
reports: onTalk(
|
|
564
|
+
talkPaper({}, undefined, {
|
|
565
|
+
[`${P}/talk/talk.srt`]: "1\n00:00:02,000 --> 00:00:02,900\nLate.\n",
|
|
566
|
+
}),
|
|
567
|
+
1,
|
|
568
|
+
),
|
|
569
|
+
silent: talkPaper(),
|
|
570
|
+
},
|
|
466
571
|
};
|
|
467
572
|
|
|
468
573
|
/**
|
|
@@ -491,7 +596,9 @@ type Message = z.infer<typeof LintResults>[number]["messages"][number] & {
|
|
|
491
596
|
};
|
|
492
597
|
|
|
493
598
|
/** `paperlint lint --json` over the tree, in-process through the CLI: every message, by file. */
|
|
494
|
-
async function lintTree(
|
|
599
|
+
async function lintTree(
|
|
600
|
+
tree: Record<string, string | Uint8Array>,
|
|
601
|
+
): Promise<Message[]> {
|
|
495
602
|
const dir = writeTree(useTempDir("paperlint-rule-cases-"), {
|
|
496
603
|
"package.json": '{"name":"consumer","private":true}',
|
|
497
604
|
...tree,
|