@astrosheep/keiyaku 0.1.47 → 0.1.49
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/build/.tsbuildinfo +1 -1
- package/build/agents/round-runner.js +1 -1
- package/build/agents/selector.js +1 -1
- package/build/common/constants.js +6 -1
- package/build/common/errors.js +1 -1
- package/build/common/response-style.js +4 -1
- package/build/config/term-presets/constants.js +24 -0
- package/build/config/term-presets/default-preset.js +119 -0
- package/build/config/term-presets/index.js +5 -0
- package/build/config/term-presets/mischief-preset.js +105 -0
- package/build/config/term-presets/pocket-preset.js +105 -0
- package/build/config/term-presets/resolver.js +52 -0
- package/build/config/term-presets/types.js +1 -0
- package/build/handlers/ask.js +10 -120
- package/build/handlers/close.js +2 -9
- package/build/handlers/drive.js +1 -15
- package/build/handlers/shared.js +1 -2
- package/build/handlers/start.js +0 -17
- package/build/handlers/status.js +2 -6
- package/build/index.js +2 -2
- package/build/types/git-diff.js +1 -0
- package/build/utils/ask-history.js +75 -0
- package/build/utils/draft.js +22 -0
- package/build/utils/git-diff/constants.js +9 -0
- package/build/utils/git-diff/filter.js +70 -0
- package/build/utils/git-diff/incremental.js +111 -0
- package/build/utils/git-diff/index.js +3 -0
- package/build/utils/git-diff/parsers.js +160 -0
- package/build/utils/git-diff/preview.js +157 -0
- package/build/utils/git-diff/stat.js +27 -0
- package/build/utils/git-diff/types.js +1 -0
- package/build/utils/git-ops.js +9 -0
- package/build/utils/git.js +1 -1
- package/build/utils/keiyaku-document/index.js +13 -0
- package/build/utils/keiyaku-document/lex.js +178 -0
- package/build/utils/keiyaku-document/parser.js +242 -0
- package/build/utils/keiyaku-document/render.js +68 -0
- package/build/utils/keiyaku-document/sections.js +105 -0
- package/build/utils/keiyaku-document/types.js +6 -0
- package/build/utils/keiyaku-document.test.js +12 -1
- package/build/utils/trace.js +6 -7
- package/build/workflow/ask-execution.js +81 -0
- package/build/workflow/drive.js +10 -5
- package/build/workflow/iterate-plan.js +1 -1
- package/build/workflow/keiyaku-draft.js +1 -1
- package/build/workflow/{contract.js → keiyaku.js} +2 -2
- package/build/workflow/markdown-normalization.js +3 -2
- package/build/workflow/present.js +68 -13
- package/build/workflow/response-builders.js +75 -44
- package/build/workflow/response-meta.js +15 -0
- package/build/workflow/response-renderer.js +2 -13
- package/build/workflow/round-summary.js +1 -1
- package/build/workflow/start.js +8 -3
- package/build/workflow/status.js +129 -62
- package/package.json +1 -1
- package/build/config/term-presets.js +0 -398
- package/build/utils/git-diff.js +0 -519
- package/build/utils/keiyaku-document.js +0 -539
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { DIFF_PREVIEW_TEXT_MAX_CHARS } from "../../common/constants.js";
|
|
2
|
+
import { createGit, wrapGitError } from "../git-ops.js";
|
|
3
|
+
import { DIFF_EXCLUDES, DIFF_PREVIEW_MAX_FILES, DIFF_PREVIEW_MAX_HUNKS_PER_FILE, DIFF_PREVIEW_MAX_LINES_PER_HUNK, DIFF_PREVIEW_MAX_PRELUDE_LINES, } from "./constants.js";
|
|
4
|
+
import { parseDiffPathFromHeader, parseNameStatus, parseNumStat, splitDiffByFile } from "./parsers.js";
|
|
5
|
+
import { buildDiffPathspec, getDefaultPathspec, readStatDiff } from "./stat.js";
|
|
6
|
+
export async function getDiffStats(cwd, baseBranch) {
|
|
7
|
+
const git = createGit(cwd);
|
|
8
|
+
const pathspec = buildDiffPathspec(baseBranch);
|
|
9
|
+
let rawNumStat;
|
|
10
|
+
try {
|
|
11
|
+
rawNumStat = await git.raw(["diff", "--numstat", ...pathspec]);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
throw wrapGitError(`diff --numstat ${baseBranch}...HEAD`, err, cwd);
|
|
15
|
+
}
|
|
16
|
+
const rows = parseNumStat(rawNumStat);
|
|
17
|
+
return {
|
|
18
|
+
filesChanged: rows.length,
|
|
19
|
+
insertions: rows.reduce((sum, row) => sum + row.additions, 0),
|
|
20
|
+
deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export async function getDiffPreviewText(cwd, baseBranch) {
|
|
24
|
+
const git = createGit(cwd);
|
|
25
|
+
const range = `${baseBranch}...HEAD`;
|
|
26
|
+
return readStatDiff(git, cwd, range, getDefaultPathspec(), DIFF_PREVIEW_TEXT_MAX_CHARS);
|
|
27
|
+
}
|
|
28
|
+
function buildPatchPreview(section) {
|
|
29
|
+
// Keep a small, representative prelude (diff header, index, ---/+++), then first N hunks.
|
|
30
|
+
const firstHunkIdx = section.findIndex((line) => line.startsWith("@@ "));
|
|
31
|
+
const prelude = firstHunkIdx === -1 ? section : section.slice(0, firstHunkIdx).slice(0, DIFF_PREVIEW_MAX_PRELUDE_LINES);
|
|
32
|
+
if (firstHunkIdx === -1) {
|
|
33
|
+
const truncated = section.length > DIFF_PREVIEW_MAX_PRELUDE_LINES;
|
|
34
|
+
const lines = truncated
|
|
35
|
+
? [...prelude, `... [truncated ${section.length - prelude.length} prelude line(s)]`]
|
|
36
|
+
: prelude;
|
|
37
|
+
return { patch: lines.join("\n"), truncated };
|
|
38
|
+
}
|
|
39
|
+
const hunks = [];
|
|
40
|
+
let current = [];
|
|
41
|
+
for (const line of section.slice(firstHunkIdx)) {
|
|
42
|
+
if (line.startsWith("@@ ")) {
|
|
43
|
+
if (current.length > 0)
|
|
44
|
+
hunks.push(current);
|
|
45
|
+
current = [line];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (current.length > 0)
|
|
49
|
+
current.push(line);
|
|
50
|
+
}
|
|
51
|
+
if (current.length > 0)
|
|
52
|
+
hunks.push(current);
|
|
53
|
+
let truncated = false;
|
|
54
|
+
const shownHunks = hunks.slice(0, DIFF_PREVIEW_MAX_HUNKS_PER_FILE).map((hunk) => {
|
|
55
|
+
if (hunk.length <= DIFF_PREVIEW_MAX_LINES_PER_HUNK)
|
|
56
|
+
return hunk;
|
|
57
|
+
truncated = true;
|
|
58
|
+
return [
|
|
59
|
+
...hunk.slice(0, DIFF_PREVIEW_MAX_LINES_PER_HUNK),
|
|
60
|
+
`... [truncated ${hunk.length - DIFF_PREVIEW_MAX_LINES_PER_HUNK} line(s) in this hunk]`,
|
|
61
|
+
];
|
|
62
|
+
});
|
|
63
|
+
if (hunks.length > DIFF_PREVIEW_MAX_HUNKS_PER_FILE) {
|
|
64
|
+
truncated = true;
|
|
65
|
+
}
|
|
66
|
+
const lines = [
|
|
67
|
+
...prelude,
|
|
68
|
+
...shownHunks.flat(),
|
|
69
|
+
...(hunks.length > DIFF_PREVIEW_MAX_HUNKS_PER_FILE
|
|
70
|
+
? [`... [omitted ${hunks.length - DIFF_PREVIEW_MAX_HUNKS_PER_FILE} hunk(s)]`]
|
|
71
|
+
: []),
|
|
72
|
+
];
|
|
73
|
+
return { patch: lines.join("\n"), truncated };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Gets a compact, structured diff preview for quick review.
|
|
77
|
+
* - Always includes full stats for the whole range.
|
|
78
|
+
* - Includes patch previews for the top churn files only (hunk-based truncation).
|
|
79
|
+
*/
|
|
80
|
+
export async function getDiff(cwd, baseBranch) {
|
|
81
|
+
const git = createGit(cwd);
|
|
82
|
+
const pathspec = buildDiffPathspec(baseBranch);
|
|
83
|
+
const range = `${baseBranch}...HEAD`;
|
|
84
|
+
let rawNumStat;
|
|
85
|
+
try {
|
|
86
|
+
rawNumStat = await git.raw(["diff", "--numstat", ...pathspec]);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
throw wrapGitError(`diff --numstat ${range}`, err);
|
|
90
|
+
}
|
|
91
|
+
const rows = parseNumStat(rawNumStat);
|
|
92
|
+
const stats = {
|
|
93
|
+
filesChanged: rows.length,
|
|
94
|
+
insertions: rows.reduce((sum, row) => sum + row.additions, 0),
|
|
95
|
+
deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
|
|
96
|
+
};
|
|
97
|
+
let rawNameStatus = "";
|
|
98
|
+
try {
|
|
99
|
+
rawNameStatus = await git.raw(["diff", "--name-status", ...pathspec]);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
throw wrapGitError(`diff --name-status ${range}`, err);
|
|
103
|
+
}
|
|
104
|
+
const statusByPath = parseNameStatus(rawNameStatus);
|
|
105
|
+
const sorted = [...rows].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
|
|
106
|
+
const selected = sorted.slice(0, DIFF_PREVIEW_MAX_FILES);
|
|
107
|
+
const omittedFileCount = Math.max(0, rows.length - selected.length);
|
|
108
|
+
const selectedPaths = selected.map((row) => row.path);
|
|
109
|
+
let rawPatch = "";
|
|
110
|
+
if (selectedPaths.length > 0) {
|
|
111
|
+
try {
|
|
112
|
+
rawPatch = await git.raw([
|
|
113
|
+
"diff",
|
|
114
|
+
"--no-color",
|
|
115
|
+
"--no-ext-diff",
|
|
116
|
+
"--unified=3",
|
|
117
|
+
range,
|
|
118
|
+
"--",
|
|
119
|
+
...selectedPaths,
|
|
120
|
+
...DIFF_EXCLUDES,
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
throw wrapGitError(`diff --no-color --no-ext-diff --unified=3 ${range} -- <top files>`, err);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const sections = rawPatch ? splitDiffByFile(rawPatch) : [];
|
|
128
|
+
const patchByPath = new Map();
|
|
129
|
+
for (const section of sections) {
|
|
130
|
+
const path = parseDiffPathFromHeader(section[0] ?? "");
|
|
131
|
+
if (!path)
|
|
132
|
+
continue;
|
|
133
|
+
patchByPath.set(path, buildPatchPreview(section));
|
|
134
|
+
}
|
|
135
|
+
const files = selected.map((row) => {
|
|
136
|
+
const status = statusByPath.get(row.path);
|
|
137
|
+
const patch = patchByPath.get(row.path)?.patch ?? "";
|
|
138
|
+
const truncated = patchByPath.get(row.path)?.truncated ?? false;
|
|
139
|
+
return {
|
|
140
|
+
path: row.path,
|
|
141
|
+
status: status?.status ?? "M",
|
|
142
|
+
oldPath: status && (status.status === "R" || status.status === "C") ? status.oldPath : undefined,
|
|
143
|
+
additions: row.additions,
|
|
144
|
+
deletions: row.deletions,
|
|
145
|
+
binary: row.binary,
|
|
146
|
+
patch,
|
|
147
|
+
truncated,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
range,
|
|
152
|
+
excludes: DIFF_EXCLUDES,
|
|
153
|
+
stats,
|
|
154
|
+
files,
|
|
155
|
+
omittedFileCount,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { wrapGitError } from "../git-ops.js";
|
|
2
|
+
import { DIFF_EXCLUDES } from "./constants.js";
|
|
3
|
+
export function buildDiffPathspec(baseBranch) {
|
|
4
|
+
return [`${baseBranch}...HEAD`, "--", ".", ...DIFF_EXCLUDES];
|
|
5
|
+
}
|
|
6
|
+
export function renderStatDiffPreviewText(output, maxChars) {
|
|
7
|
+
const trimmed = output.trim();
|
|
8
|
+
if (!trimmed)
|
|
9
|
+
return "No diff.";
|
|
10
|
+
if (trimmed.length <= maxChars)
|
|
11
|
+
return trimmed;
|
|
12
|
+
const cut = trimmed.slice(0, maxChars);
|
|
13
|
+
return `${cut}\n...[truncated ${trimmed.length - maxChars} chars]...`;
|
|
14
|
+
}
|
|
15
|
+
export async function readStatDiff(git, cwd, range, pathspec, maxChars) {
|
|
16
|
+
let output = "";
|
|
17
|
+
try {
|
|
18
|
+
output = await git.raw(["diff", "--stat=120,80", "--compact-summary", range, ...pathspec]);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
throw wrapGitError(`diff --stat=120,80 --compact-summary ${range}`, err, cwd);
|
|
22
|
+
}
|
|
23
|
+
return renderStatDiffPreviewText(output, maxChars);
|
|
24
|
+
}
|
|
25
|
+
export function getDefaultPathspec() {
|
|
26
|
+
return ["--", ".", ...DIFF_EXCLUDES];
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/build/utils/git-ops.js
CHANGED
|
@@ -96,6 +96,15 @@ export async function getActiveKeiyakuBranch(cwd) {
|
|
|
96
96
|
const current = await getCurrentBranch(cwd);
|
|
97
97
|
return current.startsWith(KEIYAKU_BRANCH_PREFIX) ? current : null;
|
|
98
98
|
}
|
|
99
|
+
export async function getActiveKeiyakuGitState(cwd) {
|
|
100
|
+
const branch = await getActiveKeiyakuBranch(cwd);
|
|
101
|
+
if (!branch)
|
|
102
|
+
return null;
|
|
103
|
+
const baseBranch = await getKeiyakuBase(cwd, branch);
|
|
104
|
+
if (!baseBranch)
|
|
105
|
+
return null;
|
|
106
|
+
return { branch, baseBranch };
|
|
107
|
+
}
|
|
99
108
|
export async function listLocalKeiyakuBranches(cwd) {
|
|
100
109
|
const git = createGit(cwd);
|
|
101
110
|
let branches;
|
package/build/utils/git.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export * from "./git-ops.js";
|
|
2
|
-
export * from "./git-diff.js";
|
|
2
|
+
export * from "./git-diff/index.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsibility boundary:
|
|
3
|
+
* - This module is a markdown-like parser/renderer utility layer only.
|
|
4
|
+
* - It handles syntax-level concerns (tokens, AST, section extraction, rendering).
|
|
5
|
+
* - It MUST NOT encode keiyaku workflow/domain semantics such as required fields
|
|
6
|
+
* (goal/context/rules/criteria), reserved business section names, or
|
|
7
|
+
* start/drive policy decisions.
|
|
8
|
+
* - Domain-specific draft/schema logic belongs in workflow-layer modules.
|
|
9
|
+
*/
|
|
10
|
+
export { KeiyakuParseError, } from "./types.js";
|
|
11
|
+
export { parseToAST } from "./parser.js";
|
|
12
|
+
export { extractListItems, renderNodeContent, renderSectionContent } from "./render.js";
|
|
13
|
+
export { hasTopLevelHeaders, parseMarkdownListSection, parseMarkdownSections, parseMarkdownStructure } from "./sections.js";
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
function countLeadingSpaces(line) {
|
|
2
|
+
let idx = 0;
|
|
3
|
+
while (idx < line.length && line[idx] === " ") {
|
|
4
|
+
idx += 1;
|
|
5
|
+
}
|
|
6
|
+
return idx;
|
|
7
|
+
}
|
|
8
|
+
function parseFence(trimmedLine) {
|
|
9
|
+
if (!trimmedLine.startsWith("```"))
|
|
10
|
+
return null;
|
|
11
|
+
let idx = 0;
|
|
12
|
+
while (idx < trimmedLine.length && trimmedLine[idx] === "`") {
|
|
13
|
+
idx += 1;
|
|
14
|
+
}
|
|
15
|
+
if (idx < 3)
|
|
16
|
+
return null;
|
|
17
|
+
return {
|
|
18
|
+
length: idx,
|
|
19
|
+
info: trimmedLine.slice(idx).trim(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function isAsciiDigit(char) {
|
|
23
|
+
return char !== undefined && char >= "0" && char <= "9";
|
|
24
|
+
}
|
|
25
|
+
function isSpaceOrTab(char) {
|
|
26
|
+
return char === " " || char === "\t";
|
|
27
|
+
}
|
|
28
|
+
function parseHeader(trimmedLine) {
|
|
29
|
+
let level = 0;
|
|
30
|
+
while (level < trimmedLine.length && trimmedLine[level] === "#") {
|
|
31
|
+
level += 1;
|
|
32
|
+
}
|
|
33
|
+
if (level === 0)
|
|
34
|
+
return null;
|
|
35
|
+
if (!isSpaceOrTab(trimmedLine[level]))
|
|
36
|
+
return null;
|
|
37
|
+
const text = trimmedLine.slice(level).trim();
|
|
38
|
+
if (!text)
|
|
39
|
+
return null;
|
|
40
|
+
return { level, text };
|
|
41
|
+
}
|
|
42
|
+
function parseBlockquote(line) {
|
|
43
|
+
let idx = 0;
|
|
44
|
+
while (idx < line.length && line[idx] === " ") {
|
|
45
|
+
idx += 1;
|
|
46
|
+
}
|
|
47
|
+
if (idx > 3 || line[idx] !== ">")
|
|
48
|
+
return null;
|
|
49
|
+
let markerEnd = idx + 1;
|
|
50
|
+
if (line[markerEnd] === " ") {
|
|
51
|
+
markerEnd += 1;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
marker: line.slice(0, markerEnd),
|
|
55
|
+
body: line.slice(markerEnd),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function stripUtf8Bom(content) {
|
|
59
|
+
if (!content.startsWith("\uFEFF"))
|
|
60
|
+
return content;
|
|
61
|
+
return content.slice(1);
|
|
62
|
+
}
|
|
63
|
+
function parseListMarker(line) {
|
|
64
|
+
let indent = 0;
|
|
65
|
+
while (indent < line.length && line[indent] === " ") {
|
|
66
|
+
indent += 1;
|
|
67
|
+
}
|
|
68
|
+
const markerStart = indent;
|
|
69
|
+
const markerChar = line[markerStart];
|
|
70
|
+
let markerEnd = markerStart;
|
|
71
|
+
let ordered = false;
|
|
72
|
+
if (markerChar === "-" || markerChar === "*" || markerChar === "+") {
|
|
73
|
+
markerEnd += 1;
|
|
74
|
+
}
|
|
75
|
+
else if (isAsciiDigit(markerChar)) {
|
|
76
|
+
while (isAsciiDigit(line[markerEnd])) {
|
|
77
|
+
markerEnd += 1;
|
|
78
|
+
}
|
|
79
|
+
const orderedSuffix = line[markerEnd];
|
|
80
|
+
if (orderedSuffix !== "." && orderedSuffix !== ")")
|
|
81
|
+
return null;
|
|
82
|
+
markerEnd += 1;
|
|
83
|
+
ordered = true;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (!isSpaceOrTab(line[markerEnd]))
|
|
89
|
+
return null;
|
|
90
|
+
let contentStart = markerEnd;
|
|
91
|
+
while (isSpaceOrTab(line[contentStart])) {
|
|
92
|
+
contentStart += 1;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
indent,
|
|
96
|
+
ordered,
|
|
97
|
+
marker: line.slice(markerStart, markerEnd),
|
|
98
|
+
body: line.slice(contentStart),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function isBlankLine(line) {
|
|
102
|
+
return line.trim().length === 0;
|
|
103
|
+
}
|
|
104
|
+
export function stripTrailingBlankLines(lines) {
|
|
105
|
+
const trimmed = [...lines];
|
|
106
|
+
while (trimmed.length > 0 && isBlankLine(trimmed[trimmed.length - 1])) {
|
|
107
|
+
trimmed.pop();
|
|
108
|
+
}
|
|
109
|
+
return trimmed;
|
|
110
|
+
}
|
|
111
|
+
export function isSectionHeaderToken(token) {
|
|
112
|
+
return token.type === "header" && (token.level === 1 || token.level === 2);
|
|
113
|
+
}
|
|
114
|
+
export function isFenceClosingToken(token, fence) {
|
|
115
|
+
return token.type === "fence" && token.length >= fence.length;
|
|
116
|
+
}
|
|
117
|
+
export function lexMarkdown(content) {
|
|
118
|
+
const lines = stripUtf8Bom(content).split(/\r?\n/);
|
|
119
|
+
const tokens = [];
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const leadingSpaces = countLeadingSpaces(line);
|
|
122
|
+
if (leadingSpaces <= 3) {
|
|
123
|
+
const trimmedLine = line.trimStart();
|
|
124
|
+
const fence = parseFence(trimmedLine);
|
|
125
|
+
if (fence) {
|
|
126
|
+
tokens.push({
|
|
127
|
+
type: "fence",
|
|
128
|
+
raw: line,
|
|
129
|
+
leadingSpaces,
|
|
130
|
+
length: fence.length,
|
|
131
|
+
info: fence.info,
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const header = parseHeader(trimmedLine);
|
|
136
|
+
if (header) {
|
|
137
|
+
tokens.push({
|
|
138
|
+
type: "header",
|
|
139
|
+
raw: line,
|
|
140
|
+
leadingSpaces,
|
|
141
|
+
level: header.level,
|
|
142
|
+
text: header.text,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const marker = parseListMarker(line);
|
|
148
|
+
if (marker) {
|
|
149
|
+
tokens.push({
|
|
150
|
+
type: "list_marker",
|
|
151
|
+
raw: line,
|
|
152
|
+
leadingSpaces,
|
|
153
|
+
indent: marker.indent,
|
|
154
|
+
ordered: marker.ordered,
|
|
155
|
+
marker: marker.marker,
|
|
156
|
+
body: marker.body,
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const blockquote = parseBlockquote(line);
|
|
161
|
+
if (blockquote) {
|
|
162
|
+
tokens.push({
|
|
163
|
+
type: "blockquote",
|
|
164
|
+
raw: line,
|
|
165
|
+
leadingSpaces,
|
|
166
|
+
marker: blockquote.marker,
|
|
167
|
+
body: blockquote.body,
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
tokens.push({
|
|
172
|
+
type: "text",
|
|
173
|
+
raw: line,
|
|
174
|
+
leadingSpaces,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return tokens;
|
|
178
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { isFenceClosingToken, isSectionHeaderToken, lexMarkdown, stripTrailingBlankLines } from "./lex.js";
|
|
2
|
+
import { KeiyakuParseError, } from "./types.js";
|
|
3
|
+
class MarkdownParser {
|
|
4
|
+
tokens;
|
|
5
|
+
options;
|
|
6
|
+
index = 0;
|
|
7
|
+
constructor(tokens, options) {
|
|
8
|
+
this.tokens = tokens;
|
|
9
|
+
this.options = options;
|
|
10
|
+
}
|
|
11
|
+
parseDocument() {
|
|
12
|
+
return {
|
|
13
|
+
type: "document",
|
|
14
|
+
children: this.parseBlocks(() => false),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
parseBlocks(shouldStop) {
|
|
18
|
+
const blocks = [];
|
|
19
|
+
while (!this.isEof()) {
|
|
20
|
+
if (shouldStop())
|
|
21
|
+
break;
|
|
22
|
+
const token = this.peek();
|
|
23
|
+
if (!token)
|
|
24
|
+
break;
|
|
25
|
+
if (this.options.allowSections && isSectionHeaderToken(token)) {
|
|
26
|
+
blocks.push(this.parseSection());
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (token.type === "fence") {
|
|
30
|
+
blocks.push(this.parseCodeBlock());
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (token.type === "header") {
|
|
34
|
+
blocks.push(this.parseHeading());
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (token.type === "list_marker" && token.indent <= 3) {
|
|
38
|
+
blocks.push(this.parseList(token.indent, token.ordered));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (token.type === "blockquote") {
|
|
42
|
+
blocks.push(this.parseBlockquote());
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
blocks.push(this.parseText(shouldStop));
|
|
46
|
+
}
|
|
47
|
+
return blocks;
|
|
48
|
+
}
|
|
49
|
+
parseSection() {
|
|
50
|
+
const header = this.consume();
|
|
51
|
+
if (!header || !isSectionHeaderToken(header)) {
|
|
52
|
+
throw new KeiyakuParseError("invalid markdown parse state: expected section header");
|
|
53
|
+
}
|
|
54
|
+
const children = this.parseBlocks(() => {
|
|
55
|
+
const next = this.peek();
|
|
56
|
+
return next ? this.options.allowSections && isSectionHeaderToken(next) : false;
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
type: "section",
|
|
60
|
+
level: header.level,
|
|
61
|
+
title: header.text,
|
|
62
|
+
children,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
parseCodeBlock() {
|
|
66
|
+
const opener = this.consume();
|
|
67
|
+
if (!opener || opener.type !== "fence") {
|
|
68
|
+
throw new KeiyakuParseError("invalid markdown parse state: expected code fence");
|
|
69
|
+
}
|
|
70
|
+
const lines = [opener.raw];
|
|
71
|
+
const fence = { length: opener.length };
|
|
72
|
+
while (!this.isEof()) {
|
|
73
|
+
const token = this.consume();
|
|
74
|
+
if (!token)
|
|
75
|
+
break;
|
|
76
|
+
lines.push(token.raw);
|
|
77
|
+
if (isFenceClosingToken(token, fence)) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
type: "code_block",
|
|
83
|
+
fenceLength: opener.length,
|
|
84
|
+
info: opener.info,
|
|
85
|
+
lines,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
parseHeading() {
|
|
89
|
+
const header = this.consume();
|
|
90
|
+
if (!header || header.type !== "header") {
|
|
91
|
+
throw new KeiyakuParseError("invalid markdown parse state: expected heading");
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
type: "heading",
|
|
95
|
+
level: header.level,
|
|
96
|
+
text: header.text,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
parseBlockquote() {
|
|
100
|
+
const lines = [];
|
|
101
|
+
let marker = "> ";
|
|
102
|
+
while (!this.isEof()) {
|
|
103
|
+
const token = this.peek();
|
|
104
|
+
if (!token || token.type !== "blockquote")
|
|
105
|
+
break;
|
|
106
|
+
marker = token.marker;
|
|
107
|
+
lines.push(token.body);
|
|
108
|
+
this.consume();
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
type: "blockquote",
|
|
112
|
+
marker,
|
|
113
|
+
lines,
|
|
114
|
+
value: lines.join("\n"),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
parseList(indent, ordered) {
|
|
118
|
+
const items = [];
|
|
119
|
+
while (!this.isEof()) {
|
|
120
|
+
const token = this.peek();
|
|
121
|
+
if (!token || token.type !== "list_marker")
|
|
122
|
+
break;
|
|
123
|
+
if (token.indent !== indent)
|
|
124
|
+
break;
|
|
125
|
+
if (token.ordered !== ordered)
|
|
126
|
+
break;
|
|
127
|
+
this.consume();
|
|
128
|
+
items.push(this.parseListItem(token, indent));
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
type: "list",
|
|
132
|
+
ordered,
|
|
133
|
+
indent,
|
|
134
|
+
items,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
parseListItem(markerToken, listIndent) {
|
|
138
|
+
const lines = [markerToken.body];
|
|
139
|
+
let itemFence = null;
|
|
140
|
+
while (!this.isEof()) {
|
|
141
|
+
const token = this.peek();
|
|
142
|
+
if (!token)
|
|
143
|
+
break;
|
|
144
|
+
if (itemFence) {
|
|
145
|
+
const consumed = this.consume();
|
|
146
|
+
if (!consumed)
|
|
147
|
+
break;
|
|
148
|
+
lines.push(consumed.raw);
|
|
149
|
+
if (isFenceClosingToken(consumed, itemFence)) {
|
|
150
|
+
itemFence = null;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (this.options.allowSections && isSectionHeaderToken(token)) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
if (token.type === "header" && token.leadingSpaces <= listIndent) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
if (token.type === "fence" && token.leadingSpaces <= listIndent) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (token.type === "list_marker") {
|
|
164
|
+
if (token.indent < listIndent) {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
if (token.indent === listIndent) {
|
|
168
|
+
if (token.ordered !== markerToken.ordered) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const consumed = this.consume();
|
|
175
|
+
if (!consumed)
|
|
176
|
+
break;
|
|
177
|
+
lines.push(consumed.raw);
|
|
178
|
+
if (consumed.type === "fence") {
|
|
179
|
+
itemFence = { length: consumed.length };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const normalizedLines = stripTrailingBlankLines(lines);
|
|
183
|
+
const body = normalizedLines.join("\n");
|
|
184
|
+
const children = parseToAST(body, { allowSections: false }).children;
|
|
185
|
+
return {
|
|
186
|
+
type: "list_item",
|
|
187
|
+
marker: markerToken.marker,
|
|
188
|
+
indent: markerToken.indent,
|
|
189
|
+
children,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
parseText(shouldStop) {
|
|
193
|
+
const lines = [];
|
|
194
|
+
while (!this.isEof()) {
|
|
195
|
+
if (shouldStop())
|
|
196
|
+
break;
|
|
197
|
+
const token = this.peek();
|
|
198
|
+
if (!token)
|
|
199
|
+
break;
|
|
200
|
+
if (token.type === "fence")
|
|
201
|
+
break;
|
|
202
|
+
if (token.type === "header")
|
|
203
|
+
break;
|
|
204
|
+
if (token.type === "list_marker" && token.indent <= 3)
|
|
205
|
+
break;
|
|
206
|
+
if (token.type === "blockquote")
|
|
207
|
+
break;
|
|
208
|
+
if (this.options.allowSections && isSectionHeaderToken(token))
|
|
209
|
+
break;
|
|
210
|
+
lines.push(token.raw);
|
|
211
|
+
this.consume();
|
|
212
|
+
}
|
|
213
|
+
if (lines.length === 0) {
|
|
214
|
+
const fallback = this.consume();
|
|
215
|
+
if (fallback) {
|
|
216
|
+
lines.push(fallback.raw);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
type: "text",
|
|
221
|
+
lines,
|
|
222
|
+
value: lines.join("\n"),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
peek() {
|
|
226
|
+
return this.tokens[this.index];
|
|
227
|
+
}
|
|
228
|
+
consume() {
|
|
229
|
+
const token = this.tokens[this.index];
|
|
230
|
+
this.index += 1;
|
|
231
|
+
return token;
|
|
232
|
+
}
|
|
233
|
+
isEof() {
|
|
234
|
+
return this.index >= this.tokens.length;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
export function parseToAST(content, options = {}) {
|
|
238
|
+
const parser = new MarkdownParser(lexMarkdown(content), {
|
|
239
|
+
allowSections: options.allowSections ?? true,
|
|
240
|
+
});
|
|
241
|
+
return parser.parseDocument();
|
|
242
|
+
}
|