claudeup 4.41.0 → 4.42.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/package.json +4 -4
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/markdown-renderer.test.ts +143 -0
- package/src/__tests__/marketplaces.test.ts +4 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/data/marketplaces.ts +22 -0
- package/src/services/local-marketplace.ts +110 -19
- package/src/services/marketplace-catalog.ts +34 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +18 -0
- package/src/services/skills-manager.ts +531 -14
- package/src/ui/adapters/pluginsAdapter.ts +154 -17
- package/src/ui/components/primitives/KeyValueLine.tsx +8 -2
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/pluginRenderers.tsx +415 -9
- package/src/ui/screens/PluginsScreen.tsx +181 -53
- package/src/ui/state/reducer.ts +56 -0
- package/src/ui/state/types.ts +42 -0
- package/src/ui/theme-mode.ts +67 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { Marketplace } from "../../types/index.js";
|
|
2
1
|
import type { PluginInfo } from "../../services/plugin-manager.js";
|
|
2
|
+
import type { PluginComponent } from "../../services/skills-manager.js";
|
|
3
|
+
import type { Marketplace } from "../../types/index.js";
|
|
4
|
+
import type { PluginContentsEntry } from "../state/types.js";
|
|
3
5
|
|
|
4
6
|
// Virtual marketplace name for the community sub-section of claude-plugins-official
|
|
5
7
|
export const COMMUNITY_VIRTUAL_MARKETPLACE =
|
|
@@ -31,9 +33,51 @@ export interface PluginPluginItem {
|
|
|
31
33
|
plugin: PluginInfo;
|
|
32
34
|
/** Fuzzy match highlight indices, if search is active */
|
|
33
35
|
matches?: number[];
|
|
36
|
+
/** True when this plugin's contents can be opened with ← / →. */
|
|
37
|
+
isExpandable: boolean;
|
|
38
|
+
/** True when its component rows follow below. */
|
|
39
|
+
isExpanded: boolean;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
/**
|
|
43
|
+
* One component inside an expanded plugin — display only.
|
|
44
|
+
*
|
|
45
|
+
* Claude Code installs plugins, not individual pieces, so these rows carry no
|
|
46
|
+
* action. They exist because a plugin row alone says nothing about what
|
|
47
|
+
* installing it brings: a marketplace that publishes its whole repo as one
|
|
48
|
+
* plugin (mattpocock, ecc, ponytail, addy-agent-skills, humanizer and
|
|
49
|
+
* opendesign all do) rendered as a single unexplained line, and a plugin whose
|
|
50
|
+
* only content is an MCP server — mnemex — looked like it shipped nothing at
|
|
51
|
+
* all.
|
|
52
|
+
*/
|
|
53
|
+
export interface PluginComponentItem {
|
|
54
|
+
id: string;
|
|
55
|
+
kind: "component";
|
|
56
|
+
label: string;
|
|
57
|
+
/** The plugin this belongs to, for the detail panel. */
|
|
58
|
+
plugin: PluginInfo;
|
|
59
|
+
component: PluginComponent;
|
|
60
|
+
/** Repo the component's file is read from. */
|
|
61
|
+
repo: string;
|
|
62
|
+
/** Cache key for this component's content. */
|
|
63
|
+
detailKey: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Placeholder row shown while a plugin's contents load, or when they cannot. */
|
|
67
|
+
export interface PluginContentsStatusItem {
|
|
68
|
+
id: string;
|
|
69
|
+
kind: "contents-status";
|
|
70
|
+
label: string;
|
|
71
|
+
plugin: PluginInfo;
|
|
72
|
+
status: "loading" | "error" | "empty";
|
|
73
|
+
error?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type PluginBrowserItem =
|
|
77
|
+
| PluginCategoryItem
|
|
78
|
+
| PluginPluginItem
|
|
79
|
+
| PluginComponentItem
|
|
80
|
+
| PluginContentsStatusItem;
|
|
37
81
|
|
|
38
82
|
// ─── Adapter ─────────────────────────────────────────────────────────────────
|
|
39
83
|
|
|
@@ -41,6 +85,94 @@ export interface BuildPluginBrowserItemsArgs {
|
|
|
41
85
|
marketplaces: Marketplace[];
|
|
42
86
|
plugins: PluginInfo[];
|
|
43
87
|
collapsedMarketplaces: Set<string>;
|
|
88
|
+
expandedPlugins?: Set<string>;
|
|
89
|
+
pluginContents?: Map<string, PluginContentsEntry>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A plugin's contents are listed from its repo tree, so a plugin with no repo
|
|
94
|
+
* to read (a local-path marketplace, or a plugin whose marketplace has no git
|
|
95
|
+
* remote) cannot be expanded. Offering the arrow there would open a row that
|
|
96
|
+
* can only ever say "could not load".
|
|
97
|
+
*/
|
|
98
|
+
export function pluginContentsRepo(
|
|
99
|
+
plugin: PluginInfo,
|
|
100
|
+
marketplace: Marketplace,
|
|
101
|
+
): string | undefined {
|
|
102
|
+
return plugin.sourceRepo || marketplace.source.repo || undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Appends a plugin row, plus its component rows when expanded.
|
|
107
|
+
*/
|
|
108
|
+
function pushPluginItems(
|
|
109
|
+
items: PluginBrowserItem[],
|
|
110
|
+
plugin: PluginInfo,
|
|
111
|
+
marketplace: Marketplace,
|
|
112
|
+
expandedPlugins: Set<string>,
|
|
113
|
+
pluginContents: Map<string, PluginContentsEntry>,
|
|
114
|
+
): void {
|
|
115
|
+
const repo = pluginContentsRepo(plugin, marketplace);
|
|
116
|
+
const isExpandable = !plugin.isOrphaned && !!repo;
|
|
117
|
+
const isExpanded = isExpandable && expandedPlugins.has(plugin.id);
|
|
118
|
+
|
|
119
|
+
items.push({
|
|
120
|
+
id: `pl:${plugin.id}`,
|
|
121
|
+
kind: "plugin",
|
|
122
|
+
label: plugin.name,
|
|
123
|
+
plugin,
|
|
124
|
+
isExpandable,
|
|
125
|
+
isExpanded,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!isExpanded) return;
|
|
129
|
+
|
|
130
|
+
const entry = pluginContents.get(plugin.id);
|
|
131
|
+
if (!entry || entry.status === "loading") {
|
|
132
|
+
items.push({
|
|
133
|
+
id: `c:${plugin.id}:loading`,
|
|
134
|
+
kind: "contents-status",
|
|
135
|
+
label: "Reading the plugin…",
|
|
136
|
+
plugin,
|
|
137
|
+
status: "loading",
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (entry.status === "error") {
|
|
142
|
+
items.push({
|
|
143
|
+
id: `c:${plugin.id}:error`,
|
|
144
|
+
kind: "contents-status",
|
|
145
|
+
label: "Could not read the plugin",
|
|
146
|
+
plugin,
|
|
147
|
+
status: "error",
|
|
148
|
+
error: entry.error,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const components = entry.components ?? [];
|
|
153
|
+
if (components.length === 0) {
|
|
154
|
+
items.push({
|
|
155
|
+
id: `c:${plugin.id}:empty`,
|
|
156
|
+
kind: "contents-status",
|
|
157
|
+
label: "Ships no skills, commands, agents or MCP servers",
|
|
158
|
+
plugin,
|
|
159
|
+
status: "empty",
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
for (const component of components) {
|
|
164
|
+
items.push({
|
|
165
|
+
id: `c:${plugin.id}:${component.kind}:${component.repoPath}:${component.name}`,
|
|
166
|
+
kind: "component",
|
|
167
|
+
label: component.name,
|
|
168
|
+
plugin,
|
|
169
|
+
component,
|
|
170
|
+
// `repo` is non-null here: without one the plugin is not expandable and
|
|
171
|
+
// this loop is unreachable.
|
|
172
|
+
repo: repo as string,
|
|
173
|
+
detailKey: `${repo}/${component.repoPath}#${component.name}`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
44
176
|
}
|
|
45
177
|
|
|
46
178
|
/**
|
|
@@ -90,6 +222,8 @@ export function buildPluginBrowserItems({
|
|
|
90
222
|
marketplaces,
|
|
91
223
|
plugins,
|
|
92
224
|
collapsedMarketplaces,
|
|
225
|
+
expandedPlugins = new Set(),
|
|
226
|
+
pluginContents = new Map(),
|
|
93
227
|
}: BuildPluginBrowserItemsArgs): PluginBrowserItem[] {
|
|
94
228
|
const pluginsByMarketplace = new Map<string, PluginInfo[]>();
|
|
95
229
|
for (const plugin of plugins) {
|
|
@@ -139,12 +273,13 @@ export function buildPluginBrowserItems({
|
|
|
139
273
|
});
|
|
140
274
|
if (isEnabled && anthropicHasPlugins && !anthropicCollapsed) {
|
|
141
275
|
for (const plugin of anthropicPlugins) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
kind: "plugin",
|
|
145
|
-
label: plugin.name,
|
|
276
|
+
pushPluginItems(
|
|
277
|
+
items,
|
|
146
278
|
plugin,
|
|
147
|
-
|
|
279
|
+
marketplace,
|
|
280
|
+
expandedPlugins,
|
|
281
|
+
pluginContents,
|
|
282
|
+
);
|
|
148
283
|
}
|
|
149
284
|
}
|
|
150
285
|
|
|
@@ -175,12 +310,13 @@ export function buildPluginBrowserItems({
|
|
|
175
310
|
});
|
|
176
311
|
if (!communityCollapsed) {
|
|
177
312
|
for (const plugin of communityPlugins) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
kind: "plugin",
|
|
181
|
-
label: plugin.name,
|
|
313
|
+
pushPluginItems(
|
|
314
|
+
items,
|
|
182
315
|
plugin,
|
|
183
|
-
|
|
316
|
+
communityVirtualMp,
|
|
317
|
+
expandedPlugins,
|
|
318
|
+
pluginContents,
|
|
319
|
+
);
|
|
184
320
|
}
|
|
185
321
|
}
|
|
186
322
|
}
|
|
@@ -205,12 +341,13 @@ export function buildPluginBrowserItems({
|
|
|
205
341
|
// Plugins under this marketplace (if expanded)
|
|
206
342
|
if (isEnabled && hasPlugins && !isCollapsed) {
|
|
207
343
|
for (const plugin of marketplacePlugins) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
kind: "plugin",
|
|
211
|
-
label: plugin.name,
|
|
344
|
+
pushPluginItems(
|
|
345
|
+
items,
|
|
212
346
|
plugin,
|
|
213
|
-
|
|
347
|
+
marketplace,
|
|
348
|
+
expandedPlugins,
|
|
349
|
+
pluginContents,
|
|
350
|
+
);
|
|
214
351
|
}
|
|
215
352
|
}
|
|
216
353
|
}
|
|
@@ -8,12 +8,18 @@ interface KeyValueLineProps {
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Aligned label-value pair for detail panels.
|
|
11
|
-
*
|
|
11
|
+
*
|
|
12
|
+
* Labels pad to a 10-column gutter. The trailing space is inside the padding,
|
|
13
|
+
* not appended after it, so a label at or past 10 columns still separates from
|
|
14
|
+
* its value instead of running into it: every caller used to pass a short fixed
|
|
15
|
+
* label, and the first arbitrary one — a skill's `disable-model-invocation`
|
|
16
|
+
* frontmatter key — rendered as `disable-model-invocationtrue`. Labels of 9
|
|
17
|
+
* columns or fewer are unaffected.
|
|
12
18
|
*/
|
|
13
19
|
export function KeyValueLine({ label, value }: KeyValueLineProps) {
|
|
14
20
|
return (
|
|
15
21
|
<text fg={theme.colors.text}>
|
|
16
|
-
<span fg={theme.colors.muted}>{label
|
|
22
|
+
<span fg={theme.colors.muted}>{`${label} `.padEnd(10)}</span>
|
|
17
23
|
{value}
|
|
18
24
|
</text>
|
|
19
25
|
);
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { theme } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Markdown → OpenTUI elements, for the detail pane.
|
|
6
|
+
*
|
|
7
|
+
* Why not a library. `glow` is a Go binary: it cannot ship inside
|
|
8
|
+
* `bun build --compile`, and shelling out to something the user may not have
|
|
9
|
+
* installed turns a display detail into a runtime dependency. The JS options
|
|
10
|
+
* (`marked-terminal` and friends) emit ANSI escape sequences, which is the
|
|
11
|
+
* wrong output format — OpenTUI composes `<text>`/`<span>` elements with theme
|
|
12
|
+
* colours, and feeding it raw escapes bypasses the theme and breaks on a
|
|
13
|
+
* re-render. This module is ~200 lines and emits elements directly.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately partial. It covers what SKILL.md files actually contain:
|
|
16
|
+
* headings, fenced code, tables, rules, lists, blockquotes, and inline
|
|
17
|
+
* bold/code. Anything unrecognised falls through as plain text, which is what
|
|
18
|
+
* the pane showed for everything before.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface MarkdownRenderResult {
|
|
22
|
+
nodes: React.ReactNode[];
|
|
23
|
+
/** Lines beyond `maxLines` that were not rendered. */
|
|
24
|
+
withheld: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RenderOptions {
|
|
28
|
+
/** Columns available to the pane, used to size table columns. */
|
|
29
|
+
width: number;
|
|
30
|
+
/** Render-cost guard; the pane itself scrolls. */
|
|
31
|
+
maxLines: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Inline spans ────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Split a line into `code`, **bold** and plain runs.
|
|
38
|
+
*
|
|
39
|
+
* One pass over both markers rather than nested passes: a nested pass would
|
|
40
|
+
* re-scan text already claimed by a code span and turn `` `a**b` `` into a bold
|
|
41
|
+
* run inside code.
|
|
42
|
+
*/
|
|
43
|
+
function inlineSpans(text: string, keyPrefix: string): React.ReactNode[] {
|
|
44
|
+
const nodes: React.ReactNode[] = [];
|
|
45
|
+
const pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)/g;
|
|
46
|
+
let last = 0;
|
|
47
|
+
let match: RegExpExecArray | null = pattern.exec(text);
|
|
48
|
+
let i = 0;
|
|
49
|
+
|
|
50
|
+
while (match) {
|
|
51
|
+
if (match.index > last) {
|
|
52
|
+
nodes.push(
|
|
53
|
+
<span key={`${keyPrefix}-t${i}`}>{text.slice(last, match.index)}</span>,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (match[1]) {
|
|
57
|
+
nodes.push(
|
|
58
|
+
<span key={`${keyPrefix}-c${i}`} fg={theme.colors.link}>
|
|
59
|
+
{match[1].slice(1, -1)}
|
|
60
|
+
</span>,
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
nodes.push(
|
|
64
|
+
<strong key={`${keyPrefix}-b${i}`}>{match[2].slice(2, -2)}</strong>,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
last = match.index + match[0].length;
|
|
68
|
+
i += 1;
|
|
69
|
+
match = pattern.exec(text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (last < text.length) {
|
|
73
|
+
nodes.push(<span key={`${keyPrefix}-t${i}`}>{text.slice(last)}</span>);
|
|
74
|
+
}
|
|
75
|
+
return nodes;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Tables ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Strip inline markers from a table cell.
|
|
82
|
+
*
|
|
83
|
+
* Cells are padded to a column width, so they cannot carry `<span>` children
|
|
84
|
+
* the way body text does without the padding and the spans disagreeing about
|
|
85
|
+
* length. Removing the markers is the honest trade: a cell reading
|
|
86
|
+
* `` `context`, `callers` `` printed its backticks verbatim, which is the raw
|
|
87
|
+
* source leaking into a rendered view.
|
|
88
|
+
*/
|
|
89
|
+
function plainCell(cell: string): string {
|
|
90
|
+
return cell
|
|
91
|
+
.trim()
|
|
92
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
93
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
94
|
+
.replace(/<br\s*\/?>/gi, " ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function splitRow(line: string): string[] {
|
|
98
|
+
return line
|
|
99
|
+
.replace(/^\s*\|/, "")
|
|
100
|
+
.replace(/\|\s*$/, "")
|
|
101
|
+
.split("|")
|
|
102
|
+
.map(plainCell);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isSeparatorRow = (line: string): boolean =>
|
|
106
|
+
/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-");
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Render a table as aligned columns with a rule under the header.
|
|
110
|
+
*
|
|
111
|
+
* Pipes are dropped rather than drawn. In a pane this narrow the vertical bars
|
|
112
|
+
* cost a column each and, once a cell wraps, line up with nothing — which is
|
|
113
|
+
* exactly how the raw source read before.
|
|
114
|
+
*/
|
|
115
|
+
function renderTable(
|
|
116
|
+
rows: string[][],
|
|
117
|
+
width: number,
|
|
118
|
+
key: string,
|
|
119
|
+
): React.ReactNode[] {
|
|
120
|
+
const columns = Math.max(...rows.map((r) => r.length));
|
|
121
|
+
const natural: number[] = [];
|
|
122
|
+
for (let c = 0; c < columns; c++) {
|
|
123
|
+
natural.push(Math.max(1, ...rows.map((r) => (r[c] ?? "").length)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Shrink the widest column until the row fits. Shrinking the widest keeps a
|
|
127
|
+
// narrow key column (Mode, Level) intact and takes the space out of the prose
|
|
128
|
+
// column, which is the one that can afford it.
|
|
129
|
+
const gap = 2;
|
|
130
|
+
const budget = Math.max(20, width - 1);
|
|
131
|
+
const widths = [...natural];
|
|
132
|
+
const total = () => widths.reduce((a, b) => a + b, 0) + gap * (columns - 1);
|
|
133
|
+
while (total() > budget) {
|
|
134
|
+
const widest = widths.indexOf(Math.max(...widths));
|
|
135
|
+
if (widths[widest] <= 6) break;
|
|
136
|
+
widths[widest] -= 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fit = (cell: string, w: number) =>
|
|
140
|
+
cell.length <= w ? cell.padEnd(w) : `${cell.slice(0, Math.max(1, w - 1))}…`;
|
|
141
|
+
|
|
142
|
+
const nodes: React.ReactNode[] = [];
|
|
143
|
+
rows.forEach((row, r) => {
|
|
144
|
+
const line = widths
|
|
145
|
+
.map((w, c) => fit(row[c] ?? "", w))
|
|
146
|
+
.join(" ".repeat(gap))
|
|
147
|
+
.trimEnd();
|
|
148
|
+
|
|
149
|
+
nodes.push(
|
|
150
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: table rows never reorder
|
|
151
|
+
<text key={`${key}-r${r}`} fg={theme.colors.muted}>
|
|
152
|
+
{" "}
|
|
153
|
+
{r === 0 ? <strong>{line}</strong> : line}
|
|
154
|
+
</text>,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (r === 0) {
|
|
158
|
+
nodes.push(
|
|
159
|
+
<text key={`${key}-rule`} fg={theme.colors.dim}>
|
|
160
|
+
{" "}
|
|
161
|
+
{widths.map((w) => "─".repeat(w)).join(" ".repeat(gap))}
|
|
162
|
+
</text>,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return nodes;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Document ────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const HEADING_COLORS = [
|
|
172
|
+
theme.colors.accent,
|
|
173
|
+
theme.colors.accent,
|
|
174
|
+
theme.colors.info,
|
|
175
|
+
theme.colors.info,
|
|
176
|
+
theme.colors.muted,
|
|
177
|
+
theme.colors.muted,
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
export function renderMarkdown(
|
|
181
|
+
source: string,
|
|
182
|
+
{ width, maxLines }: RenderOptions,
|
|
183
|
+
): MarkdownRenderResult {
|
|
184
|
+
const lines = source.split("\n");
|
|
185
|
+
const nodes: React.ReactNode[] = [];
|
|
186
|
+
let i = 0;
|
|
187
|
+
let emitted = 0;
|
|
188
|
+
|
|
189
|
+
const push = (node: React.ReactNode) => {
|
|
190
|
+
nodes.push(node);
|
|
191
|
+
emitted += 1;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
while (i < lines.length && emitted < maxLines) {
|
|
195
|
+
const line = lines[i];
|
|
196
|
+
|
|
197
|
+
// Fenced code. The fence markers are dropped and the block is tinted, so
|
|
198
|
+
// the reader sees a code block rather than three backticks they have to
|
|
199
|
+
// mentally strip.
|
|
200
|
+
const fence = line.match(/^\s*```\s*(\S*)/);
|
|
201
|
+
if (fence) {
|
|
202
|
+
const lang = fence[1];
|
|
203
|
+
i += 1;
|
|
204
|
+
if (lang) {
|
|
205
|
+
push(
|
|
206
|
+
<text key={`f${i}`} fg={theme.colors.dim}>
|
|
207
|
+
{" "}
|
|
208
|
+
{lang}
|
|
209
|
+
</text>,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
while (i < lines.length && !/^\s*```/.test(lines[i])) {
|
|
213
|
+
const code = lines[i];
|
|
214
|
+
push(
|
|
215
|
+
<text key={`fc${i}`} fg={theme.colors.link}>
|
|
216
|
+
{` ${code}` || " "}
|
|
217
|
+
</text>,
|
|
218
|
+
);
|
|
219
|
+
i += 1;
|
|
220
|
+
if (emitted >= maxLines) break;
|
|
221
|
+
}
|
|
222
|
+
i += 1; // closing fence
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Table: a pipe row whose successor is a separator row.
|
|
227
|
+
if (
|
|
228
|
+
line.trim().startsWith("|") &&
|
|
229
|
+
i + 1 < lines.length &&
|
|
230
|
+
isSeparatorRow(lines[i + 1])
|
|
231
|
+
) {
|
|
232
|
+
const rows: string[][] = [splitRow(line)];
|
|
233
|
+
i += 2;
|
|
234
|
+
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
|
235
|
+
rows.push(splitRow(lines[i]));
|
|
236
|
+
i += 1;
|
|
237
|
+
}
|
|
238
|
+
for (const node of renderTable(rows, width, `tb${i}`)) push(node);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Horizontal rule. Drawn, rather than left as three hyphens that read as
|
|
243
|
+
// a stray line of text.
|
|
244
|
+
if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
|
|
245
|
+
push(
|
|
246
|
+
<text key={`hr${i}`} fg={theme.colors.dim}>
|
|
247
|
+
{"─".repeat(Math.max(4, Math.min(width - 1, 60)))}
|
|
248
|
+
</text>,
|
|
249
|
+
);
|
|
250
|
+
i += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
255
|
+
if (heading) {
|
|
256
|
+
const level = heading[1].length;
|
|
257
|
+
push(
|
|
258
|
+
<text key={`h${i}`} fg={HEADING_COLORS[level - 1]}>
|
|
259
|
+
<strong>{heading[2]}</strong>
|
|
260
|
+
</text>,
|
|
261
|
+
);
|
|
262
|
+
i += 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
267
|
+
if (quote) {
|
|
268
|
+
push(
|
|
269
|
+
<text key={`q${i}`} fg={theme.colors.muted}>
|
|
270
|
+
<span fg={theme.colors.dim}>{" │ "}</span>
|
|
271
|
+
{inlineSpans(quote[1], `q${i}`)}
|
|
272
|
+
</text>,
|
|
273
|
+
);
|
|
274
|
+
i += 1;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const list = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
|
279
|
+
if (list) {
|
|
280
|
+
const indent = " ".repeat(Math.min(list[1].length, 8));
|
|
281
|
+
const bullet = /\d/.test(list[2]) ? list[2] : "•";
|
|
282
|
+
push(
|
|
283
|
+
<text key={`li${i}`} fg={theme.colors.muted}>
|
|
284
|
+
{` ${indent}`}
|
|
285
|
+
<span fg={theme.colors.dim}>{bullet} </span>
|
|
286
|
+
{inlineSpans(list[3], `li${i}`)}
|
|
287
|
+
</text>,
|
|
288
|
+
);
|
|
289
|
+
i += 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
push(
|
|
294
|
+
<text key={`p${i}`} fg={theme.colors.muted}>
|
|
295
|
+
{line ? inlineSpans(line, `p${i}`) : " "}
|
|
296
|
+
</text>,
|
|
297
|
+
);
|
|
298
|
+
i += 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { nodes, withheld: Math.max(0, lines.length - i) };
|
|
302
|
+
}
|