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 CHANGED
@@ -1,58 +1,122 @@
1
- # OpenCode FMT Plugin
1
+ # OpenCode FMT
2
2
 
3
- A unified, single-pass text optimization plugin for OpenCode that sanitizes emojis and formats markdown tables deterministically.
3
+ [![Bun Version](https://img.shields.io/badge/Bun-%3E%3D1.3.11-blue?logo=bun&logoColor=white)](https://bun.sh)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- 1. **Deterministic Emoji Sanitizer:** Surgically removes emojis from AI outputs without altering the surrounding whitespace or layout.
8
- 2. **Isolated Table Aligner:** Formats Markdown tables by calculating maximum column widths and applying deterministic padding, ensuring visual correctness.
9
- 3. **Zero-Dependency Engine:** Processes the AI output in a single pass ($O(n)$ latency) without generating an AST or relying on third-party libraries like Prettier or Remark.
10
- 4. **Flat Configuration:** Simple, flat boolean configuration file with nested-object crash safety.
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
- ## Installation
18
+ ---
13
19
 
14
- This is an OpenCode-native TypeScript plugin. OpenCode executes it via Bun.
20
+ ## Quick Start
15
21
 
16
- There are no external parsing dependencies required.
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
- "name": "opencode-postgen-pipeline",
22
- "private": true,
23
- "type": "module",
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
- ## Configuration
34
+ ### Configuration
33
35
 
34
- Create a `config.json` inside your plugin root directory. The plugin only accepts a flat structure.
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
- "logMetrics": false
42
+ "concealmentAware": true,
43
+ "logging": false
41
44
  }
42
45
  ```
43
46
 
44
- ## Performance
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
- To prove the core value proposition of an $O(n)$ single pass, this plugin includes latency telemetry.
116
+ ---
47
117
 
48
- **Benchmark:**
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
- ## Architectural Constraints (Strict)
120
+ This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
53
121
 
54
- This plugin is governed by strict, locked technical rules to preserve its performance:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-fmt",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -23,4 +23,4 @@
23
23
  "peerDependencies": {
24
24
  "typescript": "^5"
25
25
  }
26
- }
26
+ }
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 loadConfig(pluginDir: string): PluginConfig {
17
- const configPath = path.join(pluginDir, "config.json");
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
- throw new Error(
21
- `[opencode-fmt] Configuration file "config.json" is missing. Please create it in the project root.`,
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
- (finalConfig as unknown as Record<string, unknown>)[key] = value;
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
  }
@@ -1,6 +1,5 @@
1
- export const EMOJI_REGEX =
1
+ const EMOJI_REGEX =
2
2
  /\p{Emoji_Presentation}|\p{Extended_Pictographic}|\u200d|\ufe0f|\u20e3|\u20e0|\p{RI}/gu;
3
-
4
3
  export function sanitizeEmojis(text: string): string {
5
4
  if (!text) return text;
6
5
  return text.replace(EMOJI_REGEX, "");
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 lines = text.split("\n");
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 = config.alignTables ? alignTable(tableBuffer) : tableBuffer;
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 = processedLine.trim().startsWith("|");
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("\n");
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 logPath = path.join(PLUGIN_DIR, "opencode-fmt.log");
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(PLUGIN_DIR);
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
  },
@@ -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 ":" + "-".repeat(w - 2) + ":";
17
- if (align === "right") return "-".repeat(w - 1) + ":";
18
- if (align === "left") return ":" + "-".repeat(w - 1);
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
- function padCell(cell: string, width: number, align: Alignment): string {
23
- const padding = Math.max(0, width - cell.length);
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
- export function alignTable(rows: string[]): string[] {
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
- const cells = trimmedRow.split("|").map((c) => c.trim());
51
- const isSeparator = cells.length > 0 && cells.every((c) => /^\s*:?-+:?\s*$/.test(c));
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 = cells[j].length;
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] || 0;
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
+ }
@@ -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 | long |");
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("| Name | Score |");
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 = ["| Left | Center | Right |", "| :--- | :---: | ---: |", "| l | center | rvalue |"];
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[1]).toBe("| :--- | :-----: | ------: |");
54
- expect(result[2]).toBe("| l | center | rvalue |");
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 = { stripEmojis: false, alignTables: true, logMetrics: false };
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.";
@@ -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("throws error if config.json is missing", () => {
25
+ test("creates config.json with defaults when file is missing and returns DEFAULT_CONFIG", () => {
28
26
  spyOn(fs, "existsSync").mockReturnValue(false);
29
- expect(() => loadConfig(dir)).toThrow(
30
- /Configuration file "config.json" is missing/,
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(dir)).toThrow(/Failed to parse config.json/);
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(dir);
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(dir)).toThrow(/Nested object at "nested"/);
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(dir) as Record<string, unknown>;
78
+ const config = loadConfig() as Record<string, unknown>;
75
79
  expect(config.ignoredPaths).toEqual(["node_modules"]);
76
80
  });
77
81
  });
@@ -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 = { stripEmojis: true, alignTables: true, logMetrics: false };
5
- const cfgOff = { stripEmojis: false, alignTables: false, logMetrics: false };
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
  });
@@ -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("throws error when initializing with missing config", async () => {
58
+ test("initializes with default config when config.json is missing", async () => {
57
59
  spyOn(fs, "existsSync").mockReturnValue(false);
58
60
 
59
- await expect(OpenCodeFmt(mockInput as PluginInput)).rejects.toThrow(
60
- /Configuration file "config.json" is missing/,
61
- );
61
+ const hooks = await OpenCodeFmt(mockInput as PluginInput);
62
62
 
63
- expect(fsp.appendFile).toHaveBeenCalledWith(
64
- expect.stringContaining("opencode-fmt.log"),
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"));
@@ -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
- }