pr-docent 0.1.1 → 0.2.1
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 +131 -93
- package/bin/pr-docent.mjs +32 -4
- package/host/claude.mjs +81 -0
- package/host/native.mjs +3 -2
- package/host/pr-docent-host.mjs +46 -64
- package/host/render-short.mjs +96 -0
- package/host/short-cli.mjs +56 -0
- package/host/short.mjs +71 -0
- package/package.json +7 -2
- package/scripts/check-short.mjs +37 -0
- package/scripts/short-prompt.md +63 -0
- package/scripts/tour-prompt.md +11 -8
- package/src/short-scenes.js +127 -0
- package/src/short.js +158 -0
- package/src/tour.js +39 -3
|
@@ -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
|
+
}
|
package/src/tour.js
CHANGED
|
@@ -17,6 +17,36 @@
|
|
|
17
17
|
|
|
18
18
|
export const LEVELS = ["focus", "skim", "skip"];
|
|
19
19
|
|
|
20
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The commits a tour was made for, when its generator recorded them: the PR's head commit and the
|
|
24
|
+
* diff's base (the merge base with the target branch). Written by the tools, never by Claude.
|
|
25
|
+
*/
|
|
26
|
+
function revisionOf(pr) {
|
|
27
|
+
return SHA.test(pr.head ?? "") ? { head: pr.head, ...(SHA.test(pr.base ?? "") ? { base: pr.base } : {}) } : {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a tour still matches the PR, given the PR's current { head, base, commits }:
|
|
32
|
+
* "current"; "added" (new commits on top); "rewritten" (force push, rebase, fixup: the tour's
|
|
33
|
+
* head is gone from the PR's commits); "base" (same commits against a different base, such as a
|
|
34
|
+
* retargeted PR); "unknown" (the tour doesn't say, or the PR couldn't be read).
|
|
35
|
+
* Commit ids cover all history below them, so an unchanged head means unchanged commits.
|
|
36
|
+
*/
|
|
37
|
+
export function tourFreshness(tourPr, current) {
|
|
38
|
+
if (!tourPr?.head || !current?.head) return "unknown";
|
|
39
|
+
if (tourPr.head !== current.head) return current.commits?.includes(tourPr.head) ? "added" : "rewritten";
|
|
40
|
+
return tourPr.base && current.base && tourPr.base !== current.base ? "base" : "current";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Of the tour made here and the one posted in the comments, the one to play: a current one first, then the local one. */
|
|
44
|
+
export function pickTour(local, posted, current) {
|
|
45
|
+
const fresh = (raw) => raw && tourFreshness(raw.pr, current) === "current";
|
|
46
|
+
if (fresh(local) || !fresh(posted)) return local ?? posted;
|
|
47
|
+
return posted;
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
/** Validates and normalizes raw JSON. Returns { tour, errors }; tour is null when there are errors. */
|
|
21
51
|
export function parseTour(raw) {
|
|
22
52
|
const errors = [];
|
|
@@ -52,7 +82,7 @@ export function parseTour(raw) {
|
|
|
52
82
|
return {
|
|
53
83
|
tour: {
|
|
54
84
|
version: 1,
|
|
55
|
-
pr: { owner: pr.owner, repo: pr.repo, number: pr.number },
|
|
85
|
+
pr: { owner: pr.owner, repo: pr.repo, number: pr.number, ...revisionOf(pr) },
|
|
56
86
|
title: String(raw.title ?? ""),
|
|
57
87
|
summary: String(raw.summary ?? ""),
|
|
58
88
|
chapters,
|
|
@@ -155,14 +185,20 @@ export function findTourInBlocks(blocks, { owner, repo, number }) {
|
|
|
155
185
|
} catch {
|
|
156
186
|
continue;
|
|
157
187
|
}
|
|
158
|
-
|
|
188
|
+
const forThisPr = raw?.version === 1 && raw.pr?.owner === owner && raw.pr?.repo === repo && raw.pr?.number === number;
|
|
189
|
+
if (forThisPr && Array.isArray(raw.chapters)) found = raw; // a short in the same comment has scenes instead
|
|
159
190
|
}
|
|
160
191
|
return found;
|
|
161
192
|
}
|
|
162
193
|
|
|
163
194
|
/** "ko" when the tour's text has Hangul, otherwise "en": the language to narrate it in. */
|
|
164
195
|
export function tourLanguage(tour) {
|
|
165
|
-
const text = [
|
|
196
|
+
const text = [
|
|
197
|
+
tour.title,
|
|
198
|
+
tour.summary,
|
|
199
|
+
...tour.chapters.flatMap((c) => [c.title, c.summary]),
|
|
200
|
+
...flattenSteps(tour).flatMap((s) => [s.note, s.point]),
|
|
201
|
+
].join(" ");
|
|
166
202
|
return /[가-힣]/.test(text) ? "ko" : "en";
|
|
167
203
|
}
|
|
168
204
|
|