pr-docent 0.1.0 β 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +214 -0
- package/README.md +141 -80
- package/bin/pr-docent.mjs +34 -5
- package/host/claude.mjs +78 -0
- package/host/native.mjs +3 -7
- package/host/pr-docent-host.mjs +66 -75
- package/host/render-short.mjs +96 -0
- package/host/short-cli.mjs +56 -0
- package/host/short.mjs +71 -0
- package/package.json +8 -2
- package/scripts/check-short.mjs +37 -0
- package/scripts/check-tour.mjs +18 -6
- package/scripts/short-prompt.md +63 -0
- package/scripts/tour-prompt.md +36 -19
- package/src/diff.js +18 -2
- package/src/guidelines.js +102 -0
- package/src/short-scenes.js +127 -0
- package/src/short.js +158 -0
- package/src/tour.js +44 -2
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
You are writing a PR Docent short for pull request {{PR}}: a 30-45 second vertical explainer,
|
|
2
|
+
played with narration in the reviewer's browser, for teammates who will not read the code, such as
|
|
3
|
+
planners and people on other teams. It shows no code. Each scene is a simple picture of the idea
|
|
4
|
+
(a flow, a before-and-after, a structure, or a few points) that builds up while one sentence is
|
|
5
|
+
read aloud and subtitled.
|
|
6
|
+
|
|
7
|
+
## Inputs
|
|
8
|
+
|
|
9
|
+
- `{{OUT}}/tour.json`: the reviewers' tour of this PR. Start from it; it already knows what
|
|
10
|
+
matters and why.
|
|
11
|
+
- `{{OUT}}/pr.json`: the PR's title, body and head commit.
|
|
12
|
+
- `{{OUT}}/pr.diff` and the changed files in the current directory, if you need to check a detail.
|
|
13
|
+
|
|
14
|
+
## Output
|
|
15
|
+
|
|
16
|
+
Write the short to `{{OUT}}/short.json`, then run
|
|
17
|
+
|
|
18
|
+
node {{TOOLS}}/scripts/check-short.mjs {{OUT}}/short.json {{PR}}
|
|
19
|
+
|
|
20
|
+
and fix every problem it reports until it prints `OK`. Then stop. Use these absolute paths as
|
|
21
|
+
written and do not change directory: `{{OUT}}/short.json` is the only file you may write.
|
|
22
|
+
|
|
23
|
+
## Format
|
|
24
|
+
|
|
25
|
+
Write every text in the short in {{LANGUAGE}}: the title, hook, headlines, narration, labels,
|
|
26
|
+
items and outro. The example below only shows the shape.
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"version": 1,
|
|
31
|
+
"pr": { "owner": "...", "repo": "...", "number": 0 },
|
|
32
|
+
"title": "Short title",
|
|
33
|
+
"hook": "One sentence on what changes and why",
|
|
34
|
+
"scenes": [
|
|
35
|
+
{ "kind": "compare", "headline": "What changes", "narration": "One sentence",
|
|
36
|
+
"before": { "label": "Before", "items": ["..."] }, "after": { "label": "After", "items": ["..."] } },
|
|
37
|
+
{ "kind": "flow", "headline": "How it works", "narration": "One sentence",
|
|
38
|
+
"steps": [{ "icon": "π", "label": "Short name", "detail": "One-line detail", "status": "same" },
|
|
39
|
+
{ "icon": "π€", "label": "...", "detail": "...", "status": "new" }],
|
|
40
|
+
"links": ["Label on the arrow"] },
|
|
41
|
+
{ "kind": "structure", "headline": "Where it fits", "narration": "One sentence",
|
|
42
|
+
"groups": [{ "label": "Layer or module", "items": [{ "icon": "π¦", "label": "...", "status": "changed" }] }] },
|
|
43
|
+
{ "kind": "points", "headline": "Remember", "narration": "One sentence",
|
|
44
|
+
"points": [{ "icon": "β
", "text": "Short point" }] }
|
|
45
|
+
],
|
|
46
|
+
"outro": "One sentence on the impact or what to check"
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
- Write in {{LANGUAGE}}. Plain words a non-developer understands: what changes for users, data,
|
|
53
|
+
operations or the team. Name a component only when the picture needs it, and call it by what
|
|
54
|
+
it does ("the tour checker", "the payment module") rather than by a class or file name.
|
|
55
|
+
- 2 to 4 scenes that tell one story, usually: what changes (compare) β how it works (flow or
|
|
56
|
+
structure) β what it means (points). Pick the kinds that fit this PR; do not force all four.
|
|
57
|
+
- Mark what this PR adds as `"status": "new"` and what it modifies as `"changed"`; leave the rest
|
|
58
|
+
as `"same"` so the eye goes to the change.
|
|
59
|
+
- Keep it small enough for a phone: flow 2-5 steps, compare 1-3 items per side, structure 1-3
|
|
60
|
+
groups and at most 8 items, points 2-3. Headlines at most 24 characters, labels at most 16,
|
|
61
|
+
details at most 24, every sentence (hook, narration, outro) one sentence of at most 80.
|
|
62
|
+
- One emoji per icon, chosen to be recognisable at a glance.
|
|
63
|
+
- Never invent behaviour the PR does not have.
|
package/scripts/tour-prompt.md
CHANGED
|
@@ -16,7 +16,7 @@ code while they listen, so the tour must say what to look at and why, in reading
|
|
|
16
16
|
|
|
17
17
|
Write the tour to `{{OUT}}/tour.json`, then run
|
|
18
18
|
|
|
19
|
-
node {{TOOLS}}/scripts/check-tour.mjs {{OUT}}/tour.json {{OUT}}/pr.diff {{PR}}
|
|
19
|
+
node {{TOOLS}}/scripts/check-tour.mjs {{OUT}}/tour.json {{OUT}}/pr.diff {{PR}} --guidelines {{OUT}}/guidelines.md
|
|
20
20
|
|
|
21
21
|
and fix every problem it reports until it prints `OK`. Then stop. Do not post comments or
|
|
22
22
|
change any other file. Use these absolute paths as written and do not change directory:
|
|
@@ -24,16 +24,19 @@ change any other file. Use these absolute paths as written and do not change dir
|
|
|
24
24
|
|
|
25
25
|
## Format
|
|
26
26
|
|
|
27
|
+
Write every text in the tour in {{LANGUAGE}}: the title, the summaries, chapter titles, notes,
|
|
28
|
+
review points and skipped reasons. The example below only shows the shape.
|
|
29
|
+
|
|
27
30
|
```json
|
|
28
31
|
{
|
|
29
32
|
"version": 1,
|
|
30
33
|
"pr": { "owner": "...", "repo": "...", "number": 0 },
|
|
31
|
-
"title": "
|
|
32
|
-
"summary": "PR
|
|
34
|
+
"title": "One-line title",
|
|
35
|
+
"summary": "What the PR changes and why, in 2-4 sentences",
|
|
33
36
|
"chapters": [
|
|
34
37
|
{
|
|
35
|
-
"title": "1.
|
|
36
|
-
"summary": "
|
|
38
|
+
"title": "1. Chapter title",
|
|
39
|
+
"summary": "One sentence",
|
|
37
40
|
"steps": [
|
|
38
41
|
{
|
|
39
42
|
"file": "path/from/repo/root.kt",
|
|
@@ -41,26 +44,37 @@ change any other file. Use these absolute paths as written and do not change dir
|
|
|
41
44
|
"side": "right",
|
|
42
45
|
"level": "focus",
|
|
43
46
|
"note": [
|
|
44
|
-
{ "text": "
|
|
45
|
-
{ "text": "
|
|
47
|
+
{ "text": "This sentence points at the lines it talks about.", "lines": [41, 45] },
|
|
48
|
+
{ "text": "The next sentence points at other lines.", "lines": [50, 58] }
|
|
46
49
|
],
|
|
47
|
-
"point": "
|
|
50
|
+
"point": "A specific question for the reviewer to check"
|
|
48
51
|
}
|
|
49
52
|
]
|
|
50
53
|
}
|
|
51
54
|
],
|
|
52
|
-
"skipped": [{ "file": "package-lock.json", "reason": "
|
|
55
|
+
"skipped": [{ "file": "package-lock.json", "reason": "dependency lock file" }]
|
|
53
56
|
}
|
|
54
57
|
```
|
|
55
58
|
|
|
56
59
|
## Rules
|
|
57
60
|
|
|
61
|
+
Be brief. Reviewers listen while they look at the code, so every sentence must earn its place.
|
|
62
|
+
The checker enforces these limits for this diff ({{CHANGED_LINES}} changed lines):
|
|
63
|
+
- At most {{MAX_STEPS}} steps in total.
|
|
64
|
+
- At most {{SENTENCES_PER_STEP}} sentences per step, each at most {{SENTENCE_CHARS}} characters.
|
|
65
|
+
- `point`: one question, at most {{POINT_CHARS}} characters.
|
|
66
|
+
- `summary`: at most {{SUMMARY_CHARS}} characters; each chapter `summary`: at most
|
|
67
|
+
{{CHAPTER_SUMMARY_CHARS}}.
|
|
68
|
+
|
|
58
69
|
Coverage and order
|
|
59
|
-
- Every changed file must be visited by a step or listed in `skipped` with a reason
|
|
60
|
-
|
|
70
|
+
- Every changed file must be visited by a step or listed in `skipped` with a short reason.
|
|
71
|
+
- Make steps only for changes a reviewer must think about. Put trivial changes in `skipped`
|
|
72
|
+
instead of steps: comment or doc-comment edits, imports, docs, test fixtures that pass values
|
|
73
|
+
through, lock files, generated code, renames, binaries. A file can have a step for its real
|
|
74
|
+
change and nothing for its trivial ones.
|
|
75
|
+
- Merge neighbouring changes that make one point into one step; never repeat a step.
|
|
61
76
|
- Order chapters the way a person should read the change, not by file path: what changes and
|
|
62
|
-
why, then the core logic, then how it is wired in, then tests
|
|
63
|
-
- A large file may need several steps; a trivial one may share a chapter with related files.
|
|
77
|
+
why, then the core logic, then how it is wired in, then tests.
|
|
64
78
|
|
|
65
79
|
Lines
|
|
66
80
|
- `lines` are line numbers in the new file (`"side": "right"`, the default). Use
|
|
@@ -72,13 +86,16 @@ Lines
|
|
|
72
86
|
step as a whole.
|
|
73
87
|
|
|
74
88
|
Notes are spoken aloud
|
|
75
|
-
- Write in {{LANGUAGE}}. One
|
|
76
|
-
- Say
|
|
89
|
+
- Write in {{LANGUAGE}}. One sentence per note entry, one idea per sentence.
|
|
90
|
+
- Say why the change matters: intent, risk, or how it connects to other code. Do not restate
|
|
91
|
+
what the code plainly shows ("adds a parameter", "imports X", "updates the comment").
|
|
77
92
|
- Avoid symbols, brackets and long lists that sound bad in speech. Name identifiers as written.
|
|
78
93
|
|
|
79
94
|
Levels and review points
|
|
80
95
|
- `focus`: where a bug would hurt or a design decision is made; at most about a third of the
|
|
81
|
-
steps. `skim`: worth a look.
|
|
82
|
-
- `point` is
|
|
83
|
-
a failure mode, a contract another caller relies on. Leave it out when there is nothing
|
|
84
|
-
to check. Never invent problems.
|
|
96
|
+
steps. `skim`: worth a look.
|
|
97
|
+
- `point` is one concrete question for the reviewer, grounded in the code you read: an edge
|
|
98
|
+
case, a failure mode, a contract another caller relies on. Leave it out when there is nothing
|
|
99
|
+
real to check. Never invent problems.
|
|
100
|
+
|
|
101
|
+
{{GUIDELINES}}
|
package/src/diff.js
CHANGED
|
@@ -43,16 +43,20 @@ export function visibleLines(diffText) {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Problems that would make the tour point at the wrong place or skip part of the PR:
|
|
46
|
-
* files outside the diff, lines not shown on the page, beats outside their step,
|
|
47
|
-
* changed files the tour neither visits nor skips.
|
|
46
|
+
* files outside the diff, lines not shown on the page, beats outside their step, repeated
|
|
47
|
+
* steps, and changed files the tour neither visits nor skips.
|
|
48
48
|
*/
|
|
49
49
|
export function checkTour(tour, files) {
|
|
50
50
|
const errors = [];
|
|
51
51
|
const visited = new Set();
|
|
52
|
+
const seen = new Set();
|
|
52
53
|
tour.chapters.forEach((chapter, c) =>
|
|
53
54
|
chapter.steps.forEach((step, s) => {
|
|
54
55
|
const where = `chapters[${c}].steps[${s}] (${step.file})`;
|
|
55
56
|
visited.add(step.file);
|
|
57
|
+
const key = `${step.file}|${step.side}|${step.lines}`;
|
|
58
|
+
if (seen.has(key)) errors.push(`${where}: repeats an earlier step on the same lines`);
|
|
59
|
+
seen.add(key);
|
|
56
60
|
const file = files.get(step.file);
|
|
57
61
|
if (!file) return errors.push(`${where}: file is not changed in this pull request`);
|
|
58
62
|
const shown = file[step.side];
|
|
@@ -80,6 +84,18 @@ export function checkTour(tour, files) {
|
|
|
80
84
|
return errors;
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
/** Added plus removed lines, which sets how long a tour of the diff may be. */
|
|
88
|
+
export function changedLineCount(diffText) {
|
|
89
|
+
let count = 0;
|
|
90
|
+
let inHunk = false;
|
|
91
|
+
for (const line of String(diffText).split("\n")) {
|
|
92
|
+
if (line.startsWith("diff --git ")) inHunk = false;
|
|
93
|
+
else if (line.startsWith("@@")) inHunk = true;
|
|
94
|
+
else if (inHunk && (line.startsWith("+") || line.startsWith("-"))) count++;
|
|
95
|
+
}
|
|
96
|
+
return count;
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
function range([start, end]) {
|
|
84
100
|
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
|
85
101
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// How long a generated tour may be, and a repository's own instructions for it.
|
|
2
|
+
// Pure functions β no DOM, no chrome.*, no file system.
|
|
3
|
+
//
|
|
4
|
+
// A repository can add .github/pr-docent.md. Its optional front matter overrides the limits,
|
|
5
|
+
// and the rest is appended to the generator's instructions:
|
|
6
|
+
//
|
|
7
|
+
// ---
|
|
8
|
+
// maxSteps: 5
|
|
9
|
+
// sentenceChars: 100
|
|
10
|
+
// ---
|
|
11
|
+
// Use the domain terms μμ₯, μλΆ and νμ λΌμΈ. Always mark migrations as focus.
|
|
12
|
+
|
|
13
|
+
import { flattenSteps, splitSentences } from "./tour.js";
|
|
14
|
+
|
|
15
|
+
export const GUIDELINES_PATH = ".github/pr-docent.md";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_LIMITS = {
|
|
18
|
+
maxSteps: null, // null: 3 + 1 per 25 changed lines, at most 20
|
|
19
|
+
sentencesPerStep: 3,
|
|
20
|
+
sentenceChars: 80,
|
|
21
|
+
pointChars: 120,
|
|
22
|
+
summaryChars: 200,
|
|
23
|
+
chapterSummaryChars: 60,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** { limits, instructions, errors } from the text of .github/pr-docent.md (or "" when absent). */
|
|
27
|
+
export function parseGuidelines(text) {
|
|
28
|
+
const limits = { ...DEFAULT_LIMITS };
|
|
29
|
+
const errors = [];
|
|
30
|
+
const match = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
31
|
+
for (const line of match ? match[1].split(/\r?\n/) : []) {
|
|
32
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
33
|
+
const [, key, value] = line.replace(/\s+#.*$/, "").match(/^\s*(\w+)\s*:\s*(.*?)\s*$/) ?? [];
|
|
34
|
+
if (!key || !(key in DEFAULT_LIMITS)) errors.push(`Unknown setting: ${line.trim()}`);
|
|
35
|
+
else if (!/^\d+$/.test(value) || Number(value) < 1) errors.push(`${key} must be a positive whole number`);
|
|
36
|
+
else limits[key] = Number(value);
|
|
37
|
+
}
|
|
38
|
+
const instructions = String(text ?? "").slice(match ? match[0].length : 0).trim();
|
|
39
|
+
return { limits, instructions, errors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many steps a tour of a diff with [changedLines] added or removed lines may have. */
|
|
43
|
+
export function stepBudget(changedLines, limits = DEFAULT_LIMITS) {
|
|
44
|
+
return limits.maxSteps ?? Math.min(20, 3 + Math.ceil(changedLines / 25));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Problems that make a tour wordier than the limits allow. */
|
|
48
|
+
export function checkBrevity(tour, { changedLines, limits = DEFAULT_LIMITS }) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
const steps = flattenSteps(tour);
|
|
51
|
+
const budget = stepBudget(changedLines, limits);
|
|
52
|
+
if (steps.length > budget) {
|
|
53
|
+
errors.push(`${steps.length} steps for ${changedLines} changed lines; at most ${budget}. Merge related steps and list trivial changes (comments, imports, docs, pass-through code) in "skipped".`);
|
|
54
|
+
}
|
|
55
|
+
if (tour.summary.length > limits.summaryChars) errors.push(`summary is ${tour.summary.length} characters; at most ${limits.summaryChars}`);
|
|
56
|
+
tour.chapters.forEach((chapter, c) => {
|
|
57
|
+
if (chapter.summary.length > limits.chapterSummaryChars) {
|
|
58
|
+
errors.push(`chapters[${c}].summary is ${chapter.summary.length} characters; at most ${limits.chapterSummaryChars}`);
|
|
59
|
+
}
|
|
60
|
+
chapter.steps.forEach((step, s) => {
|
|
61
|
+
const where = `chapters[${c}].steps[${s}] (${step.file})`;
|
|
62
|
+
const sentences = step.beats.flatMap((beat) => splitSentences(beat.text).map((x) => x.text));
|
|
63
|
+
if (sentences.length > limits.sentencesPerStep) {
|
|
64
|
+
errors.push(`${where}: ${sentences.length} sentences; at most ${limits.sentencesPerStep}. Keep only what the code doesn't already say.`);
|
|
65
|
+
}
|
|
66
|
+
for (const sentence of sentences) {
|
|
67
|
+
if (sentence.length > limits.sentenceChars) {
|
|
68
|
+
errors.push(`${where}: a sentence is ${sentence.length} characters; at most ${limits.sentenceChars}: "${sentence.slice(0, 30)}β¦"`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (step.point.length > limits.pointChars) {
|
|
72
|
+
errors.push(`${where}: point is ${step.point.length} characters; at most ${limits.pointChars}. Ask one question.`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
return errors;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The generator's instructions: [template] (scripts/tour-prompt.md) with the paths, the limits
|
|
81
|
+
* for this diff and the repository's own instructions filled in.
|
|
82
|
+
*/
|
|
83
|
+
export function buildPrompt(template, { pr, out, tools, language, changedLines, guidelinesText = "" }) {
|
|
84
|
+
const { limits, instructions } = parseGuidelines(guidelinesText);
|
|
85
|
+
const vars = {
|
|
86
|
+
PR: pr,
|
|
87
|
+
OUT: out,
|
|
88
|
+
TOOLS: tools,
|
|
89
|
+
LANGUAGE: language,
|
|
90
|
+
CHANGED_LINES: changedLines,
|
|
91
|
+
MAX_STEPS: stepBudget(changedLines, limits),
|
|
92
|
+
SENTENCES_PER_STEP: limits.sentencesPerStep,
|
|
93
|
+
SENTENCE_CHARS: limits.sentenceChars,
|
|
94
|
+
POINT_CHARS: limits.pointChars,
|
|
95
|
+
SUMMARY_CHARS: limits.summaryChars,
|
|
96
|
+
CHAPTER_SUMMARY_CHARS: limits.chapterSummaryChars,
|
|
97
|
+
GUIDELINES: instructions
|
|
98
|
+
? `## Repository guidelines (${GUIDELINES_PATH})\n\nThese come from the repository and take precedence over the rules above.\n\n${instructions}\n`
|
|
99
|
+
: "",
|
|
100
|
+
};
|
|
101
|
+
return template.replace(/{{(\w+)}}/g, (match, name) => (name in vars ? String(vars[name]) : match));
|
|
102
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// How a short looks: the scene markup and styles shared by the extension's live player and the
|
|
2
|
+
// MP4 renderer, so a short looks the same in both. Pure string building β no DOM, no chrome.*.
|
|
3
|
+
// Everything is laid out on a 1080x1920 frame (.short-frame) that players scale to fit.
|
|
4
|
+
|
|
5
|
+
import { revealSteps } from "./short.js";
|
|
6
|
+
|
|
7
|
+
export const FRAME_WIDTH = 1080;
|
|
8
|
+
export const FRAME_HEIGHT = 1920;
|
|
9
|
+
|
|
10
|
+
/** The opening card, the picture scenes, and the closing card. */
|
|
11
|
+
export function sceneList(short) {
|
|
12
|
+
return [
|
|
13
|
+
{ kind: "card", headline: short.title, narration: short.hook, opening: true },
|
|
14
|
+
...short.scenes,
|
|
15
|
+
{ kind: "card", headline: short.title, narration: short.outro, opening: false },
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How many elements [scene] reveals one at a time (cards show all at once). */
|
|
20
|
+
export function sceneSteps(scene) {
|
|
21
|
+
return scene.kind === "card" ? 1 : revealSteps(scene);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where to start reading [text] to land near [fraction] of the way through it: the start of the
|
|
26
|
+
* word there, since speech can only start at a character offset and a cut word sounds broken.
|
|
27
|
+
*/
|
|
28
|
+
export function wordStartAt(text, fraction) {
|
|
29
|
+
const at = Math.floor(Math.min(Math.max(fraction, 0), 1) * text.length);
|
|
30
|
+
if (at >= text.length) return 0;
|
|
31
|
+
const space = text.lastIndexOf(" ", at);
|
|
32
|
+
return space < 0 ? 0 : space + 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const esc = (value) => String(value ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
36
|
+
|
|
37
|
+
/** Classes for the [i]th element when [shown] are visible: hidden until its turn, lit when it arrives. */
|
|
38
|
+
const reveal = (i, shown) => (i >= shown ? " hidden" : i === shown - 1 ? " current" : "");
|
|
39
|
+
|
|
40
|
+
function diagram(scene, shown, changedLabel) {
|
|
41
|
+
const badge = (status) => (status === "new" ? '<em class="tag new">NEW</em>' : status === "changed" ? `<em class="tag changed">${esc(changedLabel)}</em>` : "");
|
|
42
|
+
if (scene.kind === "flow") {
|
|
43
|
+
return `<div class="flow">${scene.steps.map((step, i) => `
|
|
44
|
+
${i > 0 ? `<div class="arrow${reveal(i, shown)}">${scene.links[i - 1] ? `<span>${esc(scene.links[i - 1])}</span>` : ""}</div>` : ""}
|
|
45
|
+
<div class="node ${step.status}${reveal(i, shown)}"><div class="ico">${esc(step.icon)}</div>
|
|
46
|
+
<div><b>${esc(step.label)}${badge(step.status)}</b>${step.detail ? `<small>${esc(step.detail)}</small>` : ""}</div></div>`).join("")}</div>`;
|
|
47
|
+
}
|
|
48
|
+
if (scene.kind === "compare") {
|
|
49
|
+
const side = (value, cls, i) => `<div class="side ${cls}${reveal(i, shown)}"><p class="side-label">${esc(value.label)}</p>
|
|
50
|
+
<ul>${value.items.map((item) => `<li>${esc(item)}</li>`).join("")}</ul></div>`;
|
|
51
|
+
return `<div class="compare">${side(scene.before, "before", 0)}<div class="arrow${reveal(1, shown)}"></div>${side(scene.after, "after", 1)}</div>`;
|
|
52
|
+
}
|
|
53
|
+
if (scene.kind === "structure") {
|
|
54
|
+
return `<div class="structure">${scene.groups.map((group, i) => `
|
|
55
|
+
<div class="group${reveal(i, shown)}"><p class="group-label">${esc(group.label)}</p><div class="chips">${group.items.map((item) =>
|
|
56
|
+
`<span class="chip ${item.status}">${item.icon ? `${esc(item.icon)} ` : ""}${esc(item.label)}${badge(item.status)}</span>`).join("")}</div></div>`).join("")}</div>`;
|
|
57
|
+
}
|
|
58
|
+
if (scene.kind === "points") {
|
|
59
|
+
return `<div class="points">${scene.points.map((point, i) => `
|
|
60
|
+
<div class="point${reveal(i, shown)}"><div class="ico">${esc(point.icon || "β’")}</div><p>${esc(point.text)}</p></div>`).join("")}</div>`;
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The inside of a .short-frame for [scene] with [shown] of its elements visible. */
|
|
66
|
+
export function sceneMarkup(scene, { short, shown }) {
|
|
67
|
+
const { owner, repo, number } = short.pr;
|
|
68
|
+
const changedLabel = /[κ°-ν£]/.test(JSON.stringify(short)) ? "μμ " : "Changed";
|
|
69
|
+
const main = scene.kind === "card"
|
|
70
|
+
? `<div class="card"><p class="repo">${esc(scene.opening ? `${owner}/${repo} #${number}` : `github.com/${owner}/${repo}/pull/${number}`)}</p><h1>${esc(scene.headline)}</h1></div>`
|
|
71
|
+
: `<h2>${esc(scene.headline)}</h2><div class="stage">${diagram(scene, shown, changedLabel)}</div>`;
|
|
72
|
+
return `<header>PR Docent <span>Β· ${esc(`${owner}/${repo} #${number}`)}</span></header>
|
|
73
|
+
<main>${main}</main>
|
|
74
|
+
<footer><p class="subtitle">${esc(scene.narration)}</p></footer>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const SHORT_CSS = `
|
|
78
|
+
.short-frame, .short-frame * { box-sizing: border-box; margin: 0; padding: 0; }
|
|
79
|
+
.short-frame { width: ${FRAME_WIDTH}px; height: ${FRAME_HEIGHT}px; overflow: hidden; display: flex; flex-direction: column;
|
|
80
|
+
background: linear-gradient(160deg, #161b22 0%, #0d1117 55%, #1f1235 100%); color: #f0f6fc;
|
|
81
|
+
font-family: -apple-system, "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", sans-serif; word-break: keep-all; }
|
|
82
|
+
.short-frame header { padding: 64px 72px 0; font-size: 30px; color: #a78bfa; font-weight: 700; }
|
|
83
|
+
.short-frame header span { color: #8b949e; font-weight: 500; }
|
|
84
|
+
.short-frame main { flex: 1; display: flex; flex-direction: column; padding: 0 72px; }
|
|
85
|
+
.short-frame .card { margin: auto 0; }
|
|
86
|
+
.short-frame .card .repo { font-size: 34px; color: #8b949e; margin-bottom: 28px; }
|
|
87
|
+
.short-frame .card h1 { font-size: 80px; line-height: 1.25; font-weight: 800; }
|
|
88
|
+
.short-frame h2 { padding-top: 56px; font-size: 66px; line-height: 1.25; font-weight: 800; color: #f0f6fc; }
|
|
89
|
+
.short-frame .stage { flex: 1; display: flex; align-items: center; justify-content: center; }
|
|
90
|
+
.short-frame .hidden { opacity: 0; }
|
|
91
|
+
.short-frame .current { box-shadow: 0 0 0 4px rgba(167, 139, 250, .55), 0 0 48px rgba(130, 80, 223, .55); }
|
|
92
|
+
.short-frame .node, .short-frame .side, .short-frame .group, .short-frame .point, .short-frame .arrow { transition: opacity .45s ease, box-shadow .45s ease; }
|
|
93
|
+
.short-frame .tag { font-style: normal; margin-left: 14px; font-size: 22px; padding: 4px 12px; border-radius: 999px; vertical-align: middle; }
|
|
94
|
+
.short-frame .tag.new { background: #8250df; } .short-frame .tag.changed { background: #9a6700; }
|
|
95
|
+
.short-frame .ico { font-size: 60px; width: 80px; text-align: center; flex: none; }
|
|
96
|
+
.short-frame .flow { width: 100%; display: flex; flex-direction: column; align-items: center; }
|
|
97
|
+
.short-frame .node { width: 100%; border-radius: 28px; padding: 28px 36px; background: #161b22; border: 3px solid #30363d; display: flex; gap: 26px; align-items: center; }
|
|
98
|
+
.short-frame .node b { display: block; font-size: 44px; }
|
|
99
|
+
.short-frame .node small { display: block; font-size: 30px; color: #8b949e; margin-top: 6px; }
|
|
100
|
+
.short-frame .node.new { border-color: #8250df; background: rgba(130, 80, 223, .14); }
|
|
101
|
+
.short-frame .node.changed { border-color: #bf8700; background: rgba(191, 135, 0, .12); }
|
|
102
|
+
.short-frame .arrow { height: 76px; width: 6px; background: #484f58; position: relative; margin: 6px 0 14px; flex: none; }
|
|
103
|
+
.short-frame .arrow::after { content: ""; position: absolute; bottom: -10px; left: -12px; border: 15px solid transparent; border-top-color: #484f58; }
|
|
104
|
+
.short-frame .arrow.current { box-shadow: none; }
|
|
105
|
+
.short-frame .arrow span { position: absolute; left: 32px; top: 18px; white-space: nowrap; font-size: 28px; color: #8b949e; }
|
|
106
|
+
.short-frame .compare { width: 100%; display: flex; flex-direction: column; align-items: center; }
|
|
107
|
+
.short-frame .side { width: 100%; border-radius: 28px; padding: 34px 40px; border: 3px solid #30363d; background: #161b22; }
|
|
108
|
+
.short-frame .side.before { opacity: .75; } .short-frame .side.before.hidden { opacity: 0; }
|
|
109
|
+
.short-frame .side.after { border-color: #8250df; background: rgba(130, 80, 223, .14); }
|
|
110
|
+
.short-frame .side-label { font-size: 32px; font-weight: 700; color: #a78bfa; margin-bottom: 16px; }
|
|
111
|
+
.short-frame .side.before .side-label { color: #8b949e; }
|
|
112
|
+
.short-frame .side li { list-style: none; font-size: 42px; line-height: 1.5; font-weight: 600; }
|
|
113
|
+
.short-frame .side li::before { content: "Β· "; color: #8b949e; }
|
|
114
|
+
.short-frame .structure { width: 100%; display: flex; flex-direction: column; gap: 34px; }
|
|
115
|
+
.short-frame .group { border-radius: 28px; padding: 30px 34px; border: 3px dashed #30363d; }
|
|
116
|
+
.short-frame .group-label { font-size: 32px; font-weight: 700; color: #8b949e; margin-bottom: 18px; }
|
|
117
|
+
.short-frame .chips { display: flex; flex-wrap: wrap; gap: 16px; }
|
|
118
|
+
.short-frame .chip { font-size: 38px; font-weight: 700; padding: 16px 26px; border-radius: 20px; background: #161b22; border: 3px solid #30363d; }
|
|
119
|
+
.short-frame .chip.new { border-color: #8250df; background: rgba(130, 80, 223, .16); }
|
|
120
|
+
.short-frame .chip.changed { border-color: #bf8700; background: rgba(191, 135, 0, .12); }
|
|
121
|
+
.short-frame .points { width: 100%; display: flex; flex-direction: column; gap: 30px; }
|
|
122
|
+
.short-frame .point { display: flex; gap: 26px; align-items: center; border-radius: 28px; padding: 34px 36px; background: #161b22; border: 3px solid #30363d; }
|
|
123
|
+
.short-frame .point p { font-size: 46px; line-height: 1.35; font-weight: 700; }
|
|
124
|
+
.short-frame footer { padding: 0 64px 96px; }
|
|
125
|
+
.short-frame .subtitle { background: rgba(13, 17, 23, .86); border: 2px solid #8250df; border-radius: 28px; padding: 30px 40px;
|
|
126
|
+
font-size: 42px; line-height: 1.4; font-weight: 700; text-align: center; }
|
|
127
|
+
`;
|
package/src/short.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Shorts: a 30-45 second vertical video that explains a pull request to teammates who won't
|
|
2
|
+
// review it (planners, other teams) with pictures instead of code. Written from the PR's tour,
|
|
3
|
+
// rendered by the local host. Pure functions β no DOM, no chrome.*, no file system.
|
|
4
|
+
//
|
|
5
|
+
// A short (version 1):
|
|
6
|
+
// {
|
|
7
|
+
// "version": 1,
|
|
8
|
+
// "pr": { "owner": "...", "repo": "...", "number": 42 },
|
|
9
|
+
// "title": "...", "hook": "What changes and why, in one sentence.", // the opening card
|
|
10
|
+
// "scenes": [ ...one of the kinds below... ],
|
|
11
|
+
// "outro": "What it means or what to watch for, in one sentence." // the closing card
|
|
12
|
+
// }
|
|
13
|
+
// Every scene has "headline" (a few words, shown large) and "narration" (read aloud, subtitled).
|
|
14
|
+
// flow: "steps": [{ "icon", "label", "detail", "status" }], "links": ["label between steps", ...]
|
|
15
|
+
// compare: "before": { "label", "items": [...] }, "after": { "label", "items": [...] }
|
|
16
|
+
// structure: "groups": [{ "label", "items": [{ "icon", "label", "status" }] }]
|
|
17
|
+
// points: "points": [{ "icon", "text" }]
|
|
18
|
+
// status is "new", "changed" or "same" (default) and marks what this pull request adds or changes.
|
|
19
|
+
|
|
20
|
+
export const SHORT_LIMITS = {
|
|
21
|
+
minScenes: 2, maxScenes: 4, titleChars: 40, headlineChars: 24, sentenceChars: 80,
|
|
22
|
+
labelChars: 16, detailChars: 24, itemChars: 28, pointChars: 32,
|
|
23
|
+
};
|
|
24
|
+
export const SCENE_KINDS = ["flow", "compare", "structure", "points"];
|
|
25
|
+
const STATUSES = ["new", "changed", "same"];
|
|
26
|
+
|
|
27
|
+
const text = (value) => (typeof value === "string" ? value.trim() : "");
|
|
28
|
+
|
|
29
|
+
/** Validates and normalizes raw JSON. Returns { short, errors }; short is null when there are errors. */
|
|
30
|
+
export function parseShort(raw) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
const fail = (message) => errors.push(message);
|
|
33
|
+
if (!raw || typeof raw !== "object") return { short: null, errors: ["Short must be a JSON object"] };
|
|
34
|
+
if (raw.version !== 1) fail(`Unsupported short version: ${raw.version}`);
|
|
35
|
+
const pr = raw.pr || {};
|
|
36
|
+
if (!pr.owner || !pr.repo || !Number.isInteger(pr.number)) fail("pr.owner, pr.repo and pr.number are required");
|
|
37
|
+
for (const field of ["title", "hook", "outro"]) if (!text(raw[field])) fail(`${field} is required`);
|
|
38
|
+
if (!Array.isArray(raw.scenes)) fail("scenes must be a list");
|
|
39
|
+
|
|
40
|
+
const scenes = (raw.scenes || []).map((scene, i) => {
|
|
41
|
+
const where = `scenes[${i}]`;
|
|
42
|
+
const kind = scene?.kind;
|
|
43
|
+
if (!SCENE_KINDS.includes(kind)) fail(`${where}.kind must be one of ${SCENE_KINDS.join(", ")}`);
|
|
44
|
+
if (!text(scene?.headline)) fail(`${where}.headline is required`);
|
|
45
|
+
if (!text(scene?.narration)) fail(`${where}.narration is required`);
|
|
46
|
+
const status = (value, at) => {
|
|
47
|
+
const s = value ?? "same";
|
|
48
|
+
if (!STATUSES.includes(s)) fail(`${at}.status must be one of ${STATUSES.join(", ")}`);
|
|
49
|
+
return s;
|
|
50
|
+
};
|
|
51
|
+
const list = (value, at) => {
|
|
52
|
+
if (!Array.isArray(value) || value.length === 0) fail(`${at} must be a non-empty list`);
|
|
53
|
+
return Array.isArray(value) ? value : [];
|
|
54
|
+
};
|
|
55
|
+
const base = { kind, headline: text(scene?.headline), narration: text(scene?.narration) };
|
|
56
|
+
if (kind === "flow") {
|
|
57
|
+
const steps = list(scene.steps, `${where}.steps`).map((step, s) => ({
|
|
58
|
+
icon: text(step?.icon), label: text(step?.label), detail: text(step?.detail), status: status(step?.status, `${where}.steps[${s}]`),
|
|
59
|
+
}));
|
|
60
|
+
steps.forEach((step, s) => !step.label && fail(`${where}.steps[${s}].label is required`));
|
|
61
|
+
return { ...base, steps, links: (Array.isArray(scene.links) ? scene.links : []).map(text) };
|
|
62
|
+
}
|
|
63
|
+
if (kind === "compare") {
|
|
64
|
+
const side = (value, name) => ({ label: text(value?.label), items: list(value?.items, `${where}.${name}.items`).map(text) });
|
|
65
|
+
return { ...base, before: side(scene.before, "before"), after: side(scene.after, "after") };
|
|
66
|
+
}
|
|
67
|
+
if (kind === "structure") {
|
|
68
|
+
const groups = list(scene.groups, `${where}.groups`).map((group, g) => ({
|
|
69
|
+
label: text(group?.label),
|
|
70
|
+
items: list(group?.items, `${where}.groups[${g}].items`).map((item, j) => ({
|
|
71
|
+
icon: text(item?.icon), label: text(item?.label), status: status(item?.status, `${where}.groups[${g}].items[${j}]`),
|
|
72
|
+
})),
|
|
73
|
+
}));
|
|
74
|
+
return { ...base, groups };
|
|
75
|
+
}
|
|
76
|
+
if (kind === "points") {
|
|
77
|
+
return { ...base, points: list(scene.points, `${where}.points`).map((point) => ({ icon: text(point?.icon), text: text(point?.text) })) };
|
|
78
|
+
}
|
|
79
|
+
return base;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (errors.length) return { short: null, errors };
|
|
83
|
+
return {
|
|
84
|
+
short: { version: 1, pr: { owner: pr.owner, repo: pr.repo, number: pr.number }, title: text(raw.title), hook: text(raw.hook), scenes, outro: text(raw.outro) },
|
|
85
|
+
errors: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Problems that would make the video long or crowded on a phone screen. */
|
|
90
|
+
export function checkShort(short, limits = SHORT_LIMITS) {
|
|
91
|
+
const errors = [];
|
|
92
|
+
const tooLong = (value, max, where) => {
|
|
93
|
+
if (value.length > max) errors.push(`${where} is ${value.length} characters; at most ${max}: "${value.slice(0, 24)}β¦"`);
|
|
94
|
+
};
|
|
95
|
+
const count = (n, min, max, where) => {
|
|
96
|
+
if (n < min || n > max) errors.push(`${where}: ${n}; use ${min}-${max}`);
|
|
97
|
+
};
|
|
98
|
+
count(short.scenes.length, limits.minScenes, limits.maxScenes, "scenes (the opening and closing cards come on top)");
|
|
99
|
+
tooLong(short.title, limits.titleChars, "title");
|
|
100
|
+
tooLong(short.hook, limits.sentenceChars, "hook");
|
|
101
|
+
tooLong(short.outro, limits.sentenceChars, "outro");
|
|
102
|
+
short.scenes.forEach((scene, i) => {
|
|
103
|
+
const where = `scenes[${i}] (${scene.kind})`;
|
|
104
|
+
tooLong(scene.headline, limits.headlineChars, `${where} headline`);
|
|
105
|
+
tooLong(scene.narration, limits.sentenceChars, `${where} narration`);
|
|
106
|
+
if (scene.kind === "flow") {
|
|
107
|
+
count(scene.steps.length, 2, 5, `${where} steps`);
|
|
108
|
+
scene.steps.forEach((step, s) => {
|
|
109
|
+
tooLong(step.label, limits.labelChars, `${where} steps[${s}].label`);
|
|
110
|
+
tooLong(step.detail, limits.detailChars, `${where} steps[${s}].detail`);
|
|
111
|
+
});
|
|
112
|
+
if (scene.links.length > scene.steps.length - 1) errors.push(`${where}: ${scene.links.length} links for ${scene.steps.length} steps; at most one between each pair`);
|
|
113
|
+
scene.links.forEach((link, l) => tooLong(link, limits.labelChars, `${where} links[${l}]`));
|
|
114
|
+
} else if (scene.kind === "compare") {
|
|
115
|
+
for (const name of ["before", "after"]) {
|
|
116
|
+
tooLong(scene[name].label, limits.labelChars, `${where} ${name}.label`);
|
|
117
|
+
count(scene[name].items.length, 1, 3, `${where} ${name}.items`);
|
|
118
|
+
scene[name].items.forEach((item, j) => tooLong(item, limits.itemChars, `${where} ${name}.items[${j}]`));
|
|
119
|
+
}
|
|
120
|
+
} else if (scene.kind === "structure") {
|
|
121
|
+
count(scene.groups.length, 1, 3, `${where} groups`);
|
|
122
|
+
count(scene.groups.reduce((n, g) => n + g.items.length, 0), 1, 8, `${where} items in all groups`);
|
|
123
|
+
scene.groups.forEach((group, g) => {
|
|
124
|
+
tooLong(group.label, limits.labelChars, `${where} groups[${g}].label`);
|
|
125
|
+
group.items.forEach((item, j) => tooLong(item.label, limits.labelChars, `${where} groups[${g}].items[${j}].label`));
|
|
126
|
+
});
|
|
127
|
+
} else if (scene.kind === "points") {
|
|
128
|
+
count(scene.points.length, 2, 3, `${where} points`);
|
|
129
|
+
scene.points.forEach((point, p) => tooLong(point.text, limits.pointChars, `${where} points[${p}]`));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return errors;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** How many elements a scene reveals one at a time while its sentence is read. */
|
|
136
|
+
export function revealSteps(scene) {
|
|
137
|
+
if (scene.kind === "flow") return scene.steps.length;
|
|
138
|
+
if (scene.kind === "compare") return 2;
|
|
139
|
+
if (scene.kind === "structure") return scene.groups.length;
|
|
140
|
+
if (scene.kind === "points") return scene.points.length;
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The short for [pr] among the code blocks of the PR's comments, or null (like findTourInBlocks). */
|
|
145
|
+
export function findShortInBlocks(blocks, { owner, repo, number }) {
|
|
146
|
+
let found = null;
|
|
147
|
+
for (const text of blocks) {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = JSON.parse(text);
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const forThisPr = raw?.version === 1 && raw.pr?.owner === owner && raw.pr?.repo === repo && raw.pr?.number === number;
|
|
155
|
+
if (forThisPr && Array.isArray(raw.scenes) && typeof raw.hook === "string") found = raw;
|
|
156
|
+
}
|
|
157
|
+
return found;
|
|
158
|
+
}
|