pr-shepherd 0.39.0 → 0.41.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.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +20 -2
  3. package/bin/api.d.mts +1 -1
  4. package/bin/api.mjs +10 -26
  5. package/bin/commands/journal/journal-item.mjs +57 -7
  6. package/bin/commands/journal/journal-markdown.d.mts +0 -1
  7. package/bin/commands/journal/journal-markdown.mjs +1 -6
  8. package/bin/journal/append.d.mts +8 -0
  9. package/bin/journal/append.mjs +96 -0
  10. package/bin/journal/extract.d.mts +16 -0
  11. package/bin/journal/extract.mjs +71 -0
  12. package/bin/journal/index.d.mts +4 -0
  13. package/bin/journal/index.mjs +4 -0
  14. package/bin/journal/markdown-backticks.d.mts +9 -0
  15. package/bin/journal/markdown-backticks.mjs +34 -0
  16. package/bin/journal/markdown-container.d.mts +22 -0
  17. package/bin/journal/markdown-container.mjs +110 -0
  18. package/bin/journal/markdown-html.d.mts +20 -0
  19. package/bin/journal/markdown-html.mjs +119 -0
  20. package/bin/journal/markdown-line.d.mts +10 -0
  21. package/bin/journal/markdown-line.mjs +168 -0
  22. package/bin/journal/markdown-setext.d.mts +7 -0
  23. package/bin/journal/markdown-setext.mjs +15 -0
  24. package/bin/journal/markdown-structure.d.mts +2 -0
  25. package/bin/journal/markdown-structure.mjs +35 -0
  26. package/bin/journal/reconcile.d.mts +18 -0
  27. package/bin/journal/reconcile.mjs +187 -0
  28. package/bin/mcp/server.mjs +15 -4
  29. package/bin/pr-reference.d.mts +7 -0
  30. package/bin/pr-reference.mjs +36 -0
  31. package/package.json +5 -1
  32. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  33. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  34. package/plugins/pr-shepherd/.mcp.json +1 -1
  35. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +2 -2
  36. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.39.0",
4
+ "version": "0.41.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -25,7 +25,7 @@ Full reference: [docs/README.md](docs/README.md). Feature matrix: [docs/features
25
25
 
26
26
  `pr-shepherd` moves deterministic PR orchestration into a local MCP server, with a CLI for shells and CI. Both interfaces fetch the same GitHub state, emit raw-enough context, and return a numbered plan for the calling agent to follow.
27
27
 
28
- The MCP server exposes three tools: `iterate`, `apply`, and `build_suggestion_patch`. `apply` accepts ordered review mutations, file-view mutations, and journal entries. The shipped skills are thin dispatchers for those tools.
28
+ The MCP server exposes three tools: `iterate`, `apply`, and `build_suggestion_patch`. `apply` accepts ordered review mutations, file-view mutations, and journal entries. Direct MCP calls require a repository-qualified `pr`: a GitHub PR URL or `owner/repo#N`, matching the repository where the server started. The CLI and programmatic API also retain bare-number and current-branch PR discovery. The shipped skills are thin dispatchers for those tools.
29
29
 
30
30
  Each tick returns exactly one action:
31
31
 
@@ -115,7 +115,7 @@ Grok:
115
115
  /pr-shepherd 42
116
116
  ```
117
117
 
118
- MCP clients call `iterate` once per tick, then use `apply` for review/file/journal mutations and `build_suggestion_patch` for an anchored suggestion. `iterate` returns the same structured action data as the CLI, including its review mutation arguments. The client owns recurrence, so this works consistently in Codex, Claude Code, Grok, and any other stdio MCP client.
118
+ MCP clients call `iterate` once per tick, then use `apply` for review/file/journal mutations and `build_suggestion_patch` for an anchored suggestion. Every direct MCP call supplies the same repository-qualified PR reference. `iterate` returns the same structured action data as the CLI, including its review mutation arguments. The client owns recurrence, so this works consistently in Codex, Claude Code, Grok, and any other stdio MCP client.
119
119
 
120
120
  The CLI remains useful for shell workflows. Its canonical polling form is:
121
121
 
@@ -133,6 +133,24 @@ pr-shepherd iterate 42 # single tick
133
133
 
134
134
  Use `apply` with ordered operations to reply/resolve/minimize/dismiss review items, mark changed files viewed, or append an idempotent Shepherd Journal item. Use `build_suggestion_patch` to turn one review suggestion into a validated patch and commit metadata; it never changes the worktree or git history.
135
135
 
136
+ ### Extract Shepherd Journal Entries
137
+
138
+ The pure `pr-shepherd/journal` entry point can extract one validated journal without GitHub access:
139
+
140
+ ```ts
141
+ import { extractShepherdJournal } from "pr-shepherd/journal";
142
+
143
+ const result = extractShepherdJournal(prBody);
144
+ if (!result.ok) throw new Error(result.error);
145
+
146
+ for (const entry of result.journal?.entries ?? []) console.log(entry);
147
+ ```
148
+
149
+ The result identifies canonical `details` versus historical `legacy` H2 journals and returns each
150
+ complete Markdown list item with LF line endings. It fails closed for malformed or ambiguous
151
+ containers and ignores journal-shaped examples hidden in Markdown constructs. The full journal API,
152
+ including append and reconciliation helpers, is documented in [docs/api.md](docs/api.md).
153
+
136
154
  ### Clean Local State
137
155
 
138
156
  `pr-shepherd` stores seen markers, fix-attempt counters, stall fingerprints, ready-delay markers, and logs under `$PR_SHEPHERD_STATE_DIR` (default `$TMPDIR/pr-shepherd-state`).
package/bin/api.d.mts CHANGED
@@ -6,7 +6,7 @@ export interface CreatePrShepherdOptions {
6
6
  /** Working directory used for git, config, and classification-rule lookups. */
7
7
  cwd?: string;
8
8
  }
9
- /** A positive PR number or canonical GitHub pull-request URL. */
9
+ /** A positive PR number, GitHub pull-request URL, or owner/repo#number reference. */
10
10
  export type PrReference = number | string;
11
11
  export type IterateInput = Omit<IterateCommandOptions, "format" | "prNumber"> & {
12
12
  pr?: PrReference;
package/bin/api.mjs CHANGED
@@ -8,6 +8,7 @@ import { runMarkFilesAsViewed, } from "./commands/mark-files-as-viewed.mjs";
8
8
  import { runResolveMutate } from "./commands/resolve-mutate.mjs";
9
9
  import { runWithExecutionCwd } from "./execution-context.mjs";
10
10
  import { getRepoInfo } from "./github/client.mjs";
11
+ import { parsePrReference } from "./pr-reference.mjs";
11
12
  /** Raised before any API mutation when an input cannot be validated. */
12
13
  export class PrShepherdValidationError extends Error {
13
14
  constructor(message) {
@@ -108,7 +109,7 @@ function validateApplyInput(input) {
108
109
  }
109
110
  for (const operation of input.operations)
110
111
  validateOperation(operation);
111
- parsePrReference(input.pr);
112
+ validatePrReference(input.pr);
112
113
  }
113
114
  function validateOperation(operation) {
114
115
  if (!operation || typeof operation !== "object") {
@@ -192,38 +193,21 @@ function validateSuggestionPatchInput(input) {
192
193
  if (input.description !== undefined && typeof input.description !== "string") {
193
194
  throw new PrShepherdValidationError("buildSuggestionPatch.description must be a string");
194
195
  }
195
- parsePrReference(input.pr);
196
+ validatePrReference(input.pr);
196
197
  }
197
- function parsePrReference(pr) {
198
- if (pr === undefined)
199
- return {};
200
- if (typeof pr === "number" && Number.isInteger(pr) && pr > 0)
201
- return { number: pr };
202
- if (typeof pr === "string") {
203
- try {
204
- const url = new URL(pr);
205
- const parts = url.pathname.split("/").filter(Boolean);
206
- if ((url.protocol === "https:" || url.protocol === "http:") &&
207
- (url.hostname === "github.com" || url.hostname === "www.github.com") &&
208
- parts.length === 4 &&
209
- parts[2] === "pull" &&
210
- /^[1-9][0-9]*$/.test(parts[3])) {
211
- return { number: Number(parts[3]), repository: `${parts[0]}/${parts[1]}` };
212
- }
213
- }
214
- catch {
215
- // Construct the uniform public validation error below.
216
- }
217
- }
218
- throw new PrShepherdValidationError("pr must be a positive number or a GitHub pull-request URL");
198
+ function validatePrReference(pr) {
199
+ const parsed = parsePrReference(pr);
200
+ if (parsed !== null)
201
+ return parsed;
202
+ throw new PrShepherdValidationError("pr must be a positive number, a GitHub pull-request URL, or owner/repo#number");
219
203
  }
220
204
  async function resolvePrReference(pr) {
221
- const parsed = parsePrReference(pr);
205
+ const parsed = validatePrReference(pr);
222
206
  if (parsed.repository !== undefined) {
223
207
  const repo = await getRepoInfo();
224
208
  const currentRepository = `${repo.owner}/${repo.name}`;
225
209
  if (parsed.repository.toLowerCase() !== currentRepository.toLowerCase()) {
226
- throw new PrShepherdValidationError(`PR URL repository ${parsed.repository} does not match the configured repository ${currentRepository}`);
210
+ throw new PrShepherdValidationError(`PR reference repository ${parsed.repository} does not match the configured repository ${currentRepository}`);
227
211
  }
228
212
  }
229
213
  return parsed.number;
@@ -1,4 +1,53 @@
1
- import { isJournalLikeSummary, isReservedJournalMarker } from "./journal-markdown.mjs";
1
+ import { inQuotedHtmlAttribute, rawHtmlEnd, rawHtmlStart } from "../../journal/markdown-html.mjs";
2
+ import { stripMarkdownContainer } from "../../journal/markdown-container.mjs";
3
+ import { scanMarkdownLines } from "../../journal/markdown-line.mjs";
4
+ const RESERVED_CONTAINER_TAG = /(?:<details(?:\s+[^>]*)?\/?>|<\/details>)|<summary(?:\s+[^>]*)?>\s*Shepherd\s+Journal\b/i;
5
+ function reservedContainerTag(lines) {
6
+ const syntax = scanMarkdownLines(lines);
7
+ for (const [index] of lines.entries()) {
8
+ const match = RESERVED_CONTAINER_TAG.exec(syntax[index].visiblePrefix);
9
+ if (match)
10
+ return match[0];
11
+ }
12
+ return reservedContainerTagInRawHtml(lines);
13
+ }
14
+ function reservedContainerTagInRawHtml(lines) {
15
+ let block = null;
16
+ for (const line of lines) {
17
+ if (block) {
18
+ const content = stripMarkdownContainer(line, block.container);
19
+ if (content === null) {
20
+ block = null;
21
+ }
22
+ else {
23
+ const marker = reservedContainerTagOutsideHtmlAttributes(content);
24
+ if (marker)
25
+ return marker;
26
+ if (rawHtmlEnd(block, content) !== null)
27
+ block = null;
28
+ continue;
29
+ }
30
+ }
31
+ const opening = rawHtmlStart(line);
32
+ if (!opening)
33
+ continue;
34
+ const content = stripMarkdownContainer(line, opening.container) ?? "";
35
+ const marker = reservedContainerTagOutsideHtmlAttributes(content);
36
+ if (marker)
37
+ return marker;
38
+ if (rawHtmlEnd(opening, content) === null)
39
+ block = opening;
40
+ }
41
+ return null;
42
+ }
43
+ function reservedContainerTagOutsideHtmlAttributes(line) {
44
+ const expression = new RegExp(RESERVED_CONTAINER_TAG.source, "gi");
45
+ for (const match of line.matchAll(expression)) {
46
+ if (!inQuotedHtmlAttribute(line, match.index))
47
+ return match[0];
48
+ }
49
+ return null;
50
+ }
2
51
  export function validateJournalItem(input) {
3
52
  const lines = input.split("\n").map((line) => line.trimEnd());
4
53
  const nonBlank = lines.filter((line) => line.trim() !== "");
@@ -11,6 +60,13 @@ export function validateJournalItem(input) {
11
60
  error: `journal item must start with "- <text>"; got: ${JSON.stringify(nonBlank[0].slice(0, 40))}`,
12
61
  };
13
62
  }
63
+ const marker = reservedContainerTag(lines);
64
+ if (marker) {
65
+ return {
66
+ ok: false,
67
+ error: `journal item must not contain journal container marker ${JSON.stringify(marker)}`,
68
+ };
69
+ }
14
70
  for (const line of nonBlank.slice(1)) {
15
71
  if (line.startsWith("#")) {
16
72
  return {
@@ -18,12 +74,6 @@ export function validateJournalItem(input) {
18
74
  error: "journal item lines must not start with # (would break section structure)",
19
75
  };
20
76
  }
21
- if (isReservedJournalMarker(line.trim()) || isJournalLikeSummary(line.trim())) {
22
- return {
23
- ok: false,
24
- error: `journal item must not contain standalone journal container marker ${JSON.stringify(line.trim())}`,
25
- };
26
- }
27
77
  }
28
78
  return { ok: true, item: lines.join("\n").trim() };
29
79
  }
@@ -8,6 +8,5 @@ export type MarkdownScanState = {
8
8
  };
9
9
  export declare function findDetailsClose(lines: string[], startIdx: number): number;
10
10
  export declare function isJournalLikeSummary(line: string): boolean;
11
- export declare function isReservedJournalMarker(line: string): boolean;
12
11
  export declare function skipMarkdownLine(state: MarkdownScanState, line: string): boolean;
13
12
  export {};
@@ -1,4 +1,4 @@
1
- import { SHEPHERD_JOURNAL_DETAILS_CLOSE, SHEPHERD_JOURNAL_DETAILS_SUMMARY, } from "../shepherd-journal.mjs";
1
+ import { SHEPHERD_JOURNAL_DETAILS_CLOSE } from "../shepherd-journal.mjs";
2
2
  export function findDetailsClose(lines, startIdx) {
3
3
  let depth = 1;
4
4
  const state = { fence: null, comment: false };
@@ -23,11 +23,6 @@ export function findDetailsClose(lines, startIdx) {
23
23
  export function isJournalLikeSummary(line) {
24
24
  return /^<summary>\s*Shepherd\s+Journal\b/i.test(line);
25
25
  }
26
- export function isReservedJournalMarker(line) {
27
- return (isDetailsOpening(line) ||
28
- line === SHEPHERD_JOURNAL_DETAILS_SUMMARY ||
29
- line === SHEPHERD_JOURNAL_DETAILS_CLOSE);
30
- }
31
26
  function isDetailsOpening(line) {
32
27
  return /^<details(?:\s+[^>]*)?>$/.test(line);
33
28
  }
@@ -0,0 +1,8 @@
1
+ import { validateJournalItem } from "../commands/journal/journal-item.mts";
2
+ export { validateJournalItem };
3
+ export interface AppendResult {
4
+ body: string;
5
+ mutated: boolean;
6
+ sectionExisted: boolean;
7
+ }
8
+ export declare function appendJournalItem(body: string, item: string): AppendResult;
@@ -0,0 +1,96 @@
1
+ import { validateJournalItem } from "../commands/journal/journal-item.mjs";
2
+ import { fenceStart } from "./markdown-container.mjs";
3
+ import { rawHtmlStart } from "./markdown-html.mjs";
4
+ import { containsJournalEntry, scanShepherdJournal } from "./reconcile.mjs";
5
+ import { isSafeMarkdownInsertionPoint } from "./markdown-line.mjs";
6
+ const OPEN = "<details>";
7
+ const SUMMARY = "<summary>Shepherd Journal</summary>";
8
+ const CLOSE = "</details>";
9
+ export { validateJournalItem };
10
+ export function appendJournalItem(body, item) {
11
+ const validated = validateJournalItem(item);
12
+ if (!validated.ok)
13
+ throw new Error(validated.error);
14
+ if (validated.item
15
+ .split("\n")
16
+ .slice(1)
17
+ .some((line) => /^(?:[-+*]|\d{1,9}[.)])[ \t]+/.test(line)))
18
+ throw new Error("journal item must contain exactly one top-level list item");
19
+ item = validated.item;
20
+ if (item
21
+ .split("\n")
22
+ .some((line, index) => fenceStart(index === 0 ? line.slice(2) : line) ||
23
+ rawHtmlStart(index === 0 ? line.slice(2) : line)))
24
+ throw new Error("journal item must not start with a fenced or raw HTML block");
25
+ const newline = body.includes("\r\n") ? "\r\n" : "\n";
26
+ const lines = body.replaceAll("\r\n", "\n").split("\n");
27
+ const bounds = scanShepherdJournal(lines);
28
+ if (bounds === "error")
29
+ throw new Error("malformed, duplicate, or ambiguous Shepherd Journal container");
30
+ if (!bounds) {
31
+ if (!isSafeMarkdownInsertionPoint(lines))
32
+ throw new Error("cannot append Shepherd Journal inside an unterminated Markdown construct");
33
+ return create(lines, item, newline);
34
+ }
35
+ const content = lines.slice(bounds.contentStart, bounds.contentEnd);
36
+ if (bounds.format === "details" && containsJournalEntry(content, item))
37
+ return { body, mutated: false, sectionExisted: true };
38
+ const next = [...trimEnd(content), ...item.split("\n")];
39
+ if (bounds.format === "details") {
40
+ return {
41
+ body: [
42
+ ...lines.slice(0, bounds.contentStart),
43
+ ...next,
44
+ ...lines.slice(bounds.contentEnd),
45
+ ].join(newline),
46
+ mutated: true,
47
+ sectionExisted: true,
48
+ };
49
+ }
50
+ const legacyContent = trim(content);
51
+ const canonical = [
52
+ OPEN,
53
+ SUMMARY,
54
+ "",
55
+ ...legacyContent,
56
+ ...(containsJournalEntry(content, item) ? [] : item.split("\n")),
57
+ CLOSE,
58
+ ];
59
+ const suffix = lines.slice(bounds.end);
60
+ return {
61
+ body: [
62
+ ...lines.slice(0, bounds.start),
63
+ ...canonical,
64
+ ...(suffix[0]?.trim() ? [""] : []),
65
+ ...suffix,
66
+ ].join(newline),
67
+ mutated: true,
68
+ sectionExisted: true,
69
+ };
70
+ }
71
+ function create(lines, item, newline) {
72
+ const existing = trimEnd(lines);
73
+ return {
74
+ body: [
75
+ ...existing,
76
+ ...(existing.length ? [""] : []),
77
+ OPEN,
78
+ SUMMARY,
79
+ "",
80
+ ...item.split("\n"),
81
+ CLOSE,
82
+ ].join(newline),
83
+ mutated: true,
84
+ sectionExisted: false,
85
+ };
86
+ }
87
+ function trimEnd(lines) {
88
+ let end = lines.length;
89
+ while (end > 0 && lines[end - 1].trim() === "")
90
+ end--;
91
+ return lines.slice(0, end);
92
+ }
93
+ function trim(lines) {
94
+ const start = lines.findIndex((line) => line.trim() !== "");
95
+ return start === -1 ? [] : trimEnd(lines.slice(start));
96
+ }
@@ -0,0 +1,16 @@
1
+ /** Result of extracting the single visible structural Shepherd Journal from Markdown. */
2
+ export type ShepherdJournalExtraction = {
3
+ journal: null;
4
+ ok: true;
5
+ } | {
6
+ journal: {
7
+ entries: string[];
8
+ format: "details" | "legacy";
9
+ };
10
+ ok: true;
11
+ } | {
12
+ error: string;
13
+ ok: false;
14
+ };
15
+ /** Extract ordered journal entries while failing closed on malformed or ambiguous content. */
16
+ export declare function extractShepherdJournal(body: string): ShepherdJournalExtraction;
@@ -0,0 +1,71 @@
1
+ import { parseShepherdJournalEntries, scanShepherdJournal } from "./reconcile.mjs";
2
+ import { isMarkdownBlockStart, scanMarkdownLines } from "./markdown-line.mjs";
3
+ import { stripMarkdownContainer } from "./markdown-container.mjs";
4
+ import { isIndentedCode } from "./markdown-structure.mjs";
5
+ function hasUnrecognizedJournalContent(lines) {
6
+ const syntax = scanMarkdownLines(lines);
7
+ let foundEntry = false;
8
+ let lazyContinuation = false;
9
+ let nestedBlock = false;
10
+ for (const [index, line] of lines.entries()) {
11
+ const scanned = syntax[index];
12
+ if (scanned.visiblePrefix.startsWith("- ") && /^- \S/.test(line)) {
13
+ foundEntry = true;
14
+ lazyContinuation = true;
15
+ nestedBlock = false;
16
+ continue;
17
+ }
18
+ if (line.trim() === "") {
19
+ if (foundEntry)
20
+ lazyContinuation = false;
21
+ continue;
22
+ }
23
+ if (!foundEntry)
24
+ return true;
25
+ const content = stripMarkdownContainer(line, [{ kind: "list", width: 2 }]);
26
+ if (content === null) {
27
+ if (!lazyContinuation || isMarkdownBlockStart(line))
28
+ return true;
29
+ continue;
30
+ }
31
+ if (scanned.ignored || isMarkdownBlockStart(content) || isIndentedCode(content)) {
32
+ lazyContinuation = false;
33
+ nestedBlock = true;
34
+ continue;
35
+ }
36
+ if (nestedBlock && /^[ \t]/.test(content)) {
37
+ lazyContinuation = false;
38
+ continue;
39
+ }
40
+ lazyContinuation = true;
41
+ nestedBlock = false;
42
+ }
43
+ return false;
44
+ }
45
+ /** Extract ordered journal entries while failing closed on malformed or ambiguous content. */
46
+ export function extractShepherdJournal(body) {
47
+ const lines = body.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
48
+ const journal = scanShepherdJournal(lines);
49
+ if (journal === "error") {
50
+ return {
51
+ error: "malformed, duplicate, or ambiguous Shepherd Journal container",
52
+ ok: false,
53
+ };
54
+ }
55
+ if (!journal)
56
+ return { journal: null, ok: true };
57
+ const content = lines.slice(journal.contentStart, journal.contentEnd);
58
+ if (hasUnrecognizedJournalContent(content)) {
59
+ return {
60
+ error: "Shepherd Journal content uses an unrecognized entry format",
61
+ ok: false,
62
+ };
63
+ }
64
+ return {
65
+ journal: {
66
+ entries: parseShepherdJournalEntries(content).map((entry) => entry.join("\n")),
67
+ format: journal.format,
68
+ },
69
+ ok: true,
70
+ };
71
+ }
@@ -0,0 +1,4 @@
1
+ /** GitHub-free Shepherd Journal helpers for programmatic PR-body reconciliation. */
2
+ export { appendJournalItem, validateJournalItem, type AppendResult } from "./append.mts";
3
+ export { extractShepherdJournal, type ShepherdJournalExtraction } from "./extract.mts";
4
+ export { reconcileShepherdJournal, type ShepherdJournalReconcileResult } from "./reconcile.mts";
@@ -0,0 +1,4 @@
1
+ /** GitHub-free Shepherd Journal helpers for programmatic PR-body reconciliation. */
2
+ export { appendJournalItem, validateJournalItem } from "./append.mjs";
3
+ export { extractShepherdJournal } from "./extract.mjs";
4
+ export { reconcileShepherdJournal } from "./reconcile.mjs";
@@ -0,0 +1,9 @@
1
+ export type BacktickRun = {
2
+ escaped: boolean;
3
+ index: number;
4
+ length: number;
5
+ quoted: boolean;
6
+ };
7
+ export declare function backtickRuns(line: string): BacktickRun[];
8
+ export declare function nextBacktickRun(runs: BacktickRun[], offset: number, length?: number): BacktickRun | undefined;
9
+ export declare function nextCodeOpener(runs: BacktickRun[], offset: number): BacktickRun | undefined;
@@ -0,0 +1,34 @@
1
+ export function backtickRuns(line) {
2
+ const runs = [];
3
+ let inTag = false;
4
+ let quote = null;
5
+ for (let i = 0; i < line.length; i++) {
6
+ if (!inTag && line[i] === "<" && /^\/?[A-Za-z]/.test(line.slice(i + 1)))
7
+ inTag = true;
8
+ else if (inTag && quote) {
9
+ if (line[i] === quote)
10
+ quote = null;
11
+ }
12
+ else if (inTag && (line[i] === '"' || line[i] === "'"))
13
+ quote = line[i] === '"' ? '"' : "'";
14
+ else if (inTag && line[i] === ">")
15
+ inTag = false;
16
+ if (line[i] !== "`")
17
+ continue;
18
+ let slashes = 0;
19
+ while (line[i - slashes - 1] === "\\")
20
+ slashes++;
21
+ let end = i;
22
+ while (line[end] === "`")
23
+ end++;
24
+ runs.push({ escaped: slashes % 2 === 1, index: i, length: end - i, quoted: quote !== null });
25
+ i = end - 1;
26
+ }
27
+ return runs;
28
+ }
29
+ export function nextBacktickRun(runs, offset, length) {
30
+ return runs.find((run) => run.index >= offset && (length === undefined || run.length === length));
31
+ }
32
+ export function nextCodeOpener(runs, offset) {
33
+ return runs.find((run) => run.index >= offset && !run.quoted);
34
+ }
@@ -0,0 +1,22 @@
1
+ export type MarkdownContainer = Array<{
2
+ kind: "list";
3
+ width: number;
4
+ } | {
5
+ kind: "quote";
6
+ }>;
7
+ export declare function markdownContainer(line: string): {
8
+ content: string;
9
+ indent: number;
10
+ tokens: MarkdownContainer;
11
+ };
12
+ export declare function resolveMarkdownContainer(line: string, active: MarkdownContainer): ReturnType<typeof markdownContainer>;
13
+ export declare function fenceStart(line: string, parsed?: {
14
+ content: string;
15
+ indent: number;
16
+ tokens: MarkdownContainer;
17
+ }): {
18
+ container: MarkdownContainer;
19
+ length: number;
20
+ marker: string;
21
+ } | null;
22
+ export declare function stripMarkdownContainer(line: string, tokens: MarkdownContainer): string | null;
@@ -0,0 +1,110 @@
1
+ function visualColumn(line, end) {
2
+ let column = 0;
3
+ for (let i = 0; i < end; i++)
4
+ column = line[i] === "\t" ? column + (4 - (column % 4)) : column + 1;
5
+ return column;
6
+ }
7
+ function quoteEnd(line, offset) {
8
+ let marker = offset;
9
+ while (marker < offset + 3 && line[marker] === " ")
10
+ marker++;
11
+ if (line[marker] !== ">")
12
+ return null;
13
+ marker++;
14
+ return line[marker] === " " || line[marker] === "\t" ? marker + 1 : marker;
15
+ }
16
+ function listEnd(line, offset) {
17
+ let marker = offset;
18
+ while (marker < offset + 3 && line[marker] === " ")
19
+ marker++;
20
+ const match = line.slice(marker).match(/^(?:[-+*]|\d{1,9}[.)])/)?.[0];
21
+ if (!match)
22
+ return null;
23
+ let end = marker + match.length;
24
+ const paddingStart = end;
25
+ const startColumn = visualColumn(line, offset);
26
+ let column = visualColumn(line, end);
27
+ let padding = 0;
28
+ while (line[end] === " " || line[end] === "\t") {
29
+ const nextColumn = line[end] === "\t" ? column + (4 - (column % 4)) : column + 1;
30
+ padding += nextColumn - column;
31
+ column = nextColumn;
32
+ end++;
33
+ }
34
+ if (padding === 0)
35
+ return null;
36
+ if (padding > 4) {
37
+ end = paddingStart + 1;
38
+ const actualWidth = visualColumn(line, end) - visualColumn(line, paddingStart);
39
+ column = visualColumn(line, paddingStart) + 1;
40
+ return { end, indent: actualWidth - 1, width: column - startColumn };
41
+ }
42
+ return { end, indent: 0, width: column - startColumn };
43
+ }
44
+ export function markdownContainer(line) {
45
+ const tokens = [];
46
+ let indent = 0;
47
+ let offset = 0;
48
+ while (true) {
49
+ const quote = quoteEnd(line, offset);
50
+ if (quote !== null) {
51
+ tokens.push({ kind: "quote" });
52
+ offset = quote;
53
+ continue;
54
+ }
55
+ const list = listEnd(line, offset);
56
+ if (!list)
57
+ break;
58
+ tokens.push({ kind: "list", width: list.width });
59
+ offset = list.end;
60
+ indent = list.indent;
61
+ if (indent)
62
+ break;
63
+ }
64
+ return { content: line.slice(offset), indent, tokens };
65
+ }
66
+ export function resolveMarkdownContainer(line, active) {
67
+ for (let depth = active.length; depth > 0; depth--) {
68
+ const retained = active.slice(0, depth);
69
+ const continuation = stripMarkdownContainer(line, retained);
70
+ if (continuation === null)
71
+ continue;
72
+ const nested = markdownContainer(continuation);
73
+ return {
74
+ content: nested.content,
75
+ indent: nested.indent,
76
+ tokens: [...retained, ...nested.tokens],
77
+ };
78
+ }
79
+ return markdownContainer(line);
80
+ }
81
+ export function fenceStart(line, parsed = markdownContainer(line)) {
82
+ const match = `${" ".repeat(parsed.indent)}${parsed.content}`.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
83
+ return match && !(match[1][0] === "`" && match[2].includes("`"))
84
+ ? { container: parsed.tokens, length: match[1].length, marker: match[1][0] }
85
+ : null;
86
+ }
87
+ export function stripMarkdownContainer(line, tokens) {
88
+ let offset = 0;
89
+ for (const [index, token] of tokens.entries()) {
90
+ if (token.kind === "quote") {
91
+ const end = quoteEnd(line, offset);
92
+ if (end === null)
93
+ return null;
94
+ offset = end;
95
+ continue;
96
+ }
97
+ if (line.slice(offset).trim() === "" &&
98
+ tokens.slice(index).every((item) => item.kind === "list"))
99
+ return "";
100
+ const startColumn = visualColumn(line, offset);
101
+ let column = startColumn;
102
+ while (column - startColumn < token.width && (line[offset] === " " || line[offset] === "\t")) {
103
+ column = line[offset] === "\t" ? column + (4 - (column % 4)) : column + 1;
104
+ offset++;
105
+ }
106
+ if (column - startColumn < token.width)
107
+ return null;
108
+ }
109
+ return line.slice(offset);
110
+ }
@@ -0,0 +1,20 @@
1
+ import { type MarkdownContainer } from "./markdown-container.mts";
2
+ export type RawHtmlBlock = {
3
+ container: MarkdownContainer;
4
+ kind: "closing-tag";
5
+ tag: string;
6
+ } | {
7
+ container: MarkdownContainer;
8
+ kind: "blank-line";
9
+ } | {
10
+ container: MarkdownContainer;
11
+ kind: "terminator";
12
+ terminator: string;
13
+ };
14
+ export declare function rawHtmlStart(line: string, parsed?: {
15
+ content: string;
16
+ indent: number;
17
+ tokens: MarkdownContainer;
18
+ }): RawHtmlBlock | null;
19
+ export declare function rawHtmlEnd(block: RawHtmlBlock, content: string): number | null;
20
+ export declare function inQuotedHtmlAttribute(line: string, offset: number): boolean;