opencode-fmt 0.1.0 → 0.2.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 +96 -32
- package/package.json +2 -2
- package/src/config.ts +29 -20
- package/src/emoji-sanitizer.ts +1 -2
- package/src/engine.ts +23 -16
- package/src/index.ts +5 -14
- package/src/table-aligner.ts +51 -25
- package/tests/aligner.test.ts +122 -10
- package/tests/config.test.ts +15 -11
- package/tests/engine.test.ts +27 -2
- package/tests/index.test.ts +12 -6
- package/debug.ts +0 -16
- package/src/passes/echo.ts +0 -11
- package/src/pipeline.ts +0 -21
package/README.md
CHANGED
|
@@ -1,58 +1,122 @@
|
|
|
1
|
-
# OpenCode FMT
|
|
1
|
+
# OpenCode FMT
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://bun.sh)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
A unified text optimization plugin for **OpenCode** that sanitizes emojis and formats markdown tables. Built for high-performance pipelines where minimal latency is a requirement.
|
|
7
|
+
|
|
8
|
+
---
|
|
4
9
|
|
|
5
10
|
## Features
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
- **Deterministic Emoji Sanitizer:** Removes emojis from AI outputs without altering the surrounding whitespace or layout.
|
|
13
|
+
- **Isolated Table Aligner:** Formats Markdown tables by calculating maximum column widths and applying deterministic padding, ensuring visual correctness.
|
|
14
|
+
- **Low-Latency Engine:** Processes AI output efficiently without a full AST-based parser, maintaining near-linear time processing.
|
|
15
|
+
- **Concealment-Aware Widths:** Intelligently calculates table widths by accounting for markdown markers like **bold**, *italics*, **bold**, or *italics* which are hidden in many views.
|
|
16
|
+
- **Flat Configuration:** Simple, flat boolean configuration file with nested-object validation.
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
---
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
## Quick Start
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
## Plugin Configuration
|
|
23
|
+
|
|
24
|
+
Add the plugin name to your OpenCode config file `plugins` section in `~/.config/opencode/config.json`:
|
|
17
25
|
|
|
18
26
|
```json
|
|
19
|
-
// package.json (dev setup)
|
|
20
27
|
{
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"packageManager": "bun@1.3.11",
|
|
25
|
-
"devDependencies": {
|
|
26
|
-
"typescript": "5.9.3",
|
|
27
|
-
"bun-types": "latest"
|
|
28
|
-
}
|
|
28
|
+
"plugins": [
|
|
29
|
+
"opencode-fmt"
|
|
30
|
+
]
|
|
29
31
|
}
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
### Configuration
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
The plugin automatically initializes a default configuration at `~/.config/opencode/fmt/config.json` on its first run if it doesn't already exist.
|
|
35
37
|
|
|
36
38
|
```json
|
|
37
39
|
{
|
|
38
40
|
"stripEmojis": true,
|
|
39
41
|
"alignTables": true,
|
|
40
|
-
"
|
|
42
|
+
"concealmentAware": true,
|
|
43
|
+
"logging": false
|
|
41
44
|
}
|
|
42
45
|
```
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
> [!NOTE]
|
|
48
|
+
> All configuration keys are **booleans**. Top-level arrays and unknown keys in `config.json` are accepted for forward compatibility, but current core behavior only uses the documented keys below. Nested objects are strictly prohibited.
|
|
49
|
+
|
|
50
|
+
| Key | Default | Description |
|
|
51
|
+
| :--- | :--- | :--- |
|
|
52
|
+
| `stripEmojis` | `true` | Removes all emojis from the output. |
|
|
53
|
+
| `alignTables` | `true` | Dynamically aligns Markdown tables for visual consistency. |
|
|
54
|
+
| `concealmentAware` | `true` | Accounts for hidden markdown markers (e.g. `**`, `_`) in column widths. |
|
|
55
|
+
| `logging` | `false` | Enables duration and raw/clean output logging to `opencode-fmt.log`. |
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Examples
|
|
60
|
+
|
|
61
|
+
### Emoji Sanitization
|
|
62
|
+
|
|
63
|
+
The sanitizer removes pictographic and dingbat symbols from the text stream.
|
|
64
|
+
|
|
65
|
+
**Before:** `Hello [hand-wave], our launch [rocket] was a success [party]!`
|
|
66
|
+
**After:** `Hello , our launch was a success !`
|
|
67
|
+
|
|
68
|
+
*(Note: Surrounding whitespaces are strictly preserved for deterministic layout stability, which may result in double spaces where an emoji was removed).*
|
|
69
|
+
|
|
70
|
+
> [!NOTE]
|
|
71
|
+
> If a response contains only emojis (and optional whitespace characters), `opencode-fmt` will replace it with a redaction placeholder:
|
|
72
|
+
> `[Only emojis received and redacted by opencode-fmt]`
|
|
73
|
+
|
|
74
|
+
### Table Alignment
|
|
75
|
+
|
|
76
|
+
The aligner intelligently pads columns based on the longest cell in each column.
|
|
77
|
+
|
|
78
|
+
**Before:**
|
|
79
|
+
|
|
80
|
+
```markdown
|
|
81
|
+
| Header 1 | H2 |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Val | Long Value |
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**After:**
|
|
87
|
+
|
|
88
|
+
```markdown
|
|
89
|
+
| Header 1 | H2 |
|
|
90
|
+
| -------- | ---------- |
|
|
91
|
+
| Val | Long Value |
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Architectural Constraints
|
|
97
|
+
|
|
98
|
+
This plugin is governed by strict technical rules to preserve its performance:
|
|
99
|
+
|
|
100
|
+
1. **AST-Free Processing:** No heavyweight parsing into an Abstract Syntax Tree.
|
|
101
|
+
2. **Zero Runtime Dependencies:** Built purely on native Bun/Node APIs and the core plugin hook.
|
|
102
|
+
3. **Flat Config:** No nested objects are allowed in `config.json` to prevent parsing overhead. The configuration is stored in `~/.config/opencode/fmt/config.json`. Top-level arrays are permitted for list-based features. Any unknown keys are merged but ignored by core logic.
|
|
103
|
+
4. **Diff Stability:** Non-target text (pure paragraphs) is preserved byte-for-byte in the output.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Inspiration
|
|
108
|
+
|
|
109
|
+
This plugin was inspired by two community plugins that pioneered single-feature plugin design:
|
|
110
|
+
|
|
111
|
+
- **[opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter)** by @franlol — Automatic Markdown table formatting with concealment mode support
|
|
112
|
+
- **[opencode-unmoji](https://codeberg.org/bastiangx/opencode-unmoji)** by @bastiangx — Strips emojis from agent output
|
|
113
|
+
|
|
114
|
+
Both showed that focused, lightweight plugins can solve specific pain points elegantly. This plugin follows the same philosophy: one concern, done well.
|
|
45
115
|
|
|
46
|
-
|
|
116
|
+
---
|
|
47
117
|
|
|
48
|
-
|
|
49
|
-
Processing a highly-mixed payload of 10,000 words containing text, emojis, and tables:
|
|
50
|
-
* **Result:** `~4.0ms` (Target limit: `< 50ms`)
|
|
118
|
+
## License
|
|
51
119
|
|
|
52
|
-
|
|
120
|
+
This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
|
|
53
121
|
|
|
54
|
-
|
|
55
|
-
1. **Single-pass only:** No second iterations over the buffer.
|
|
56
|
-
2. **Zero runtime dependencies.**
|
|
57
|
-
3. **Flat config:** No nested structures or custom AST rules.
|
|
58
|
-
4. **Diff Stability:** Non-target text (pure paragraphs) is preserved byte-for-byte in the output to avoid noisy Git diffs.
|
|
122
|
+
© 2026 Ritesh Kumar Pal
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
4
5
|
export interface PluginConfig {
|
|
5
6
|
stripEmojis: boolean;
|
|
6
7
|
alignTables: boolean;
|
|
8
|
+
concealmentAware: boolean;
|
|
7
9
|
logging: boolean;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export const DEFAULT_CONFIG: PluginConfig = {
|
|
11
13
|
stripEmojis: true,
|
|
12
14
|
alignTables: true,
|
|
15
|
+
concealmentAware: true,
|
|
13
16
|
logging: false,
|
|
14
17
|
};
|
|
15
18
|
|
|
16
|
-
export function
|
|
17
|
-
const
|
|
19
|
+
export function getConfigDir(): string {
|
|
20
|
+
const configDir = path.join(os.homedir(), ".config", "opencode", "fmt");
|
|
21
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
22
|
+
return configDir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadConfig(): PluginConfig {
|
|
26
|
+
const configDir = getConfigDir();
|
|
27
|
+
const configPath = path.join(configDir, "config.json");
|
|
18
28
|
|
|
19
29
|
if (!fs.existsSync(configPath)) {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
fs.writeFileSync(
|
|
31
|
+
configPath,
|
|
32
|
+
JSON.stringify(DEFAULT_CONFIG, null, "\t"),
|
|
33
|
+
"utf-8",
|
|
22
34
|
);
|
|
35
|
+
return { ...DEFAULT_CONFIG };
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
let rawConfig: Record<string, unknown> = {};
|
|
@@ -38,28 +51,24 @@ export function loadConfig(pluginDir: string): PluginConfig {
|
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
const finalConfig: PluginConfig = { ...DEFAULT_CONFIG };
|
|
41
|
-
|
|
42
|
-
for (const key of Object.keys(DEFAULT_CONFIG) as Array<keyof PluginConfig>) {
|
|
43
|
-
if (Object.hasOwn(rawConfig, key)) {
|
|
44
|
-
const value = rawConfig[key];
|
|
45
|
-
if (typeof value === typeof DEFAULT_CONFIG[key]) {
|
|
46
|
-
(finalConfig[key] as unknown) = value;
|
|
47
|
-
} else {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`[opencode-fmt] Config Error: Invalid type for "${key}". Expected ${typeof DEFAULT_CONFIG[key]}, got ${typeof value}.`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
54
|
for (const [key, value] of Object.entries(rawConfig)) {
|
|
56
55
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
57
56
|
throw new Error(
|
|
58
57
|
`[opencode-fmt] Config Error: Nested object at "${key}". Only flat keys allowed.`,
|
|
59
58
|
);
|
|
60
59
|
}
|
|
61
|
-
(
|
|
60
|
+
if (Object.hasOwn(DEFAULT_CONFIG, key)) {
|
|
61
|
+
const expectedType = typeof DEFAULT_CONFIG[key as keyof PluginConfig];
|
|
62
|
+
if (typeof value === expectedType) {
|
|
63
|
+
(finalConfig as unknown as Record<string, unknown>)[key] = value;
|
|
64
|
+
} else {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`[opencode-fmt] Config Error: Invalid type for "${key}". Expected ${expectedType}, got ${typeof value}.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
(finalConfig as unknown as Record<string, unknown>)[key] = value;
|
|
71
|
+
}
|
|
62
72
|
}
|
|
63
|
-
|
|
64
73
|
return finalConfig as PluginConfig;
|
|
65
74
|
}
|
package/src/emoji-sanitizer.ts
CHANGED
package/src/engine.ts
CHANGED
|
@@ -1,36 +1,46 @@
|
|
|
1
1
|
import type { PluginConfig } from "./config";
|
|
2
2
|
import { sanitizeEmojis } from "./emoji-sanitizer";
|
|
3
3
|
import { alignTable } from "./table-aligner";
|
|
4
|
-
|
|
5
4
|
export function runPipeline(text: string, config: PluginConfig): string {
|
|
6
5
|
if (!text) return text;
|
|
7
|
-
|
|
8
|
-
const
|
|
6
|
+
const lines = text.split(/\r?\n/);
|
|
7
|
+
const isCRLF = text.includes("\r\n");
|
|
9
8
|
const resultLines: string[] = [];
|
|
10
|
-
|
|
11
9
|
let isInTable = false;
|
|
12
10
|
let tableBuffer: string[] = [];
|
|
13
|
-
|
|
11
|
+
let hasSeparator = false;
|
|
14
12
|
const flushTable = () => {
|
|
15
13
|
if (tableBuffer.length > 0) {
|
|
16
|
-
const output =
|
|
14
|
+
const output =
|
|
15
|
+
config.alignTables && hasSeparator
|
|
16
|
+
? alignTable(tableBuffer, config.concealmentAware)
|
|
17
|
+
: tableBuffer;
|
|
17
18
|
resultLines.push(...output);
|
|
18
19
|
tableBuffer = [];
|
|
20
|
+
hasSeparator = false;
|
|
19
21
|
}
|
|
20
22
|
};
|
|
21
|
-
|
|
22
23
|
for (const line of lines) {
|
|
23
24
|
let processedLine = line;
|
|
24
|
-
|
|
25
25
|
if (config.stripEmojis) {
|
|
26
26
|
processedLine = sanitizeEmojis(processedLine);
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
const isTableRow =
|
|
30
|
-
|
|
28
|
+
const trimmedLine = processedLine.trim();
|
|
29
|
+
const isTableRow = trimmedLine.startsWith("|");
|
|
31
30
|
if (isTableRow) {
|
|
32
31
|
isInTable = true;
|
|
33
32
|
tableBuffer.push(processedLine);
|
|
33
|
+
const content = trimmedLine.slice(1, -1);
|
|
34
|
+
if (
|
|
35
|
+
content.length > 0 &&
|
|
36
|
+
/^[|\s:.-]+$/.test(content) &&
|
|
37
|
+
content.includes("-")
|
|
38
|
+
) {
|
|
39
|
+
const cells = content.split("|");
|
|
40
|
+
if (cells.every((c) => /^\s*:?-+:?\s*$/.test(c))) {
|
|
41
|
+
hasSeparator = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
34
44
|
} else {
|
|
35
45
|
if (isInTable) {
|
|
36
46
|
flushTable();
|
|
@@ -39,14 +49,11 @@ export function runPipeline(text: string, config: PluginConfig): string {
|
|
|
39
49
|
resultLines.push(processedLine);
|
|
40
50
|
}
|
|
41
51
|
}
|
|
42
|
-
|
|
43
52
|
flushTable();
|
|
44
|
-
|
|
45
|
-
const result = resultLines.join(
|
|
46
|
-
|
|
53
|
+
const lineEnding = isCRLF ? "\r\n" : "\n";
|
|
54
|
+
const result = resultLines.join(lineEnding);
|
|
47
55
|
if (text.trim().length > 0 && result.trim().length === 0) {
|
|
48
56
|
return "[Only emojis received and redacted by opencode-fmt]";
|
|
49
57
|
}
|
|
50
|
-
|
|
51
58
|
return result;
|
|
52
59
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
3
|
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
5
|
-
import { loadConfig, type PluginConfig } from "./config";
|
|
4
|
+
import { getConfigDir, loadConfig, type PluginConfig } from "./config";
|
|
6
5
|
import { runPipeline } from "./engine";
|
|
7
6
|
import { measureExecution } from "./metrics";
|
|
8
7
|
|
|
9
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
-
const __dirname = path.dirname(__filename);
|
|
11
|
-
const PLUGIN_DIR = path.join(__dirname, "..");
|
|
12
|
-
|
|
13
8
|
export type { PluginInput };
|
|
14
9
|
|
|
15
10
|
export const OpenCodeFmt: Plugin = async (_input: PluginInput) => {
|
|
16
|
-
const
|
|
11
|
+
const configDir = getConfigDir();
|
|
12
|
+
const logPath = path.join(configDir, "opencode-fmt.log");
|
|
17
13
|
|
|
18
14
|
let config: PluginConfig;
|
|
19
15
|
try {
|
|
20
|
-
config = loadConfig(
|
|
16
|
+
config = loadConfig();
|
|
21
17
|
if (config.logging) {
|
|
22
18
|
const bootMsg = `[opencode-fmt] ${new Date().toLocaleString()} | Plugin Started | Config: ${JSON.stringify(config)}\n`;
|
|
23
19
|
await fs.appendFile(logPath, bootMsg).catch(() => {});
|
|
@@ -31,25 +27,20 @@ export const OpenCodeFmt: Plugin = async (_input: PluginInput) => {
|
|
|
31
27
|
return {
|
|
32
28
|
"experimental.text.complete": async (_hookInput, hookOutput) => {
|
|
33
29
|
const rawText = hookOutput.text || "";
|
|
34
|
-
|
|
35
30
|
const { result: processedText, duration } = measureExecution(() =>
|
|
36
31
|
runPipeline(rawText, config),
|
|
37
32
|
);
|
|
38
|
-
|
|
39
33
|
hookOutput.text = processedText;
|
|
40
|
-
|
|
41
34
|
if (config.logging) {
|
|
42
35
|
try {
|
|
43
36
|
const timestamp = new Date().toLocaleString();
|
|
44
37
|
let logMsg = "";
|
|
45
|
-
|
|
46
38
|
if (rawText === processedText) {
|
|
47
39
|
logMsg = `[opencode-fmt] ${timestamp} | Duration: ${duration}ms | Hook triggered | Response: ${JSON.stringify(rawText)}\n`;
|
|
48
40
|
} else {
|
|
49
41
|
logMsg = `[opencode-fmt] ${timestamp} | Duration: ${duration}ms | Hook triggered | Raw: ${JSON.stringify(rawText)} | Cleaned: ${JSON.stringify(processedText)}\n`;
|
|
50
42
|
}
|
|
51
|
-
|
|
52
|
-
fs.appendFile(logPath, logMsg).catch(() => {});
|
|
43
|
+
await fs.appendFile(logPath, logMsg).catch(() => {});
|
|
53
44
|
} catch (_) {}
|
|
54
45
|
}
|
|
55
46
|
},
|
package/src/table-aligner.ts
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
type Alignment = "default" | "left" | "center" | "right";
|
|
2
|
-
|
|
3
2
|
function parseAlignment(cell: string): Alignment {
|
|
4
3
|
const trimmed = cell.trim();
|
|
5
4
|
const leftColon = trimmed.startsWith(":");
|
|
6
5
|
const rightColon = trimmed.endsWith(":");
|
|
7
|
-
|
|
8
6
|
if (leftColon && rightColon) return "center";
|
|
9
7
|
if (rightColon) return "right";
|
|
10
8
|
if (leftColon) return "left";
|
|
11
9
|
return "default";
|
|
12
10
|
}
|
|
13
|
-
|
|
14
11
|
function formatSeparator(width: number, align: Alignment): string {
|
|
15
12
|
const w = Math.max(3, width);
|
|
16
|
-
if (align === "center") return "
|
|
17
|
-
if (align === "right") return "-".repeat(w - 1)
|
|
18
|
-
if (align === "left") return "
|
|
13
|
+
if (align === "center") return `:${"-".repeat(w - 2)}:`;
|
|
14
|
+
if (align === "right") return `${"-".repeat(w - 1)}:`;
|
|
15
|
+
if (align === "left") return `:${"-".repeat(w - 1)}`;
|
|
19
16
|
return "-".repeat(w);
|
|
20
17
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
function padCell(
|
|
19
|
+
cell: string,
|
|
20
|
+
width: number,
|
|
21
|
+
align: Alignment,
|
|
22
|
+
concealmentAware: boolean,
|
|
23
|
+
): string {
|
|
24
|
+
const currentWidth = concealmentAware ? getVisualWidth(cell) : cell.length;
|
|
25
|
+
const padding = Math.max(0, width - currentWidth);
|
|
25
26
|
if (align === "center") {
|
|
26
27
|
const leftPad = Math.floor(padding / 2);
|
|
27
28
|
const rightPad = padding - leftPad;
|
|
@@ -32,55 +33,80 @@ function padCell(cell: string, width: number, align: Alignment): string {
|
|
|
32
33
|
}
|
|
33
34
|
return cell + " ".repeat(padding);
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
function getVisualWidth(text: string): number {
|
|
37
|
+
let processed = text.replace(/\[((?:[^[\]]+|\[[^\]]*\])*)\]\([^)]+\)/g, "$1");
|
|
38
|
+
processed = processed
|
|
39
|
+
.replace(/\\(.)/g, "$1")
|
|
40
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
41
|
+
.replace(/\*(.+?)\*/g, "$1")
|
|
42
|
+
.replace(/__(.+?)__/g, "$1")
|
|
43
|
+
.replace(/_(.+?)_/g, "$1")
|
|
44
|
+
.replace(/~~(.+?)~~/g, "$1")
|
|
45
|
+
.replace(/`([^`]+)`/g, "$1");
|
|
46
|
+
return processed.length;
|
|
47
|
+
}
|
|
48
|
+
export function alignTableWithConfig(
|
|
49
|
+
rows: string[],
|
|
50
|
+
concealmentAware: boolean,
|
|
51
|
+
): string[] {
|
|
37
52
|
const rowCount = rows.length;
|
|
38
53
|
if (rowCount === 0) return rows;
|
|
39
|
-
|
|
40
54
|
const tableData = new Array(rowCount);
|
|
41
55
|
const colWidths: number[] = [];
|
|
42
56
|
const colAlignments: Alignment[] = [];
|
|
43
|
-
|
|
44
57
|
for (let i = 0; i < rowCount; i++) {
|
|
45
58
|
const row = rows[i];
|
|
46
59
|
let trimmedRow = row.trim();
|
|
47
60
|
if (trimmedRow.startsWith("|")) trimmedRow = trimmedRow.slice(1);
|
|
48
61
|
if (trimmedRow.endsWith("|")) trimmedRow = trimmedRow.slice(0, -1);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
const cells = trimmedRow
|
|
63
|
+
.split(/(?<!\\)\|/)
|
|
64
|
+
.map((c) => c.trim())
|
|
65
|
+
.filter((c, index, array) => {
|
|
66
|
+
if (index === 0 && c === "") return false;
|
|
67
|
+
if (index === array.length - 1 && c === "") return false;
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
const isSeparator =
|
|
71
|
+
cells.length > 0 && cells.every((c) => /^\s*:?-+:?\s*$/.test(c));
|
|
53
72
|
tableData[i] = { cells, isSeparator };
|
|
54
|
-
|
|
55
73
|
if (isSeparator) {
|
|
56
74
|
for (let j = 0; j < cells.length; j++) {
|
|
57
75
|
colAlignments[j] = parseAlignment(cells[j]);
|
|
58
76
|
}
|
|
59
77
|
} else {
|
|
60
78
|
for (let j = 0; j < cells.length; j++) {
|
|
61
|
-
const cellLength =
|
|
79
|
+
const cellLength = concealmentAware
|
|
80
|
+
? getVisualWidth(cells[j])
|
|
81
|
+
: cells[j].length;
|
|
62
82
|
if (colWidths[j] === undefined || cellLength > colWidths[j]) {
|
|
63
83
|
colWidths[j] = cellLength;
|
|
64
84
|
}
|
|
65
85
|
}
|
|
66
86
|
}
|
|
67
87
|
}
|
|
68
|
-
|
|
69
88
|
const colCount = colWidths.length;
|
|
70
|
-
|
|
89
|
+
for (let i = 0; i < colCount; i++) {
|
|
90
|
+
colWidths[i] = Math.max(3, colWidths[i] || 0);
|
|
91
|
+
}
|
|
71
92
|
return tableData.map((row) => {
|
|
72
93
|
const paddedCells = new Array(colCount);
|
|
73
94
|
for (let i = 0; i < colCount; i++) {
|
|
74
95
|
const cell = row.cells[i] || "";
|
|
75
|
-
const width = colWidths[i]
|
|
96
|
+
const width = colWidths[i];
|
|
76
97
|
const align = colAlignments[i] || "default";
|
|
77
|
-
|
|
78
98
|
if (row.isSeparator) {
|
|
79
99
|
paddedCells[i] = formatSeparator(width, align);
|
|
80
100
|
} else {
|
|
81
|
-
paddedCells[i] = padCell(cell, width, align);
|
|
101
|
+
paddedCells[i] = padCell(cell, width, align, concealmentAware);
|
|
82
102
|
}
|
|
83
103
|
}
|
|
84
104
|
return `| ${paddedCells.join(" | ")} |`;
|
|
85
105
|
});
|
|
86
106
|
}
|
|
107
|
+
export function alignTable(
|
|
108
|
+
rows: string[],
|
|
109
|
+
concealmentAware: boolean = true,
|
|
110
|
+
): string[] {
|
|
111
|
+
return alignTableWithConfig(rows, concealmentAware);
|
|
112
|
+
}
|
package/tests/aligner.test.ts
CHANGED
|
@@ -22,9 +22,9 @@ describe("Table Aligner", () => {
|
|
|
22
22
|
test("fills missing columns in short rows", () => {
|
|
23
23
|
const input = ["| H1 | H2 |", "|---|---|", "| Single |"];
|
|
24
24
|
const result = alignTable(input);
|
|
25
|
-
expect(result[0]).toBe("| H1 | H2
|
|
25
|
+
expect(result[0]).toBe("| H1 | H2 |");
|
|
26
26
|
expect(result[1]).toBe("| ------ | --- |");
|
|
27
|
-
expect(result[2]).toBe("| Single |
|
|
27
|
+
expect(result[2]).toBe("| Single | |");
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
test("handles empty input", () => {
|
|
@@ -35,28 +35,140 @@ describe("Table Aligner", () => {
|
|
|
35
35
|
const input = ["| Item | Amount |", "|:---:|----:|", "| a | long |"];
|
|
36
36
|
const result = alignTable(input);
|
|
37
37
|
expect(result[0]).toBe("| Item | Amount |");
|
|
38
|
-
expect(result[1]).toBe("|
|
|
39
|
-
expect(result[2]).toBe("| a
|
|
38
|
+
expect(result[1]).toBe("| :--: | -----: |");
|
|
39
|
+
expect(result[2]).toBe("| a | long |");
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test("preserves right alignment", () => {
|
|
43
43
|
const input = ["| Name | Score |", "|---:|-----:|", "| Alice | 100 |"];
|
|
44
44
|
const result = alignTable(input);
|
|
45
|
-
expect(result[0]).toBe("|
|
|
46
|
-
expect(result[1]).toBe("|
|
|
45
|
+
expect(result[0]).toBe("| Name | Score |");
|
|
46
|
+
expect(result[1]).toBe("| ----: | ----: |");
|
|
47
47
|
expect(result[2]).toBe("| Alice | 100 |");
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test("preserves mixed alignment", () => {
|
|
51
|
-
const input = [
|
|
51
|
+
const input = [
|
|
52
|
+
"| Left | Center | Right |",
|
|
53
|
+
"| :--- | :---: | ---: |",
|
|
54
|
+
"| l | center | rvalue |",
|
|
55
|
+
];
|
|
56
|
+
const result = alignTable(input);
|
|
57
|
+
expect(result[0]).toBe("| Left | Center | Right |");
|
|
58
|
+
expect(result[1]).toBe("| :--- | :----: | -----: |");
|
|
59
|
+
expect(result[2]).toBe("| l | center | rvalue |");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("handles single column center", () => {
|
|
63
|
+
const input = ["| A |", "|:--:|", "| x |"];
|
|
64
|
+
const result = alignTable(input);
|
|
65
|
+
expect(result[0]).toBe("| A |");
|
|
66
|
+
expect(result[1]).toBe("| :-: |");
|
|
67
|
+
expect(result[2]).toBe("| x |");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("handles table without separator (defaults left)", () => {
|
|
71
|
+
const input = ["| A | B |", "| a | b |"];
|
|
72
|
+
const result = alignTable(input);
|
|
73
|
+
expect(result[0]).toBe("| A | B |");
|
|
74
|
+
expect(result[1]).toBe("| a | b |");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("handles escaped pipes without splitting columns", () => {
|
|
78
|
+
const input = [
|
|
79
|
+
"| Header 1 | Header 2 |",
|
|
80
|
+
"| --- | --- |",
|
|
81
|
+
"| Value with \\| pipe | Next Col |",
|
|
82
|
+
];
|
|
83
|
+
const result = alignTable(input, true);
|
|
84
|
+
expect(result[2]).toContain("\\|");
|
|
85
|
+
const matches = result[2].match(/(?<!\\)\|/g);
|
|
86
|
+
expect(matches?.length).toBe(3);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("Concealment Aware Width Calculation", () => {
|
|
91
|
+
test("concealmentAware=true strips bold for width", () => {
|
|
92
|
+
const input = ["| Feature | Status |", "|---|---|", "| **bold** | Done |"];
|
|
93
|
+
const result = alignTable(input, true);
|
|
94
|
+
expect(result[0]).toBe("| Feature | Status |");
|
|
95
|
+
expect(result[1]).toBe("| ------- | ------ |");
|
|
96
|
+
expect(result[2]).toBe("| **bold** | Done |");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("concealmentAware=true strips italic for width", () => {
|
|
100
|
+
const input = ["| Feature | Status |", "|---|---|", "| *italic* | Done |"];
|
|
101
|
+
const result = alignTable(input, true);
|
|
102
|
+
expect(result[2]).toBe("| *italic* | Done |");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("concealmentAware=true strips underscore bold for width", () => {
|
|
106
|
+
const input = ["| Feature | Status |", "|---|---|", "| __bold__ | Done |"];
|
|
107
|
+
const result = alignTable(input, true);
|
|
108
|
+
expect(result[2]).toBe("| __bold__ | Done |");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("concealmentAware=true strips underscore italic for width", () => {
|
|
112
|
+
const input = ["| Feature | Status |", "|---|---|", "| _italic_ | Done |"];
|
|
113
|
+
const result = alignTable(input, true);
|
|
114
|
+
expect(result[2]).toBe("| _italic_ | Done |");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("concealmentAware=true strips strikethrough for width", () => {
|
|
118
|
+
const input = [
|
|
119
|
+
"| Feature | Status |",
|
|
120
|
+
"|---|---|",
|
|
121
|
+
"| ~~strike~~ | Done |",
|
|
122
|
+
];
|
|
123
|
+
const result = alignTable(input, true);
|
|
124
|
+
expect(result[2]).toBe("| ~~strike~~ | Done |");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("concealmentAware=false uses raw length", () => {
|
|
128
|
+
const input = ["| Feature | Status |", "|---|---|", "| **bold** | Done |"];
|
|
129
|
+
const result = alignTable(input, false);
|
|
130
|
+
expect(result[1]).toBe("| -------- | ------ |");
|
|
131
|
+
expect(result[2]).toBe("| **bold** | Done |");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("defaults to concealmentAware=true for backwards compatibility", () => {
|
|
135
|
+
const input = ["| A | B |", "|---|---|", "| **x** | y |"];
|
|
52
136
|
const result = alignTable(input);
|
|
53
|
-
expect(result
|
|
54
|
-
|
|
137
|
+
expect(result).toEqual(alignTable(input, true));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("mixed markdown in cells", () => {
|
|
141
|
+
const input = ["| Col1 | Col2 |", "|---|--- |", "| **bold** | *italic* |"];
|
|
142
|
+
const result = alignTable(input, true);
|
|
143
|
+
expect(result[2]).toBe("| **bold** | *italic* |");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("concealmentAware=true should strip links for width", () => {
|
|
147
|
+
const input = [
|
|
148
|
+
"| Page | Link |",
|
|
149
|
+
"|---|---|",
|
|
150
|
+
"| Home | [Google](https://google.com) |",
|
|
151
|
+
];
|
|
152
|
+
const result = alignTable(input, true);
|
|
153
|
+
expect(result[0]).toBe("| Page | Link |");
|
|
154
|
+
expect(result[1]).toBe("| ---- | ------ |");
|
|
155
|
+
expect(result[2]).toBe("| Home | [Google](https://google.com) |");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("concealmentAware=true should strip escaped characters", () => {
|
|
159
|
+
const input = ["| A | B |", "|---|---|", "| \\* | \\*\\* |"];
|
|
160
|
+
const result = alignTable(input, true);
|
|
161
|
+
expect(result[2]).toBe("| \\* | \\*\\* |");
|
|
55
162
|
});
|
|
56
163
|
});
|
|
57
164
|
|
|
58
165
|
describe("Diff Stability", () => {
|
|
59
|
-
const cfg = {
|
|
166
|
+
const cfg = {
|
|
167
|
+
stripEmojis: false,
|
|
168
|
+
alignTables: true,
|
|
169
|
+
logging: false,
|
|
170
|
+
concealmentAware: true,
|
|
171
|
+
};
|
|
60
172
|
|
|
61
173
|
test("plain text with no tables passes through byte-identical", () => {
|
|
62
174
|
const input = "This is plain text.\nNo tables here.\nJust three lines.";
|
package/tests/config.test.ts
CHANGED
|
@@ -8,33 +8,37 @@ import {
|
|
|
8
8
|
test,
|
|
9
9
|
} from "bun:test";
|
|
10
10
|
import fs from "node:fs";
|
|
11
|
-
import path from "node:path";
|
|
12
11
|
import { DEFAULT_CONFIG, loadConfig } from "../src/config";
|
|
13
12
|
|
|
14
13
|
describe("Config Loader", () => {
|
|
15
|
-
const dir = "/fake/dir";
|
|
16
|
-
const _configPath = path.join(dir, "config.json");
|
|
17
|
-
|
|
18
14
|
beforeEach(() => {
|
|
15
|
+
spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
|
|
19
16
|
spyOn(fs, "existsSync");
|
|
20
17
|
spyOn(fs, "readFileSync");
|
|
18
|
+
spyOn(fs, "writeFileSync").mockImplementation(() => undefined);
|
|
21
19
|
});
|
|
22
20
|
|
|
23
21
|
afterEach(() => {
|
|
24
22
|
mock.restore();
|
|
25
23
|
});
|
|
26
24
|
|
|
27
|
-
test("
|
|
25
|
+
test("creates config.json with defaults when file is missing and returns DEFAULT_CONFIG", () => {
|
|
28
26
|
spyOn(fs, "existsSync").mockReturnValue(false);
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
|
|
28
|
+
const config = loadConfig();
|
|
29
|
+
|
|
30
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
31
|
+
expect.stringContaining("config.json"),
|
|
32
|
+
expect.stringContaining("stripEmojis"),
|
|
33
|
+
"utf-8",
|
|
31
34
|
);
|
|
35
|
+
expect(config).toEqual(DEFAULT_CONFIG);
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
test("throws error if config.json is invalid JSON", () => {
|
|
35
39
|
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
36
40
|
spyOn(fs, "readFileSync").mockReturnValue("{ bad json ]");
|
|
37
|
-
expect(() => loadConfig(
|
|
41
|
+
expect(() => loadConfig()).toThrow(/Failed to parse config.json/);
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
test("loads config and merges with defaults", () => {
|
|
@@ -43,7 +47,7 @@ describe("Config Loader", () => {
|
|
|
43
47
|
JSON.stringify({ logging: true }),
|
|
44
48
|
);
|
|
45
49
|
|
|
46
|
-
const config = loadConfig(
|
|
50
|
+
const config = loadConfig();
|
|
47
51
|
|
|
48
52
|
expect(config.logging).toBe(true);
|
|
49
53
|
expect(config.stripEmojis).toBe(DEFAULT_CONFIG.stripEmojis);
|
|
@@ -59,7 +63,7 @@ describe("Config Loader", () => {
|
|
|
59
63
|
}),
|
|
60
64
|
);
|
|
61
65
|
|
|
62
|
-
expect(() => loadConfig(
|
|
66
|
+
expect(() => loadConfig()).toThrow(/Nested object at "nested"/);
|
|
63
67
|
});
|
|
64
68
|
|
|
65
69
|
test("allows arrays in config (e.g. for future list features)", () => {
|
|
@@ -71,7 +75,7 @@ describe("Config Loader", () => {
|
|
|
71
75
|
}),
|
|
72
76
|
);
|
|
73
77
|
|
|
74
|
-
const config = loadConfig(
|
|
78
|
+
const config = loadConfig() as Record<string, unknown>;
|
|
75
79
|
expect(config.ignoredPaths).toEqual(["node_modules"]);
|
|
76
80
|
});
|
|
77
81
|
});
|
package/tests/engine.test.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { runPipeline } from "../src/engine";
|
|
3
3
|
|
|
4
|
-
const cfg = {
|
|
5
|
-
|
|
4
|
+
const cfg = {
|
|
5
|
+
stripEmojis: true,
|
|
6
|
+
alignTables: true,
|
|
7
|
+
concealmentAware: true,
|
|
8
|
+
logging: false,
|
|
9
|
+
};
|
|
10
|
+
const cfgOff = {
|
|
11
|
+
stripEmojis: false,
|
|
12
|
+
alignTables: false,
|
|
13
|
+
concealmentAware: false,
|
|
14
|
+
logging: false,
|
|
15
|
+
};
|
|
6
16
|
|
|
7
17
|
describe("Emoji Sanitizer", () => {
|
|
8
18
|
test("strips a single emoji", () => {
|
|
@@ -37,6 +47,16 @@ describe("Emoji Sanitizer", () => {
|
|
|
37
47
|
);
|
|
38
48
|
});
|
|
39
49
|
|
|
50
|
+
test("returns redaction placeholder for emojis with newlines", () => {
|
|
51
|
+
expect(runPipeline("🚀 \n\n 🔥", cfg)).toBe(
|
|
52
|
+
"[Only emojis received and redacted by opencode-fmt]",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("does NOT trigger redaction for plain whitespace messages", () => {
|
|
57
|
+
expect(runPipeline(" \n ", cfg)).toBe(" \n ");
|
|
58
|
+
});
|
|
59
|
+
|
|
40
60
|
test("preserves numbers and punctuation around emojis", () => {
|
|
41
61
|
expect(runPipeline("Step 1: 📋 check", cfg)).toBe("Step 1: check");
|
|
42
62
|
});
|
|
@@ -133,4 +153,9 @@ describe("Emoji Sanitizer", () => {
|
|
|
133
153
|
test("does NOT strip emojis when stripEmojis is false", () => {
|
|
134
154
|
expect(runPipeline("Hello 👋 World", cfgOff)).toBe("Hello 👋 World");
|
|
135
155
|
});
|
|
156
|
+
|
|
157
|
+
test("does NOT format non-table lines starting with pipes", () => {
|
|
158
|
+
const input = "| This is just a line with a pipe\nSome other text";
|
|
159
|
+
expect(runPipeline(input, cfg)).toBe(input);
|
|
160
|
+
});
|
|
136
161
|
});
|
package/tests/index.test.ts
CHANGED
|
@@ -29,6 +29,8 @@ describe("Plugin Entry (index.ts)", () => {
|
|
|
29
29
|
|
|
30
30
|
beforeEach(() => {
|
|
31
31
|
spyOn(fsp, "appendFile").mockResolvedValue(undefined as undefined);
|
|
32
|
+
spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
|
|
33
|
+
spyOn(fs, "writeFileSync").mockImplementation(() => undefined);
|
|
32
34
|
spyOn(fs, "existsSync");
|
|
33
35
|
spyOn(fs, "readFileSync");
|
|
34
36
|
});
|
|
@@ -53,15 +55,19 @@ describe("Plugin Entry (index.ts)", () => {
|
|
|
53
55
|
);
|
|
54
56
|
});
|
|
55
57
|
|
|
56
|
-
test("
|
|
58
|
+
test("initializes with default config when config.json is missing", async () => {
|
|
57
59
|
spyOn(fs, "existsSync").mockReturnValue(false);
|
|
58
60
|
|
|
59
|
-
await
|
|
60
|
-
/Configuration file "config.json" is missing/,
|
|
61
|
-
);
|
|
61
|
+
const hooks = await OpenCodeFmt(mockInput as PluginInput);
|
|
62
62
|
|
|
63
|
-
expect(
|
|
64
|
-
|
|
63
|
+
expect(hooks).toBeDefined();
|
|
64
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
65
|
+
expect.stringContaining("config.json"),
|
|
66
|
+
expect.stringContaining("stripEmojis"),
|
|
67
|
+
"utf-8",
|
|
68
|
+
);
|
|
69
|
+
expect(fsp.appendFile).not.toHaveBeenCalledWith(
|
|
70
|
+
expect.anything(),
|
|
65
71
|
expect.stringContaining("FATAL STARTUP ERROR"),
|
|
66
72
|
);
|
|
67
73
|
});
|
package/debug.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { alignTable } from "./src/table-aligner";
|
|
2
|
-
|
|
3
|
-
const inputCenter = ["| Item | Amount |", "|:---:|----:|", "| a | long |"];
|
|
4
|
-
console.log("Center test: ");
|
|
5
|
-
const resultCenter = alignTable(inputCenter);
|
|
6
|
-
console.log(resultCenter.join("\n"));
|
|
7
|
-
|
|
8
|
-
const inputRight = ["| Name | Score |", "|---:|-----:|", "| Alice | 100 |"];
|
|
9
|
-
console.log("\nRight test: ");
|
|
10
|
-
const resultRight = alignTable(inputRight);
|
|
11
|
-
console.log(resultRight.join("\n"));
|
|
12
|
-
|
|
13
|
-
const inputMixed = ["| Left | Center | Right |", "| :--- | :---: | ---: |", "| l | center | rvalue |"];
|
|
14
|
-
console.log("\nMixed test: ");
|
|
15
|
-
const resultMixed = alignTable(inputMixed);
|
|
16
|
-
console.log(resultMixed.join("\n"));
|
package/src/passes/echo.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { Config, Pass } from "../config.js";
|
|
2
|
-
|
|
3
|
-
export const echoPass: Pass = {
|
|
4
|
-
name: "echo",
|
|
5
|
-
enabled: () => true,
|
|
6
|
-
transform: (text: string, _cfg: Config) => {
|
|
7
|
-
process.stderr.write(`opencode-fmt: echo — received ${text.length} chars\n`);
|
|
8
|
-
process.stderr.write(`opencode-fmt: echo — received text:\n${text}\n---\n`);
|
|
9
|
-
return text;
|
|
10
|
-
}
|
|
11
|
-
};
|
package/src/pipeline.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import type { Config, Pass } from "./config.js";
|
|
2
|
-
import { echoPass } from "./passes/echo.js";
|
|
3
|
-
|
|
4
|
-
const passes: Pass[] = [
|
|
5
|
-
echoPass,
|
|
6
|
-
];
|
|
7
|
-
|
|
8
|
-
export function runPipeline(text: string, cfg: Config): string {
|
|
9
|
-
if (!text || passes.length === 0) return text;
|
|
10
|
-
|
|
11
|
-
let result = text;
|
|
12
|
-
|
|
13
|
-
for (let i = 0, len = passes.length; i < len; i++) {
|
|
14
|
-
const pipe = passes[i];
|
|
15
|
-
if (pipe.enabled(cfg)) {
|
|
16
|
-
result = pipe.transform(result, cfg);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return result;
|
|
21
|
-
}
|