pr-shepherd 0.59.0 → 0.59.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/.claude-plugin/plugin.json +1 -1
- package/README.md +38 -45
- package/bin/commands/iterate/index.mjs +11 -7
- package/bin/journal/journal-container.d.mts +6 -0
- package/bin/journal/journal-container.mjs +33 -0
- package/bin/journal/markdown-details.d.mts +15 -0
- package/bin/journal/markdown-details.mjs +92 -0
- package/bin/journal/markdown-line.d.mts +2 -0
- package/bin/journal/markdown-line.mjs +3 -3
- package/bin/journal/reconcile.mjs +14 -31
- package/package.json +1 -1
- package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,43 @@ Drive a pull request to merge-ready with a coding agent. It works with Claude Co
|
|
|
4
4
|
|
|
5
5
|
**Estimated per PR session: −42% cost and −19% turns vs. `gh`, −77% cost vs. the GitHub MCP server. While waiting on CI: ~60 GraphQL points an hour vs. ~360 for `gh pr checks --watch`.**
|
|
6
6
|
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Gathers the PR's review items, CI failures, and merge requirements in one call.
|
|
10
|
+
- Returns one action with numbered instructions and exact commands. `CANCEL` ends the loop and carries none.
|
|
11
|
+
- Never mutates git. The agent edits, commits, pushes, and decides what each comment deserves.
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
$ pr-shepherd 123
|
|
15
|
+
|
|
16
|
+
# PR #123 [FIX_CODE]
|
|
17
|
+
|
|
18
|
+
**status** `UNRESOLVED_COMMENTS` · **repo** `owner/repo`
|
|
19
|
+
**summary** 3 passing
|
|
20
|
+
|
|
21
|
+
## Review threads
|
|
22
|
+
|
|
23
|
+
### `threadId=PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice · MEMBER)
|
|
24
|
+
|
|
25
|
+
> The variable name is misleading.
|
|
26
|
+
|
|
27
|
+
## Failing checks
|
|
28
|
+
|
|
29
|
+
- `24697658766` — `CI › lint / typecheck / test (22.x)` [conclusion: FAILURE]
|
|
30
|
+
> oxfmt
|
|
31
|
+
|
|
32
|
+
## Instructions
|
|
33
|
+
|
|
34
|
+
1. Fix each warranted item.
|
|
35
|
+
2. Triage `## Failing checks`. Playbook: "CI failure triage".
|
|
36
|
+
3. Commit and push any changes.
|
|
37
|
+
4. Journal key decisions or rejections with URLs or IDs: `pr-shepherd apply journal 123 '- <decision>'`
|
|
38
|
+
5. `$DISMISS_MESSAGE`: one sentence on what changed. Run even if no code changed: `pr-shepherd apply review 123 --reply-thread-ids PRRT_kwDOSGizTs58XB1L --message "$DISMISS_MESSAGE" --require-sha HEAD`
|
|
39
|
+
6. Rerun Shepherd now.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
More features are in [docs/features.md](docs/features.md).
|
|
43
|
+
|
|
7
44
|
## Outcomes
|
|
8
45
|
|
|
9
46
|
| Outcome | Headline | Evidence |
|
|
@@ -17,23 +54,6 @@ Drive a pull request to merge-ready with a coding agent. It works with Claude Co
|
|
|
17
54
|
|
|
18
55
|
**[jongleberry.com/pr-shepherd](https://jongleberry.com/pr-shepherd/)** covers the why and the design principles.
|
|
19
56
|
|
|
20
|
-
## What it does
|
|
21
|
-
|
|
22
|
-
- Gathers the PR's review items, CI failures, and merge requirements in one call.
|
|
23
|
-
- Returns one action with numbered instructions and exact commands. `CANCEL` ends the loop and carries none.
|
|
24
|
-
- Never mutates git. The agent edits, commits, pushes, and decides what each comment deserves.
|
|
25
|
-
|
|
26
|
-
```mermaid
|
|
27
|
-
flowchart LR
|
|
28
|
-
A[Agent] -->|iterate PR| S[pr-shepherd]
|
|
29
|
-
S -->|batched, conditional reads| G[(GitHub)]
|
|
30
|
-
G --> S
|
|
31
|
-
S -->|one action + numbered instructions| A
|
|
32
|
-
A -->|fix, push, run the printed commands| G
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Without Shepherd, an agent fans out across `gh`, the GitHub MCP server, and GraphQL to rebuild PR state, then guesses the next step. Details for each outcome follow; more features are in [docs/features.md](docs/features.md).
|
|
36
|
-
|
|
37
57
|
## Outcome details
|
|
38
58
|
|
|
39
59
|
### Context and correctness
|
|
@@ -179,34 +199,7 @@ Requirements:
|
|
|
179
199
|
pr-shepherd 42 # shell: poll for work (270s cap; --until-terminal removes it)
|
|
180
200
|
```
|
|
181
201
|
|
|
182
|
-
Leave out the PR number to use the current branch.
|
|
183
|
-
|
|
184
|
-
```text
|
|
185
|
-
# PR #123 [FIX_CODE]
|
|
186
|
-
|
|
187
|
-
**status** `UNRESOLVED_COMMENTS` · **repo** `owner/repo`
|
|
188
|
-
**summary** 3 passing
|
|
189
|
-
|
|
190
|
-
## Review threads
|
|
191
|
-
|
|
192
|
-
### `threadId=PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice · MEMBER)
|
|
193
|
-
|
|
194
|
-
> The variable name is misleading.
|
|
195
|
-
|
|
196
|
-
## Failing checks
|
|
197
|
-
|
|
198
|
-
- `24697658766` — `CI › lint / typecheck / test (22.x)` [conclusion: FAILURE]
|
|
199
|
-
> oxfmt
|
|
200
|
-
|
|
201
|
-
## Instructions
|
|
202
|
-
|
|
203
|
-
1. Fix each warranted item.
|
|
204
|
-
2. Triage `## Failing checks`. Playbook: "CI failure triage".
|
|
205
|
-
3. Commit and push any changes.
|
|
206
|
-
4. Journal key decisions or rejections with URLs or IDs: `pr-shepherd apply journal 123 '- <decision>'`
|
|
207
|
-
5. `$DISMISS_MESSAGE`: one sentence on what changed. Run even if no code changed: `pr-shepherd apply review 123 --reply-thread-ids PRRT_kwDOSGizTs58XB1L --message "$DISMISS_MESSAGE" --require-sha HEAD`
|
|
208
|
-
6. Rerun Shepherd now.
|
|
209
|
-
```
|
|
202
|
+
Leave out the PR number to use the current branch.
|
|
210
203
|
|
|
211
204
|
## Documentation
|
|
212
205
|
|
|
@@ -9,7 +9,7 @@ import { classifyReviewSummaries } from "./classify.mjs";
|
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
10
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
11
11
|
import { handleFixCode } from "./fix-code.mjs";
|
|
12
|
-
import { normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
12
|
+
import { isConfiguredBotAuthor, normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
13
13
|
import { autoMinimizeComments } from "../../comments/resolve.mjs";
|
|
14
14
|
import { hasCheckDrivenActionableWork } from "../check-annotations.mjs";
|
|
15
15
|
import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mjs";
|
|
@@ -96,14 +96,19 @@ async function runIterateCore(opts) {
|
|
|
96
96
|
reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
|
|
97
97
|
}
|
|
98
98
|
// A first look at an already-minimized comment is only an acknowledgment.
|
|
99
|
-
//
|
|
100
|
-
// it must restart the ready delay and void an existing READY receipt.
|
|
99
|
+
// A non-bot edit that no longer matches a noise rule may carry new feedback;
|
|
100
|
+
// it must restart the ready delay and void an existing READY receipt. Bots
|
|
101
|
+
// regenerate minimized summaries on their own, so their edits surface for
|
|
102
|
+
// acknowledgment without restarting the countdown.
|
|
103
|
+
const editedMinimizedFeedback = report.comments.firstLook.some((comment) => comment.isMinimized === true &&
|
|
104
|
+
comment.edited === true &&
|
|
105
|
+
!isConfiguredBotAuthor(comment, botUsernames));
|
|
101
106
|
const hasReadinessWork = report.threads.actionable.length > 0 ||
|
|
102
107
|
report.threads.resolutionOnly.length > 0 ||
|
|
103
108
|
report.threads.firstLook.length > 0 ||
|
|
104
109
|
(report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
|
|
105
110
|
report.comments.actionable.length > 0 ||
|
|
106
|
-
|
|
111
|
+
editedMinimizedFeedback ||
|
|
107
112
|
(report.comments.minimizeIds?.length ?? 0) > 0 ||
|
|
108
113
|
report.changesRequestedReviews.length > 0 ||
|
|
109
114
|
hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status) ||
|
|
@@ -124,7 +129,7 @@ async function runIterateCore(opts) {
|
|
|
124
129
|
!hasReadinessWork &&
|
|
125
130
|
!activeMerge &&
|
|
126
131
|
staleAncestry === null;
|
|
127
|
-
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, checkContext);
|
|
132
|
+
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, editedMinimizedFeedback, checkContext);
|
|
128
133
|
const headSha = report.headSha ?? "unknown";
|
|
129
134
|
// The elapsed marker survives a hidden-comment acknowledgement tick and is
|
|
130
135
|
// consumed only when this tick cancels or merges. A receipt that is still
|
|
@@ -386,7 +391,7 @@ async function recordReadyReceipt(key, report, context) {
|
|
|
386
391
|
}
|
|
387
392
|
}
|
|
388
393
|
/** Clear a stale READY receipt; return whether a current receipt remains. */
|
|
389
|
-
async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
|
|
394
|
+
async function revalidateReadyReceipt(key, report, hasReadinessWork, editedMinimizedFeedback, context) {
|
|
390
395
|
const receipt = await readReadyReceipt(key);
|
|
391
396
|
if (!receipt)
|
|
392
397
|
return false;
|
|
@@ -394,7 +399,6 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
|
|
|
394
399
|
report.mergeStatus.state === "OPEN" &&
|
|
395
400
|
!report.mergeStatus.isDraft &&
|
|
396
401
|
Boolean(report.headSha && report.baseRefOid);
|
|
397
|
-
const editedMinimizedFeedback = report.comments.firstLook.some((comment) => comment.isMinimized && comment.edited === true);
|
|
398
402
|
if (report.mergeStatus.state !== "OPEN" ||
|
|
399
403
|
report.mergeStatus.isDraft ||
|
|
400
404
|
!report.headSha ||
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { scanMarkdownLines } from "./markdown-line.mts";
|
|
2
|
+
export declare const LEGACY: RegExp;
|
|
3
|
+
export declare const JOURNAL_SUMMARY: RegExp;
|
|
4
|
+
export declare const CLOSE = "</details>";
|
|
5
|
+
/** Return the line of the canonical `</details>` closing a journal container, or null. */
|
|
6
|
+
export declare function closeJournalContainer(lines: string[], syntax: ReturnType<typeof scanMarkdownLines>, start: number): number | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
|
|
2
|
+
export const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
|
|
3
|
+
export const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
|
|
4
|
+
export const CLOSE = "</details>";
|
|
5
|
+
/** Return the line of the canonical `</details>` closing a journal container, or null. */
|
|
6
|
+
export function closeJournalContainer(lines, syntax, start) {
|
|
7
|
+
let depth = 1;
|
|
8
|
+
let html = rawHtmlState();
|
|
9
|
+
for (let i = start; i < lines.length; i++) {
|
|
10
|
+
if (syntax[i].nested)
|
|
11
|
+
continue;
|
|
12
|
+
if (syntax[i].rawHtml) {
|
|
13
|
+
for (const tag of rawDetailsTags(lines[i], html))
|
|
14
|
+
if ((depth += tag) === 0)
|
|
15
|
+
return null;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
html = rawHtmlState();
|
|
19
|
+
if (syntax[i].ignored)
|
|
20
|
+
continue;
|
|
21
|
+
const visible = syntax[i].visiblePrefix;
|
|
22
|
+
if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
|
|
23
|
+
return null;
|
|
24
|
+
depth += detailsTags(visible, false);
|
|
25
|
+
const closes = detailsTags(visible, true);
|
|
26
|
+
if (closes) {
|
|
27
|
+
depth -= closes;
|
|
28
|
+
if (depth <= 0)
|
|
29
|
+
return depth === 0 && lines[i] === CLOSE ? i : null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function detailsTags(line: string, closing: boolean): number;
|
|
2
|
+
/** Tokenizer state carried across the lines of one raw HTML block. */
|
|
3
|
+
export type RawHtmlState = {
|
|
4
|
+
mode: "comment" | "data" | "raw-text" | "tag";
|
|
5
|
+
quote: '"' | "'" | null;
|
|
6
|
+
rawTag: string | null;
|
|
7
|
+
terminator: RegExp;
|
|
8
|
+
};
|
|
9
|
+
export declare const rawHtmlState: () => RawHtmlState;
|
|
10
|
+
/**
|
|
11
|
+
* Return `1` per `<details>` opener and `-1` per closer that the browser
|
|
12
|
+
* creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
|
|
13
|
+
* elements, and (possibly multiline) attribute values do not count.
|
|
14
|
+
*/
|
|
15
|
+
export declare function rawDetailsTags(line: string, state: RawHtmlState): Array<1 | -1>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
|
|
2
|
+
import { structuralDetailsStart } from "./markdown-structure.mjs";
|
|
3
|
+
export function detailsTags(line, closing) {
|
|
4
|
+
if (structuralDetailsStart(line) === null)
|
|
5
|
+
return 0;
|
|
6
|
+
const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
|
|
7
|
+
return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
|
|
8
|
+
}
|
|
9
|
+
const RAW_TEXT = new Set([
|
|
10
|
+
"iframe",
|
|
11
|
+
"noembed",
|
|
12
|
+
"noframes",
|
|
13
|
+
"script",
|
|
14
|
+
"style",
|
|
15
|
+
"textarea",
|
|
16
|
+
"title",
|
|
17
|
+
"xmp",
|
|
18
|
+
]);
|
|
19
|
+
export const rawHtmlState = () => ({
|
|
20
|
+
mode: "data",
|
|
21
|
+
quote: null,
|
|
22
|
+
rawTag: null,
|
|
23
|
+
terminator: /--!?>/,
|
|
24
|
+
});
|
|
25
|
+
// Comments, CDATA sections, and bogus comments such as `<!x>`, `<?x>`, and `</ x>`.
|
|
26
|
+
const COMMENTS = [
|
|
27
|
+
[/^<!--/, /--!?>/],
|
|
28
|
+
[/^<!\[CDATA\[/, /\]\]>/],
|
|
29
|
+
[/^<(?:[!?]|\/[^A-Za-z>])/, />/],
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Return `1` per `<details>` opener and `-1` per closer that the browser
|
|
33
|
+
* creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
|
|
34
|
+
* elements, and (possibly multiline) attribute values do not count.
|
|
35
|
+
*/
|
|
36
|
+
export function rawDetailsTags(line, state) {
|
|
37
|
+
const tags = [];
|
|
38
|
+
let i = 0;
|
|
39
|
+
while (i < line.length) {
|
|
40
|
+
if (state.mode === "comment") {
|
|
41
|
+
const end = state.terminator.exec(line.slice(i));
|
|
42
|
+
if (!end)
|
|
43
|
+
break;
|
|
44
|
+
state.mode = "data";
|
|
45
|
+
i += end.index + end[0].length;
|
|
46
|
+
}
|
|
47
|
+
else if (state.mode === "raw-text") {
|
|
48
|
+
const end = new RegExp(`</${state.rawTag}(?=[\\s/>]|$)`, "i").exec(line.slice(i));
|
|
49
|
+
if (!end)
|
|
50
|
+
break;
|
|
51
|
+
state.mode = "tag";
|
|
52
|
+
state.rawTag = null;
|
|
53
|
+
i += end.index + end[0].length;
|
|
54
|
+
}
|
|
55
|
+
else if (state.mode === "tag") {
|
|
56
|
+
const char = line[i++];
|
|
57
|
+
if (state.quote) {
|
|
58
|
+
if (char === state.quote)
|
|
59
|
+
state.quote = null;
|
|
60
|
+
}
|
|
61
|
+
else if (char === '"' || char === "'")
|
|
62
|
+
state.quote = char;
|
|
63
|
+
else if (char === ">")
|
|
64
|
+
state.mode = state.rawTag ? "raw-text" : "data";
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const open = line.indexOf("<", i);
|
|
68
|
+
if (open === -1)
|
|
69
|
+
break;
|
|
70
|
+
const comment = COMMENTS.map(([start, end]) => [start.exec(line.slice(open)), end]).find(([start]) => start);
|
|
71
|
+
if (comment) {
|
|
72
|
+
state.mode = "comment";
|
|
73
|
+
state.terminator = comment[1];
|
|
74
|
+
i = open + comment[0][0].length;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Tag names run until whitespace, `/`, or `>`, so `details:x` is not `details`.
|
|
78
|
+
const tag = /^<(\/?)([A-Za-z][^\s/>]*)/.exec(line.slice(open));
|
|
79
|
+
if (!tag) {
|
|
80
|
+
i = open + 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const name = tag[2].toLowerCase();
|
|
84
|
+
if (name === "details")
|
|
85
|
+
tags.push(tag[1] ? -1 : 1);
|
|
86
|
+
state.mode = "tag";
|
|
87
|
+
state.rawTag = !tag[1] && RAW_TEXT.has(name) ? name : null;
|
|
88
|
+
i = open + tag[0].length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return tags;
|
|
92
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
/** `rawHtml` marks lines inside a blank-line-terminated raw HTML block, whose tags still render. */
|
|
1
2
|
type MarkdownLine = {
|
|
2
3
|
ignored: boolean;
|
|
3
4
|
nested: boolean;
|
|
5
|
+
rawHtml: boolean;
|
|
4
6
|
visiblePrefix: string;
|
|
5
7
|
};
|
|
6
8
|
/** Return whether a Markdown line starts a block that interrupts inline content. */
|
|
@@ -35,7 +35,7 @@ function scanMarkdown(lines) {
|
|
|
35
35
|
const container = resolveMarkdownContainer(line, activeContainer);
|
|
36
36
|
activeContainer = container.tokens;
|
|
37
37
|
const nested = container.tokens.length > 0;
|
|
38
|
-
const push = (ignored, visiblePrefix) => result.push({ ignored, nested, visiblePrefix });
|
|
38
|
+
const push = (ignored, visiblePrefix, rawHtml = false) => result.push({ ignored, nested, rawHtml, visiblePrefix });
|
|
39
39
|
if (indentedCodeContainer) {
|
|
40
40
|
if (stripMarkdownContainer(line, indentedCodeContainer) !== null) {
|
|
41
41
|
push(true, "");
|
|
@@ -53,7 +53,7 @@ function scanMarkdown(lines) {
|
|
|
53
53
|
else {
|
|
54
54
|
const end = rawHtmlEnd(html, content);
|
|
55
55
|
if (end === null) {
|
|
56
|
-
push(true, "");
|
|
56
|
+
push(true, "", html.kind === "blank-line");
|
|
57
57
|
continue;
|
|
58
58
|
}
|
|
59
59
|
html = null;
|
|
@@ -100,7 +100,7 @@ function scanMarkdown(lines) {
|
|
|
100
100
|
const end = rawHtmlEnd(openingHtml, content);
|
|
101
101
|
if (end === null) {
|
|
102
102
|
html = openingHtml;
|
|
103
|
-
push(true, "");
|
|
103
|
+
push(true, "", openingHtml.kind === "blank-line");
|
|
104
104
|
continue;
|
|
105
105
|
}
|
|
106
106
|
allowCodeOpeners = false;
|
|
@@ -1,42 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
|
|
2
|
+
import { CLOSE, closeJournalContainer, JOURNAL_SUMMARY, LEGACY } from "./journal-container.mjs";
|
|
2
3
|
import { isSafeMarkdownInsertionPoint, scanMarkdownLines } from "./markdown-line.mjs";
|
|
3
4
|
import { setextParagraphStart } from "./markdown-setext.mjs";
|
|
4
|
-
import { structuralDetailsStart } from "./markdown-structure.mjs";
|
|
5
|
-
const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
|
|
6
|
-
const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
|
|
7
5
|
const SETEXT = /^ {0,3}(?:=+|-+)[ \t]*$/;
|
|
8
|
-
const CLOSE = "</details>";
|
|
9
6
|
const OPEN = "<details>";
|
|
10
7
|
const SUMMARY = "<summary>Shepherd Journal</summary>";
|
|
11
8
|
const stripCr = (s) => s.replace(/\r$/, "");
|
|
12
|
-
function detailsTags(line, closing) {
|
|
13
|
-
if (structuralDetailsStart(line) === null)
|
|
14
|
-
return 0;
|
|
15
|
-
const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
|
|
16
|
-
return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
|
|
17
|
-
}
|
|
18
|
-
function close(lines, syntax, start) {
|
|
19
|
-
let depth = 1;
|
|
20
|
-
for (let i = start; i < lines.length; i++) {
|
|
21
|
-
if (syntax[i].ignored || syntax[i].nested)
|
|
22
|
-
continue;
|
|
23
|
-
const visible = syntax[i].visiblePrefix;
|
|
24
|
-
if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
|
|
25
|
-
return null;
|
|
26
|
-
depth += detailsTags(visible, false);
|
|
27
|
-
const closes = detailsTags(visible, true);
|
|
28
|
-
if (closes) {
|
|
29
|
-
depth -= closes;
|
|
30
|
-
if (depth <= 0)
|
|
31
|
-
return depth === 0 && lines[i] === CLOSE ? i : null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
9
|
export function scanShepherdJournal(lines) {
|
|
37
10
|
const syntax = scanMarkdownLines(lines);
|
|
38
11
|
const found = [];
|
|
39
12
|
let detailsDepth = 0;
|
|
13
|
+
let html = rawHtmlState();
|
|
40
14
|
let legacy = null;
|
|
41
15
|
for (let i = 0; i < lines.length; i++) {
|
|
42
16
|
if (!syntax[i].ignored &&
|
|
@@ -48,7 +22,16 @@ export function scanShepherdJournal(lines) {
|
|
|
48
22
|
legacy.contentEnd = legacy.end = i;
|
|
49
23
|
continue;
|
|
50
24
|
}
|
|
51
|
-
if (syntax[i].
|
|
25
|
+
if (syntax[i].nested)
|
|
26
|
+
continue;
|
|
27
|
+
if (syntax[i].rawHtml) {
|
|
28
|
+
// Raw-HTML details stay open past the block; stray closers are ignored, as in the browser.
|
|
29
|
+
for (const tag of rawDetailsTags(lines[i], html))
|
|
30
|
+
detailsDepth = Math.max(0, detailsDepth + tag);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
html = rawHtmlState();
|
|
34
|
+
if (syntax[i].ignored)
|
|
52
35
|
continue;
|
|
53
36
|
const visible = syntax[i].visiblePrefix;
|
|
54
37
|
detailsDepth += detailsTags(visible, false);
|
|
@@ -66,7 +49,7 @@ export function scanShepherdJournal(lines) {
|
|
|
66
49
|
lines[i - 1] !== OPEN ||
|
|
67
50
|
lines[i + 1] !== "")
|
|
68
51
|
return "error";
|
|
69
|
-
const end =
|
|
52
|
+
const end = closeJournalContainer(lines, syntax, i + 2);
|
|
70
53
|
if (end === null)
|
|
71
54
|
return "error";
|
|
72
55
|
detailsDepth--;
|
package/package.json
CHANGED