@payconductor/houndsman 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -39
- package/package.json +1 -1
- package/src/cli/commands/context-command.ts +11 -9
- package/src/cli/commands/doctor-command.ts +5 -0
- package/src/cli/commands/findings-command.ts +13 -3
- package/src/cli/commands/review-command.ts +1 -2
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +8 -3
- package/src/cli/index.ts +5 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +216 -125
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/commands/status.ts +63 -0
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +53 -75
- package/src/core/github-review.ts +620 -57
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +271 -134
- package/src/core/paths.ts +26 -17
- package/src/core/pr-context.ts +19 -5
- package/src/core/project-config.ts +4 -5
- package/src/core/renderers/manifest-writer.ts +29 -18
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-layout.ts +4 -104
- package/src/core/run-status.ts +59 -50
- package/src/core/services/git-repository.ts +16 -0
- package/src/core/services/project-files.ts +13 -1
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +271 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +162 -0
- package/src/core/storage/sqlite/schema.ts +116 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
- package/src/core/storage/types.ts +152 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/index.ts +27 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +144 -23
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +50 -15
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +181 -0
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +58 -10
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/utils/code-highlight.ts +422 -0
- package/src/server/client/utils/constants.ts +226 -0
- package/src/server/client/utils/json-highlight.ts +55 -0
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +81 -0
- package/src/server/index.ts +154 -16
- package/src/server/run-repository.ts +153 -136
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/example-review.md +22 -14
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +55 -32
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +7 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
- package/src/tests/baseline.test.ts +79 -34
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +1 -1
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +56 -5
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +149 -0
- package/src/tests/review-resume.test.ts +101 -0
- package/src/tests/review-scope.test.ts +302 -0
- package/src/tests/review-scripts.test.ts +81 -5
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +86 -23
- package/src/tests/server.test.ts +184 -96
- package/src/tests/storage.test.ts +449 -0
- package/src/server/client/constants.ts +0 -91
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
|
@@ -1,48 +1,169 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Painel de artefatos:
|
|
2
|
+
* Painel de artefatos: lista todos os artefatos persistidos da run e renderiza
|
|
3
|
+
* o conteúdo bruto. JSON ganha destaque de sintaxe; markdown é renderizado com
|
|
4
|
+
* o mesmo parser/highlight do restante da UI. Briefs e relatórios ficam
|
|
5
|
+
* auditáveis pela mesma navegação.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
|
-
import { useEffect, useState } from "preact/hooks";
|
|
6
|
-
import {
|
|
8
|
+
import { useEffect, useMemo, useState } from "preact/hooks";
|
|
9
|
+
import { MarkdownText } from "./MarkdownText";
|
|
10
|
+
import { api } from "./utils/api";
|
|
11
|
+
import {
|
|
12
|
+
ARTIFACT_KIND_LABELS,
|
|
13
|
+
describeArtifact,
|
|
14
|
+
displayArtifactName,
|
|
15
|
+
groupArtifacts,
|
|
16
|
+
isJsonArtifact,
|
|
17
|
+
isMarkdownArtifact,
|
|
18
|
+
} from "./utils/constants";
|
|
19
|
+
import { formatJson, highlightJson } from "./utils/json-highlight";
|
|
20
|
+
import type { ArtifactMeta } from "./utils/types";
|
|
21
|
+
|
|
22
|
+
/** Prefere o relatório consolidado; senão, o primeiro artefato disponível. */
|
|
23
|
+
function pickDefault(items: ArtifactMeta[]): string {
|
|
24
|
+
return (items.find((item) => item.name === "review.md") ?? items[0])?.name ?? "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** JSON com destaque de sintaxe (tokens em spans, sem lib externa). */
|
|
28
|
+
function JsonView({ content }: { content: string }) {
|
|
29
|
+
const tokens = useMemo(() => highlightJson(formatJson(content)), [content]);
|
|
30
|
+
return (
|
|
31
|
+
<pre class="artifact-view json-view">
|
|
32
|
+
{tokens.map((token) => (
|
|
33
|
+
<span class={token.type === "plain" ? undefined : `json-${token.type}`}>
|
|
34
|
+
{token.text}
|
|
35
|
+
</span>
|
|
36
|
+
))}
|
|
37
|
+
</pre>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
7
40
|
|
|
8
41
|
export function ArtifactsPanel({ slug }: { slug: string }) {
|
|
9
|
-
const [
|
|
42
|
+
const [items, setItems] = useState<ArtifactMeta[]>([]);
|
|
43
|
+
const [name, setName] = useState("");
|
|
10
44
|
const [content, setContent] = useState("");
|
|
11
45
|
const [error, setError] = useState("");
|
|
46
|
+
const [loading, setLoading] = useState(false);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
setError("");
|
|
51
|
+
api<{ artifacts: ArtifactMeta[] }>(`/api/runs/${slug}/artifacts`)
|
|
52
|
+
.then(({ artifacts }) => {
|
|
53
|
+
if (cancelled) return;
|
|
54
|
+
setItems(artifacts);
|
|
55
|
+
setName((current) =>
|
|
56
|
+
artifacts.some((item) => item.name === current)
|
|
57
|
+
? current
|
|
58
|
+
: pickDefault(artifacts),
|
|
59
|
+
);
|
|
60
|
+
})
|
|
61
|
+
.catch((err) => {
|
|
62
|
+
if (cancelled) return;
|
|
63
|
+
setItems([]);
|
|
64
|
+
setName("");
|
|
65
|
+
setError(String(err));
|
|
66
|
+
});
|
|
67
|
+
return () => {
|
|
68
|
+
cancelled = true;
|
|
69
|
+
};
|
|
70
|
+
}, [slug]);
|
|
12
71
|
|
|
13
72
|
useEffect(() => {
|
|
73
|
+
if (!name) {
|
|
74
|
+
setContent("");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let cancelled = false;
|
|
78
|
+
setLoading(true);
|
|
14
79
|
setError("");
|
|
15
|
-
fetch(`/api/runs/${slug}/artifacts/${name}`)
|
|
80
|
+
fetch(`/api/runs/${slug}/artifacts/${encodeURI(name)}`)
|
|
16
81
|
.then(async (res) => {
|
|
17
82
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
18
|
-
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
if (!cancelled) setContent(text);
|
|
19
85
|
})
|
|
20
86
|
.catch((err) => {
|
|
87
|
+
if (cancelled) return;
|
|
21
88
|
setContent("");
|
|
22
89
|
setError(String(err));
|
|
90
|
+
})
|
|
91
|
+
.finally(() => {
|
|
92
|
+
if (!cancelled) setLoading(false);
|
|
23
93
|
});
|
|
94
|
+
return () => {
|
|
95
|
+
cancelled = true;
|
|
96
|
+
};
|
|
24
97
|
}, [slug, name]);
|
|
25
98
|
|
|
99
|
+
const groups = useMemo(() => groupArtifacts(items), [items]);
|
|
100
|
+
const selected = useMemo(
|
|
101
|
+
() => items.find((item) => item.name === name),
|
|
102
|
+
[items, name],
|
|
103
|
+
);
|
|
104
|
+
|
|
26
105
|
return (
|
|
27
106
|
<div class="artifacts">
|
|
28
|
-
<div class="artifact-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
107
|
+
<div class="artifact-layout">
|
|
108
|
+
<nav class="artifact-list" aria-label="Artefatos da revisão">
|
|
109
|
+
{groups.map((group) => (
|
|
110
|
+
<div class="artifact-group" key={group.key}>
|
|
111
|
+
<p class="artifact-group-label">{group.label}</p>
|
|
112
|
+
{group.items.map((item) => (
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
key={item.name}
|
|
116
|
+
title={item.name}
|
|
117
|
+
class={`artifact-item${item.name === name ? " active" : ""}`}
|
|
118
|
+
onClick={() => setName(item.name)}
|
|
119
|
+
>
|
|
120
|
+
<span class="artifact-item-name">
|
|
121
|
+
{displayArtifactName(item)}
|
|
122
|
+
</span>
|
|
123
|
+
<span class="artifact-item-kind">
|
|
124
|
+
{ARTIFACT_KIND_LABELS[item.kind] ?? item.kind}
|
|
125
|
+
</span>
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
{items.length === 0 && !loading && (
|
|
131
|
+
<p class="empty">Nenhum artefato persistido nesta run.</p>
|
|
132
|
+
)}
|
|
133
|
+
</nav>
|
|
134
|
+
|
|
135
|
+
<section class="artifact-pane">
|
|
136
|
+
{selected ? (
|
|
137
|
+
<header class="artifact-head">
|
|
138
|
+
<div>
|
|
139
|
+
<h3 class="artifact-title">{selected.name}</h3>
|
|
140
|
+
<p class="artifact-desc">{describeArtifact(selected)}</p>
|
|
141
|
+
</div>
|
|
142
|
+
<span class="artifact-kind-badge">
|
|
143
|
+
{ARTIFACT_KIND_LABELS[selected.kind] ?? selected.kind}
|
|
144
|
+
</span>
|
|
145
|
+
</header>
|
|
146
|
+
) : null}
|
|
147
|
+
|
|
148
|
+
{error ? (
|
|
149
|
+
<p class="error">{error}</p>
|
|
150
|
+
) : loading ? (
|
|
151
|
+
<p class="empty">Carregando artefato…</p>
|
|
152
|
+
) : selected ? (
|
|
153
|
+
isJsonArtifact(selected) ? (
|
|
154
|
+
<JsonView content={content} />
|
|
155
|
+
) : isMarkdownArtifact(selected) ? (
|
|
156
|
+
<div class="artifact-view artifact-markdown">
|
|
157
|
+
<MarkdownText text={content} />
|
|
158
|
+
</div>
|
|
159
|
+
) : (
|
|
160
|
+
<pre class="artifact-view">{content || "—"}</pre>
|
|
161
|
+
)
|
|
162
|
+
) : (
|
|
163
|
+
<p class="empty">Selecione um artefato para visualizar.</p>
|
|
164
|
+
)}
|
|
165
|
+
</section>
|
|
39
166
|
</div>
|
|
40
|
-
<p class="artifact-desc">{ARTIFACT_INFO[name]}</p>
|
|
41
|
-
{error ? (
|
|
42
|
-
<p class="error">{error}</p>
|
|
43
|
-
) : (
|
|
44
|
-
<pre class="artifact-view">{content}</pre>
|
|
45
|
-
)}
|
|
46
167
|
</div>
|
|
47
168
|
);
|
|
48
169
|
}
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
* o toggle "ausentes".
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { DECISION_LABELS, DECISIONS, SEVERITY_LABELS, SEVERITY_ORDER } from "./constants";
|
|
7
6
|
import { Search, Watch } from "./icons";
|
|
8
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
DECISION_LABELS,
|
|
9
|
+
DECISIONS,
|
|
10
|
+
SEVERITY_LABELS,
|
|
11
|
+
SEVERITY_ORDER,
|
|
12
|
+
} from "./utils/constants";
|
|
13
|
+
import type { Decision, Severity } from "./utils/types";
|
|
9
14
|
|
|
10
15
|
export type FiltersState = {
|
|
11
16
|
query: string;
|
|
@@ -6,20 +6,23 @@
|
|
|
6
6
|
* poluição visual da triagem; reabrir volta a expandir.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
10
|
-
import { api } from "./api";
|
|
11
|
-
import { DECISION_LABELS, DECISIONS, SEVERITY_LABELS } from "./constants";
|
|
12
|
-
import { vscodeFileLink } from "./format";
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
13
10
|
import {
|
|
14
11
|
Check,
|
|
15
12
|
ChevronRight,
|
|
16
13
|
CodeIcon,
|
|
17
14
|
ExternalLink,
|
|
15
|
+
GitHub,
|
|
18
16
|
Sparkles,
|
|
19
17
|
Spinner,
|
|
20
18
|
Watch,
|
|
21
19
|
} from "./icons";
|
|
22
|
-
import
|
|
20
|
+
import { MarkdownText } from "./MarkdownText";
|
|
21
|
+
import { api } from "./utils/api";
|
|
22
|
+
import { highlightCode, languageFromFile } from "./utils/code-highlight";
|
|
23
|
+
import { DECISION_LABELS, DECISIONS, SEVERITY_LABELS } from "./utils/constants";
|
|
24
|
+
import { vscodeFileLink } from "./utils/format";
|
|
25
|
+
import type { Finding } from "./utils/types";
|
|
23
26
|
|
|
24
27
|
/** Debounce do auto-save do comentário (ms). */
|
|
25
28
|
const AUTOSAVE_MS = 700;
|
|
@@ -102,6 +105,13 @@ export function FindingCard({
|
|
|
102
105
|
|
|
103
106
|
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
104
107
|
const href = finding.absFile ? vscodeFileLink(finding.absFile, finding.line) : null;
|
|
108
|
+
const highlighted = useMemo(
|
|
109
|
+
() =>
|
|
110
|
+
finding.evidence
|
|
111
|
+
? highlightCode(finding.evidence, languageFromFile(finding.file))
|
|
112
|
+
: null,
|
|
113
|
+
[finding.evidence, finding.file],
|
|
114
|
+
);
|
|
105
115
|
const toggle = () => setOpen((current) => !current);
|
|
106
116
|
|
|
107
117
|
return (
|
|
@@ -146,17 +156,20 @@ export function FindingCard({
|
|
|
146
156
|
{finding.github &&
|
|
147
157
|
(finding.github.url ? (
|
|
148
158
|
<a
|
|
149
|
-
class="sent-tag"
|
|
159
|
+
class="sent-tag gh"
|
|
150
160
|
href={finding.github.url}
|
|
151
161
|
target="_blank"
|
|
152
162
|
rel="noreferrer"
|
|
153
|
-
title=
|
|
163
|
+
title={`Comentário enviado ao GitHub (PR #${finding.github.pr}) — abrir`}
|
|
154
164
|
>
|
|
155
|
-
<
|
|
165
|
+
<GitHub size={12} /> enviado ao GitHub
|
|
156
166
|
</a>
|
|
157
167
|
) : (
|
|
158
|
-
<span
|
|
159
|
-
|
|
168
|
+
<span
|
|
169
|
+
class="sent-tag gh"
|
|
170
|
+
title={`Comentário enviado ao GitHub (PR #${finding.github.pr})`}
|
|
171
|
+
>
|
|
172
|
+
<GitHub size={12} /> enviado ao GitHub
|
|
160
173
|
</span>
|
|
161
174
|
))}
|
|
162
175
|
<span class="spacer" />
|
|
@@ -186,14 +199,36 @@ export function FindingCard({
|
|
|
186
199
|
<div class="meta-row">{finding.rule}</div>
|
|
187
200
|
|
|
188
201
|
<div class="action-block">
|
|
189
|
-
<
|
|
190
|
-
|
|
202
|
+
<Sparkles size={16} class="action-icon" />
|
|
203
|
+
<div class="action-text">
|
|
191
204
|
<span class="action-label">Ação:</span>
|
|
192
|
-
{finding.action}
|
|
193
|
-
</
|
|
205
|
+
<MarkdownText text={finding.action} />
|
|
206
|
+
</div>
|
|
194
207
|
</div>
|
|
195
208
|
|
|
196
|
-
{finding.evidence &&
|
|
209
|
+
{finding.evidence &&
|
|
210
|
+
(highlighted ? (
|
|
211
|
+
<div class="evidence-block">
|
|
212
|
+
<div class="evidence-head">
|
|
213
|
+
<span class="code-lang">{highlighted.label}</span>
|
|
214
|
+
</div>
|
|
215
|
+
<pre class="evidence code-evidence">
|
|
216
|
+
{highlighted.tokens.map((token) => (
|
|
217
|
+
<span
|
|
218
|
+
class={
|
|
219
|
+
token.type === "plain"
|
|
220
|
+
? undefined
|
|
221
|
+
: `tok-${token.type}`
|
|
222
|
+
}
|
|
223
|
+
>
|
|
224
|
+
{token.text}
|
|
225
|
+
</span>
|
|
226
|
+
))}
|
|
227
|
+
</pre>
|
|
228
|
+
</div>
|
|
229
|
+
) : (
|
|
230
|
+
<pre class="evidence">{finding.evidence}</pre>
|
|
231
|
+
))}
|
|
197
232
|
|
|
198
233
|
<div class="decision-tags-block">
|
|
199
234
|
<div class="decisions">
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { useMemo } from "preact/hooks";
|
|
6
|
-
import { SEVERITY_LABELS, SEVERITY_ORDER } from "./constants";
|
|
7
6
|
import { FindingCard } from "./FindingCard";
|
|
8
7
|
import { Alert } from "./icons";
|
|
9
|
-
import
|
|
8
|
+
import { SEVERITY_LABELS, SEVERITY_ORDER } from "./utils/constants";
|
|
9
|
+
import type { Finding, Severity } from "./utils/types";
|
|
10
10
|
|
|
11
11
|
export function FindingsList({
|
|
12
12
|
slug,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render de markdown mínimo para o campo `action` do finding e artefatos.
|
|
3
|
+
*
|
|
4
|
+
* Sem HTML cru: tudo vira nós do Preact. Blocos de código reutilizam o
|
|
5
|
+
* highlight de código; links passam por `safeUrl` e caminhos de arquivo ganham
|
|
6
|
+
* destaque próprio.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useMemo } from "preact/hooks";
|
|
10
|
+
import { highlightCode, languageFromName } from "./utils/code-highlight";
|
|
11
|
+
import {
|
|
12
|
+
type InlineToken,
|
|
13
|
+
isFilePath,
|
|
14
|
+
type MarkdownBlock,
|
|
15
|
+
parseInline,
|
|
16
|
+
parseMarkdown,
|
|
17
|
+
safeUrl,
|
|
18
|
+
} from "./utils/markdown";
|
|
19
|
+
|
|
20
|
+
function renderInlineToken(token: InlineToken) {
|
|
21
|
+
switch (token.type) {
|
|
22
|
+
case "code":
|
|
23
|
+
return (
|
|
24
|
+
<code class={`md-code${isFilePath(token.text) ? " md-path" : ""}`}>
|
|
25
|
+
{token.text}
|
|
26
|
+
</code>
|
|
27
|
+
);
|
|
28
|
+
case "path":
|
|
29
|
+
return <code class="md-path">{token.text}</code>;
|
|
30
|
+
case "strong":
|
|
31
|
+
return <strong>{token.text}</strong>;
|
|
32
|
+
case "em":
|
|
33
|
+
return <em>{token.text}</em>;
|
|
34
|
+
case "link": {
|
|
35
|
+
const label = token.text.replace(/^`+|`+$/g, "");
|
|
36
|
+
const url = safeUrl(token.url);
|
|
37
|
+
if (url) {
|
|
38
|
+
return (
|
|
39
|
+
<a href={url} target="_blank" rel="noreferrer">
|
|
40
|
+
{label}
|
|
41
|
+
</a>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return isFilePath(label) || isFilePath(token.url) ? (
|
|
45
|
+
<code class="md-path">{label}</code>
|
|
46
|
+
) : (
|
|
47
|
+
token.text
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
return token.text;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function InlineTokens({ text }: { text: string }) {
|
|
56
|
+
const tokens = useMemo(() => parseInline(text), [text]);
|
|
57
|
+
return <>{tokens.map((token) => renderInlineToken(token))}</>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function CodeBlock({ code, language }: { code: string; language: string | null }) {
|
|
61
|
+
const highlighted = useMemo(
|
|
62
|
+
() => highlightCode(code, languageFromName(language)),
|
|
63
|
+
[code, language],
|
|
64
|
+
);
|
|
65
|
+
return (
|
|
66
|
+
<pre class="md-code-block">
|
|
67
|
+
{highlighted
|
|
68
|
+
? highlighted.tokens.map((token) => (
|
|
69
|
+
<span
|
|
70
|
+
class={
|
|
71
|
+
token.type === "plain" ? undefined : `tok-${token.type}`
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
{token.text}
|
|
75
|
+
</span>
|
|
76
|
+
))
|
|
77
|
+
: code}
|
|
78
|
+
</pre>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Renderiza blocos markdown já parseados (seções de artefato, ação etc.). */
|
|
83
|
+
export function MarkdownBlocks({ blocks }: { blocks: MarkdownBlock[] }) {
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
{blocks.map((block) => {
|
|
87
|
+
switch (block.type) {
|
|
88
|
+
case "paragraph":
|
|
89
|
+
return (
|
|
90
|
+
<p class="md-p">
|
|
91
|
+
<InlineTokens text={block.text} />
|
|
92
|
+
</p>
|
|
93
|
+
);
|
|
94
|
+
case "heading": {
|
|
95
|
+
const level = Math.min(Math.max(block.level, 1), 6);
|
|
96
|
+
const Tag = `h${level}` as
|
|
97
|
+
| "h1"
|
|
98
|
+
| "h2"
|
|
99
|
+
| "h3"
|
|
100
|
+
| "h4"
|
|
101
|
+
| "h5"
|
|
102
|
+
| "h6";
|
|
103
|
+
return (
|
|
104
|
+
<Tag class={`md-h md-h${level}`}>
|
|
105
|
+
<InlineTokens text={block.text} />
|
|
106
|
+
</Tag>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
case "list": {
|
|
110
|
+
const items = block.items.map((item) => (
|
|
111
|
+
<li>
|
|
112
|
+
<InlineTokens text={item} />
|
|
113
|
+
</li>
|
|
114
|
+
));
|
|
115
|
+
return block.ordered ? (
|
|
116
|
+
<ol class="md-list">{items}</ol>
|
|
117
|
+
) : (
|
|
118
|
+
<ul class="md-list">{items}</ul>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
case "table":
|
|
122
|
+
return (
|
|
123
|
+
<div class="md-table-wrap">
|
|
124
|
+
<table class="md-table">
|
|
125
|
+
<thead>
|
|
126
|
+
<tr>
|
|
127
|
+
{block.header.map((cell, column) => (
|
|
128
|
+
<th
|
|
129
|
+
style={{
|
|
130
|
+
textAlign: block.align[column],
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<InlineTokens text={cell} />
|
|
134
|
+
</th>
|
|
135
|
+
))}
|
|
136
|
+
</tr>
|
|
137
|
+
</thead>
|
|
138
|
+
<tbody>
|
|
139
|
+
{block.rows.map((row) => (
|
|
140
|
+
<tr key={row.join("|")}>
|
|
141
|
+
{block.header.map((_, column) => (
|
|
142
|
+
<td
|
|
143
|
+
style={{
|
|
144
|
+
textAlign:
|
|
145
|
+
block.align[column],
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<InlineTokens
|
|
149
|
+
text={row[column] ?? ""}
|
|
150
|
+
/>
|
|
151
|
+
</td>
|
|
152
|
+
))}
|
|
153
|
+
</tr>
|
|
154
|
+
))}
|
|
155
|
+
</tbody>
|
|
156
|
+
</table>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
case "code":
|
|
160
|
+
return <CodeBlock code={block.code} language={block.language} />;
|
|
161
|
+
case "quote":
|
|
162
|
+
return (
|
|
163
|
+
<blockquote class="md-quote">
|
|
164
|
+
<InlineTokens text={block.text} />
|
|
165
|
+
</blockquote>
|
|
166
|
+
);
|
|
167
|
+
case "hr":
|
|
168
|
+
return <hr class="md-hr" />;
|
|
169
|
+
default:
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
})}
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Renderiza o markdown do action/artefato preservando blocos e listas. */
|
|
178
|
+
export function MarkdownText({ text }: { text: string }) {
|
|
179
|
+
const blocks = useMemo(() => parseMarkdown(text), [text]);
|
|
180
|
+
return <MarkdownBlocks blocks={blocks} />;
|
|
181
|
+
}
|