pi-yank 0.1.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 +106 -0
- package/extensions/yank-core.js +159 -0
- package/extensions/yank.ts +157 -0
- package/package.json +23 -0
- package/tests/yank-core.test.mjs +84 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# pi-yank
|
|
2
|
+
|
|
3
|
+
Lightweight `/yank` extension for [pi](https://github.com/badlogic/pi-mono).
|
|
4
|
+
|
|
5
|
+
`/yank` copies the last assistant response to the clipboard, or lets you choose an individual fenced code block from that response.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Copy the last assistant message to the clipboard
|
|
10
|
+
- Detect fenced Markdown code blocks in the last assistant message
|
|
11
|
+
- Use arrow keys + Enter to choose a specific code block
|
|
12
|
+
- Optionally disable the menu for the current session so `/yank` behaves like `/copy`
|
|
13
|
+
- Reuse pi's official clipboard pipeline via `copyToClipboard()` for `/copy`-compatible behavior
|
|
14
|
+
- Zero third-party runtime dependencies
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
### Quick test
|
|
19
|
+
|
|
20
|
+
Load temporarily without installing:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi --extension /path/to/pi-yank/extensions/yank.ts
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Install as pi package
|
|
27
|
+
|
|
28
|
+
Globally:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install /path/to/pi-yank
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Project-local:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install -l /path/to/pi-yank
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Install as a project-local extension file
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
mkdir -p .pi/extensions
|
|
44
|
+
cp /path/to/pi-yank/extensions/yank.ts .pi/extensions/yank.ts
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Install as a global extension file
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
mkdir -p ~/.pi/agent/extensions
|
|
51
|
+
cp /path/to/pi-yank/extensions/yank.ts ~/.pi/agent/extensions/yank.ts
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
Run:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
/yank
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Behavior:
|
|
63
|
+
|
|
64
|
+
- No assistant message yet → shows `No agent messages to copy yet.`
|
|
65
|
+
- Last assistant message has no fenced code blocks → copies the full message
|
|
66
|
+
- Last assistant message has code blocks → opens a selection menu
|
|
67
|
+
- Selecting a code block copies only that code block's content
|
|
68
|
+
- Selecting the session option disables the menu for later `/yank` calls in the current session
|
|
69
|
+
- Pressing `Escape` in the selector cancels without copying
|
|
70
|
+
|
|
71
|
+
### Code block detection notes
|
|
72
|
+
|
|
73
|
+
`/yank` uses a line-by-line fenced block parser instead of a simple regex.
|
|
74
|
+
This makes it more robust for LLM output such as:
|
|
75
|
+
|
|
76
|
+
- a top-level ````markdown` fenced block containing literal nested ` ```js ` examples
|
|
77
|
+
- multiple top-level sibling code blocks
|
|
78
|
+
- `~~~` fenced blocks
|
|
79
|
+
- unterminated fences, which are ignored
|
|
80
|
+
|
|
81
|
+
Menu previews now include more context when available, for example:
|
|
82
|
+
|
|
83
|
+
- `1. Full last message (24 lines • 812 chars)`
|
|
84
|
+
- `2. Code block — markdown: # Title (18 lines • 640 chars)`
|
|
85
|
+
- `3. Code block — js: console.log("hi") (1 line • 18 chars)`
|
|
86
|
+
- `4. Always copy full last message in this session`
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
Run parser tests:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm run check
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Package structure
|
|
97
|
+
|
|
98
|
+
This repository follows pi package conventions:
|
|
99
|
+
|
|
100
|
+
- extension entry: `extensions/yank.ts`
|
|
101
|
+
- `package.json` includes a `pi` manifest
|
|
102
|
+
- pi core package is declared as a `peerDependency`
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ content: string; preview: string; startLine: number; infoString: string }} CodeBlock
|
|
3
|
+
* @typedef {{ fenceChar: '`' | '~'; fenceLength: number; lines: string[]; startLine: number; infoString: string }} OpenCodeBlock
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract top-level fenced code blocks from markdown-like text.
|
|
8
|
+
*
|
|
9
|
+
* This is intentionally tolerant of LLM output that contains a large fenced
|
|
10
|
+
* markdown block which itself includes literal nested fences as examples.
|
|
11
|
+
* Only fully-closed top-level blocks are returned as yank candidates.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} markdown
|
|
14
|
+
* @returns {CodeBlock[]}
|
|
15
|
+
*/
|
|
16
|
+
export function extractCodeBlocks(markdown) {
|
|
17
|
+
/** @type {CodeBlock[]} */
|
|
18
|
+
const blocks = [];
|
|
19
|
+
/** @type {OpenCodeBlock[]} */
|
|
20
|
+
const openBlocks = [];
|
|
21
|
+
const lines = markdown.split(/\r?\n/);
|
|
22
|
+
|
|
23
|
+
for (let index = 0; index < lines.length; index++) {
|
|
24
|
+
const line = lines[index];
|
|
25
|
+
const fence = parseFenceLine(line);
|
|
26
|
+
|
|
27
|
+
if (!fence) {
|
|
28
|
+
for (const block of openBlocks) {
|
|
29
|
+
block.lines.push(line);
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (openBlocks.length === 0) {
|
|
35
|
+
openBlocks.push({
|
|
36
|
+
fenceChar: fence.char,
|
|
37
|
+
fenceLength: fence.length,
|
|
38
|
+
lines: [],
|
|
39
|
+
startLine: index,
|
|
40
|
+
infoString: fence.info,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const currentBlock = openBlocks[openBlocks.length - 1];
|
|
46
|
+
const closesCurrentBlock =
|
|
47
|
+
fence.char === currentBlock.fenceChar &&
|
|
48
|
+
fence.length >= currentBlock.fenceLength &&
|
|
49
|
+
fence.info.length === 0;
|
|
50
|
+
|
|
51
|
+
if (closesCurrentBlock) {
|
|
52
|
+
for (
|
|
53
|
+
let parentIndex = 0;
|
|
54
|
+
parentIndex < openBlocks.length - 1;
|
|
55
|
+
parentIndex++
|
|
56
|
+
) {
|
|
57
|
+
openBlocks[parentIndex].lines.push(line);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const finishedBlock = openBlocks.pop();
|
|
61
|
+
if (finishedBlock && openBlocks.length === 0) {
|
|
62
|
+
const content = finishedBlock.lines.join("\n");
|
|
63
|
+
blocks.push({
|
|
64
|
+
content,
|
|
65
|
+
preview: "",
|
|
66
|
+
startLine: finishedBlock.startLine,
|
|
67
|
+
infoString: finishedBlock.infoString,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const block of openBlocks) {
|
|
74
|
+
block.lines.push(line);
|
|
75
|
+
}
|
|
76
|
+
openBlocks.push({
|
|
77
|
+
fenceChar: fence.char,
|
|
78
|
+
fenceLength: fence.length,
|
|
79
|
+
lines: [],
|
|
80
|
+
startLine: index,
|
|
81
|
+
infoString: fence.info,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return blocks
|
|
86
|
+
.sort((left, right) => left.startLine - right.startLine)
|
|
87
|
+
.map((block, index) => ({
|
|
88
|
+
...block,
|
|
89
|
+
preview: buildCodeBlockPreview(
|
|
90
|
+
block.content,
|
|
91
|
+
index + 1,
|
|
92
|
+
block.infoString,
|
|
93
|
+
),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} line
|
|
99
|
+
* @returns {{ char: '`' | '~'; length: number; info: string } | null}
|
|
100
|
+
*/
|
|
101
|
+
export function parseFenceLine(line) {
|
|
102
|
+
const match = /^[ \t]*(`{3,}|~{3,})([^\n]*)$/.exec(line);
|
|
103
|
+
if (!match) return null;
|
|
104
|
+
|
|
105
|
+
const fence = match[1];
|
|
106
|
+
const info = match[2]?.trim() ?? "";
|
|
107
|
+
if (fence[0] === "`" && info.includes("`")) return null;
|
|
108
|
+
|
|
109
|
+
const char = fence[0];
|
|
110
|
+
if (char !== "`" && char !== "~") return null;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
char,
|
|
114
|
+
length: fence.length,
|
|
115
|
+
info,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} content
|
|
121
|
+
* @param {number} index
|
|
122
|
+
* @param {string} [infoString]
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
export function buildCodeBlockPreview(content, index, infoString = "") {
|
|
126
|
+
const firstNonEmptyLine = content
|
|
127
|
+
.split(/\r?\n/)
|
|
128
|
+
.map((line) => line.trim())
|
|
129
|
+
.find((line) => line.length > 0);
|
|
130
|
+
|
|
131
|
+
const language = getFenceLabel(infoString);
|
|
132
|
+
if (!firstNonEmptyLine) {
|
|
133
|
+
return language
|
|
134
|
+
? `${language}: Code block ${index}`
|
|
135
|
+
: `Code block ${index}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const body = truncate(firstNonEmptyLine, 72);
|
|
139
|
+
return language ? `${language}: ${body}` : body;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {string} infoString
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
export function getFenceLabel(infoString) {
|
|
147
|
+
const label = infoString.trim().split(/\s+/)[0] ?? "";
|
|
148
|
+
return label;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} value
|
|
153
|
+
* @param {number} maxLength
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
export function truncate(value, maxLength) {
|
|
157
|
+
if (value.length <= maxLength) return value;
|
|
158
|
+
return `${value.slice(0, maxLength - 1)}…`;
|
|
159
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyToClipboard,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionCommandContext,
|
|
5
|
+
type SessionEntry,
|
|
6
|
+
} from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { extractCodeBlocks } from "./yank-core.js";
|
|
8
|
+
|
|
9
|
+
interface AssistantMessageLike {
|
|
10
|
+
role?: unknown;
|
|
11
|
+
stopReason?: unknown;
|
|
12
|
+
content?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const COPY_SUCCESS_MESSAGE = "Copied last agent message to clipboard";
|
|
16
|
+
const COPY_EMPTY_MESSAGE = "No agent messages to copy yet.";
|
|
17
|
+
const COPY_CODE_BLOCK_SUCCESS_MESSAGE = "Copied code block to clipboard";
|
|
18
|
+
const MENU_DISABLED_MESSAGE =
|
|
19
|
+
"Future /yank commands will copy the full last message in this session";
|
|
20
|
+
|
|
21
|
+
function getLastAssistantText(entries: SessionEntry[]): string | undefined {
|
|
22
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
23
|
+
const entry = entries[i];
|
|
24
|
+
if (entry.type !== "message") continue;
|
|
25
|
+
|
|
26
|
+
const message = entry.message as AssistantMessageLike;
|
|
27
|
+
if (message.role !== "assistant") continue;
|
|
28
|
+
|
|
29
|
+
const text = extractAssistantText(message);
|
|
30
|
+
if (text) return text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractAssistantText(
|
|
37
|
+
message: AssistantMessageLike,
|
|
38
|
+
): string | undefined {
|
|
39
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
40
|
+
if (message.stopReason === "aborted" && content.length === 0) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let text = "";
|
|
45
|
+
for (const item of content) {
|
|
46
|
+
if (!item || typeof item !== "object") continue;
|
|
47
|
+
const part = item as { type?: unknown; text?: unknown };
|
|
48
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
49
|
+
text += part.text;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const trimmed = text.trim();
|
|
54
|
+
return trimmed || undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function countNonEmptyLines(text: string): number {
|
|
58
|
+
return text.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function summarizeBlock(content: string): string {
|
|
62
|
+
const lines = countNonEmptyLines(content);
|
|
63
|
+
const chars = content.length;
|
|
64
|
+
return `${lines} line${lines === 1 ? "" : "s"} • ${chars} chars`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildMenuOptions(
|
|
68
|
+
text: string,
|
|
69
|
+
codeBlocks: ReturnType<typeof extractCodeBlocks>,
|
|
70
|
+
) {
|
|
71
|
+
const fullMessageOption = `1. Full last message (${summarizeBlock(text)})`;
|
|
72
|
+
const codeBlockOptions = codeBlocks.map(
|
|
73
|
+
(block, index) =>
|
|
74
|
+
`${index + 2}. Code block — ${block.preview} (${summarizeBlock(block.content)})`,
|
|
75
|
+
);
|
|
76
|
+
const skipMenuOption = `${codeBlocks.length + 2}. Always copy full last message in this session`;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
fullMessageOption,
|
|
80
|
+
codeBlockOptions,
|
|
81
|
+
skipMenuOption,
|
|
82
|
+
all: [fullMessageOption, ...codeBlockOptions, skipMenuOption],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function copyAndNotify(
|
|
87
|
+
text: string,
|
|
88
|
+
successMessage: string,
|
|
89
|
+
ctx: ExtensionCommandContext,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
await copyToClipboard(text);
|
|
92
|
+
ctx.ui.notify(successMessage, "info");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function yankExtension(pi: ExtensionAPI) {
|
|
96
|
+
let skipMenuForSession = false;
|
|
97
|
+
|
|
98
|
+
pi.registerCommand("yank", {
|
|
99
|
+
description: "Copy the last assistant message or a code block from it",
|
|
100
|
+
handler: async (_args, ctx) => {
|
|
101
|
+
const text = getLastAssistantText(ctx.sessionManager.getBranch());
|
|
102
|
+
if (!text) {
|
|
103
|
+
ctx.ui.notify(COPY_EMPTY_MESSAGE, "error");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const codeBlocks = extractCodeBlocks(text);
|
|
108
|
+
if (!ctx.hasUI || skipMenuForSession || codeBlocks.length === 0) {
|
|
109
|
+
try {
|
|
110
|
+
await copyAndNotify(text, COPY_SUCCESS_MESSAGE, ctx);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
ctx.ui.notify(
|
|
113
|
+
error instanceof Error ? error.message : String(error),
|
|
114
|
+
"error",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const menu = buildMenuOptions(text, codeBlocks);
|
|
121
|
+
const selected = await ctx.ui.select("Yank from last response", menu.all);
|
|
122
|
+
if (!selected) return;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
if (selected === menu.fullMessageOption) {
|
|
126
|
+
await copyAndNotify(text, COPY_SUCCESS_MESSAGE, ctx);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (selected === menu.skipMenuOption) {
|
|
131
|
+
skipMenuForSession = true;
|
|
132
|
+
await copyAndNotify(text, COPY_SUCCESS_MESSAGE, ctx);
|
|
133
|
+
ctx.ui.notify(MENU_DISABLED_MESSAGE, "info");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const codeBlockIndex = menu.codeBlockOptions.indexOf(selected);
|
|
138
|
+
const codeBlock = codeBlocks[codeBlockIndex];
|
|
139
|
+
if (!codeBlock) {
|
|
140
|
+
ctx.ui.notify("Invalid selection", "error");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await copyAndNotify(
|
|
145
|
+
codeBlock.content,
|
|
146
|
+
COPY_CODE_BLOCK_SUCCESS_MESSAGE,
|
|
147
|
+
ctx,
|
|
148
|
+
);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
ctx.ui.notify(
|
|
151
|
+
error instanceof Error ? error.message : String(error),
|
|
152
|
+
"error",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-yank",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Lightweight /yank extension for pi that copies the last assistant message or a selected code block.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "echo 'nothing to build'",
|
|
13
|
+
"check": "node --test tests/*.test.mjs"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
17
|
+
},
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"./extensions/yank.ts"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildCodeBlockPreview,
|
|
5
|
+
extractCodeBlocks,
|
|
6
|
+
} from "../extensions/yank-core.js";
|
|
7
|
+
|
|
8
|
+
test("extracts a single outer markdown block when nested fences appear as literal examples", () => {
|
|
9
|
+
const text = `我来为你创建一个 Markdown 格式的测试文本:
|
|
10
|
+
|
|
11
|
+
\`\`\`markdown
|
|
12
|
+
# Markdown 格式测试文档
|
|
13
|
+
|
|
14
|
+
## 代码块
|
|
15
|
+
|
|
16
|
+
### JavaScript 代码
|
|
17
|
+
\`\`\`javascript
|
|
18
|
+
function hello() {
|
|
19
|
+
console.log("Hello, World!");
|
|
20
|
+
}
|
|
21
|
+
\`\`\`
|
|
22
|
+
|
|
23
|
+
### Python 代码
|
|
24
|
+
\`\`\`python
|
|
25
|
+
def hello():
|
|
26
|
+
print("Hello, World!")
|
|
27
|
+
\`\`\`
|
|
28
|
+
|
|
29
|
+
*文档结束*
|
|
30
|
+
\`\`\`
|
|
31
|
+
|
|
32
|
+
说明文字`;
|
|
33
|
+
|
|
34
|
+
const blocks = extractCodeBlocks(text);
|
|
35
|
+
assert.equal(blocks.length, 1);
|
|
36
|
+
assert.match(blocks[0].content, /function hello\(\)/);
|
|
37
|
+
assert.match(blocks[0].content, /def hello\(\):/);
|
|
38
|
+
assert.equal(blocks[0].preview, "markdown: # Markdown 格式测试文档");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("extracts sibling top-level code blocks independently", () => {
|
|
42
|
+
const text = `before
|
|
43
|
+
\`\`\`js
|
|
44
|
+
console.log("a");
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
between
|
|
48
|
+
|
|
49
|
+
\`\`\`python
|
|
50
|
+
print("b")
|
|
51
|
+
\`\`\`
|
|
52
|
+
after`;
|
|
53
|
+
|
|
54
|
+
const blocks = extractCodeBlocks(text);
|
|
55
|
+
assert.equal(blocks.length, 2);
|
|
56
|
+
assert.equal(blocks[0].content, 'console.log("a");');
|
|
57
|
+
assert.equal(blocks[1].content, 'print("b")');
|
|
58
|
+
assert.equal(blocks[0].preview, 'js: console.log("a");');
|
|
59
|
+
assert.equal(blocks[1].preview, 'python: print("b")');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("supports tilde fences", () => {
|
|
63
|
+
const text = `~~~sql
|
|
64
|
+
select 1;
|
|
65
|
+
~~~`;
|
|
66
|
+
const blocks = extractCodeBlocks(text);
|
|
67
|
+
assert.equal(blocks.length, 1);
|
|
68
|
+
assert.equal(blocks[0].content, "select 1;");
|
|
69
|
+
assert.equal(blocks[0].preview, "sql: select 1;");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("ignores unterminated fences", () => {
|
|
73
|
+
const text = `\`\`\`ts
|
|
74
|
+
const x = 1;`;
|
|
75
|
+
const blocks = extractCodeBlocks(text);
|
|
76
|
+
assert.equal(blocks.length, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("preview falls back cleanly for empty blocks", () => {
|
|
80
|
+
assert.equal(
|
|
81
|
+
buildCodeBlockPreview("", 3, "markdown"),
|
|
82
|
+
"markdown: Code block 3",
|
|
83
|
+
);
|
|
84
|
+
});
|